From c1ee9b40e033203c7b9519e3dd1d4729394bcae8 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 5 May 2023 20:20:35 -0500 Subject: [PATCH 01/59] Try to undisable this test --- .../kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java index 5c7a076..089dd19 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java @@ -27,7 +27,6 @@ import java.nio.charset.StandardCharsets; import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; import com.kingsrook.qqq.materialdashboard.lib.javalin.CapturedContext; import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.openqa.selenium.By; import static com.kingsrook.qqq.materialdashboard.tests.QueryScreenTest.addQueryFilterInput; @@ -69,7 +68,6 @@ public class SavedFiltersTest extends QBaseSeleniumTest ** *******************************************************************************/ @Test - @Disabled void testNavigatingBackAndForth() { qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); From 4fa42680511bce9fdd5028de39b07303999008fa Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Sat, 6 May 2023 19:33:37 -0500 Subject: [PATCH 02/59] try more specific selector for click --- .../kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java index 089dd19..deedbf4 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java @@ -95,7 +95,7 @@ public class SavedFiltersTest extends QBaseSeleniumTest ////////////////////////////// qSeleniumLib.takeScreenshotToFile("before-johnny-click"); qSeleniumLib.waitForSeconds(1); // wait for the filters menu to fully disappear? if this doesn't work, try a different word to look for... - qSeleniumLib.waitForSelectorContaining("DIV", "jdoe@kingsrook.com").click(); + qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", "jdoe@kingsrook.com").click(); qSeleniumLib.takeScreenshotToFile("after-johnny-click"); qSeleniumLib.waitForSelectorContaining("H5", "Viewing Person: John Doe"); From ca622d3f10e9f4c432081ef0dc3b1c3f1d081a63 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Sun, 7 May 2023 19:37:26 -0500 Subject: [PATCH 03/59] Add highlight of the element we're trying to click --- .../qqq/materialdashboard/lib/QSeleniumLib.java | 12 ++++++++++++ .../materialdashboard/tests/SavedFiltersTest.java | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java index e04a9b7..d53692e 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java @@ -9,6 +9,7 @@ import org.apache.commons.io.FileUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.OutputType; import org.openqa.selenium.StaleElementReferenceException; import org.openqa.selenium.WebDriver; @@ -261,6 +262,17 @@ public class QSeleniumLib + /******************************************************************************* + ** + *******************************************************************************/ + public void highlightElement(WebElement element) + { + JavascriptExecutor js = (JavascriptExecutor) driver; + js.executeScript("arguments[0].setAttribute('style', 'background: yellow; border: 3px solid red;');", element); + } + + + @FunctionalInterface public interface Code { diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java index deedbf4..a3cb480 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java @@ -29,6 +29,7 @@ import com.kingsrook.qqq.materialdashboard.lib.javalin.CapturedContext; import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; import static com.kingsrook.qqq.materialdashboard.tests.QueryScreenTest.addQueryFilterInput; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -95,6 +96,9 @@ public class SavedFiltersTest extends QBaseSeleniumTest ////////////////////////////// qSeleniumLib.takeScreenshotToFile("before-johnny-click"); qSeleniumLib.waitForSeconds(1); // wait for the filters menu to fully disappear? if this doesn't work, try a different word to look for... + WebElement webElement = qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", "jdoe@kingsrook.com"); + qSeleniumLib.highlightElement(webElement); + qSeleniumLib.takeScreenshotToFile("after-johnny-highlight"); qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", "jdoe@kingsrook.com").click(); qSeleniumLib.takeScreenshotToFile("after-johnny-click"); qSeleniumLib.waitForSelectorContaining("H5", "Viewing Person: John Doe"); From ce6aa31cfb894ed326f2fba6231bb8125876eb87 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 8 May 2023 09:57:09 -0500 Subject: [PATCH 04/59] Try again, more specific selector everywhere, highlight the 3rd time? --- .../materialdashboard/tests/SavedFiltersTest.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java index a3cb480..0ec990e 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java @@ -94,13 +94,8 @@ public class SavedFiltersTest extends QBaseSeleniumTest ////////////////////////////// // click into a view screen // ////////////////////////////// - qSeleniumLib.takeScreenshotToFile("before-johnny-click"); qSeleniumLib.waitForSeconds(1); // wait for the filters menu to fully disappear? if this doesn't work, try a different word to look for... - WebElement webElement = qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", "jdoe@kingsrook.com"); - qSeleniumLib.highlightElement(webElement); - qSeleniumLib.takeScreenshotToFile("after-johnny-highlight"); qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", "jdoe@kingsrook.com").click(); - qSeleniumLib.takeScreenshotToFile("after-johnny-click"); qSeleniumLib.waitForSelectorContaining("H5", "Viewing Person: John Doe"); ///////////////////////////////////////////////////// @@ -125,7 +120,7 @@ public class SavedFiltersTest extends QBaseSeleniumTest ////////////////////////////// // click into a view screen // ////////////////////////////// - qSeleniumLib.waitForSelectorContaining("DIV", "jdoe@kingsrook.com").click(); + qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", "jdoe@kingsrook.com").click(); qSeleniumLib.waitForSelectorContaining("H5", "Viewing Person: John Doe"); /////////////////////////////////////////////////////////////////////////////// @@ -164,7 +159,12 @@ public class SavedFiltersTest extends QBaseSeleniumTest ////////////////////////////// // click into a view screen // ////////////////////////////// - qSeleniumLib.waitForSelectorContaining("DIV", "jdoe@kingsrook.com").click(); + qSeleniumLib.takeScreenshotToFile("before-johnny-click"); + WebElement webElement = qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", "jdoe@kingsrook.com"); + qSeleniumLib.highlightElement(webElement); + qSeleniumLib.takeScreenshotToFile("after-johnny-highlight"); + qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", "jdoe@kingsrook.com").click(); + qSeleniumLib.takeScreenshotToFile("after-johnny-click"); qSeleniumLib.waitForSelectorContaining("H5", "Viewing Person: John Doe"); ///////////////////////////////////////////////////////////////////////////////// From e4d6ba79ed3b4f23ed853d4a63f1b20fcc45f604 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 8 May 2023 10:25:18 -0500 Subject: [PATCH 05/59] Try again (last one worked!) without the screenshot & highlight, just the better selector everywhere --- .../qqq/materialdashboard/tests/SavedFiltersTest.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java index 0ec990e..b35a948 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java @@ -29,7 +29,6 @@ import com.kingsrook.qqq.materialdashboard.lib.javalin.CapturedContext; import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; import org.openqa.selenium.By; -import org.openqa.selenium.WebElement; import static com.kingsrook.qqq.materialdashboard.tests.QueryScreenTest.addQueryFilterInput; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -159,12 +158,7 @@ public class SavedFiltersTest extends QBaseSeleniumTest ////////////////////////////// // click into a view screen // ////////////////////////////// - qSeleniumLib.takeScreenshotToFile("before-johnny-click"); - WebElement webElement = qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", "jdoe@kingsrook.com"); - qSeleniumLib.highlightElement(webElement); - qSeleniumLib.takeScreenshotToFile("after-johnny-highlight"); qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", "jdoe@kingsrook.com").click(); - qSeleniumLib.takeScreenshotToFile("after-johnny-click"); qSeleniumLib.waitForSelectorContaining("H5", "Viewing Person: John Doe"); ///////////////////////////////////////////////////////////////////////////////// From 51bbcc9d35faaa93101a5d64695b615991f16f89 Mon Sep 17 00:00:00 2001 From: Tim Chamberlain Date: Mon, 8 May 2023 11:19:15 -0500 Subject: [PATCH 06/59] made popover open to the right on password copy field, fixed icon size on widget schedules --- src/qqq/styles/qqq-override-styles.css | 8 ++++++++ src/qqq/utils/qqq/ValueUtils.tsx | 9 ++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index 160e1aa..6bdd6a1 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -391,3 +391,11 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } { font-size: 2rem !important; } + +.dashboard-schedule-icon +{ + font-size: 1rem !important; + position: relative; + top: -13px; + margin-right: 3px; +} diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index ebf059a..655c22f 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -25,7 +25,8 @@ import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QField import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import "datejs"; // https://github.com/datejs/Datejs -import {Box, Chip, ClickAwayListener, Icon} from "@mui/material"; +import {Chip, ClickAwayListener, Icon} from "@mui/material"; +import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Tooltip from "@mui/material/Tooltip"; import parse from "html-react-parser"; @@ -525,15 +526,13 @@ function RevealComponent({fieldName, value, usage}: {fieldName: string, value: s {displayValue} copyToClipboard(e, value)} sx={{cursor: "pointer", fontSize: "15px !important", position: "relative", top: "3px", marginLeft: "5px"}}>copy From fadde8d46967fbbb3f589994ef9506135188302a Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 8 May 2023 11:43:28 -0500 Subject: [PATCH 07/59] Avoid null-pointer when doing link to table that user doesn't have permission to (e.g., not in meta-data) --- src/qqq/utils/DataGridUtils.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/qqq/utils/DataGridUtils.tsx b/src/qqq/utils/DataGridUtils.tsx index 54d3243..66ecb54 100644 --- a/src/qqq/utils/DataGridUtils.tsx +++ b/src/qqq/utils/DataGridUtils.tsx @@ -107,7 +107,10 @@ export default class DataGridUtils if(metaData) { joinLinkBase = metaData.getTablePath(join.joinTable); - joinLinkBase += joinLinkBase.endsWith("/") ? "" : "/"; + if(joinLinkBase) + { + joinLinkBase += joinLinkBase.endsWith("/") ? "" : "/"; + } } if(join?.joinTable?.fields?.values()) From 163b79a889c436db9c581d46199e4f62e0f8c913 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 8 May 2023 11:53:49 -0500 Subject: [PATCH 08/59] Do not show fields from join tables that user does not have permission to (e.g., that aren't in meta data) --- src/qqq/utils/DataGridUtils.tsx | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/qqq/utils/DataGridUtils.tsx b/src/qqq/utils/DataGridUtils.tsx index 66ecb54..f43364f 100644 --- a/src/qqq/utils/DataGridUtils.tsx +++ b/src/qqq/utils/DataGridUtils.tsx @@ -97,25 +97,28 @@ export default class DataGridUtils const columns = [] as GridColDef[]; this.addColumnsForTable(tableMetaData, linkBase, columns, columnSort, null, null); - if(tableMetaData.exposedJoins) + if(metaData) { - for (let i = 0; i < tableMetaData.exposedJoins.length; i++) + if(tableMetaData.exposedJoins) { - const join = tableMetaData.exposedJoins[i]; - - let joinLinkBase = null; - if(metaData) + for (let i = 0; i < tableMetaData.exposedJoins.length; i++) { - joinLinkBase = metaData.getTablePath(join.joinTable); - if(joinLinkBase) + const join = tableMetaData.exposedJoins[i]; + let joinTableName = join.joinTable.name; + if(metaData.tables.has(joinTableName) && metaData.tables.get(joinTableName).readPermission) { - joinLinkBase += joinLinkBase.endsWith("/") ? "" : "/"; - } - } + let joinLinkBase = null; + joinLinkBase = metaData.getTablePath(join.joinTable); + if(joinLinkBase) + { + joinLinkBase += joinLinkBase.endsWith("/") ? "" : "/"; + } - if(join?.joinTable?.fields?.values()) - { - this.addColumnsForTable(join.joinTable, joinLinkBase, columns, columnSort, join.joinTable.name + ".", join.label + ": "); + if(join?.joinTable?.fields?.values()) + { + this.addColumnsForTable(join.joinTable, joinLinkBase, columns, columnSort, joinTableName + ".", join.label + ": "); + } + } } } } From 6deee8758515fd636c0e1cef8b775acbb138271a Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 8 May 2023 14:18:03 -0500 Subject: [PATCH 09/59] Added dont-go-async flag --- src/qqq/pages/records/query/ColumnStats.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/qqq/pages/records/query/ColumnStats.tsx b/src/qqq/pages/records/query/ColumnStats.tsx index fcf8231..e84dab0 100644 --- a/src/qqq/pages/records/query/ColumnStats.tsx +++ b/src/qqq/pages/records/query/ColumnStats.tsx @@ -19,6 +19,7 @@ * along with this program. If not, see . */ +import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection"; @@ -80,6 +81,7 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro formData.append("tableName", tableMetaData.name); formData.append("fieldName", fullFieldName); formData.append("filterJSON", JSON.stringify(filter)); + formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 300 * 1000); if(orderBy) { formData.append("orderBy", orderBy); From ebafc3e97ebbef0b2defe0ee2b1ae30d00427fbf Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 9 May 2023 13:02:54 -0500 Subject: [PATCH 10/59] Updates to work with branch-specific maven deployments in/with circleci --- .circleci/adjust-pom-version.sh | 23 +++++++++++++++++++++++ .circleci/config.yml | 4 ++++ 2 files changed, 27 insertions(+) create mode 100755 .circleci/adjust-pom-version.sh diff --git a/.circleci/adjust-pom-version.sh b/.circleci/adjust-pom-version.sh new file mode 100755 index 0000000..054fbdf --- /dev/null +++ b/.circleci/adjust-pom-version.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +if [ -z "$CIRCLE_BRANCH" ] && [ -z "$CIRCLE_TAG" ]; then + echo "Error: env vars CIRCLE_BRANCH and CIRCLE_TAG were not set." + exit 1; +fi + +if [ "$CIRCLE_BRANCH" == "dev" ] || [ "$CIRCLE_BRANCH" == "staging" ] || [ "$CIRCLE_BRANCH" == "main" ]; then + echo "On a primary branch [$CIRCLE_BRANCH] - will not edit the pom version."; + exit 0; +fi + +if [ -n "$CIRCLE_BRANCH" ]; then + SLUG=$(echo $CIRCLE_BRANCH | sed 's/[^a-zA-Z0-9]/-/g') +else + SLUG=$(echo $CIRCLE_TAG | sed 's/^snapshot-//g') +fi + +POM=$(dirname $0)/../pom.xml + +echo "Updating $POM to: $SLUG-SNAPSHOT" +sed -i "s/.*/$SLUG-SNAPSHOT<\/revision>/" $POM +git diff $POM diff --git a/.circleci/config.yml b/.circleci/config.yml index f6d9483..99c4892 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -71,6 +71,10 @@ commands: mvn_deploy: steps: - checkout + - run: + name: Adjust pom version + command: | + .circleci/adjust-pom-version.sh - restore_cache: keys: - v1-dependencies-{{ checksum "pom.xml" }} From bc4181908ac716b465419abe6c56ff2da98164bd Mon Sep 17 00:00:00 2001 From: Tim Chamberlain Date: Wed, 10 May 2023 15:43:09 -0500 Subject: [PATCH 11/59] CTLE-433: fixed reveal adornment style --- src/qqq/utils/qqq/ValueUtils.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index 655c22f..ca8d1b9 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -521,7 +521,7 @@ function RevealComponent({fieldName, value, usage}: {fieldName: string, value: s { displayValue && ( adornmentFieldsMap.get(fieldName) === true ? ( - + handleRevealIconClick(e, fieldName)} sx={{cursor: "pointer", fontSize: "15px !important", position: "relative", top: "3px", marginRight: "5px"}}>visibility_on {displayValue} @@ -540,7 +540,7 @@ function RevealComponent({fieldName, value, usage}: {fieldName: string, value: s ):( - handleRevealIconClick(e, fieldName)} sx={{cursor: "pointer", fontSize: "15px !important", position: "relative", top: "3px", marginRight: "5px"}}>visibility_off{displayValue} + handleRevealIconClick(e, fieldName)} sx={{cursor: "pointer", fontSize: "15px !important", position: "relative", top: "3px", marginRight: "5px"}}>visibility_off{displayValue} ) ) } From 306640e0ee8f9afaecc4510f5192c6a60ae570fc Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 10 May 2023 17:08:37 -0500 Subject: [PATCH 12/59] Handle warnings on delete; move more between-page state to state object (out of query string); --- package.json | 2 +- src/qqq/components/forms/EntityForm.tsx | 25 ++++---- src/qqq/pages/records/query/RecordQuery.tsx | 42 ++++++++----- src/qqq/pages/records/view/RecordView.tsx | 65 +++++++++++++++++---- src/qqq/utils/HtmlUtils.ts | 42 +++++++++++++ 5 files changed, 138 insertions(+), 38 deletions(-) create mode 100644 src/qqq/utils/HtmlUtils.ts diff --git a/package.json b/package.json index a258814..7b9f81a 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.61", + "@kingsrook/qqq-frontend-core": "1.0.62", "@mui/icons-material": "5.4.1", "@mui/material": "5.11.1", "@mui/styles": "5.11.1", diff --git a/src/qqq/components/forms/EntityForm.tsx b/src/qqq/components/forms/EntityForm.tsx index 471735c..6e1a748 100644 --- a/src/qqq/components/forms/EntityForm.tsx +++ b/src/qqq/components/forms/EntityForm.tsx @@ -41,6 +41,7 @@ import QDynamicForm from "qqq/components/forms/DynamicForm"; import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils"; import MDTypography from "qqq/components/legacy/MDTypography"; import QRecordSidebar from "qqq/components/misc/RecordSidebar"; +import HtmlUtils from "qqq/utils/HtmlUtils"; import Client from "qqq/utils/qqq/Client"; import TableUtils from "qqq/utils/qqq/TableUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; @@ -82,7 +83,6 @@ function EntityForm(props: Props): JSX.Element const [warningContent, setWarningContent] = useState(""); const [asyncLoadInited, setAsyncLoadInited] = useState(false); - const [formValues, setFormValues] = useState({} as { [key: string]: string }); const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); const [record, setRecord] = useState(null as QRecord); const [tableSections, setTableSections] = useState(null as QTableSection[]); @@ -185,8 +185,6 @@ function EntityForm(props: Props): JSX.Element initialValues[key] = record.values.get(key); }); - //? safe to delete? setFormValues(formValues); - if (!tableMetaData.capabilities.has(Capability.TABLE_UPDATE)) { setNotAllowedError("Records may not be edited in this table"); @@ -416,8 +414,8 @@ function EntityForm(props: Props): JSX.Element } else { - const path = `${location.pathname.replace(/\/edit$/, "")}?updateSuccess=true`; - navigate(path); + const path = location.pathname.replace(/\/edit$/, ""); + navigate(path, {state: {updateSuccess: true}}); } }) .catch((error) => @@ -427,12 +425,13 @@ function EntityForm(props: Props): JSX.Element if(error.message.toLowerCase().startsWith("warning")) { - const path = `${location.pathname.replace(/\/edit$/, "")}?updateSuccess=true&warning=${encodeURIComponent(error.message)}`; - navigate(path); + const path = location.pathname.replace(/\/edit$/, ""); + navigate(path, {state: {updateSuccess: true, warning: error.message}}); } else { setAlertContent(error.message); + HtmlUtils.autoScroll(0); } }); } @@ -448,20 +447,22 @@ function EntityForm(props: Props): JSX.Element } else { - const path = `${location.pathname.replace(/create$/, record.values.get(tableMetaData.primaryKeyField))}?createSuccess=true`; - navigate(path); + const path = location.pathname.replace(/create$/, record.values.get(tableMetaData.primaryKeyField)); + navigate(path, {state: {createSuccess: true}}); } }) .catch((error) => { if(error.message.toLowerCase().startsWith("warning")) { - const path = `${location.pathname.replace(/create$/, record.values.get(tableMetaData.primaryKeyField))}?createSuccess=true&warning=${encodeURIComponent(error.message)}`; + const path = location.pathname.replace(/create$/, record.values.get(tableMetaData.primaryKeyField)); navigate(path); + navigate(path, {state: {createSuccess: true, warning: error.message}}); } else { setAlertContent(error.message); + HtmlUtils.autoScroll(0); } }); } @@ -499,12 +500,12 @@ function EntityForm(props: Props): JSX.Element {alertContent ? ( - {alertContent} + setAlertContent(null)}>{alertContent} ) : ("")} {warningContent ? ( - {warningContent} + setWarningContent(null)}>{warningContent} ) : ("")} diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index a28910b..4af7be4 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -97,12 +97,31 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const tableName = table.name; const [searchParams] = useSearchParams(); - const [showSuccessfullyDeletedAlert, setShowSuccessfullyDeletedAlert] = useState(searchParams.has("deleteSuccess")); + const [showSuccessfullyDeletedAlert, setShowSuccessfullyDeletedAlert] = useState(false); + const [warningAlert, setWarningAlert] = useState(null as string); const [successAlert, setSuccessAlert] = useState(null as string); const location = useLocation(); const navigate = useNavigate(); + if(location.state) + { + let state: any = location.state; + if(state["deleteSuccess"]) + { + setShowSuccessfullyDeletedAlert(true); + delete state["deleteSuccess"]; + } + + if(state["warning"]) + { + setWarningAlert(state["warning"]); + delete state["warning"]; + } + + window.history.replaceState(state, ""); + } + const pathParts = location.pathname.replace(/\/+$/, "").split("/"); //////////////////////////////////////////// @@ -1815,23 +1834,20 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element )} { (tableLabel && showSuccessfullyDeletedAlert) ? ( - - { - setShowSuccessfullyDeletedAlert(false); - }}> - {`${tableLabel} successfully deleted`} - + setShowSuccessfullyDeletedAlert(false)}>{`${tableLabel} successfully deleted`} ) : null } { (successAlert) ? ( - - { - setSuccessAlert(null); - }}> - {successAlert} - + setSuccessAlert(null)}>{successAlert} + + ) : null + } + { + (warningAlert) ? ( + + setWarningAlert(null)}>{warningAlert} ) : null } diff --git a/src/qqq/pages/records/view/RecordView.tsx b/src/qqq/pages/records/view/RecordView.tsx index aac96eb..546648b 100644 --- a/src/qqq/pages/records/view/RecordView.tsx +++ b/src/qqq/pages/records/view/RecordView.tsx @@ -43,7 +43,7 @@ import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Modal from "@mui/material/Modal"; import React, {useContext, useEffect, useState} from "react"; -import {useLocation, useNavigate, useParams, useSearchParams} from "react-router-dom"; +import {useLocation, useNavigate, useParams} from "react-router-dom"; import QContext from "QContext"; import AuditBody from "qqq/components/audits/AuditBody"; import {QActionsMenuButton, QCancelButton, QDeleteButton, QEditButton} from "qqq/components/buttons/DefaultButtons"; @@ -53,6 +53,7 @@ import DashboardWidgets from "qqq/components/widgets/DashboardWidgets"; import BaseLayout from "qqq/layouts/BaseLayout"; import ProcessRun from "qqq/pages/processes/ProcessRun"; import HistoryUtils from "qqq/utils/HistoryUtils"; +import HtmlUtils from "qqq/utils/HtmlUtils"; import Client from "qqq/utils/qqq/Client"; import ProcessUtils from "qqq/utils/qqq/ProcessUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; @@ -97,10 +98,10 @@ function RecordView({table, launchProcess}: Props): JSX.Element const [tableProcesses, setTableProcesses] = useState([] as QProcessMetaData[]); const [allTableProcesses, setAllTableProcesses] = useState([] as QProcessMetaData[]); const [actionsMenu, setActionsMenu] = useState(null); - const [notFoundMessage, setNotFoundMessage] = useState(null); + const [notFoundMessage, setNotFoundMessage] = useState(null as string); + const [errorMessage, setErrorMessage] = useState(null as string) const [successMessage, setSuccessMessage] = useState(null as string); const [warningMessage, setWarningMessage] = useState(null as string); - const [searchParams] = useSearchParams(); const {setPageHeader} = useContext(QContext); const [activeModalProcess, setActiveModalProcess] = useState(null as QProcessMetaData); const [reloadCounter, setReloadCounter] = useState(0); @@ -116,6 +117,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element { setSuccessMessage(null); setNotFoundMessage(null); + setErrorMessage(null); setAsyncLoadInited(false); setTableMetaData(null); setRecord(null); @@ -423,14 +425,26 @@ function RecordView({table, launchProcess}: Props): JSX.Element setSectionFieldElements(sectionFieldElements); setNonT1TableSections(nonT1TableSections); - if (searchParams.get("createSuccess") || searchParams.get("updateSuccess")) + if(location.state) { - setSuccessMessage(`${tableMetaData.label} successfully ${searchParams.get("createSuccess") ? "created" : "updated"}`); - } - if (searchParams.get("warning")) - { - setWarningMessage(searchParams.get("warning")); + let state: any = location.state; + if (state["createSuccess"] || state["updateSuccess"]) + { + setSuccessMessage(`${tableMetaData.label} successfully ${state["createSuccess"] ? "created" : "updated"}`); + } + + if (state["warning"]) + { + setWarningMessage(state["warning"]); + } + + delete state["createSuccess"] + delete state["updateSuccess"] + delete state["warning"] + + window.history.replaceState(state, ""); } + })(); } @@ -452,8 +466,25 @@ function RecordView({table, launchProcess}: Props): JSX.Element await qController.delete(tableName, id) .then(() => { - const path = `${pathParts.slice(0, -1).join("/")}?deleteSuccess=true`; - navigate(path); + const path = pathParts.slice(0, -1).join("/"); + navigate(path, {state: {deleteSuccess: true}}); + }) + .catch((error) => + { + setDeleteConfirmationOpen(false); + console.log("Caught:"); + console.log(error); + + if(error.message.toLowerCase().startsWith("warning")) + { + const path = pathParts.slice(0, -1).join("/"); + navigate(path, {state: {deleteSuccess: true, warning: error.message}}); + } + else + { + setErrorMessage(error.message); + HtmlUtils.autoScroll(0); + } }); })(); }; @@ -648,7 +679,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element { successMessage ? - + { setSuccessMessage(null); }}> @@ -666,6 +697,16 @@ function RecordView({table, launchProcess}: Props): JSX.Element : ("") } + { + errorMessage ? + + { + setErrorMessage(null); + }}> + {errorMessage} + + : ("") + } diff --git a/src/qqq/utils/HtmlUtils.ts b/src/qqq/utils/HtmlUtils.ts new file mode 100644 index 0000000..8b4887e --- /dev/null +++ b/src/qqq/utils/HtmlUtils.ts @@ -0,0 +1,42 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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 default class HtmlUtils +{ + + /******************************************************************************* + ** Since our pages are set (w/ style on the HTML element) to smooth scroll, + ** if you ever want to do an "auto" scroll (e.g., instant, not smooth), you can + ** call this method, which will remove that style, and then put it back. + *******************************************************************************/ + static autoScroll = (top: number, left: number = 0) => + { + let htmlElement = document.querySelector("html"); + const initialScrollBehavior = htmlElement.style.scrollBehavior; + htmlElement.style.scrollBehavior = "auto"; + setTimeout(() => + { + window.scrollTo({top: top, left: left, behavior: "auto"}); + htmlElement.style.scrollBehavior = initialScrollBehavior; + }); + }; + +} \ No newline at end of file From ff9ebeea1b04f359db464e1ab7471515fda32137 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 10 May 2023 17:08:52 -0500 Subject: [PATCH 13/59] working bulk-edit test --- .../materialdashboard/tests/BulkEditTest.java | 114 ++++++++++++++++++ .../processes/person.bulkEdit/step/edit.json | 34 ++++-- .../person.bulkEdit/step/review-result.json | 60 +++++++++ .../person.bulkEdit/step/review.json | 40 ++++++ 4 files changed, 237 insertions(+), 11 deletions(-) create mode 100755 src/test/java/com/kingsrook/qqq/materialdashboard/tests/BulkEditTest.java create mode 100644 src/test/resources/fixtures/processes/person.bulkEdit/step/review-result.json create mode 100644 src/test/resources/fixtures/processes/person.bulkEdit/step/review.json diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/BulkEditTest.java b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/BulkEditTest.java new file mode 100755 index 0000000..54ec041 --- /dev/null +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/BulkEditTest.java @@ -0,0 +1,114 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2022. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.materialdashboard.tests; + + +import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import org.junit.jupiter.api.Test; + + +/******************************************************************************* + ** Test for the scripts table + *******************************************************************************/ +public class BulkEditTest extends QBaseSeleniumTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected void addJavalinRoutes(QSeleniumJavalin qSeleniumJavalin) + { + super.addJavalinRoutes(qSeleniumJavalin); + addCommonRoutesForThisTest(qSeleniumJavalin); + qSeleniumJavalin + .withRouteToFile("/metaData/process/person.bulkEdit", "metaData/process/person.bulkEdit.json") + .withRouteToFile("/processes/person.bulkEdit/init", "/processes/person.bulkEdit/init.json") + .withRouteToFile("/processes/person.bulkEdit/74a03a7d-2f53-4784-9911-3a21f7646c43/step/edit", "/processes/person.bulkEdit/step/edit.json") + .withRouteToFile("/processes/person.bulkEdit/74a03a7d-2f53-4784-9911-3a21f7646c43/step/review", "/processes/person.bulkEdit/step/review.json") + ; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void addCommonRoutesForThisTest(QSeleniumJavalin qSeleniumJavalin) + { + qSeleniumJavalin.withRouteToFile("/data/person/count", "data/person/count.json"); + qSeleniumJavalin.withRouteToFile("/data/person/query", "data/person/index.json"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + // @RepeatedTest(100) + void test() + { + qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); + qSeleniumLib.waitForSelectorContaining("button", "selection").click(); + qSeleniumLib.waitForSelectorContaining("li", "This page").click(); + qSeleniumLib.waitForSelectorContaining("div", "records on this page are selected"); + + qSeleniumLib.waitForSelectorContaining("button", "action").click(); + qSeleniumLib.waitForSelectorContaining("li", "bulk edit").click(); + + ///////////////// + // edit screen // + ///////////////// + qSeleniumLib.waitForSelector("#bulkEditSwitch-firstName").click(); + qSeleniumLib.waitForSelector("input[name=firstName]").click(); + qSeleniumLib.waitForSelector("input[name=firstName]").sendKeys("John"); + qSeleniumLib.waitForSelectorContaining("button", "next").click(); + + /////////////////////// + // validation screen // + /////////////////////// + qSeleniumLib.waitForSelectorContaining("span", "How would you like to proceed").click(); + qSeleniumLib.waitForSelectorContaining("button", "next").click(); + + ////////////////////////////////////////////////////////////// + // need to change the result of the 'review' step this time // + ////////////////////////////////////////////////////////////// + qSeleniumLib.waitForSelectorContaining("div", "Person Bulk Edit: Review").click(); + qSeleniumJavalin.clearRoutes(); + qSeleniumJavalin.stop(); + addCommonRoutesForThisTest(qSeleniumJavalin); + qSeleniumJavalin.withRouteToFile("/processes/person.bulkEdit/74a03a7d-2f53-4784-9911-3a21f7646c43/step/review", "/processes/person.bulkEdit/step/review-result.json"); + qSeleniumJavalin.restart(); + qSeleniumLib.waitForSelectorContaining("button", "submit").click(); + + /////////////////// + // result screen // + /////////////////// + qSeleniumLib.waitForSelectorContaining("div", "Person Bulk Edit: Result").click(); + qSeleniumLib.waitForSelectorContaining("button", "close").click(); + + // qSeleniumLib.waitForever(); + } + +} diff --git a/src/test/resources/fixtures/processes/person.bulkEdit/step/edit.json b/src/test/resources/fixtures/processes/person.bulkEdit/step/edit.json index b3e0fde..40a417b 100644 --- a/src/test/resources/fixtures/processes/person.bulkEdit/step/edit.json +++ b/src/test/resources/fixtures/processes/person.bulkEdit/step/edit.json @@ -1,12 +1,24 @@ { - "values": { - "firstName": "Kahhhhn", - "valuesBeingUpdated": "First Name will be set to: Kahhhhn", - "bulkEditEnabledFields": "firstName", - "recordsParam": "recordIds", - "recordIds": "1,2,3,4,5", - "queryFilterJSON": "{\"criteria\":[{\"fieldName\":\"id\",\"operator\":\"IN\",\"values\":[\"1\",\"2\",\"3\",\"4\",\"5\"]}]}" - }, - "processUUID": "74a03a7d-2f53-4784-9911-3a21f7646c43", - "nextStep": "review" -} + "values": { + "transactionLevel": "process", + "tableName": "person", + "recordsParam": "recordIds", + "supportsFullValidation": true, + "recordIds": "1,2,3,4,5", + "sourceTable": "person", + "extract": { + "name": "com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep", + "codeType": "JAVA" + }, + "recordCount": 5, + "previewMessage": "This is a preview of the records that will be updated.", + "transform": { + "name": "com.kingsrook.qqq.backend.core.processes.implementations.bulk.edit.BulkEditTransformStep", + "codeType": "JAVA" + }, + "destinationTable": "person", + "bulkEditEnabledFields": "firstName" + }, + "processUUID": "74a03a7d-2f53-4784-9911-3a21f7646c43", + "nextStep": "review" +} \ No newline at end of file diff --git a/src/test/resources/fixtures/processes/person.bulkEdit/step/review-result.json b/src/test/resources/fixtures/processes/person.bulkEdit/step/review-result.json new file mode 100644 index 0000000..c7b2417 --- /dev/null +++ b/src/test/resources/fixtures/processes/person.bulkEdit/step/review-result.json @@ -0,0 +1,60 @@ +{ + "values": { + "transactionLevel": "process", + "tableName": "person", + "recordsParam": "recordIds", + "supportsFullValidation": true, + "doFullValidation": true, + "recordIds": "1,2,3,4,5", + "sourceTable": "person", + "extract": { + "name": "com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep", + "codeType": "JAVA" + }, + "validationSummary": [ + { + "status": "OK", + "count": 5, + "message": "Person records will be edited.", + "singularFutureMessage": "Person record will be edited.", + "pluralFutureMessage": "Person records will be edited.", + "singularPastMessage": "Person record was edited.", + "pluralPastMessage": "Person records were edited." + }, + { + "status": "INFO", + "message": "First name will be set to John" + } + ], + "recordCount": 5, + "previewMessage": "This is a preview of the records that will be updated.", + "transform": { + "name": "com.kingsrook.qqq.backend.core.processes.implementations.bulk.edit.BulkEditTransformStep", + "codeType": "JAVA" + }, + "destinationTable": "person", + "bulkEditEnabledFields": "firstName", + "processResults": [ + { + "status": "OK", + "count": 5, + "message": "Person records were edited.", + "singularFutureMessage": "Person record will be edited.", + "pluralFutureMessage": "Person records will be edited.", + "singularPastMessage": "Person record was edited.", + "pluralPastMessage": "Person records were edited." + }, + { + "status": "INFO", + "message": "Mapping Exception Type was cleared out" + } + ], + "load": { + "name": "com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.LoadViaUpdateStep", + "codeType": "JAVA" + }, + "basepullReadyToUpdateTimestamp": true + }, + "processUUID": "74a03a7d-2f53-4784-9911-3a21f7646c43", + "nextStep": "result" +} \ No newline at end of file diff --git a/src/test/resources/fixtures/processes/person.bulkEdit/step/review.json b/src/test/resources/fixtures/processes/person.bulkEdit/step/review.json new file mode 100644 index 0000000..3f34c35 --- /dev/null +++ b/src/test/resources/fixtures/processes/person.bulkEdit/step/review.json @@ -0,0 +1,40 @@ +{ + "values": { + "transactionLevel": "process", + "tableName": "person", + "recordsParam": "recordIds", + "supportsFullValidation": true, + "doFullValidation": true, + "recordIds": "1,2,3,4,5", + "sourceTable": "person", + "extract": { + "name": "com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep", + "codeType": "JAVA" + }, + "validationSummary": [ + { + "status": "OK", + "count": 5, + "message": "Person records will be edited.", + "singularFutureMessage": "Person record will be edited.", + "pluralFutureMessage": "Person records will be edited.", + "singularPastMessage": "Person record was edited.", + "pluralPastMessage": "Person records were edited." + }, + { + "status": "INFO", + "message": "First name will be set to John" + } + ], + "recordCount": 5, + "previewMessage": "This is a preview of the records that will be updated.", + "transform": { + "name": "com.kingsrook.qqq.backend.core.processes.implementations.bulk.edit.BulkEditTransformStep", + "codeType": "JAVA" + }, + "destinationTable": "person", + "bulkEditEnabledFields": "firstName" + }, + "processUUID": "74a03a7d-2f53-4784-9911-3a21f7646c43", + "nextStep": "review" +} \ No newline at end of file From 6df245ca9954ff82689145d0cfc158d26edd0da1 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 12 May 2023 15:10:16 -0500 Subject: [PATCH 14/59] Switch to use NOT_EQUALS_OR_IS_NULL instead of NOT_EQUALS --- package.json | 2 +- src/qqq/utils/qqq/FilterUtils.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a258814..bda2769 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.61", + "@kingsrook/qqq-frontend-core": "1.0.63", "@mui/icons-material": "5.4.1", "@mui/material": "5.11.1", "@mui/styles": "5.11.1", diff --git a/src/qqq/utils/qqq/FilterUtils.ts b/src/qqq/utils/qqq/FilterUtils.ts index 7c56bf6..0aebaf7 100644 --- a/src/qqq/utils/qqq/FilterUtils.ts +++ b/src/qqq/utils/qqq/FilterUtils.ts @@ -65,7 +65,7 @@ class FilterUtils return QCriteriaOperator.EQUALS; case "isNot": case "!=": - return QCriteriaOperator.NOT_EQUALS; + return QCriteriaOperator.NOT_EQUALS_OR_IS_NULL; case "after": case ">": return QCriteriaOperator.GREATER_THAN; @@ -138,6 +138,7 @@ class FilterUtils return ("is"); } case QCriteriaOperator.NOT_EQUALS: + case QCriteriaOperator.NOT_EQUALS_OR_IS_NULL: if (field.possibleValueSourceName) { From 813067be257f3f2344cdde0bc3ea77d5ad8b1693 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 18 May 2023 15:48:39 -0500 Subject: [PATCH 15/59] Add env settings to branding, show banner in left bar --- package.json | 2 +- src/App.tsx | 1 + src/qqq/components/horseshoe/sidenav/SideNav.tsx | 10 +++++++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index bda2769..bb1b9aa 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.63", + "@kingsrook/qqq-frontend-core": "1.0.64", "@mui/icons-material": "5.4.1", "@mui/material": "5.11.1", "@mui/styles": "5.11.1", diff --git a/src/App.tsx b/src/App.tsx index 98321cd..8b5c118 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -567,6 +567,7 @@ export default function App() icon={branding.icon} logo={branding.logo} appName={branding.appName} + branding={branding} routes={sideNavRoutes} onMouseEnter={handleOnMouseEnter} onMouseLeave={handleOnMouseLeave} diff --git a/src/qqq/components/horseshoe/sidenav/SideNav.tsx b/src/qqq/components/horseshoe/sidenav/SideNav.tsx index 30d2251..8ac14e9 100644 --- a/src/qqq/components/horseshoe/sidenav/SideNav.tsx +++ b/src/qqq/components/horseshoe/sidenav/SideNav.tsx @@ -19,6 +19,7 @@ * along with this program. If not, see . */ +import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData"; import Box from "@mui/material/Box"; import Divider from "@mui/material/Divider"; import Icon from "@mui/material/Icon"; @@ -42,6 +43,7 @@ interface Props icon?: string; logo?: string; appName?: string; + branding?: QBrandingMetaData; routes: { [key: string]: | ReactNode @@ -64,7 +66,7 @@ interface Props [key: string]: any; } -function Sidenav({color, icon, logo, appName, routes, ...rest}: Props): JSX.Element +function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props): JSX.Element { const [openCollapse, setOpenCollapse] = useState(false); const [openNestedCollapse, setOpenNestedCollapse] = useState(false); @@ -328,6 +330,12 @@ function Sidenav({color, icon, logo, appName, routes, ...rest}: Props): JSX.Elem } + { + branding && branding.environmentBannerText && + + {branding.environmentBannerText} + + } Date: Thu, 18 May 2023 15:51:46 -0500 Subject: [PATCH 16/59] updated to show error if widgets dont load correctly, tried to make 'big icons' more specific and an 'opt in' --- .../components/widgets/DashboardWidgets.tsx | 32 +++++++++++++++---- src/qqq/components/widgets/Widget.tsx | 32 +++++++++++-------- src/qqq/pages/apps/Home.tsx | 5 +-- src/qqq/styles/qqq-override-styles.css | 9 +++--- 4 files changed, 51 insertions(+), 27 deletions(-) diff --git a/src/qqq/components/widgets/DashboardWidgets.tsx b/src/qqq/components/widgets/DashboardWidgets.tsx index 9929d40..b870b85 100644 --- a/src/qqq/components/widgets/DashboardWidgets.tsx +++ b/src/qqq/components/widgets/DashboardWidgets.tsx @@ -97,9 +97,19 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit widgetData[i] = {}; (async () => { - widgetData[i] = await qController.widget(widgetMetaData.name, urlParams); - setWidgetData(widgetData); - setWidgetCounter(widgetCounter + 1); + try + { + widgetData[i] = await qController.widget(widgetMetaData.name, urlParams); + setWidgetData(widgetData); + setWidgetCounter(widgetCounter + 1); + widgetData[i]["errorLoading"] = false; + } + catch(e) + { + console.error(e); + widgetData[i]["errorLoading"] = true; + } + forceUpdate(); })(); } @@ -112,9 +122,19 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit const urlParams = getQueryParams(widgetMetaDataList[index], data); setCurrentUrlParams(urlParams); - widgetData[index] = await qController.widget(widgetMetaDataList[index].name, urlParams); - setWidgetCounter(widgetCounter + 1); - setWidgetData(widgetData); + try + { + widgetData[index] = await qController.widget(widgetMetaDataList[index].name, urlParams); + setWidgetCounter(widgetCounter + 1); + setWidgetData(widgetData); + widgetData[index]["errorLoading"] = false; + } + catch(e) + { + console.error(e); + widgetData[index]["errorLoading"] = true; + } + forceUpdate(); })(); }; diff --git a/src/qqq/components/widgets/Widget.tsx b/src/qqq/components/widgets/Widget.tsx index 21f0582..5e5a4d3 100644 --- a/src/qqq/components/widgets/Widget.tsx +++ b/src/qqq/components/widgets/Widget.tsx @@ -43,6 +43,7 @@ export interface WidgetData }[][]; dropdownNeedsSelectedText?: string; hasPermission?: boolean; + errorLoading?: boolean; } @@ -297,6 +298,7 @@ function Widget(props: React.PropsWithChildren): JSX.Element } const hasPermission = props.widgetData?.hasPermission === undefined || props.widgetData?.hasPermission === true; + const errorLoading = props.widgetData?.errorLoading !== undefined && props.widgetData?.errorLoading === true; const widgetContent = @@ -360,11 +362,6 @@ function Widget(props: React.PropsWithChildren): JSX.Element ) ) } - {/* - - */} { hasPermission && ( props.labelAdditionalComponentsLeft.map((component, i) => @@ -386,22 +383,29 @@ function Widget(props: React.PropsWithChildren): JSX.Element { - hasPermission && props.widgetData?.dropdownNeedsSelectedText ? ( - - - {props.widgetData?.dropdownNeedsSelectedText} - + errorLoading ? ( + + error + An error occurred loading widget content. ) : ( - hasPermission ? ( - props.children + hasPermission && props.widgetData?.dropdownNeedsSelectedText ? ( + + + {props.widgetData?.dropdownNeedsSelectedText} + + ) : ( - You do not have permission to view this data. + hasPermission ? ( + props.children + ) : ( + You do not have permission to view this data. + ) ) ) } { - props?.footerHTML && ( + ! errorLoading && props?.footerHTML && ( {parse(props.footerHTML)} ) } diff --git a/src/qqq/pages/apps/Home.tsx b/src/qqq/pages/apps/Home.tsx index db4f326..58639d6 100644 --- a/src/qqq/pages/apps/Home.tsx +++ b/src/qqq/pages/apps/Home.tsx @@ -26,7 +26,8 @@ import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/ import {QReportMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QReportMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; -import {Box, Icon, Typography} from "@mui/material"; +import {Icon, Typography} from "@mui/material"; +import Box from "@mui/material/Box"; import Card from "@mui/material/Card"; import Divider from "@mui/material/Divider"; import Grid from "@mui/material/Grid"; @@ -316,7 +317,7 @@ function AppHome({app}: Props): JSX.Element {hasTablePermission(tableName) ? - + .MuiBox-root > .material-icons-round, -.MuiBox-root > .MuiBox-root > .material-icons-round +.big-icon .material-icons-round { font-size: 2rem !important; } .dashboard-schedule-icon { - font-size: 1rem !important; + font-size: 1.1rem !important; position: relative; - top: -13px; - margin-right: 3px; + top: -5px; + margin-right: 8px; } From 65652f04f0aac2df6153e5cddd93d01edfde67ae Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 18 May 2023 15:52:40 -0500 Subject: [PATCH 17/59] Add export to table widgets; add reload to most widgets; refactor widget label components (render in class!) --- package.json | 1 + .../components/widgets/DashboardWidgets.tsx | 36 +-- src/qqq/components/widgets/Widget.tsx | 214 +++++++++++++----- .../widgets/misc/RecordGridWidget.tsx | 1 + .../components/widgets/tables/TableWidget.tsx | 157 +++++++++++++ 5 files changed, 330 insertions(+), 79 deletions(-) create mode 100644 src/qqq/components/widgets/tables/TableWidget.tsx diff --git a/package.json b/package.json index bb1b9aa..49f6f83 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "form-data": "4.0.0", "formik": "2.2.9", "html-react-parser": "1.4.8", + "html-to-text": "^9.0.5", "http-proxy-middleware": "2.0.6", "rapidoc": "9.3.4", "react": "17.0.2", diff --git a/src/qqq/components/widgets/DashboardWidgets.tsx b/src/qqq/components/widgets/DashboardWidgets.tsx index 9929d40..005acf4 100644 --- a/src/qqq/components/widgets/DashboardWidgets.tsx +++ b/src/qqq/components/widgets/DashboardWidgets.tsx @@ -44,10 +44,10 @@ import USMapWidget from "qqq/components/widgets/misc/USMapWidget"; import ParentWidget from "qqq/components/widgets/ParentWidget"; import MultiStatisticsCard from "qqq/components/widgets/statistics/MultiStatisticsCard"; import StatisticsCard from "qqq/components/widgets/statistics/StatisticsCard"; -import TableCard from "qqq/components/widgets/tables/TableCard"; import Widget, {WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT} from "qqq/components/widgets/Widget"; import ProcessRun from "qqq/pages/processes/ProcessRun"; import Client from "qqq/utils/qqq/Client"; +import TableWidget from "./tables/TableWidget"; const qController = Client.getInstance(); @@ -221,20 +221,12 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit } { widgetMetaData.type === "table" && ( - reloadWidget(i, data)} - footerHTML={widgetData[i]?.footerHTML} isChild={areChildren} - > - - + /> ) } { @@ -254,7 +246,9 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit reloadWidget(i, data)}> + reloadWidgetCallback={(data) => reloadWidget(i, data)} + showReloadControl={false} + >
@@ -265,7 +259,9 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit widgetMetaData.type === "stepper" && ( + widgetData={widgetData[i]} + reloadWidgetCallback={(data) => reloadWidget(i, data)} + > @@ -276,7 +272,11 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit } { widgetMetaData.type === "html" && ( - + reloadWidget(i, data)} + widgetData={widgetData[i]} + > { @@ -306,8 +306,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit widgetMetaData={widgetMetaData} widgetData={widgetData[i]} isChild={areChildren} - - // reloadWidgetCallback={(data) => reloadWidget(i, data)} + reloadWidgetCallback={(data) => reloadWidget(i, data)} > reloadWidget(i, data)} isChild={areChildren} >
@@ -379,7 +379,9 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit + reloadWidgetCallback={(data) => reloadWidget(i, data)} + isChild={areChildren} + > void; + showReloadControl: boolean; isChild?: boolean; footerHTML?: string; storeDropdownSelections?: boolean; @@ -61,6 +64,7 @@ interface Props Widget.defaultProps = { isChild: false, + showReloadControl: true, widgetMetaData: {}, widgetData: {}, labelAdditionalComponentsLeft: [], @@ -68,9 +72,22 @@ Widget.defaultProps = { }; +interface LabelComponentRenderArgs +{ + navigate: NavigateFunction; + widgetProps: Props; + dropdownData: any[]; + componentIndex: number; + reloadFunction: () => void; +} + + export class LabelComponent { - + render = (args: LabelComponentRenderArgs): JSX.Element => + { + return (
Unsupported component type
) + } } @@ -86,6 +103,15 @@ export class HeaderLink extends LabelComponent this.label = label; this.to = to; } + + render = (args: LabelComponentRenderArgs): JSX.Element => + { + return ( + + {this.to ? {this.label} : null} + + ); + } } @@ -97,6 +123,7 @@ export class AddNewRecordButton extends LabelComponent defaultValues: any; disabledFields: any; + constructor(table: QTableMetaData, defaultValues: any, label: string = "Add new", disabledFields: any = defaultValues) { super(); @@ -105,6 +132,45 @@ export class AddNewRecordButton extends LabelComponent this.defaultValues = defaultValues; this.disabledFields = disabledFields; } + + openEditForm = (navigate: any, table: QTableMetaData, id: any = null, defaultValues: any, disabledFields: any) => + { + navigate(`#/createChild=${table.name}/defaultValues=${JSON.stringify(defaultValues)}/disabledFields=${JSON.stringify(disabledFields)}`) + } + + render = (args: LabelComponentRenderArgs): JSX.Element => + { + return ( + + + + ); + } +} + + +export class ExportDataButton extends LabelComponent +{ + callbackToExport: any; + label: string; + isDisabled: boolean; + + constructor(callbackToExport: any, isDisabled = false, label: string = "Export") + { + super(); + this.callbackToExport = callbackToExport; + this.isDisabled = isDisabled; + this.label = label; + } + + render = (args: LabelComponentRenderArgs): JSX.Element => + { + return ( + + + + ); + } } @@ -121,6 +187,55 @@ export class Dropdown extends LabelComponent this.options = options; this.onChangeCallback = onChangeCallback; } + + render = (args: LabelComponentRenderArgs): JSX.Element => + { + let defaultValue = null; + const dropdownName = args.widgetProps.widgetData.dropdownNameList[args.componentIndex]; + const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${args.widgetProps.widgetMetaData.name}.${dropdownName}`; + if(args.widgetProps.storeDropdownSelections) + { + /////////////////////////////////////////////////////////////////////////////////////// + // see if an existing value is stored in local storage, and if so set it in dropdown // + /////////////////////////////////////////////////////////////////////////////////////// + defaultValue = JSON.parse(localStorage.getItem(localStorageKey)); + args.dropdownData[args.componentIndex] = defaultValue?.id; + } + + return ( + + + + ); + } +} + + +export class ReloadControl extends LabelComponent +{ + callback: () => void; + + constructor(callback: () => void) + { + super(); + this.callback = callback; + } + + render = (args: LabelComponentRenderArgs): JSX.Element => + { + return ( + + + + ); + } } @@ -132,64 +247,11 @@ function Widget(props: React.PropsWithChildren): JSX.Element const navigate = useNavigate(); const [dropdownData, setDropdownData] = useState([]); const [fullScreenWidgetClassName, setFullScreenWidgetClassName] = useState(""); + const [reloading, setReloading] = useState(false); - function openEditForm(table: QTableMetaData, id: any = null, defaultValues: any, disabledFields: any) + function renderComponent(component: LabelComponent, componentIndex: number) { - navigate(`#/createChild=${table.name}/defaultValues=${JSON.stringify(defaultValues)}/disabledFields=${JSON.stringify(disabledFields)}`) - } - - function renderComponent(component: LabelComponent, index: number) - { - if(component instanceof HeaderLink) - { - const link = component as HeaderLink - return ( - - {link.to ? {link.label} : null} - - ); - } - - if (component instanceof AddNewRecordButton) - { - const addNewRecordButton = component as AddNewRecordButton - return ( - - - - ); - } - - if (component instanceof Dropdown) - { - let defaultValue = null; - const dropdownName = props.widgetData.dropdownNameList[index]; - const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${props.widgetMetaData.name}.${dropdownName}`; - if(props.storeDropdownSelections) - { - /////////////////////////////////////////////////////////////////////////////////////// - // see if an existing value is stored in local storage, and if so set it in dropdown // - /////////////////////////////////////////////////////////////////////////////////////// - defaultValue = JSON.parse(localStorage.getItem(localStorageKey)); - dropdownData[index] = defaultValue?.id; - } - - const dropdown = component as Dropdown - return ( - - - - ); - } - - return (
Unsupported component type.
) + return component.render({navigate: navigate, widgetProps: props, dropdownData: dropdownData, componentIndex: componentIndex, reloadFunction: doReload}) } @@ -209,6 +271,27 @@ function Widget(props: React.PropsWithChildren): JSX.Element }); } + const doReload = () => + { + setReloading(true); + reloadWidget(dropdownData); + } + + useEffect(() => + { + setReloading(false); + }, [props.widgetData]); + + const effectiveLabelAdditionalComponentsLeft: LabelComponent[] = []; + if(props.labelAdditionalComponentsLeft) + { + props.labelAdditionalComponentsLeft.map((component) => effectiveLabelAdditionalComponentsLeft.push(component)); + } + + if(props.reloadWidgetCallback && props.widgetData && props.showReloadControl) + { + effectiveLabelAdditionalComponentsLeft.push(new ReloadControl(doReload)) + } function handleDataChange(dropdownLabel: string, changedData: any) { @@ -299,7 +382,7 @@ function Widget(props: React.PropsWithChildren): JSX.Element const hasPermission = props.widgetData?.hasPermission === undefined || props.widgetData?.hasPermission === true; const widgetContent = - + { hasPermission ? @@ -367,7 +450,7 @@ function Widget(props: React.PropsWithChildren): JSX.Element */} { hasPermission && ( - props.labelAdditionalComponentsLeft.map((component, i) => + effectiveLabelAdditionalComponentsLeft.map((component, i) => { return ({renderComponent(component, i)}); }) @@ -385,6 +468,9 @@ function Widget(props: React.PropsWithChildren): JSX.Element } + { + props.widgetMetaData?.isCard && (reloading ? : ) + } { hasPermission && props.widgetData?.dropdownNeedsSelectedText ? ( @@ -407,7 +493,11 @@ function Widget(props: React.PropsWithChildren): JSX.Element } ; - return props.widgetMetaData?.isCard ? {widgetContent} : widgetContent; + return props.widgetMetaData?.isCard + ? + {widgetContent} + + : widgetContent; } export default Widget; diff --git a/src/qqq/components/widgets/misc/RecordGridWidget.tsx b/src/qqq/components/widgets/misc/RecordGridWidget.tsx index 419934b..2daa5fc 100644 --- a/src/qqq/components/widgets/misc/RecordGridWidget.tsx +++ b/src/qqq/components/widgets/misc/RecordGridWidget.tsx @@ -123,6 +123,7 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element return ( diff --git a/src/qqq/components/widgets/tables/TableWidget.tsx b/src/qqq/components/widgets/tables/TableWidget.tsx new file mode 100644 index 0000000..156f4ec --- /dev/null +++ b/src/qqq/components/widgets/tables/TableWidget.tsx @@ -0,0 +1,157 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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 {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; +// @ts-ignore +import {htmlToText} from "html-to-text"; +import React, {useEffect, useState} from "react"; +import TableCard from "qqq/components/widgets/tables/TableCard"; +import Widget, {ExportDataButton, WidgetData} from "qqq/components/widgets/Widget"; +import ValueUtils from "qqq/utils/qqq/ValueUtils"; + +interface Props +{ + widgetMetaData?: QWidgetMetaData; + widgetData?: WidgetData; + reloadWidgetCallback?: (params: string) => void; + isChild?: boolean; +} + +TableWidget.defaultProps = { + foo: null, +}; + +function download(filename: string, text: string) +{ + var element = document.createElement("a"); + element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(text)); + element.setAttribute("download", filename); + + element.style.display = "none"; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); +} + +function TableWidget(props: Props): JSX.Element +{ + const rows = props.widgetData?.rows; + const columns = props.widgetData?.columns; + + const exportCallback = () => + { + if (props.widgetData && rows && columns) + { + console.log(props.widgetData); + + let csv = ""; + for (let j = 0; j < columns.length; j++) + { + if (j > 0) + { + csv += ","; + } + csv += `"${columns[j].header}"`; + } + csv += "\n"; + + for (let i = 0; i < rows.length; i++) + { + for (let j = 0; j < columns.length; j++) + { + if (j > 0) + { + csv += ","; + } + + const cell = rows[i][columns[j].accessor]; + const text = htmlToText(cell, + { + selectors: [ + {selector: "a", format: "inline"}, + {selector: ".MuiIcon-root", format: "skip"}, + {selector: ".button", format: "skip"} + ] + }); + csv += `"${text}"`; + } + csv += "\n"; + } + + console.log(csv); + + const fileName = props.widgetData.label + "-" + ValueUtils.formatDateTimeISO8601(new Date()) + ".csv"; + download(fileName, csv); + } + else + { + alert("Error exporting widget data."); + } + }; + + + const [exportDataButton, setExportDataButton] = useState(new ExportDataButton(() => exportCallback(), true)); + const [isExportDisabled, setIsExportDisabled] = useState(true); + const [componentLeft, setComponentLeft] = useState([exportDataButton]) + + useEffect(() => + { + if (props.widgetData && columns && rows && rows.length > 0) + { + console.log("Setting export disabled false") + setIsExportDisabled(false); + } + else + { + console.log("Setting export disabled true") + setIsExportDisabled(true); + } + }, [props.widgetData]) + + useEffect(() => + { + console.log("Setting new export button with disabled=" + isExportDisabled) + setComponentLeft([new ExportDataButton(() => exportCallback(), isExportDisabled)]); + }, [isExportDisabled]) + + return ( + props.reloadWidgetCallback(data)} + footerHTML={props.widgetData?.footerHTML} + isChild={props.isChild} + labelAdditionalComponentsLeft={componentLeft} + > + + + ); +} + +export default TableWidget; From 96bc57f5f9a18f749ae4a1c64897ed4bef88a023 Mon Sep 17 00:00:00 2001 From: Tim Chamberlain Date: Thu, 18 May 2023 19:36:16 -0500 Subject: [PATCH 18/59] fixed layout, null checks --- .../components/widgets/DashboardWidgets.tsx | 24 +++++++++++++++---- src/qqq/components/widgets/Widget.tsx | 2 +- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/qqq/components/widgets/DashboardWidgets.tsx b/src/qqq/components/widgets/DashboardWidgets.tsx index b870b85..831e735 100644 --- a/src/qqq/components/widgets/DashboardWidgets.tsx +++ b/src/qqq/components/widgets/DashboardWidgets.tsx @@ -102,12 +102,18 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit widgetData[i] = await qController.widget(widgetMetaData.name, urlParams); setWidgetData(widgetData); setWidgetCounter(widgetCounter + 1); - widgetData[i]["errorLoading"] = false; + if(widgetData[i]) + { + widgetData[i]["errorLoading"] = false; + } } catch(e) { console.error(e); - widgetData[i]["errorLoading"] = true; + if(widgetData[i]) + { + widgetData[i]["errorLoading"] = true; + } } forceUpdate(); @@ -121,23 +127,31 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit { const urlParams = getQueryParams(widgetMetaDataList[index], data); setCurrentUrlParams(urlParams); + widgetData[index] = {}; try { widgetData[index] = await qController.widget(widgetMetaDataList[index].name, urlParams); setWidgetCounter(widgetCounter + 1); setWidgetData(widgetData); - widgetData[index]["errorLoading"] = false; + + if (widgetData[index]) + { + widgetData[index]["errorLoading"] = false; + } } catch(e) { console.error(e); - widgetData[index]["errorLoading"] = true; + if (widgetData[index]) + { + widgetData[index]["errorLoading"] = true; + } } forceUpdate(); })(); - }; + } function getQueryParams(widgetMetaData: QWidgetMetaData, extraParams: string): string { diff --git a/src/qqq/components/widgets/Widget.tsx b/src/qqq/components/widgets/Widget.tsx index 5e5a4d3..447cfc9 100644 --- a/src/qqq/components/widgets/Widget.tsx +++ b/src/qqq/components/widgets/Widget.tsx @@ -384,7 +384,7 @@ function Widget(props: React.PropsWithChildren): JSX.Element { errorLoading ? ( - + error An error occurred loading widget content. From 75268b6b3c4843aa368e258a982089786465a8a7 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 19 May 2023 09:14:42 -0500 Subject: [PATCH 19/59] Some final adjustments for widget reload & export --- package.json | 2 +- src/qqq/components/widgets/Widget.tsx | 41 +++++++++++++---- .../components/widgets/tables/TableWidget.tsx | 44 +++++++------------ src/qqq/utils/qqq/ValueUtils.tsx | 8 ++++ 4 files changed, 57 insertions(+), 38 deletions(-) diff --git a/package.json b/package.json index 49f6f83..1797de4 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.64", + "@kingsrook/qqq-frontend-core": "1.0.65", "@mui/icons-material": "5.4.1", "@mui/material": "5.11.1", "@mui/styles": "5.11.1", diff --git a/src/qqq/components/widgets/Widget.tsx b/src/qqq/components/widgets/Widget.tsx index a44ed1a..cd23c2e 100644 --- a/src/qqq/components/widgets/Widget.tsx +++ b/src/qqq/components/widgets/Widget.tsx @@ -26,10 +26,12 @@ import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; import Icon from "@mui/material/Icon"; import LinearProgress from "@mui/material/LinearProgress"; +import Tooltip from "@mui/material/Tooltip/Tooltip"; import Typography from "@mui/material/Typography"; import parse from "html-react-parser"; import React, {useEffect, useState} from "react"; import {Link, useNavigate, NavigateFunction} from "react-router-dom"; +import {bool} from "yup"; import colors from "qqq/components/legacy/colors"; import DropdownMenu, {DropdownOption} from "qqq/components/widgets/components/DropdownMenu"; @@ -166,8 +168,8 @@ export class ExportDataButton extends LabelComponent render = (args: LabelComponentRenderArgs): JSX.Element => { return ( - - + + ); } @@ -231,8 +233,8 @@ export class ReloadControl extends LabelComponent render = (args: LabelComponentRenderArgs): JSX.Element => { return ( - - + + ); } @@ -283,16 +285,15 @@ function Widget(props: React.PropsWithChildren): JSX.Element }, [props.widgetData]); const effectiveLabelAdditionalComponentsLeft: LabelComponent[] = []; + if(props.reloadWidgetCallback && props.widgetData && props.showReloadControl && props.widgetMetaData.showReloadButton) + { + effectiveLabelAdditionalComponentsLeft.push(new ReloadControl(doReload)) + } if(props.labelAdditionalComponentsLeft) { props.labelAdditionalComponentsLeft.map((component) => effectiveLabelAdditionalComponentsLeft.push(component)); } - if(props.reloadWidgetCallback && props.widgetData && props.showReloadControl) - { - effectiveLabelAdditionalComponentsLeft.push(new ReloadControl(doReload)) - } - function handleDataChange(dropdownLabel: string, changedData: any) { if(dropdownData) @@ -380,8 +381,29 @@ function Widget(props: React.PropsWithChildren): JSX.Element } const hasPermission = props.widgetData?.hasPermission === undefined || props.widgetData?.hasPermission === true; + + const isSet = (v: any): boolean => + { + return(v !== null && v !== undefined); + } + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // to avoid taking up the space of the Box with the label and icon and label-components (since it has a height), only output that box if we need any of the components // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + let needLabelBox = false; + if(hasPermission) + { + needLabelBox ||= (effectiveLabelAdditionalComponentsLeft && effectiveLabelAdditionalComponentsLeft.length > 0); + needLabelBox ||= (effectiveLabelAdditionalComponentsRight && effectiveLabelAdditionalComponentsRight.length > 0); + needLabelBox ||= isSet(props.widgetMetaData?.icon); + needLabelBox ||= isSet(props.widgetData?.label); + needLabelBox ||= isSet(props.widgetMetaData?.label); + } + const widgetContent = + { + needLabelBox && { @@ -468,6 +490,7 @@ function Widget(props: React.PropsWithChildren): JSX.Element } + } { props.widgetMetaData?.isCard && (reloading ? : ) } diff --git a/src/qqq/components/widgets/tables/TableWidget.tsx b/src/qqq/components/widgets/tables/TableWidget.tsx index 156f4ec..2aad023 100644 --- a/src/qqq/components/widgets/tables/TableWidget.tsx +++ b/src/qqq/components/widgets/tables/TableWidget.tsx @@ -56,9 +56,22 @@ function download(filename: string, text: string) function TableWidget(props: Props): JSX.Element { + const [isExportDisabled, setIsExportDisabled] = useState(true); + const rows = props.widgetData?.rows; const columns = props.widgetData?.columns; + useEffect(() => + { + let isExportDisabled = true; + if (props.widgetData && columns && rows && rows.length > 0) + { + isExportDisabled = false; + } + setIsExportDisabled(isExportDisabled); + + }, [props.widgetMetaData, props.widgetData]); + const exportCallback = () => { if (props.widgetData && rows && columns) @@ -101,40 +114,15 @@ function TableWidget(props: Props): JSX.Element console.log(csv); - const fileName = props.widgetData.label + "-" + ValueUtils.formatDateTimeISO8601(new Date()) + ".csv"; + const fileName = (props.widgetData.label ?? props.widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv"; download(fileName, csv); } else { - alert("Error exporting widget data."); + alert("There is no data available to export."); } }; - - const [exportDataButton, setExportDataButton] = useState(new ExportDataButton(() => exportCallback(), true)); - const [isExportDisabled, setIsExportDisabled] = useState(true); - const [componentLeft, setComponentLeft] = useState([exportDataButton]) - - useEffect(() => - { - if (props.widgetData && columns && rows && rows.length > 0) - { - console.log("Setting export disabled false") - setIsExportDisabled(false); - } - else - { - console.log("Setting export disabled true") - setIsExportDisabled(true); - } - }, [props.widgetData]) - - useEffect(() => - { - console.log("Setting new export button with disabled=" + isExportDisabled) - setComponentLeft([new ExportDataButton(() => exportCallback(), isExportDisabled)]); - }, [isExportDisabled]) - return ( props.reloadWidgetCallback(data)} footerHTML={props.widgetData?.footerHTML} isChild={props.isChild} - labelAdditionalComponentsLeft={componentLeft} + labelAdditionalComponentsLeft={props.widgetMetaData?.showExportButton ? [new ExportDataButton(() => exportCallback(), isExportDisabled)] : []} > (value < 10 ? `0${value}` : `${value}`); + const d = new Date(); + const dateString = `${d.getFullYear()}-${zp(d.getMonth() + 1)}-${zp(d.getDate())} ${zp(d.getHours())}${zp(d.getMinutes())}`; + return (date); + } + public static getFullWeekday(date: Date) { if (!(date instanceof Date)) From 1011271b5e74b51d9655ba38dc0a25bfd1c42b06 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 19 May 2023 11:17:16 -0500 Subject: [PATCH 20/59] Add export to ColumnStats; fix formatDateTimeForFileName --- src/qqq/pages/records/query/ColumnStats.tsx | 46 +++++++++++++++++++-- src/qqq/utils/qqq/ValueUtils.tsx | 2 +- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/qqq/pages/records/query/ColumnStats.tsx b/src/qqq/pages/records/query/ColumnStats.tsx index e84dab0..6e48eaa 100644 --- a/src/qqq/pages/records/query/ColumnStats.tsx +++ b/src/qqq/pages/records/query/ColumnStats.tsx @@ -54,6 +54,21 @@ ColumnStats.defaultProps = { const qController = Client.getInstance(); +// todo - merge w/ same function in TableWidget +function download(filename: string, text: string) +{ + var element = document.createElement("a"); + element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(text)); + element.setAttribute("download", filename); + + element.style.display = "none"; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); +} + function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Props): JSX.Element { const [statusString, setStatusString] = useState("Calculating statistics..."); @@ -97,6 +112,8 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro } else { + // todo - job running! + const result = processResult as QJobComplete; const statFieldObjects = result.values.statsFields; @@ -174,6 +191,24 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro setStatusString("Refreshing...") } + const doExport = () => + { + let csv = `"${fieldMetaData.label}","Count"\n`; + for (let i = 0; i < valueCounts.length; i++) + { + let fieldValue = valueCounts[i].displayValues.get(fieldMetaData.name); + if(fieldValue === undefined) + { + fieldValue = ""; + } + + csv += `"${fieldValue}",${valueCounts[i].values.get("count")}\n`; + } + + const fileName = tableMetaData.label + " - " + fieldMetaData.label + " Column Stats " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv"; + download(fileName, csv); + } + function Loading() { return ( @@ -200,9 +235,14 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro {statusString ?? <> } - + + + + diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index c6de9b1..ccb68d0 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -278,7 +278,7 @@ class ValueUtils const zp = (value: number): string => (value < 10 ? `0${value}` : `${value}`); const d = new Date(); const dateString = `${d.getFullYear()}-${zp(d.getMonth() + 1)}-${zp(d.getDate())} ${zp(d.getHours())}${zp(d.getMinutes())}`; - return (date); + return (dateString); } public static getFullWeekday(date: Date) From 3a7cadf5c20d1bf44e9357c4152d44b78b316341 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 19 May 2023 11:51:20 -0500 Subject: [PATCH 21/59] Clean csv values; Update qfc - for audit count fix --- package.json | 2 +- src/qqq/components/widgets/tables/TableWidget.tsx | 2 +- src/qqq/pages/records/query/ColumnStats.tsx | 12 ++++-------- src/qqq/utils/qqq/ValueUtils.tsx | 13 +++++++++++++ 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 1797de4..65b58a2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.65", + "@kingsrook/qqq-frontend-core": "1.0.66", "@mui/icons-material": "5.4.1", "@mui/material": "5.11.1", "@mui/styles": "5.11.1", diff --git a/src/qqq/components/widgets/tables/TableWidget.tsx b/src/qqq/components/widgets/tables/TableWidget.tsx index 2aad023..0f66067 100644 --- a/src/qqq/components/widgets/tables/TableWidget.tsx +++ b/src/qqq/components/widgets/tables/TableWidget.tsx @@ -107,7 +107,7 @@ function TableWidget(props: Props): JSX.Element {selector: ".button", format: "skip"} ] }); - csv += `"${text}"`; + csv += `"${ValueUtils.cleanForCsv(text)}"`; } csv += "\n"; } diff --git a/src/qqq/pages/records/query/ColumnStats.tsx b/src/qqq/pages/records/query/ColumnStats.tsx index 6e48eaa..65d6077 100644 --- a/src/qqq/pages/records/query/ColumnStats.tsx +++ b/src/qqq/pages/records/query/ColumnStats.tsx @@ -193,16 +193,12 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro const doExport = () => { - let csv = `"${fieldMetaData.label}","Count"\n`; + let csv = `"${ValueUtils.cleanForCsv(fieldMetaData.label)}","Count"\n`; for (let i = 0; i < valueCounts.length; i++) { - let fieldValue = valueCounts[i].displayValues.get(fieldMetaData.name); - if(fieldValue === undefined) - { - fieldValue = ""; - } - - csv += `"${fieldValue}",${valueCounts[i].values.get("count")}\n`; + const fieldValue = valueCounts[i].displayValues.get(fieldMetaData.name); + const count = valueCounts[i].values.get("count"); + csv += `"${ValueUtils.cleanForCsv(fieldValue)}",${count}\n`; } const fileName = tableMetaData.label + " - " + fieldMetaData.label + " Column Stats " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv"; diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index ccb68d0..d8c1332 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -418,6 +418,19 @@ class ValueUtils return toPush; } + + /******************************************************************************* + ** for building CSV in frontends, cleanse null & undefined, and escape "'s + *******************************************************************************/ + public static cleanForCsv(param: any): string + { + if(param === undefined || param === null) + { + return (""); + } + + return (String(param).replaceAll(/"/g, "\"\"")); + } } //////////////////////////////////////////////////////////////////////////////////////////////// From f7ff4cf2fc16e56b0013e34e3ac449c9aab57f4d Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 19 May 2023 14:54:57 -0500 Subject: [PATCH 22/59] Add export to RecordGridWidget --- src/qqq/components/widgets/Widget.tsx | 8 +-- .../widgets/misc/RecordGridWidget.tsx | 62 ++++++++++++++++++- .../components/widgets/tables/TableWidget.tsx | 18 +----- src/qqq/pages/records/query/ColumnStats.tsx | 19 +----- src/qqq/utils/HtmlUtils.ts | 62 +++++++++++++++++++ 5 files changed, 131 insertions(+), 38 deletions(-) create mode 100644 src/qqq/utils/HtmlUtils.ts diff --git a/src/qqq/components/widgets/Widget.tsx b/src/qqq/components/widgets/Widget.tsx index 32c320e..6ad8a89 100644 --- a/src/qqq/components/widgets/Widget.tsx +++ b/src/qqq/components/widgets/Widget.tsx @@ -155,22 +155,22 @@ export class AddNewRecordButton extends LabelComponent export class ExportDataButton extends LabelComponent { callbackToExport: any; - label: string; + tooltipTitle: string; isDisabled: boolean; - constructor(callbackToExport: any, isDisabled = false, label: string = "Export") + constructor(callbackToExport: any, isDisabled = false, tooltipTitle: string = "Export") { super(); this.callbackToExport = callbackToExport; this.isDisabled = isDisabled; - this.label = label; + this.tooltipTitle = tooltipTitle; } render = (args: LabelComponentRenderArgs): JSX.Element => { return ( - + ); } diff --git a/src/qqq/components/widgets/misc/RecordGridWidget.tsx b/src/qqq/components/widgets/misc/RecordGridWidget.tsx index 2daa5fc..6f02ba8 100644 --- a/src/qqq/components/widgets/misc/RecordGridWidget.tsx +++ b/src/qqq/components/widgets/misc/RecordGridWidget.tsx @@ -25,9 +25,11 @@ import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import {DataGridPro, GridCallbackDetails, GridRowParams, MuiEvent} from "@mui/x-data-grid-pro"; import React, {useEffect, useState} from "react"; import {useNavigate} from "react-router-dom"; -import Widget, {AddNewRecordButton, HeaderLink, LabelComponent} from "qqq/components/widgets/Widget"; +import Widget, {AddNewRecordButton, ExportDataButton, HeaderLink, LabelComponent} from "qqq/components/widgets/Widget"; import DataGridUtils from "qqq/utils/DataGridUtils"; +import HtmlUtils from "qqq/utils/HtmlUtils"; import Client from "qqq/utils/qqq/Client"; +import ValueUtils from "qqq/utils/qqq/ValueUtils"; interface Props { @@ -42,7 +44,9 @@ const qController = Client.getInstance(); function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element { const [rows, setRows] = useState([]); + const [records, setRecords] = useState([] as QRecord[]) const [columns, setColumns] = useState([]); + const [allColumns, setAllColumns] = useState([]) const navigate = useNavigate(); useEffect(() => @@ -68,6 +72,11 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element const childTablePath = data.tablePath ? data.tablePath + (data.tablePath.endsWith("/") ? "" : "/") : data.tablePath; const columns = DataGridUtils.setupGridColumns(tableMetaData, childTablePath, null, "bySection"); + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + // capture all-columns to use for the export (before we might splice some away from the on-screen display) // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + setAllColumns(JSON.parse(JSON.stringify(columns))); + //////////////////////////////////////////////////////////////// // do not not show the foreign-key column of the parent table // //////////////////////////////////////////////////////////////// @@ -84,16 +93,67 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element } setRows(rows); + setRecords(records) setColumns(columns); } }, [data]); + const exportCallback = () => + { + let csv = ""; + for (let i = 0; i < allColumns.length; i++) + { + csv += `${i > 0 ? "," : ""}"${ValueUtils.cleanForCsv(allColumns[i].headerName)}"` + } + csv += "\n"; + + for (let i = 0; i < records.length; i++) + { + for (let j = 0; j < allColumns.length; j++) + { + const value = records[i].displayValues.get(allColumns[j].field) ?? records[i].values.get(allColumns[j].field) + csv += `${j > 0 ? "," : ""}"${ValueUtils.cleanForCsv(value)}"` + } + csv += "\n"; + } + + const fileName = (data?.label ?? widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv"; + HtmlUtils.download(fileName, csv); + } + + /////////////////// + // view all link // + /////////////////// const labelAdditionalComponentsLeft: LabelComponent[] = [] if(data && data.viewAllLink) { labelAdditionalComponentsLeft.push(new HeaderLink("View All", data.viewAllLink)); } + /////////////////// + // export button // + /////////////////// + let isExportDisabled = true; + let tooltipTitle = "Export"; + if (data && data.childTableMetaData && data.queryOutput && data.queryOutput.records && data.queryOutput.records.length > 0) + { + isExportDisabled = false; + + if(data.totalRows && data.queryOutput.records.length < data.totalRows) + { + tooltipTitle = "Export these " + data.queryOutput.records.length + " records." + if(data.viewAllLink) + { + tooltipTitle += "\nClick View All to export all records."; + } + } + } + + labelAdditionalComponentsLeft.push(new ExportDataButton(() => exportCallback(), isExportDisabled, tooltipTitle)) + + //////////////////// + // add new button // + //////////////////// const labelAdditionalComponentsRight: LabelComponent[] = [] if(data && data.canAddChildRecord) { diff --git a/src/qqq/components/widgets/tables/TableWidget.tsx b/src/qqq/components/widgets/tables/TableWidget.tsx index 0f66067..a9e98c4 100644 --- a/src/qqq/components/widgets/tables/TableWidget.tsx +++ b/src/qqq/components/widgets/tables/TableWidget.tsx @@ -26,6 +26,7 @@ import {htmlToText} from "html-to-text"; import React, {useEffect, useState} from "react"; import TableCard from "qqq/components/widgets/tables/TableCard"; import Widget, {ExportDataButton, WidgetData} from "qqq/components/widgets/Widget"; +import HtmlUtils from "qqq/utils/HtmlUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; interface Props @@ -37,23 +38,8 @@ interface Props } TableWidget.defaultProps = { - foo: null, }; -function download(filename: string, text: string) -{ - var element = document.createElement("a"); - element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(text)); - element.setAttribute("download", filename); - - element.style.display = "none"; - document.body.appendChild(element); - - element.click(); - - document.body.removeChild(element); -} - function TableWidget(props: Props): JSX.Element { const [isExportDisabled, setIsExportDisabled] = useState(true); @@ -115,7 +101,7 @@ function TableWidget(props: Props): JSX.Element console.log(csv); const fileName = (props.widgetData.label ?? props.widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv"; - download(fileName, csv); + HtmlUtils.download(fileName, csv); } else { diff --git a/src/qqq/pages/records/query/ColumnStats.tsx b/src/qqq/pages/records/query/ColumnStats.tsx index 65d6077..63743a2 100644 --- a/src/qqq/pages/records/query/ColumnStats.tsx +++ b/src/qqq/pages/records/query/ColumnStats.tsx @@ -27,7 +27,6 @@ import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJo import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; -import {TablePagination} from "@mui/material"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Grid from "@mui/material/Grid"; @@ -38,6 +37,7 @@ import {DataGridPro, GridSortModel} from "@mui/x-data-grid-pro"; import FormData from "form-data"; import React, {useEffect, useState} from "react"; import DataGridUtils from "qqq/utils/DataGridUtils"; +import HtmlUtils from "qqq/utils/HtmlUtils"; import Client from "qqq/utils/qqq/Client"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; @@ -54,21 +54,6 @@ ColumnStats.defaultProps = { const qController = Client.getInstance(); -// todo - merge w/ same function in TableWidget -function download(filename: string, text: string) -{ - var element = document.createElement("a"); - element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(text)); - element.setAttribute("download", filename); - - element.style.display = "none"; - document.body.appendChild(element); - - element.click(); - - document.body.removeChild(element); -} - function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Props): JSX.Element { const [statusString, setStatusString] = useState("Calculating statistics..."); @@ -202,7 +187,7 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro } const fileName = tableMetaData.label + " - " + fieldMetaData.label + " Column Stats " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv"; - download(fileName, csv); + HtmlUtils.download(fileName, csv); } function Loading() diff --git a/src/qqq/utils/HtmlUtils.ts b/src/qqq/utils/HtmlUtils.ts new file mode 100644 index 0000000..5197867 --- /dev/null +++ b/src/qqq/utils/HtmlUtils.ts @@ -0,0 +1,62 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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 . + */ + +/******************************************************************************* + ** Utility functions for basic html/webpage/browser things. + *******************************************************************************/ +export default class HtmlUtils +{ + + /******************************************************************************* + ** Since our pages are set (w/ style on the HTML element) to smooth scroll, + ** if you ever want to do an "auto" scroll (e.g., instant, not smooth), you can + ** call this method, which will remove that style, and then put it back. + *******************************************************************************/ + static autoScroll = (top: number, left: number = 0) => + { + let htmlElement = document.querySelector("html"); + const initialScrollBehavior = htmlElement.style.scrollBehavior; + htmlElement.style.scrollBehavior = "auto"; + setTimeout(() => + { + window.scrollTo({top: top, left: left, behavior: "auto"}); + htmlElement.style.scrollBehavior = initialScrollBehavior; + }); + }; + + /******************************************************************************* + ** Download a client-side generated file (e.g., csv). + *******************************************************************************/ + static download = (filename: string, text: string) => + { + var element = document.createElement("a"); + element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(text)); + element.setAttribute("download", filename); + + element.style.display = "none"; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); + }; + +} \ No newline at end of file From 3d86bbfb7109e7bcd4a564cd4c30187e99f37958 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 22 May 2023 08:43:21 -0500 Subject: [PATCH 23/59] Custom columns panel - for showing join tables hierarchically --- .../components/query/CustomColumnsPanel.tsx | 399 ++++++++++++++++++ src/qqq/pages/records/query/RecordQuery.tsx | 122 ++---- src/qqq/styles/qqq-override-styles.css | 8 + 3 files changed, 431 insertions(+), 98 deletions(-) create mode 100644 src/qqq/components/query/CustomColumnsPanel.tsx diff --git a/src/qqq/components/query/CustomColumnsPanel.tsx b/src/qqq/components/query/CustomColumnsPanel.tsx new file mode 100644 index 0000000..b398dd1 --- /dev/null +++ b/src/qqq/components/query/CustomColumnsPanel.tsx @@ -0,0 +1,399 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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 {Box, FormControlLabel, FormGroup} from "@mui/material"; +import Button from "@mui/material/Button"; +import Icon from "@mui/material/Icon"; +import IconButton from "@mui/material/IconButton"; +import Stack from "@mui/material/Stack"; +import Switch from "@mui/material/Switch"; +import TextField from "@mui/material/TextField"; +import {GridColDef, GridSlotsComponentsProps, useGridApiContext, useGridSelector} from "@mui/x-data-grid-pro"; +import {GridColumnsPanelProps} from "@mui/x-data-grid/components/panel/GridColumnsPanel"; +import {gridColumnDefinitionsSelector, gridColumnVisibilityModelSelector} from "@mui/x-data-grid/hooks/features/columns/gridColumnsSelector"; +import React, {createRef, forwardRef, useEffect, useReducer, useState} from "react"; + +declare module "@mui/x-data-grid" +{ + interface ColumnsPanelPropsOverrides + { + tableMetaData: QTableMetaData; + initialOpenedGroups: { [name: string]: boolean }; + openGroupsChanger: (openedGroups: { [name: string]: boolean }) => void; + initialFilterText: string; + filterTextChanger: (filterText: string) => void; + } +} + +export const CustomColumnsPanel = forwardRef( + function MyCustomColumnsPanel(props: GridSlotsComponentsProps["columnsPanel"], ref) + { + const apiRef = useGridApiContext(); + const columns = useGridSelector(apiRef, gridColumnDefinitionsSelector); + const columnVisibilityModel = useGridSelector(apiRef, gridColumnVisibilityModelSelector); + const [, forceUpdate] = useReducer((x) => x + 1, 0); + const someRef = createRef(); + + const [openGroups, setOpenGroups] = useState(props.initialOpenedGroups || {}); + const openGroupsBecauseOfFilter = {} as { [name: string]: boolean }; + const [lastScrollTop, setLastScrollTop] = useState(0); + const [filterText, setFilterText] = useState(props.initialFilterText); + + ///////////////////////////////////////////////////////////////////// + // set up the list of tables - e.g., main table plus exposed joins // + ///////////////////////////////////////////////////////////////////// + const tables: QTableMetaData[] = []; + tables.push(props.tableMetaData); + + console.log(`Open groups: ${JSON.stringify(openGroups)}`); + + if (props.tableMetaData.exposedJoins) + { + for (let i = 0; i < props.tableMetaData.exposedJoins.length; i++) + { + tables.push(props.tableMetaData.exposedJoins[i].joinTable); + } + } + + const isCheckboxColumn = (column: GridColDef): boolean => + { + return (column.headerName == "Checkbox selection"); + }; + + const doesColumnMatchFilterText = (column: GridColDef): boolean => + { + if (isCheckboxColumn(column)) + { + ////////////////////////////////////////// + // let's never show the checkbox column // + ////////////////////////////////////////// + return (false); + } + + if (filterText == "") + { + return (true); + } + + const columnLabelMinusTable = column.headerName.replace(/.*: /, ""); + if (columnLabelMinusTable.toLowerCase().startsWith(filterText.toLowerCase())) + { + return (true); + } + + try + { + //////////////////////////////////////////////////////////// + // try to match word-boundary followed by the filter text // + // e.g., "name" would match "First Name" or "Last Name" // + //////////////////////////////////////////////////////////// + const re = new RegExp("\\b" + filterText.toLowerCase()); + if (columnLabelMinusTable.toLowerCase().match(re)) + { + return (true); + } + } + catch(e) + { + ////////////////////////////////////////////////////////////////////////////////// + // in case text is an invalid regex... well, at least do a starts-with match... // + ////////////////////////////////////////////////////////////////////////////////// + if (columnLabelMinusTable.toLowerCase().startsWith(filterText.toLowerCase())) + { + return (true); + } + } + + return (false); + }; + + /////////////////////////////////////////////////////////////////////////////// + // build the map of list of fields, plus counts of columns & visible columns // + /////////////////////////////////////////////////////////////////////////////// + const tableFields: { [tableName: string]: GridColDef[] } = {}; + const noOfColumnsByTable: { [name: string]: number } = {}; + const noOfVisibleColumnsByTable: { [name: string]: number } = {}; + + for (let i = 0; i < tables.length; i++) + { + const tableName = tables[i].name; + tableFields[tableName] = []; + noOfColumnsByTable[tableName] = 0; + noOfVisibleColumnsByTable[tableName] = 0; + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // always sort columns by label. note, in future may offer different sorts - here's where to do it. // + /////////////////////////////////////////////////////////////////////////////////////////////////////// + const sortedColumns = [... columns]; + sortedColumns.sort((a, b): number => + { + return a.headerName.localeCompare(b.headerName); + }) + + for (let i = 0; i < sortedColumns.length; i++) + { + const column = sortedColumns[i]; + if (isCheckboxColumn(column)) + { + //////////////////////////////////////////////////////////////// + // don't count the checkbox or put it in the list for display // + //////////////////////////////////////////////////////////////// + continue; + } + + let tableName = props.tableMetaData.name; + const fieldName = column.field; + if (fieldName.indexOf(".") > -1) + { + tableName = fieldName.split(".", 2)[0]; + } + + tableFields[tableName].push(column); + + if (doesColumnMatchFilterText(column)) + { + noOfColumnsByTable[tableName]++; + if (columnVisibilityModel[column.field] !== false) + { + noOfVisibleColumnsByTable[tableName]++; + } + } + + if (filterText != "") + { + /////////////////////////////////////////////////////////////////////////////////////////// + // if there's a filter, then force open any groups (tables) with a field that matches it // + /////////////////////////////////////////////////////////////////////////////////////////// + if (doesColumnMatchFilterText(column)) + { + openGroupsBecauseOfFilter[tableName] = true; + } + } + } + + useEffect(() => + { + if (someRef && someRef.current) + { + console.log(`Trying to set scroll top to: ${lastScrollTop}`); + // @ts-ignore + someRef.current.scrollTop = lastScrollTop; + } + }, [lastScrollTop]); + + /******************************************************************************* + ** event handler for toggling the open/closed status of a group (table) + *******************************************************************************/ + const toggleColumnGroupOpen = (groupName: string) => + { + ///////////////////////////////////////////////////////////// + // if there's a filter, we don't do the normal toggling... // + ///////////////////////////////////////////////////////////// + if (filterText != "") + { + return; + } + + openGroups[groupName] = !!!openGroups[groupName]; + + const newOpenGroups = JSON.parse(JSON.stringify(openGroups)); + setOpenGroups(newOpenGroups); + props.openGroupsChanger(newOpenGroups); + + forceUpdate(); + }; + + /******************************************************************************* + ** event handler for toggling visibility state of one column + *******************************************************************************/ + const onColumnVisibilityChange = (fieldName: string) => + { + // @ts-ignore + setLastScrollTop(someRef.current.scrollTop); + + apiRef.current.setColumnVisibility(fieldName, columnVisibilityModel[fieldName] === false); + }; + + /******************************************************************************* + ** event handler for clicking table-visibility switch + *******************************************************************************/ + const onTableVisibilityClick = (event: React.MouseEvent, tableName: string) => + { + event.stopPropagation(); + + // @ts-ignore + setLastScrollTop(someRef.current.scrollTop); + + let newValue = true; + if (noOfVisibleColumnsByTable[tableName] == noOfColumnsByTable[tableName]) + { + newValue = false; + } + + for (let i = 0; i < columns.length; i++) + { + const column = columns[i]; + if (isCheckboxColumn(column)) + { + ///////////////////////////////// + // never turn the checkbox off // + ///////////////////////////////// + columnVisibilityModel[column.field] = true; + } + else + { + const fieldName = column.field; + if (fieldName.indexOf(".") > -1) + { + if (tableName === fieldName.split(".", 2)[0] && doesColumnMatchFilterText(column)) + { + columnVisibilityModel[fieldName] = newValue; + } + } + else if (tableName == props.tableMetaData.name && doesColumnMatchFilterText(column)) + { + columnVisibilityModel[fieldName] = newValue; + } + } + } + + ////////////////////////////////////////////////////////////////////////////// + // not too sure what this is doing... kinda got it from toggleAllColumns in // + // ./@mui/x-data-grid/components/panel/GridColumnsPanel.js // + ////////////////////////////////////////////////////////////////////////////// + const currentModel = gridColumnVisibilityModelSelector(apiRef); + const newModel = JSON.parse(JSON.stringify(currentModel)); + apiRef.current.setColumnVisibilityModel(newModel); + }; + + /******************************************************************************* + ** event handler for reset button - turn on only all columns from main table + *******************************************************************************/ + const resetClicked = () => + { + // @ts-ignore + setLastScrollTop(someRef.current.scrollTop); + + for (let i = 0; i < columns.length; i++) + { + const column = columns[i]; + const fieldName = column.field; + if (fieldName.indexOf(".") > -1) + { + columnVisibilityModel[fieldName] = false; + } + else + { + columnVisibilityModel[fieldName] = true; + } + } + + const currentModel = gridColumnVisibilityModelSelector(apiRef); + const newModel = JSON.parse(JSON.stringify(currentModel)); + apiRef.current.setColumnVisibilityModel(newModel); + }; + + const changeFilterText = (newValue: string) => + { + setFilterText(newValue); + props.filterTextChanger(newValue) + }; + + const filterTextChanged = (event: React.ChangeEvent) => + { + changeFilterText(event.target.value); + }; + + return ( + + + filterTextChanged(event)} + > + { + filterText != "" && + { + changeFilterText(""); + document.getElementById("findColumn").focus(); + }}>close + } + + + + + + {tables.map((table: QTableMetaData) => + ( + + toggleColumnGroupOpen(table.name)} + sx={{width: "100%", justifyContent: "flex-start", fontSize: "0.875rem", pt: 0.5}} + disableRipple={true} + > + {filterText != "" ? "horizontal_rule" : openGroups[table.name] ? "expand_more" : "expand_less"} + + 0} + onClick={(event) => onTableVisibilityClick(event, table.name)} + size="small" /> + + + {table.label} fields  + ({noOfVisibleColumnsByTable[table.name]} / {noOfColumnsByTable[table.name]}) + + + + {(openGroups[table.name] || openGroupsBecauseOfFilter[table.name]) && tableFields[table.name].map((gridColumn: any) => + { + if (doesColumnMatchFilterText(gridColumn)) + { + return ( + + onColumnVisibilityChange(gridColumn.field)} + size="small" />} + label={{gridColumn.headerName.replace(/.*: /, "")}} /> + + ); + } + } + )} + + ))} + + + + + + + + ); + } +); + diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index a28910b..7e01c35 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -46,12 +46,9 @@ import ListItemIcon from "@mui/material/ListItemIcon"; import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Modal from "@mui/material/Modal"; -import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridExportMenuItemProps, GridFilterMenuItem, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, GridRowProps, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, GridToolbarFilterButton, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridSelector} from "@mui/x-data-grid-pro"; -import {GridColumnsPanelProps} from "@mui/x-data-grid/components/panel/GridColumnsPanel"; -import {gridColumnDefinitionsSelector, gridColumnVisibilityModelSelector} from "@mui/x-data-grid/hooks/features/columns/gridColumnsSelector"; import {GridRowModel} from "@mui/x-data-grid/models/gridRows"; import FormData from "form-data"; import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react"; @@ -60,6 +57,7 @@ import QContext from "QContext"; import {QActionsMenuButton, QCancelButton, QCreateNewButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; import MenuButton from "qqq/components/buttons/MenuButton"; import SavedFilters from "qqq/components/misc/SavedFilters"; +import {CustomColumnsPanel} from "qqq/components/query/CustomColumnsPanel"; import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip"; import BaseLayout from "qqq/layouts/BaseLayout"; import ProcessRun from "qqq/pages/processes/ProcessRun"; @@ -163,6 +161,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [density, setDensity] = useState(defaultDensity); const [pinnedColumns, setPinnedColumns] = useState(defaultPinnedColumns); + const initialColumnChooserOpenGroups = {} as { [name: string]: boolean }; + initialColumnChooserOpenGroups[tableName] = true; + const [columnChooserOpenGroups, setColumnChooserOpenGroups] = useState(initialColumnChooserOpenGroups); + const [columnChooserFilterText, setColumnChooserFilterText] = useState(""); + const [tableState, setTableState] = useState(""); const [metaData, setMetaData] = useState(null as QInstance); const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); @@ -316,7 +319,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element /////////////////////////////////////////////////////////////////////// if (tableMetaData && tableMetaData.name !== tableName) { - console.log(" it looks like we changed tables - try to reload the things"); setTableMetaData(null); setColumnSortModel([]); setColumnVisibilityModel({}); @@ -833,10 +835,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setColumnVisibilityModel(columnVisibilityModel); if (columnVisibilityLocalStorageKey) { - localStorage.setItem( - columnVisibilityLocalStorageKey, - JSON.stringify(columnVisibilityModel), - ); + localStorage.setItem(columnVisibilityLocalStorageKey, JSON.stringify(columnVisibilityModel)); } }; @@ -1357,95 +1356,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); }); - //////////////////////////////////////////////////////////////////////////// - // this is a WIP example of how we could do a custom "columns" panel/menu // - //////////////////////////////////////////////////////////////////////////// - const CustomColumnsPanel = forwardRef( - function MyCustomColumnsPanel(props: GridColumnsPanelProps, ref) - { - const apiRef = useGridApiContext(); - const columns = useGridSelector(apiRef, gridColumnDefinitionsSelector); - const columnVisibilityModel = useGridSelector(apiRef, gridColumnVisibilityModelSelector); - - const [openGroups, setOpenGroups] = useState({} as { [name: string]: boolean }); - - const groups = ["Order", "Line Item"]; - - const onColumnVisibilityChange = (fieldName: string) => - { - /* - if(columnVisibilityModel[fieldName] === undefined) - { - columnVisibilityModel[fieldName] = true; - } - columnVisibilityModel[fieldName] = !columnVisibilityModel[fieldName]; - setColumnVisibilityModel(JSON.parse(JSON.stringify(columnVisibilityModel))) - */ - - console.log(`${fieldName} = ${columnVisibilityModel[fieldName]}`); - // columnVisibilityModel[fieldName] = Math.random() < 0.5; - apiRef.current.setColumnVisibility(fieldName, columnVisibilityModel[fieldName] === false); - // handleColumnVisibilityChange(JSON.parse(JSON.stringify(columnVisibilityModel))); - }; - - const toggleColumnGroup = (groupName: string) => - { - if (openGroups[groupName] === undefined) - { - openGroups[groupName] = true; - } - openGroups[groupName] = !openGroups[groupName]; - setOpenGroups(JSON.parse(JSON.stringify(openGroups))); - }; - - return ( -
- - - - - - - - {groups.map((groupName: string) => - ( - <> - toggleColumnGroup(groupName)} - sx={{width: "100%", justifyContent: "flex-start", fontSize: "0.875rem"}} - disableRipple={true} - > - {openGroups[groupName] === false ? "expand_less" : "expand_more"} - {groupName} fields - - - {openGroups[groupName] !== false && columnsModel.map((gridColumn: any) => ( - onColumnVisibilityChange(gridColumn.field)} - sx={{width: "100%", justifyContent: "flex-start", fontSize: "0.875rem", pl: "1.375rem"}} - disableRipple={true} - > - {columnVisibilityModel[gridColumn.field] === false ? "visibility_off" : "visibility"} - {gridColumn.headerName} - - ))} - - ))} - - - - - - - -
- ); - } - ); const safeToLocaleString = (n: Number): string => { @@ -1853,7 +1763,23 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element Date: Tue, 9 May 2023 13:02:54 -0500 Subject: [PATCH 24/59] Updates to work with branch-specific maven deployments in/with circleci [skip ci] --- .circleci/adjust-pom-version.sh | 23 +++++++++++++++++++++++ .circleci/config.yml | 4 ++++ 2 files changed, 27 insertions(+) create mode 100755 .circleci/adjust-pom-version.sh diff --git a/.circleci/adjust-pom-version.sh b/.circleci/adjust-pom-version.sh new file mode 100755 index 0000000..054fbdf --- /dev/null +++ b/.circleci/adjust-pom-version.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +if [ -z "$CIRCLE_BRANCH" ] && [ -z "$CIRCLE_TAG" ]; then + echo "Error: env vars CIRCLE_BRANCH and CIRCLE_TAG were not set." + exit 1; +fi + +if [ "$CIRCLE_BRANCH" == "dev" ] || [ "$CIRCLE_BRANCH" == "staging" ] || [ "$CIRCLE_BRANCH" == "main" ]; then + echo "On a primary branch [$CIRCLE_BRANCH] - will not edit the pom version."; + exit 0; +fi + +if [ -n "$CIRCLE_BRANCH" ]; then + SLUG=$(echo $CIRCLE_BRANCH | sed 's/[^a-zA-Z0-9]/-/g') +else + SLUG=$(echo $CIRCLE_TAG | sed 's/^snapshot-//g') +fi + +POM=$(dirname $0)/../pom.xml + +echo "Updating $POM to: $SLUG-SNAPSHOT" +sed -i "s/.*/$SLUG-SNAPSHOT<\/revision>/" $POM +git diff $POM diff --git a/.circleci/config.yml b/.circleci/config.yml index f6d9483..99c4892 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -71,6 +71,10 @@ commands: mvn_deploy: steps: - checkout + - run: + name: Adjust pom version + command: | + .circleci/adjust-pom-version.sh - restore_cache: keys: - v1-dependencies-{{ checksum "pom.xml" }} From 084ed0732dcbbd12366e21144c9457f7b3a28455 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 24 May 2023 17:40:30 -0500 Subject: [PATCH 25/59] Support for BLOB, file downloads --- package.json | 2 +- src/qqq/utils/DataGridUtils.tsx | 25 +++++++++++- src/qqq/utils/HtmlUtils.ts | 70 ++++++++++++++++++++++++++++++++ src/qqq/utils/qqq/ValueUtils.tsx | 63 ++++++++++++++++++++++++++-- 4 files changed, 154 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 65b58a2..a3935cc 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.66", + "@kingsrook/qqq-frontend-core": "1.0.67", "@mui/icons-material": "5.4.1", "@mui/material": "5.11.1", "@mui/styles": "5.11.1", diff --git a/src/qqq/utils/DataGridUtils.tsx b/src/qqq/utils/DataGridUtils.tsx index f43364f..98c57ef 100644 --- a/src/qqq/utils/DataGridUtils.tsx +++ b/src/qqq/utils/DataGridUtils.tsx @@ -168,6 +168,23 @@ export default class DataGridUtils sortedKeys.forEach((key) => { const field = tableMetaData.fields.get(key); + if(field.isHeavy) + { + if(field.type == QFieldType.BLOB) + { + //////////////////////////////////////////////////////// + // assume we DO want heavy blobs - as download links. // + //////////////////////////////////////////////////////// + } + else + { + /////////////////////////////////////////////////// + // otherwise, skip heavy fields on query screen. // + /////////////////////////////////////////////////// + return; + } + } + const column = this.makeColumnFromField(field, tableMetaData, namePrefix, labelPrefix); if(key === tableMetaData.primaryKeyField && linkBase && namePrefix == null) @@ -244,6 +261,7 @@ export default class DataGridUtils const widths: Map = new Map([ ["small", 100], ["medium", 200], + ["medlarge", 300], ["large", 400], ["xlarge", 600] ]); @@ -260,7 +278,7 @@ export default class DataGridUtils let headerName = labelPrefix ? labelPrefix + field.label : field.label; let fieldName = namePrefix ? namePrefix + field.name : field.name; - const column = { + const column: GridColDef = { field: fieldName, type: columnType, headerName: headerName, @@ -269,6 +287,11 @@ export default class DataGridUtils filterOperators: filterOperators, }; + if(field.type == QFieldType.BLOB) + { + column.filterable = false; + } + column.renderCell = (cellValues: any) => ( (cellValues.value) ); diff --git a/src/qqq/utils/HtmlUtils.ts b/src/qqq/utils/HtmlUtils.ts index 5197867..1bf0712 100644 --- a/src/qqq/utils/HtmlUtils.ts +++ b/src/qqq/utils/HtmlUtils.ts @@ -19,6 +19,8 @@ * along with this program. If not, see . */ +import Client from "qqq/utils/qqq/Client"; + /******************************************************************************* ** Utility functions for basic html/webpage/browser things. *******************************************************************************/ @@ -59,4 +61,72 @@ export default class HtmlUtils document.body.removeChild(element); }; + /******************************************************************************* + ** Download a server-side generated file. + *******************************************************************************/ + static downloadUrlViaIFrame = (url: string) => + { + if (document.getElementById("downloadIframe")) + { + document.body.removeChild(document.getElementById("downloadIframe")); + } + + const iframe = document.createElement("iframe"); + iframe.setAttribute("id", "downloadIframe"); + iframe.setAttribute("name", "downloadIframe"); + iframe.style.display = "none"; + // todo - onload event handler to let us know when done? + document.body.appendChild(iframe); + + const form = document.createElement("form"); + form.setAttribute("method", "post"); + form.setAttribute("action", url); + form.setAttribute("target", "downloadIframe"); + iframe.appendChild(form); + + const authorizationInput = document.createElement("input"); + authorizationInput.setAttribute("type", "hidden"); + authorizationInput.setAttribute("id", "authorizationInput"); + authorizationInput.setAttribute("name", "Authorization"); + authorizationInput.setAttribute("value", Client.getInstance().getAuthorizationHeaderValue()); + form.appendChild(authorizationInput); + + const downloadInput = document.createElement("input"); + downloadInput.setAttribute("type", "hidden"); + downloadInput.setAttribute("name", "download"); + downloadInput.setAttribute("value", "1"); + form.appendChild(downloadInput); + + form.submit(); + }; + + /******************************************************************************* + ** Open a server-side generated file from a url in a new window. + *******************************************************************************/ + static openInNewWindow = (url: string, filename: string) => + { + const openInWindow = window.open("", "_blank"); + openInWindow.document.write(` + + + ${filename} + + + + Opening ${filename}... +
+ +
+ + `); + }; + + } \ No newline at end of file diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index d8c1332..ea489e2 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -28,11 +28,14 @@ import "datejs"; // https://github.com/datejs/Datejs import {Chip, ClickAwayListener, Icon} from "@mui/material"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; import Tooltip from "@mui/material/Tooltip"; +import {makeStyles} from "@mui/styles"; import parse from "html-react-parser"; import React, {Fragment, useReducer, useState} from "react"; import AceEditor from "react-ace"; import {Link} from "react-router-dom"; +import HtmlUtils from "qqq/utils/HtmlUtils"; import Client from "qqq/utils/qqq/Client"; /******************************************************************************* @@ -192,6 +195,11 @@ class ValueUtils ); } + if (field.type == QFieldType.BLOB) + { + return (); + } + return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue)); } @@ -500,9 +508,9 @@ function CodeViewer({name, mode, code}: {name: string; mode: string; code: strin
); } -//////////////////////////////////////////////////////////////////////////////////////////////// -// little private component here, for rendering an AceEditor with some buttons/controls/state // -//////////////////////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////////////////////////////////// +// little private component here, for rendering "secret-ish" values, that you can click to reveal or copy // +//////////////////////////////////////////////////////////////////////////////////////////////////////////// function RevealComponent({fieldName, value, usage}: {fieldName: string, value: string, usage: string;}): JSX.Element { const [adornmentFieldsMap, setAdornmentFieldsMap] = useState(new Map); @@ -561,7 +569,7 @@ function RevealComponent({fieldName, value, usage}: {fieldName: string, value: s
):( - handleRevealIconClick(e, fieldName)} sx={{cursor: "pointer", fontSize: "15px !important", position: "relative", top: "3px", marginRight: "5px"}}>visibility_off{displayValue} + handleRevealIconClick(e, fieldName)} sx={{cursor: "pointer", fontSize: "15px !important", position: "relative", top: "3px", marginRight: "5px"}}>visibility_off{displayValue} ) ) } @@ -570,5 +578,52 @@ function RevealComponent({fieldName, value, usage}: {fieldName: string, value: s } +interface BlobComponentProps +{ + url: string; + filename: string; +} + +BlobComponent.defaultProps = { + foo: null, +}; + +function BlobComponent({url, filename}: BlobComponentProps): JSX.Element +{ + const download = (event: React.MouseEvent) => + { + event.stopPropagation(); + HtmlUtils.downloadUrlViaIFrame(url); + }; + + const open = (event: React.MouseEvent) => + { + event.stopPropagation(); + HtmlUtils.openInNewWindow(url, filename); + }; + + const useBlobIconStyles = makeStyles({ + blobIcon: { + marginLeft: "0.25rem", + marginRight: "0.25rem", + cursor: "pointer" + } + }) + const classes = useBlobIconStyles(); + + return ( + + {filename} + + open(e)}>open_in_new + + + download(e)}>save_alt + + + ); +} + + export default ValueUtils; From b05f12ab5cd5e7426034c1fe2925856b3522d011 Mon Sep 17 00:00:00 2001 From: Tim Chamberlain Date: Thu, 25 May 2023 10:51:35 -0500 Subject: [PATCH 26/59] fixed z-index on selection box --- src/qqq/pages/records/query/RecordQuery.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 7e01c35..ed659df 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -1516,7 +1516,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
-
+
{ From 6d1aa54d53167b5d4f1e670c18811c49331ecc02 Mon Sep 17 00:00:00 2001 From: Tim Chamberlain Date: Fri, 26 May 2023 16:08:17 -0500 Subject: [PATCH 27/59] fixed bug where all selected columns would be selected for a table when hitting back button after running a process then clicking on a new record from process results --- src/qqq/pages/records/query/RecordQuery.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index ed659df..cdc303a 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -30,7 +30,8 @@ import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobEr import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; import {QueryJoin} from "@kingsrook/qqq-frontend-core/lib/model/query/QueryJoin"; -import {Alert, Box, Collapse, TablePagination} from "@mui/material"; +import {Alert, Collapse, TablePagination} from "@mui/material"; +import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; import Dialog from "@mui/material/Dialog"; @@ -48,7 +49,7 @@ import MenuItem from "@mui/material/MenuItem"; import Modal from "@mui/material/Modal"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; -import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridExportMenuItemProps, GridFilterMenuItem, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, GridRowProps, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, GridToolbarFilterButton, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridSelector} from "@mui/x-data-grid-pro"; +import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridExportMenuItemProps, GridFilterMenuItem, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, GridToolbarFilterButton, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridSelector} from "@mui/x-data-grid-pro"; import {GridRowModel} from "@mui/x-data-grid/models/gridRows"; import FormData from "form-data"; import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react"; @@ -314,6 +315,16 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element }, [location, tableMetaData]); + const updateColumnVisibilityModel = () => + { + if (localStorage.getItem(columnVisibilityLocalStorageKey)) + { + const visibility = JSON.parse(localStorage.getItem(columnVisibilityLocalStorageKey)); + setColumnVisibilityModel(visibility); + didDefaultVisibilityComeFromLocalStorage = true; + } + } + /////////////////////////////////////////////////////////////////////// // any time these are out of sync, it means we need to reload things // /////////////////////////////////////////////////////////////////////// @@ -321,7 +332,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { setTableMetaData(null); setColumnSortModel([]); - setColumnVisibilityModel({}); + updateColumnVisibilityModel(); setColumnsModel([]); setFilterModel({items: []}); setDefaultFilterLoaded(false); From 48ebcb63c09e1b586d2e32704474a62d1aa3b47a Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 30 May 2023 10:20:01 -0500 Subject: [PATCH 28/59] Updates for supporting blobs --- package.json | 2 +- src/qqq/components/audits/AuditBody.tsx | 4 ++ src/qqq/components/forms/DynamicForm.tsx | 29 +++++++++++++- src/qqq/components/forms/EntityForm.tsx | 35 ++++++++++++----- .../records/query/GridFilterOperators.tsx | 14 +++++++ src/qqq/styles/qqq-override-styles.css | 9 ++++- src/qqq/utils/DataGridUtils.tsx | 10 ++--- src/qqq/utils/qqq/ValueUtils.tsx | 39 +++++++++++-------- 8 files changed, 108 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index a3935cc..df1703b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.67", + "@kingsrook/qqq-frontend-core": "1.0.68", "@mui/icons-material": "5.4.1", "@mui/material": "5.11.1", "@mui/styles": "5.11.1", diff --git a/src/qqq/components/audits/AuditBody.tsx b/src/qqq/components/audits/AuditBody.tsx index 072f277..f98d540 100644 --- a/src/qqq/components/audits/AuditBody.tsx +++ b/src/qqq/components/audits/AuditBody.tsx @@ -108,6 +108,10 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element { return (<>{fieldLabel}: Removed value {(oldValue)}); } + else if(message) + { + return (<>{message}); + } /* const fieldLabel = {tableMetaData?.fields?.get(fieldName)?.label ?? fieldName}; diff --git a/src/qqq/components/forms/DynamicForm.tsx b/src/qqq/components/forms/DynamicForm.tsx index 13eafb3..d98ecd2 100644 --- a/src/qqq/components/forms/DynamicForm.tsx +++ b/src/qqq/components/forms/DynamicForm.tsx @@ -19,15 +19,20 @@ * along with this program. If not, see . */ -import {colors} from "@mui/material"; +import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; +import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; +import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; +import {colors, Icon, InputLabel} from "@mui/material"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Grid from "@mui/material/Grid"; +import Tooltip from "@mui/material/Tooltip"; import {useFormikContext} from "formik"; import React, {useState} from "react"; import QDynamicFormField from "qqq/components/forms/DynamicFormField"; import DynamicSelect from "qqq/components/forms/DynamicSelect"; import MDTypography from "qqq/components/legacy/MDTypography"; +import ValueUtils from "qqq/utils/qqq/ValueUtils"; interface Props { @@ -35,6 +40,7 @@ interface Props formData: any; bulkEditMode?: boolean; bulkEditSwitchChangeHandler?: any; + record?: QRecord; } function QDynamicForm(props: Props): JSX.Element @@ -60,6 +66,14 @@ function QDynamicForm(props: Props): JSX.Element formikProps.setFieldValue(field.name, event.currentTarget.files[0]); }; + const removeFile = (fieldName: string) => + { + setFileName(null); + formikProps.setFieldValue(fieldName, null); + props.record?.values.delete(fieldName) + props.record?.displayValues.delete(fieldName) + }; + const bulkEditSwitchChanged = (name: string, value: boolean) => { bulkEditSwitchChangeHandler(name, value); @@ -94,10 +108,23 @@ function QDynamicForm(props: Props): JSX.Element if (field.type === "file") { + const pseudoField = new QFieldMetaData({name: fieldName, type: QFieldType.BLOB}); return ( + {field.label} + { + props.record && props.record.values.get(fieldName) && + Current File: + + {ValueUtils.getDisplayValue(pseudoField, props.record, "view")} + + removeFile(fieldName)}>delete + + + + } + + + ); + } +); diff --git a/src/qqq/components/query/FilterCriteriaRow.tsx b/src/qqq/components/query/FilterCriteriaRow.tsx new file mode 100644 index 0000000..9aa95b2 --- /dev/null +++ b/src/qqq/components/query/FilterCriteriaRow.tsx @@ -0,0 +1,507 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; +import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; +import Autocomplete, {AutocompleteRenderOptionState} from "@mui/material/Autocomplete"; +import Box from "@mui/material/Box"; +import FormControl from "@mui/material/FormControl/FormControl"; +import Icon from "@mui/material/Icon/Icon"; +import IconButton from "@mui/material/IconButton"; +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 FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; + + +export enum ValueMode +{ + NONE = "NONE", + SINGLE = "SINGLE", + DOUBLE = "DOUBLE", + MULTI = "MULTI", + SINGLE_DATE = "SINGLE_DATE", + SINGLE_DATE_TIME = "SINGLE_DATE_TIME", + PVS_SINGLE = "PVS_SINGLE", + PVS_MULTI = "PVS_MULTI", +} + +export interface OperatorOption +{ + label: string; + value: QCriteriaOperator; + implicitValues?: [any]; + valueMode: ValueMode; +} + + +interface FilterCriteriaRowProps +{ + id: number; + index: number; + tableMetaData: QTableMetaData; + criteria: QFilterCriteria; + booleanOperator: "AND" | "OR" | null; + updateCriteria: (newCriteria: QFilterCriteria, needDebounce: boolean) => void; + removeCriteria: () => void; + updateBooleanOperator: (newValue: string) => void; +} + +FilterCriteriaRow.defaultProps = {}; + +function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean) +{ + 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; + fieldOptions.push({field: sortedFields[i], table: tableMetaData, fieldName: fieldName}); + } +} + +export function FilterCriteriaRow({id, index, tableMetaData, criteria, booleanOperator, updateCriteria, removeCriteria, updateBooleanOperator}: FilterCriteriaRowProps): JSX.Element +{ + // console.log(`FilterCriteriaRow: criteria: ${JSON.stringify(criteria)}`); + const [operatorSelectedValue, setOperatorSelectedValue] = useState(null as OperatorOption); + const [operatorInputValue, setOperatorInputValue] = useState("") + + /////////////////////////////////////////////////////////////// + // set up the array of options for the fields Autocomplete // + // also, a groupBy function, in case there are exposed joins // + /////////////////////////////////////////////////////////////// + const fieldOptions: any[] = []; + makeFieldOptionsForTable(tableMetaData, fieldOptions, false); + let fieldsGroupBy = null; + + if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0) + { + fieldsGroupBy = (option: any) => `${option.table.label} Fields`; + + for (let i = 0; i < tableMetaData.exposedJoins.length; i++) + { + const exposedJoin = tableMetaData.exposedJoins[i]; + makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true); + } + } + + //////////////////////////////////////////////////////////// + // set up array of options for operator dropdown // + // only call the function to do it if we have a field set // + //////////////////////////////////////////////////////////// + let operatorOptions: OperatorOption[] = []; + + function setOperatorOptions(fieldName: string) + { + const [field, fieldTable] = FilterUtils.getField(tableMetaData, fieldName); + operatorOptions = []; + if (field && fieldTable) + { + ////////////////////////////////////////////////////// + // setup array of options for operator Autocomplete // + ////////////////////////////////////////////////////// + if (field.possibleValueSourceName) + { + operatorOptions.push({label: "is", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.PVS_SINGLE}); + operatorOptions.push({label: "is not", value: QCriteriaOperator.NOT_EQUALS, valueMode: ValueMode.PVS_SINGLE}); + operatorOptions.push({label: "is empty", value: QCriteriaOperator.IS_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is any of", value: QCriteriaOperator.IN, valueMode: ValueMode.PVS_MULTI}); + operatorOptions.push({label: "is none of", value: QCriteriaOperator.NOT_IN, valueMode: ValueMode.PVS_MULTI}); + } + else + { + switch (field.type) + { + case QFieldType.DECIMAL: + case QFieldType.INTEGER: + operatorOptions.push({label: "equals", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "not equals", value: QCriteriaOperator.NOT_EQUALS, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "greater than", value: QCriteriaOperator.GREATER_THAN, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "greater than or equals", value: QCriteriaOperator.GREATER_THAN_OR_EQUALS, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "less than", value: QCriteriaOperator.LESS_THAN, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "less than or equals", value: QCriteriaOperator.LESS_THAN_OR_EQUALS, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "is empty", value: QCriteriaOperator.IS_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is between", value: QCriteriaOperator.BETWEEN, valueMode: ValueMode.DOUBLE}); + operatorOptions.push({label: "is not between", value: QCriteriaOperator.NOT_BETWEEN, valueMode: ValueMode.DOUBLE}); + operatorOptions.push({label: "is any of", value: QCriteriaOperator.IN, valueMode: ValueMode.MULTI}); + operatorOptions.push({label: "is none of", value: QCriteriaOperator.NOT_IN, valueMode: ValueMode.MULTI}); + break; + case QFieldType.DATE: + operatorOptions.push({label: "is", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE_DATE}); + operatorOptions.push({label: "is not", value: QCriteriaOperator.NOT_EQUALS, valueMode: ValueMode.SINGLE_DATE}); + operatorOptions.push({label: "is after", value: QCriteriaOperator.GREATER_THAN, valueMode: ValueMode.SINGLE_DATE}); + operatorOptions.push({label: "is before", value: QCriteriaOperator.LESS_THAN, valueMode: ValueMode.SINGLE_DATE}); + operatorOptions.push({label: "is empty", value: QCriteriaOperator.IS_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE}); + //? operatorOptions.push({label: "is between", value: QCriteriaOperator.BETWEEN}); + //? operatorOptions.push({label: "is not between", value: QCriteriaOperator.NOT_BETWEEN}); + //? operatorOptions.push({label: "is any of", value: QCriteriaOperator.IN}); + //? operatorOptions.push({label: "is none of", value: QCriteriaOperator.NOT_IN}); + break; + case QFieldType.DATE_TIME: + operatorOptions.push({label: "is", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE_DATE_TIME}); + operatorOptions.push({label: "is not", value: QCriteriaOperator.NOT_EQUALS, valueMode: ValueMode.SINGLE_DATE_TIME}); + operatorOptions.push({label: "is after", value: QCriteriaOperator.GREATER_THAN, valueMode: ValueMode.SINGLE_DATE_TIME}); + operatorOptions.push({label: "is on or after", value: QCriteriaOperator.GREATER_THAN_OR_EQUALS, valueMode: ValueMode.SINGLE_DATE_TIME}); + operatorOptions.push({label: "is before", value: QCriteriaOperator.LESS_THAN, valueMode: ValueMode.SINGLE_DATE_TIME}); + operatorOptions.push({label: "is on or before", value: QCriteriaOperator.LESS_THAN_OR_EQUALS, valueMode: ValueMode.SINGLE_DATE_TIME}); + operatorOptions.push({label: "is empty", value: QCriteriaOperator.IS_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE}); + //? operatorOptions.push({label: "is between", value: QCriteriaOperator.BETWEEN}); + //? operatorOptions.push({label: "is not between", value: QCriteriaOperator.NOT_BETWEEN}); + break; + case QFieldType.BOOLEAN: + operatorOptions.push({label: "is yes", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.NONE, implicitValues: [true]}); + operatorOptions.push({label: "is no", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.NONE, implicitValues: [false]}); + operatorOptions.push({label: "is empty", value: QCriteriaOperator.IS_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE}); + /* + ? is yes or empty (is not no) + ? is no or empty (is not yes) + */ + break; + case QFieldType.BLOB: + operatorOptions.push({label: "is empty", value: QCriteriaOperator.IS_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE}); + break; + default: + operatorOptions.push({label: "equals", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "does not equal", value: QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "contains ", value: QCriteriaOperator.CONTAINS, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "does not contain", value: QCriteriaOperator.NOT_CONTAINS, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "starts with", value: QCriteriaOperator.STARTS_WITH, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "does not start with", value: QCriteriaOperator.NOT_STARTS_WITH, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "ends with", value: QCriteriaOperator.ENDS_WITH, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "does not end with", value: QCriteriaOperator.NOT_ENDS_WITH, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "is empty", value: QCriteriaOperator.IS_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is any of", value: QCriteriaOperator.IN, valueMode: ValueMode.MULTI}); + operatorOptions.push({label: "is none of", value: QCriteriaOperator.NOT_IN, valueMode: ValueMode.MULTI}); + } + } + } + } + + //////////////////////////////////////////////////////////////// + // make currently selected values appear in the Autocompletes // + //////////////////////////////////////////////////////////////// + let defaultFieldValue; + let field = null; + let fieldTable = null; + if(criteria && criteria.fieldName) + { + [field, fieldTable] = FilterUtils.getField(tableMetaData, criteria.fieldName); + if (field && fieldTable) + { + if (fieldTable.name == tableMetaData.name) + { + // @ts-ignore + defaultFieldValue = {field: field, table: tableMetaData, fieldName: criteria.fieldName}; + } + else + { + defaultFieldValue = {field: field, table: fieldTable, fieldName: criteria.fieldName}; + } + + setOperatorOptions(criteria.fieldName); + + + let newOperatorSelectedValue = operatorOptions.filter(option => + { + if(option.value == criteria.operator) + { + if(option.implicitValues) + { + return (JSON.stringify(option.implicitValues) == JSON.stringify(criteria.values)); + } + else + { + return (true); + } + } + return (false); + })[0]; + if(newOperatorSelectedValue?.label !== operatorSelectedValue?.label) + { + setOperatorSelectedValue(newOperatorSelectedValue); + setOperatorInputValue(newOperatorSelectedValue?.label); + } + } + } + + ////////////////////////////////////////////// + // event handler for booleanOperator Select // + ////////////////////////////////////////////// + const handleBooleanOperatorChange = (event: SelectChangeEvent<"AND" | "OR">, child: ReactNode) => + { + updateBooleanOperator(event.target.value); + }; + + ////////////////////////////////////////// + // event handler for field Autocomplete // + ////////////////////////////////////////// + const handleFieldChange = (event: any, newValue: any, reason: string) => + { + criteria.fieldName = newValue ? newValue.fieldName : null; + updateCriteria(criteria, false); + + setOperatorOptions(criteria.fieldName) + if(operatorOptions.length) + { + setOperatorSelectedValue(operatorOptions[0]); + setOperatorInputValue(operatorOptions[0].label); + } + else + { + setOperatorSelectedValue(null); + setOperatorInputValue(""); + } + }; + + ///////////////////////////////////////////// + // event handler for operator Autocomplete // + ///////////////////////////////////////////// + const handleOperatorChange = (event: any, newValue: any, reason: string) => + { + criteria.operator = newValue ? newValue.value : null; + + if(newValue) + { + setOperatorSelectedValue(newValue); + setOperatorInputValue(newValue.label); + + if(newValue.implicitValues) + { + criteria.values = newValue.implicitValues; + } + } + else + { + setOperatorSelectedValue(null); + setOperatorInputValue(""); + } + + updateCriteria(criteria, false); + }; + + //////////////////////////////////////// + // event handler for value text field // + //////////////////////////////////////// + const handleValueChange = (event: React.ChangeEvent | SyntheticEvent, valueIndex: number | "all" = 0, newValue?: any) => + { + // @ts-ignore + const value = newValue ? newValue : event.target.value + + if(!criteria.values) + { + criteria.values = []; + } + + if(valueIndex == "all") + { + criteria.values= value; + } + else + { + criteria.values[valueIndex] = value; + } + + updateCriteria(criteria, true); + }; + + function isFieldOptionEqual(option: any, value: any) + { + return option.fieldName === value.fieldName; + } + + function getFieldOptionLabel(option: any) + { + ///////////////////////////////////////////////////////////////////////////////////////// + // note - we're using renderFieldOption below for the actual select-box options, which // + // are always jut field label (as they are under groupings that show their table name) // + ///////////////////////////////////////////////////////////////////////////////////////// + if(option && option.field && option.table) + { + if(option.table.name == tableMetaData.name) + { + return (option.field.label); + } + else + { + return (option.table.label + ": " + option.field.label); + } + } + + return (""); + } + + ////////////////////////////////////////////////////////////////////////////////////////////// + // for options, we only want the field label (contrast with what we show in the input box, // + // which comes out of getFieldOptionLabel, which is the table-label prefix for join fields) // + ////////////////////////////////////////////////////////////////////////////////////////////// + function renderFieldOption(props: React.HTMLAttributes, option: any, state: AutocompleteRenderOptionState): ReactNode + { + let label = "" + if(option && option.field) + { + label = (option.field.label); + } + + return (
  • {label}
  • ); + } + + function isOperatorOptionEqual(option: OperatorOption, value: OperatorOption) + { + return (option?.value == value?.value && JSON.stringify(option?.implicitValues) == JSON.stringify(value?.implicitValues)); + } + + let criteriaIsValid = true; + let criteriaStatusTooltip = "This condition is fully defined and is part of your filter."; + + function isNotSet(value: any) + { + return (value === null || value == undefined || String(value).trim() === ""); + } + + if(!criteria.fieldName) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must select a field to begin to define this condition."; + } + else if(!criteria.operator) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must select an operator to continue to define this condition."; + } + else + { + if(operatorSelectedValue) + { + if (operatorSelectedValue.valueMode == ValueMode.NONE || operatorSelectedValue.implicitValues) + { + ////////////////////////////////// + // don't need to look at values // + ////////////////////////////////// + } + else if(operatorSelectedValue.valueMode == ValueMode.DOUBLE) + { + if(criteria.values.length < 2) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must enter two values to complete the definition of this condition."; + } + } + else if(operatorSelectedValue.valueMode == ValueMode.MULTI || operatorSelectedValue.valueMode == ValueMode.PVS_MULTI) + { + if(criteria.values.length < 1 || isNotSet(criteria.values[0])) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must enter one or more values complete the definition of this condition."; + } + } + else + { + if(isNotSet(criteria.values[0])) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must enter a value to complete the definition of this condition."; + } + } + } + } + + return ( + + + + close + + + + {booleanOperator && index > 0 ? + + + + : } + + + ()} + // @ts-ignore + defaultValue={defaultFieldValue} + options={fieldOptions} + onChange={handleFieldChange} + isOptionEqualToValue={(option, value) => isFieldOptionEqual(option, value)} + groupBy={fieldsGroupBy} + getOptionLabel={(option) => getFieldOptionLabel(option)} + renderOption={(props, option, state) => renderFieldOption(props, option, state)} + autoSelect={true} + autoHighlight={true} + /> + + + + ()} + options={operatorOptions} + value={operatorSelectedValue as any} + inputValue={operatorInputValue} + onChange={handleOperatorChange} + onInputChange={(e, value) => setOperatorInputValue(value)} + isOptionEqualToValue={(option, value) => isOperatorOptionEqual(option, value)} + getOptionLabel={(option: any) => option.label} + autoSelect={true} + autoHighlight={true} + /*disabled={criteria.fieldName == null}*/ + /> + + + + handleValueChange(event, valueIndex, newValue)} + /> + + + + { + criteriaIsValid + ? check + : pending + } + + + + ); +} diff --git a/src/qqq/components/query/FilterCriteriaRowValues.tsx b/src/qqq/components/query/FilterCriteriaRowValues.tsx new file mode 100644 index 0000000..e84c285 --- /dev/null +++ b/src/qqq/components/query/FilterCriteriaRowValues.tsx @@ -0,0 +1,129 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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"; +import {Chip} from "@mui/material"; +import Autocomplete from "@mui/material/Autocomplete"; +import Box from "@mui/material/Box"; +import TextField from "@mui/material/TextField"; +import React, {SyntheticEvent} from "react"; +import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; +import {OperatorOption, ValueMode} from "qqq/components/query/FilterCriteriaRow"; + +interface Props +{ + operatorOption: OperatorOption; + criteria: QFilterCriteriaWithId; + fieldType?: QFieldType; + valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void; +} + +FilterCriteriaRowValues.defaultProps = { +}; + +function FilterCriteriaRowValues({operatorOption, criteria, fieldType, valueChangeHandler}: Props): JSX.Element +{ + if(!operatorOption) + { + return
    + } + + const makeTextField = (valueIndex: number = 0, label = "Value", idPrefix="value-") => + { + let type = "search" + const inputLabelProps: any = {}; + + if(fieldType == QFieldType.INTEGER) + { + type = "number"; + } + else if(fieldType == QFieldType.DATE) + { + type = "date"; + inputLabelProps.shrink = true; + } + else if(fieldType == QFieldType.DATE_TIME) + { + type = "datetime-local"; + inputLabelProps.shrink = true; + } + + return valueChangeHandler(event, valueIndex)} + value={criteria.values[valueIndex]} + InputLabelProps={inputLabelProps} + fullWidth + // todo - x to clear value? + /> + } + + switch (operatorOption.valueMode) + { + case ValueMode.NONE: + return
    + case ValueMode.SINGLE: + return makeTextField(); + case ValueMode.SINGLE_DATE: + return makeTextField(); + case ValueMode.SINGLE_DATE_TIME: + return makeTextField(); + case ValueMode.DOUBLE: + return + + { makeTextField(0, "From", "from-") } + + + { makeTextField(1, "To", "to-") } + + ; + case ValueMode.MULTI: + let values = criteria.values; + if(values && values.length == 1 && values[0] == "") + { + values = []; + } + return ()} + options={[]} + multiple + freeSolo // todo - no debounce after enter? + selectOnFocus + clearOnBlur + limitTags={5} + value={values} + onChange={(event, value) => valueChangeHandler(event, "all", value)} + /> + case ValueMode.PVS_SINGLE: + break; + case ValueMode.PVS_MULTI: + break; + } + + return (
    ); +} + +export default FilterCriteriaRowValues; \ No newline at end of file diff --git a/src/qqq/pages/records/FilterPoc.tsx b/src/qqq/pages/records/FilterPoc.tsx new file mode 100644 index 0000000..a2ea88a --- /dev/null +++ b/src/qqq/pages/records/FilterPoc.tsx @@ -0,0 +1,75 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; +import {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 {useEffect, useState} from "react"; +import {CustomFilterPanel} from "qqq/components/query/CustomFilterPanel"; +import BaseLayout from "qqq/layouts/BaseLayout"; +import Client from "qqq/utils/qqq/Client"; + + +interface Props +{ +} + +FilterPoc.defaultProps = {}; + +function FilterPoc({}: Props): JSX.Element +{ + const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData) + const [queryFilter, setQueryFilter] = useState(new QQueryFilter()) + + const updateFilter = (newFilter: QQueryFilter) => + { + setQueryFilter(JSON.parse(JSON.stringify(newFilter))); + } + + useEffect(() => + { + (async () => + { + const table = await Client.getInstance().loadTableMetaData("order") + setTableMetaData(table); + })(); + }, []); + + return ( + + { + tableMetaData && + + + {/* @ts-ignore */} + + +
    +                  {JSON.stringify(queryFilter, null, 3)})
    +               
    +
    + } +
    + ); +} + +export default FilterPoc; diff --git a/src/qqq/pages/records/IntersectionMatrix.tsx b/src/qqq/pages/records/IntersectionMatrix.tsx new file mode 100644 index 0000000..54657c1 --- /dev/null +++ b/src/qqq/pages/records/IntersectionMatrix.tsx @@ -0,0 +1,123 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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 Box from "@mui/material/Box"; +import Checkbox from "@mui/material/Checkbox/Checkbox"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import {makeStyles} from "@mui/styles"; +import {useState} from "react"; +import BaseLayout from "qqq/layouts/BaseLayout"; + + +interface Props +{ + foo: string; +} + +IntersectionMatrix.defaultProps = { + foo: null, +}; + +const useStyles = makeStyles({ + sticky: { + position: "sticky", + left: 0, + top: 0, + background: "white", + boxShadow: "2px 2px 2px grey", + borderRight: "2px solid grey", + zIndex: 1 + } +}); + +function IntersectionMatrix({foo}: Props): JSX.Element +{ + const permissions = ["apiLog.delete", "apiLog.edit", "apiLog.insert", "apiLog.read", "apiLogUser.delete", "apiLogUser.edit", "apiLogUser.insert", "apiLogUser.read", "audit.delete", "audit.edit", "audit.insert", "audit.read", "auditDetail.delete", "auditDetail.edit", "auditDetail.insert", "auditDetail.read", "auditTable.delete", "auditTable.edit", "auditTable.insert", "auditTable.read", "auditUser.delete", "auditUser.edit", "auditUser.insert", "auditUser.read", "availableInventoryIndex.delete", "availableInventoryIndex.edit", "availableInventoryIndex.insert", "availableInventoryIndex.read", "availablePermission.delete", "availablePermission.edit", "availablePermission.insert", "availablePermission.read", "billing.hasAccess", "billingActivity.delete", "billingActivity.edit", "billingActivity.insert", "billingActivity.read", "billingDashboard.hasAccess", "billingWorksheet.delete", "billingWorksheet.edit", "billingWorksheet.insert", "billingWorksheet.read", "billingWorksheetLine.delete", "billingWorksheetLine.edit", "billingWorksheetLine.insert", "billingWorksheetLine.read", "billingWorksheetLineDetail.hasAccess", "billingWorksheetRevenueReport.hasAccess", "billingWorksheetSummary.hasAccess", "blackboxCartonization.delete", "blackboxCartonization.edit", "blackboxCartonization.insert", "blackboxCartonization.read", "blackboxStatus.delete", "blackboxStatus.edit", "blackboxStatus.insert", "blackboxStatus.read", "cancelBillingWorksheet.hasAccess", "carrier.delete", "carrier.edit", "carrier.insert", "carrier.read", "carrierAccount.delete", "carrierAccount.edit", "carrierAccount.insert", "carrierAccount.read", "carrierInvoicing.hasAccess", "carrierPerformance.hasAccess", "carrierPerformanceDashboard.hasAccess", "carrierRevenueReport.hasAccess", "carrierService.delete", "carrierService.edit", "carrierService.insert", "carrierService.read", "carrierServiceSlaExclusionDate.delete", "carrierServiceSlaExclusionDate.edit", "carrierServiceSlaExclusionDate.insert", "carrierServiceSlaExclusionDate.read", "cartonType.delete", "cartonType.edit", "cartonType.insert", "cartonType.read", "cartonization.hasAccess", "client.delete", "client.edit", "client.insert", "client.read", "clientAlias.delete", "clientAlias.edit", "clientAlias.insert", "clientAlias.read", "clientAuth0Application.delete", "clientAuth0Application.edit", "clientAuth0Application.insert", "clientAuth0Application.read", "clientAuth0ApplicationApiKey.delete", "clientAuth0ApplicationApiKey.edit", "clientAuth0ApplicationApiKey.insert", "clientAuth0ApplicationApiKey.read", "clientBillingKey.delete", "clientBillingKey.edit", "clientBillingKey.insert", "clientBillingKey.read", "clientFeeKey.delete", "clientFeeKey.edit", "clientFeeKey.insert", "clientFeeKey.read", "clientShipStationStore.delete", "clientShipStationStore.edit", "clientShipStationStore.insert", "clientShipStationStore.read", "closeBillingWorksheet.hasAccess", "connection.delete", "connection.edit", "connection.insert", "connection.read", "createBillingWorksheet.hasAccess", "createTestOrdersProcess.hasAccess", "dashboard.hasAccess", "dashboards.hasAccess", "dataBag.delete", "dataBag.edit", "dataBag.insert", "dataBag.read", "dataBagVersion.delete", "dataBagVersion.edit", "dataBagVersion.insert", "dataBagVersion.read", "dataHealthDashboard.hasAccess", "dataIndex.delete", "dataIndex.edit", "dataIndex.insert", "dataIndex.read", "deleteSavedFilter.hasAccess", "deposcoCreateTestOrdersJob.delete", "deposcoCreateTestOrdersJob.edit", "deposcoCreateTestOrdersJob.insert", "deposcoCreateTestOrdersJob.read", "deposcoCreateTestOrdersProcess.hasAccess", "deposcoCurrentExceptionsWidget.hasAccess", "deposcoCurrentStatusWidget.hasAccess", "deposcoCustomerOrder.delete", "deposcoCustomerOrder.edit", "deposcoCustomerOrder.insert", "deposcoCustomerOrder.read", "deposcoEnterpriseInventory.delete", "deposcoEnterpriseInventory.edit", "deposcoEnterpriseInventory.insert", "deposcoEnterpriseInventory.read", "deposcoItem.delete", "deposcoItem.edit", "deposcoItem.insert", "deposcoItem.read", "deposcoOrder.delete", "deposcoOrder.edit", "deposcoOrder.insert", "deposcoOrder.read", "deposcoOrderToOrder.hasAccess", "deposcoOrdersApp.hasAccess", "deposcoOrdersByClientPieChart.hasAccess", "deposcoPollForCustomerOrders.hasAccess", "deposcoPollForOrders.hasAccess", "deposcoRecentDataParentWidget.hasAccess", "deposcoReplaceLineItemProcess.hasAccess", "deposcoSalesOrder.delete", "deposcoSalesOrder.edit", "deposcoSalesOrder.insert", "deposcoSalesOrder.read", "deposcoSalesOrderLine.delete", "deposcoSalesOrderLine.edit", "deposcoSalesOrderLine.insert", "deposcoSalesOrderLine.read", "deposcoSalesOrdersBarChart.hasAccess", "deposcoSentOrder.delete", "deposcoSentOrder.edit", "deposcoSentOrder.insert", "deposcoSentOrder.read", "deposcoShipment.delete", "deposcoShipment.edit", "deposcoShipment.insert", "deposcoShipment.read", "deposcoShipmentToSystemGeneratedTrackingNo.hasAccess", "deposcoTradingPartner.delete", "deposcoTradingPartner.edit", "deposcoTradingPartner.insert", "deposcoTradingPartner.read", "developer.hasAccess", "easypostTracker.delete", "easypostTracker.edit", "easypostTracker.insert", "easypostTracker.read", "extensivOrder.delete", "extensivOrder.edit", "extensivOrder.insert", "extensivOrder.read", "extensivOrderToOrder.hasAccess", "fedexTntCache.delete", "fedexTntCache.edit", "fedexTntCache.insert", "fedexTntCache.read", "freightStudy.delete", "freightStudy.edit", "freightStudy.insert", "freightStudy.read", "freightStudyActualShipment.delete", "freightStudyActualShipment.edit", "freightStudyActualShipment.insert", "freightStudyActualShipment.read", "freightStudyAllShipmentsReport.hasAccess", "freightStudyAllShipmentsReportProcess.hasAccess", "freightStudyApp.hasAccess", "freightStudyEstimateShipments.hasAccess", "freightStudyEstimatedShipment.delete", "freightStudyEstimatedShipment.edit", "freightStudyEstimatedShipment.insert", "freightStudyEstimatedShipment.read", "freightStudyScenario.delete", "freightStudyScenario.edit", "freightStudyScenario.insert", "freightStudyScenario.read", "fuelSurcharge.delete", "fuelSurcharge.edit", "fuelSurcharge.insert", "fuelSurcharge.read", "fulfillment.hasAccess", "generateBillingActivityFromBillingWorksheet.hasAccess", "generateBillingWorksheetDocuments.hasAccess", "generateParcelInvoiceLineFromRawAxleHire.hasAccess", "generateParcelInvoiceLineFromRawCdl.hasAccess", "generateParcelInvoiceLineFromRawFedEx.hasAccess", "generateParcelInvoiceLineFromRawLso.hasAccess", "generateParcelInvoiceLineFromRawOntrac.hasAccess", "generateParcelInvoiceLineFromRawUps.hasAccess", "graceDiscountAuditReport.hasAccess", "infoplusLOB.delete", "infoplusLOB.edit", "infoplusLOB.insert", "infoplusLOB.read", "infoplusOrder.delete", "infoplusOrder.edit", "infoplusOrder.insert", "infoplusOrder.read", "infoplusOrderToOrder.hasAccess", "infoplusShipment.delete", "infoplusShipment.edit", "infoplusShipment.insert", "infoplusShipment.read", "infoplusShipmentToSystemGeneratedTrackingNumber.hasAccess", "infoplusWarehouse.delete", "infoplusWarehouse.edit", "infoplusWarehouse.insert", "infoplusWarehouse.read", "initParcelSlaStatus.hasAccess", "integrations.hasAccess", "lineItem.delete", "lineItem.edit", "lineItem.insert", "lineItem.read", "manualUpdateInvoiceLineFromRaw.hasAccess", "markBillingActivityAsException.hasAccess", "markParcelInvoiceLineAsOrphan.hasAccess", "mergeDuplicatedParcels.hasAccess", "omsOperationsDashboard.hasAccess", "optimization.hasAccess", "optimizationCarrierServiceRulesChecker.hasAccess", "optimizationCarrierServiceStateRule.delete", "optimizationCarrierServiceStateRule.edit", "optimizationCarrierServiceStateRule.insert", "optimizationCarrierServiceStateRule.read", "optimizationCarrierServiceTNTRule.delete", "optimizationCarrierServiceTNTRule.edit", "optimizationCarrierServiceTNTRule.insert", "optimizationCarrierServiceTNTRule.read", "optimizationCarrierServiceZipCodeRule.delete", "optimizationCarrierServiceZipCodeRule.edit", "optimizationCarrierServiceZipCodeRule.insert", "optimizationCarrierServiceZipCodeRule.read", "optimizationConfig.delete", "optimizationConfig.edit", "optimizationConfig.insert", "optimizationConfig.read", "optimizationConfigApp.hasAccess", "optimizationDashboard.hasAccess", "optimizationRateChecker.hasAccess", "optimizationRulesChecker.hasAccess", "optimizationStateRule.delete", "optimizationStateRule.edit", "optimizationStateRule.insert", "optimizationStateRule.read", "optimizationTNTRule.delete", "optimizationTNTRule.edit", "optimizationTNTRule.insert", "optimizationTNTRule.read", "optimizationWarehouseRoutingStateRule.delete", "optimizationWarehouseRoutingStateRule.edit", "optimizationWarehouseRoutingStateRule.insert", "optimizationWarehouseRoutingStateRule.read", "optimizationWarehouseRoutingZipCodeRule.delete", "optimizationWarehouseRoutingZipCodeRule.edit", "optimizationWarehouseRoutingZipCodeRule.insert", "optimizationWarehouseRoutingZipCodeRule.read", "optimizationZipCodeRule.delete", "optimizationZipCodeRule.edit", "optimizationZipCodeRule.insert", "optimizationZipCodeRule.read", "order.delete", "order.edit", "order.insert", "order.read", "orderAndShipmentPerformanceDashboard.hasAccess", "orderCarton.delete", "orderCarton.edit", "orderCarton.insert", "orderCarton.read", "orderCartonization.delete", "orderCartonization.edit", "orderCartonization.insert", "orderCartonization.read", "orderExtrinsic.delete", "orderExtrinsic.edit", "orderExtrinsic.insert", "orderExtrinsic.read", "orderOptimization.hasAccess", "orders.hasAccess", "ordersAndShipmentsReport.hasAccess", "outboundApiLog.delete", "outboundApiLog.edit", "outboundApiLog.insert", "outboundApiLog.read", "outboundScannedTrackingNumber.delete", "outboundScannedTrackingNumber.edit", "outboundScannedTrackingNumber.insert", "outboundScannedTrackingNumber.read", "outboundScannedTrackingNumberToParcel.hasAccess", "overview.hasAccess", "overviewDashboard.hasAccess", "parcel.delete", "parcel.edit", "parcel.insert", "parcel.read", "parcelHealthApp.hasAccess", "parcelInvoice.delete", "parcelInvoice.edit", "parcelInvoice.insert", "parcelInvoice.read", "parcelInvoiceLine.delete", "parcelInvoiceLine.edit", "parcelInvoiceLine.insert", "parcelInvoiceLine.read", "parcelInvoiceLineChargeMappingRule.delete", "parcelInvoiceLineChargeMappingRule.edit", "parcelInvoiceLineChargeMappingRule.insert", "parcelInvoiceLineChargeMappingRule.read", "parcelInvoiceLineChargeRollupRule.read", "parcelInvoiceLineToParcel.hasAccess", "parcelInvoiceRawETLAxleHire.hasAccess", "parcelInvoiceRawETLCdl.hasAccess", "parcelInvoiceRawETLFedEx.hasAccess", "parcelInvoiceRawETLLso.hasAccess", "parcelInvoiceRawETLOntrac.hasAccess", "parcelInvoiceRawETLUps.hasAccess", "parcelInvoiceShiplabsSyncAxleHire.hasAccess", "parcelInvoiceShiplabsSyncCdl.hasAccess", "parcelInvoiceShiplabsSyncFedEx.hasAccess", "parcelInvoiceShiplabsSyncLso.hasAccess", "parcelInvoiceShiplabsSyncOntrac.hasAccess", "parcelInvoiceShiplabsSyncUps.hasAccess", "parcelSlaStatus.delete", "parcelSlaStatus.edit", "parcelSlaStatus.insert", "parcelSlaStatus.read", "parcelTrackingDetail.delete", "parcelTrackingDetail.edit", "parcelTrackingDetail.insert", "parcelTrackingDetail.read", "parcels.hasAccess", "pollExtensiveForOrders.hasAccess", "pushDeposcoSalesOrders.hasAccess", "querySavedFilter.hasAccess", "rawParcelInvoiceLineAxleHire.delete", "rawParcelInvoiceLineAxleHire.edit", "rawParcelInvoiceLineAxleHire.insert", "rawParcelInvoiceLineAxleHire.read", "rawParcelInvoiceLineCdl.delete", "rawParcelInvoiceLineCdl.edit", "rawParcelInvoiceLineCdl.insert", "rawParcelInvoiceLineCdl.read", "rawParcelInvoiceLineFedEx.delete", "rawParcelInvoiceLineFedEx.edit", "rawParcelInvoiceLineFedEx.insert", "rawParcelInvoiceLineFedEx.read", "rawParcelInvoiceLineLso.delete", "rawParcelInvoiceLineLso.edit", "rawParcelInvoiceLineLso.insert", "rawParcelInvoiceLineLso.read", "rawParcelInvoiceLineOntrac.delete", "rawParcelInvoiceLineOntrac.edit", "rawParcelInvoiceLineOntrac.insert", "rawParcelInvoiceLineOntrac.read", "rawParcelInvoiceLineUps.delete", "rawParcelInvoiceLineUps.edit", "rawParcelInvoiceLineUps.insert", "rawParcelInvoiceLineUps.read", "receiveEasypostTrackerWebhook.hasAccess", "reconcileClientsOnParcelInvoiceLine.hasAccess", "reconcileClientsOnParcelInvoiceLineFromBillingWorksheet.hasAccess", "reevaluateParcelSlaStatus.hasAccess", "registerParcelAsEasypostTracker.hasAccess", "releaseOrderToWmsProcess.hasAccess", "releaseOrdersJob.delete", "releaseOrdersJob.edit", "releaseOrdersJob.insert", "releaseOrdersJob.read", "releaseOrdersJobOrder.delete", "releaseOrdersJobOrder.edit", "releaseOrdersJobOrder.insert", "releaseOrdersJobOrder.read", "releaseOrdersToWmsProcess.hasAccess", "replaceLineItem.hasAccess", "resyncOrderFromSource.hasAccess", "resyncParcelTrackingStatus.hasAccess", "resyncSystemGeneratedTrackingNumberFromSource.hasAccess", "retrySendingReleaseOrdersJob.hasAccess", "runBillingWorksheetRevenueReport.hasAccess", "runRecordScript.hasAccess", "salesOrderAutomation.hasAccess", "savedFilter.delete", "savedFilter.edit", "savedFilter.insert", "savedFilter.read", "script.delete", "script.edit", "script.insert", "script.read", "scriptLog.delete", "scriptLog.edit", "scriptLog.insert", "scriptLog.read", "scriptLogLine.delete", "scriptLogLine.edit", "scriptLogLine.insert", "scriptLogLine.read", "scriptRevision.delete", "scriptRevision.edit", "scriptRevision.insert", "scriptRevision.read", "scriptType.delete", "scriptType.edit", "scriptType.insert", "scriptType.read", "setup.hasAccess", "shipStationOrder0.delete", "shipStationOrder0.edit", "shipStationOrder0.insert", "shipStationOrder0.read", "shipStationOrderToOrder0.hasAccess", "shipStationShipment0.delete", "shipStationShipment0.edit", "shipStationShipment0.insert", "shipStationShipment0.read", "shipStationShipmentToSystemGeneratedTrackingNumber0.hasAccess", "shipStationShipmentToSystemGeneratedTrackingNumber1.hasAccess", "shipStationShipmentToSystemGeneratedTrackingNumber2.hasAccess", "shipStationShipmentToSystemGeneratedTrackingNumber3.hasAccess", "shipStationShipmentToSystemGeneratedTrackingNumber4.hasAccess", "shipStationStore0.delete", "shipStationStore0.edit", "shipStationStore0.insert", "shipStationStore0.read", "shipStationWarehouse0.delete", "shipStationWarehouse0.edit", "shipStationWarehouse0.insert", "shipStationWarehouse0.read", "shippedOrderToExtensivOrder.hasAccess", "shipping.hasAccess", "shippingDashboard.hasAccess", "storeDataBagVersion.hasAccess", "storeSavedFilter.hasAccess", "storeScriptRevision.hasAccess", "systemGeneratedTrackingNumber.delete", "systemGeneratedTrackingNumber.edit", "systemGeneratedTrackingNumber.insert", "systemGeneratedTrackingNumber.read", "systemGeneratedTrackingNumberToParcel.hasAccess", "tableTrigger.delete", "tableTrigger.edit", "tableTrigger.insert", "tableTrigger.read", "testScript.hasAccess", "totalDeposcoOrdersImported.hasAccess", "uploadFileArchive.delete", "uploadFileArchive.edit", "uploadFileArchive.insert", "uploadFileArchive.read", "warehouse.delete", "warehouse.edit", "warehouse.insert", "warehouse.read", "warehouseClientInt.delete", "warehouseClientInt.edit", "warehouseClientInt.insert", "warehouseClientInt.read", "warehouseShipStationWarehouse.delete", "warehouseShipStationWarehouse.edit", "warehouseShipStationWarehouse.insert", "warehouseShipStationWarehouse.read", "zipZone.delete", "zipZone.edit", "zipZone.insert", "zipZone.read", "zipZoneCdl.delete", "zipZoneCdl.edit", "zipZoneCdl.insert", "zipZoneCdl.read"]; + permissions.splice(50) + const roles = ["External - Customer - OMS API User", "External - Customer - Reports API", "External - Customer - Viewer", "External - Deposco - Cartonization API", "External - Optimization - Viewer", "Internal - Carrier Invoicing - Admin", "Internal - Carrier Invoicing - User", "Internal - Carrier Invoicing - Viewer", "Internal - Developer - Admin", "Internal - Engineering Team - Admin", "Internal - Executive Team", "Internal - Freight Study - Admin", "Internal - Freight Study - User", "Internal - Freight Study - Viewer", "Internal - Integrations - Viewer", "Internal - Optimization - Admin", "Internal - Optimization - User", "Internal - Optimization - Viewer", "Internal - Orders & Parcels - Admin", "Internal - Orders & Parcels - User"]; + + const classes = useStyles(); + + return ( + + + + {/* display: fixes apparent bug in mui? */} + + + { + roles.map((name) => ( + + {name} + + )) + } + + + + { + permissions.map((name) => ( + + + {name.split(/(?=[A-Z.])/).map((part, index) => ( + {part} + ))} + + { + roles.map((role) => ( + + + + )) + } + + )) + } + +
    +
    +
    + ); + + return ( + + + { + permissions.map((name) => + { + return ( + + {name} + + ) + }) + } + + + ); +} + +export default IntersectionMatrix; diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index cdc303a..4da63d7 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -59,6 +59,7 @@ import {QActionsMenuButton, QCancelButton, QCreateNewButton, QSaveButton} from " import MenuButton from "qqq/components/buttons/MenuButton"; import SavedFilters from "qqq/components/misc/SavedFilters"; import {CustomColumnsPanel} from "qqq/components/query/CustomColumnsPanel"; +import {CustomFilterPanel} from "qqq/components/query/CustomFilterPanel"; import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip"; import BaseLayout from "qqq/layouts/BaseLayout"; import ProcessRun from "qqq/pages/processes/ProcessRun"; @@ -154,7 +155,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } const [filterModel, setFilterModel] = useState({items: []} as GridFilterModel); + const [lastFetchedQFilterJSON, setLastFetchedQFilterJSON] = useState(""); const [columnSortModel, setColumnSortModel] = useState(defaultSort); + const [queryFilter, setQueryFilter] = useState(new QQueryFilter()); + const [columnVisibilityModel, setColumnVisibilityModel] = useState(defaultVisibility); const [shouldSetAllNewJoinFieldsToHidden, setShouldSetAllNewJoinFieldsToHidden] = useState(!didDefaultVisibilityComeFromLocalStorage) const [visibleJoinTables, setVisibleJoinTables] = useState(new Set()); @@ -217,7 +221,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [queryErrors, setQueryErrors] = useState({} as any); const [receivedQueryErrorTimestamp, setReceivedQueryErrorTimestamp] = useState(new Date()); - const {setPageHeader} = useContext(QContext); const [, forceUpdate] = useReducer((x) => x + 1, 0); @@ -335,6 +338,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element updateColumnVisibilityModel(); setColumnsModel([]); setFilterModel({items: []}); + setQueryFilter(new QQueryFilter()); setDefaultFilterLoaded(false); setRows([]); } @@ -512,6 +516,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element let models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, null, searchParams, filterLocalStorageKey, sortLocalStorageKey); setFilterModel(models.filter); setColumnSortModel(models.sort); + setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, models.filter, models.sort, rowsPerPage)); return; } @@ -545,6 +550,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { columnSortModel.splice(i, 1); setColumnSortModel(columnSortModel); + // todo - need to setQueryFilter? resetColumnSortModel = true; i--; } @@ -561,6 +567,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element sort: "desc", }); setColumnSortModel(columnSortModel); + // todo - need to setQueryFilter? resetColumnSortModel = true; } @@ -617,6 +624,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element }); } + setLastFetchedQFilterJSON(JSON.stringify(qFilter)); qController.query(tableName, qFilter, queryJoins).then((results) => { console.log(`Received results for query ${thisQueryId}`); @@ -859,6 +867,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const newVisibleJoinTables = getVisibleJoinTables(); if (JSON.stringify([...newVisibleJoinTables.keys()]) != JSON.stringify([...visibleJoinTables.keys()])) { + console.log("calling update table for visible join table change"); updateTable(); setVisibleJoinTables(newVisibleJoinTables); } @@ -870,9 +879,18 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element console.log(columnOrderChangeParams); }; - const handleFilterChange = (filterModel: GridFilterModel) => + const handleFilterChange = (filterModel: GridFilterModel, doSetQueryFilter = true) => { setFilterModel(filterModel); + + if(doSetQueryFilter) + { + ////////////////////////////////////////////////////////////////////////////////// + // someone might have already set the query filter, so, only set it if asked to // + ////////////////////////////////////////////////////////////////////////////////// + setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, rowsPerPage)); + } + if (filterLocalStorageKey) { localStorage.setItem(filterLocalStorageKey, JSON.stringify(filterModel)); @@ -884,6 +902,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element if (gridSort && gridSort.length > 0) { setColumnSortModel(gridSort); + setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, gridSort, rowsPerPage)); localStorage.setItem(sortLocalStorageKey, JSON.stringify(gridSort)); } }; @@ -948,8 +967,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ////////////////////////////////////// // construct the url for the export // ////////////////////////////////////// - const d = new Date(); - const dateString = `${d.getFullYear()}-${zp(d.getMonth() + 1)}-${zp(d.getDate())} ${zp(d.getHours())}${zp(d.getMinutes())}`; + const dateString = ValueUtils.formatDateTimeForFileName(new Date()); const filename = `${tableMetaData.label} Export ${dateString}.${format}`; const url = `/data/${tableMetaData.name}/export/${filename}?filter=${encodeURIComponent(JSON.stringify(buildQFilter(tableMetaData, filterModel)))}&fields=${visibleFields.join(",")}`; @@ -1087,6 +1105,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element newPath.pop(); navigate(newPath.join("/")); + console.log("calling update table for close modal"); updateTable(); }; @@ -1196,6 +1215,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return ( + { + if(fieldName.indexOf(".") > -1) + { + const nameParts = fieldName.split(".", 2); + for (let i = 0; i < tableMetaData?.exposedJoins?.length; i++) + { + const join = tableMetaData?.exposedJoins[i]; + if(join?.joinTable.name == nameParts[0]) + { + return ([join.joinTable.fields.get(nameParts[1]), join.joinTable]); + } + } + } + else + { + return ([tableMetaData.fields.get(fieldName), tableMetaData]); + } + + return (null); + } + const copyColumnValues = async (column: GridColDef) => { let data = ""; let counter = 0; if (latestQueryResults && latestQueryResults.length) { - let qFieldMetaData = tableMetaData.fields.get(column.field); + let [qFieldMetaData, fieldTable] = getFieldAndTable(column.field); for (let i = 0; i < latestQueryResults.length; i++) { let record = latestQueryResults[i] as QRecord; - const value = ValueUtils.getUnadornedValueForDisplay(qFieldMetaData, record.values.get(qFieldMetaData.name), record.displayValues.get(qFieldMetaData.name)); + const value = ValueUtils.getUnadornedValueForDisplay(qFieldMetaData, record.values.get(column.field), record.displayValues.get(column.field)); if (value !== null && value !== undefined && String(value) !== "") { data += value + "\n"; @@ -1293,24 +1335,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setFilterForColumnStats(buildQFilter(tableMetaData, filterModel)); setColumnStatsFieldName(column.field); - if(column.field.indexOf(".") > -1) - { - const nameParts = column.field.split(".", 2); - for (let i = 0; i < tableMetaData?.exposedJoins?.length; i++) - { - const join = tableMetaData?.exposedJoins[i]; - if(join?.joinTable.name == nameParts[0]) - { - setColumnStatsField(join.joinTable.fields.get(nameParts[1])); - setColumnStatsFieldTableName(nameParts[0]); - } - } - } - else - { - setColumnStatsField(tableMetaData.fields.get(column.field)); - setColumnStatsFieldTableName(tableMetaData.name); - } + const [field, fieldTable] = getFieldAndTable(column.field); + setColumnStatsField(field); + setColumnStatsFieldTableName(fieldTable.name); }; const CustomColumnMenu = forwardRef( @@ -1474,6 +1501,15 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }; + const doClearFilter = (event: React.KeyboardEvent, isYesButton: boolean = false) => + { + if (isYesButton|| event.key == "Enter") + { + setShowClearFiltersWarning(false); + handleFilterChange({items: []} as GridFilterModel); + } + } + return (
    @@ -1488,30 +1524,18 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { hasValidFilters && ( - -
    - +
    + setShowClearFiltersWarning(true)}>clear - setShowClearFiltersWarning(false)} onKeyPress={(e) => - { - if (e.key == "Enter") - { - setShowClearFiltersWarning(false) - handleFilterChange({items: []} as GridFilterModel); - } - }}> + setShowClearFiltersWarning(false)} onKeyPress={(e) => doClearFilter(e)}> Confirm - Are you sure you want to clear all filters? + Are you sure you want to remove all conditions from the current filter? setShowClearFiltersWarning(false)} /> - - { - setShowClearFiltersWarning(false); - handleFilterChange({items: []} as GridFilterModel); - }}/> + doClearFilter(null, true)}/>
    @@ -1676,6 +1700,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 // //////////////////////////////////////////////////////////////////////////////////////// + // console.log("calling update table for UE 1"); updateTable(); } }, [pageNumber, rowsPerPage, columnSortModel, currentSavedFilter]); @@ -1687,8 +1712,31 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { setTotalRecords(null); setDistinctRecords(null); + // console.log("calling update table for UE 2"); updateTable(); - }, [columnsModel, tableState, filterModel]); + }, [columnsModel, tableState]); + + useEffect(() => + { + const currentQFilter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, rowsPerPage); + currentQFilter.skip = pageNumber * rowsPerPage; + const currentQFilterJSON = JSON.stringify(currentQFilter); + + // console.log(`current ${currentQFilterJSON}`); + // console.log(`last... ${lastFetchedQFilterJSON}`); + if(currentQFilterJSON !== lastFetchedQFilterJSON) + { + setTotalRecords(null); + setDistinctRecords(null); + // console.log("calling update table for UE 3"); + updateTable(); + } + else + { + // console.log("NOT calling update table for UE 3!!"); + } + + }, [filterModel]); useEffect(() => { @@ -1696,6 +1744,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element document.scrollingElement.scrollTop = 0; }, [pageNumber, rowsPerPage]); + const updateFilter = (newFilter: QQueryFilter): void => + { + setQueryFilter(newFilter); + const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); + handleFilterChange(gridFilterModel, false); + } + if (tableMetaData && !tableMetaData.readPermission) { return ( @@ -1769,7 +1824,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission && } - @@ -1779,7 +1833,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element Pagination: CustomPagination, LoadingOverlay: Loading, ColumnMenu: CustomColumnMenu, - ColumnsPanel: CustomColumnsPanel + ColumnsPanel: CustomColumnsPanel, + FilterPanel: CustomFilterPanel }} componentsProps={{ columnsPanel: @@ -1789,8 +1844,21 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element openGroupsChanger: setColumnChooserOpenGroups, initialFilterText: columnChooserFilterText, filterTextChanger: setColumnChooserFilterText + }, + filterPanel: + { + tableMetaData: tableMetaData, + queryFilter: queryFilter, + updateFilter: updateFilter } }} + localeText={{ + toolbarFilters: "Filter", // label on the filters button. we prefer singular (1 filter has many "conditions" in it). + toolbarFiltersLabel: "", // setting these 3 to "" turns off the "Show Filters" and "Hide Filters" tooltip (which can get in the way of the actual filters panel) + toolbarFiltersTooltipShow: "", + toolbarFiltersTooltipHide: "", + toolbarFiltersTooltipActive: count => count !== 1 ? `${count} conditions` : `${count} condition` + }} pinnedColumns={pinnedColumns} onPinnedColumnsChange={handlePinnedColumnsChange} pagination @@ -1812,7 +1880,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element density={density} loading={loading} filterModel={filterModel} - onFilterModelChange={handleFilterChange} + onFilterModelChange={(model) => handleFilterChange(model)} columnVisibilityModel={columnVisibilityModel} onColumnVisibilityModelChange={handleColumnVisibilityChange} onColumnOrderChange={handleColumnOrderChange} diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index 24b2451..ce5a40d 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -405,4 +405,72 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } height: 15px !important; position: relative; top: 3px; -} \ No newline at end of file +} + +.blobIcon +{ + margin-left: 0.25rem; + margin-right: 0.25rem; + cursor: pointer; +} + +/* move the columns & filter panels on the query screen data grid up to not be below the column headers row */ +/* todo - add a class to the query screen and qualify this like that */ +.MuiDataGrid-panel +{ + top: -60px !important; +} + +.customFilterPanel .MuiAutocomplete-paper +{ + line-height: 1.375; +} + +.customFilterPanel .MuiAutocomplete-groupLabel +{ + line-height: 1.75; +} + +.customFilterPanel .MuiAutocomplete-listbox +{ + max-height: 60vh; +} + +.customFilterPanel .booleanOperatorColumn .MuiSelect-iconStandard, +.customFilterPanel .MuiSvgIcon-root +{ + font-size: 14px !important; +} + +.customFilterPanel .booleanOperatorColumn .MuiSvgIcon-root +{ + display: inline-block !important; +} + +.customFilterPanel .booleanOperatorColumn .MuiInputBase-formControl +{ + padding-bottom: calc(0.25rem + 1px); +} + +.customFilterPanel .booleanOperatorColumn .MuiSelect-iconStandard +{ + top: calc(50% - 0.75rem); +} + +.customFilterPanel .filterValuesColumn .MuiChip-root +{ + background: none; + color: black; + border: 1px solid gray; +} + +.customFilterPanel .filterValuesColumn .MuiChip-root .MuiChip-deleteIcon +{ + color: gray; +} + +.customFilterPanel .filterValuesColumn .MuiAutocomplete-tag +{ + color: #191919; + background: none; +} diff --git a/src/qqq/utils/qqq/FilterUtils.ts b/src/qqq/utils/qqq/FilterUtils.ts index 0aebaf7..7830a8e 100644 --- a/src/qqq/utils/qqq/FilterUtils.ts +++ b/src/qqq/utils/qqq/FilterUtils.ts @@ -27,7 +27,7 @@ import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QC import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; -import {GridFilterModel, GridLinkOperator, GridSortItem} from "@mui/x-data-grid-pro"; +import {GridFilterItem, GridFilterModel, GridLinkOperator, GridSortItem} from "@mui/x-data-grid-pro"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; const CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedFilterId"; @@ -256,7 +256,7 @@ class FilterUtils } else if (operator === QCriteriaOperator.IN || operator === QCriteriaOperator.NOT_IN || operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN) { - if (value == null && (operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN)) + if ((value == null || value.length < 2) && (operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN)) { ///////////////////////////////////////////////////////////////////////////////////////////////// // if we send back null, we get a 500 - bad look every time you try to set up a BETWEEN filter // @@ -538,6 +538,63 @@ class FilterUtils } + /******************************************************************************* + ** build a grid filter from a qqq filter + *******************************************************************************/ + public static buildGridFilterFromQFilter(tableMetaData: QTableMetaData, queryFilter: QQueryFilter): GridFilterModel + { + const gridItems: GridFilterItem[] = []; + + for (let i = 0; i < queryFilter.criteria.length; i++) + { + const criteria = queryFilter.criteria[i]; + const [field, fieldTable] = FilterUtils.getField(tableMetaData, criteria.fieldName); + if (field) + { + gridItems.push({columnField: criteria.fieldName, id: i, operatorValue: FilterUtils.qqqCriteriaOperatorToGrid(criteria.operator, field, criteria.values), value: FilterUtils.qqqCriteriaValuesToGrid(criteria.operator, criteria.values, field)}); + } + } + + const gridFilter: GridFilterModel = {items: gridItems, linkOperator: queryFilter.booleanOperator == "AND" ? GridLinkOperator.And : GridLinkOperator.Or}; + return (gridFilter); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public static getField(tableMetaData: QTableMetaData, fieldName: string): [QFieldMetaData, QTableMetaData] + { + if (fieldName == null) + { + return ([null, null]); + } + + if (fieldName.indexOf(".") > -1) + { + let parts = fieldName.split(".", 2); + if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length) + { + for (let i = 0; i < tableMetaData.exposedJoins.length; i++) + { + const joinTable = tableMetaData.exposedJoins[i].joinTable; + if (joinTable.name == parts[0]) + { + return ([joinTable.fields.get(parts[1]), joinTable]); + } + } + } + + console.log(`Failed to find join field: ${fieldName}`); + return ([null, null]); + } + else + { + return ([tableMetaData.fields.get(fieldName), tableMetaData]); + } + } + + /******************************************************************************* ** build a qqq filter from a grid and column sort model *******************************************************************************/ From 6ad2c9825240d38ee7d8978deb0dec6c0e4d8720 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 2 Jun 2023 11:27:44 -0500 Subject: [PATCH 30/59] Try to fix bulk edit test (routes) --- .../kingsrook/qqq/materialdashboard/lib/QBaseSeleniumTest.java | 3 ++- .../kingsrook/qqq/materialdashboard/tests/BulkEditTest.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QBaseSeleniumTest.java b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QBaseSeleniumTest.java index 1ea9301..8f3ece1 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QBaseSeleniumTest.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QBaseSeleniumTest.java @@ -74,7 +74,8 @@ public class QBaseSeleniumTest .withRouteToFile("/metaData", "metaData/index.json") .withRouteToFile("/metaData/authentication", "metaData/authentication.json") .withRouteToFile("/metaData/table/person", "metaData/table/person.json") - .withRouteToFile("/metaData/table/city", "metaData/table/person.json"); + .withRouteToFile("/metaData/table/city", "metaData/table/person.json") + .withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json"); } diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/BulkEditTest.java b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/BulkEditTest.java index 54ec041..fc78abd 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/BulkEditTest.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/BulkEditTest.java @@ -39,7 +39,6 @@ public class BulkEditTest extends QBaseSeleniumTest @Override protected void addJavalinRoutes(QSeleniumJavalin qSeleniumJavalin) { - super.addJavalinRoutes(qSeleniumJavalin); addCommonRoutesForThisTest(qSeleniumJavalin); qSeleniumJavalin .withRouteToFile("/metaData/process/person.bulkEdit", "metaData/process/person.bulkEdit.json") @@ -56,8 +55,10 @@ public class BulkEditTest extends QBaseSeleniumTest *******************************************************************************/ private void addCommonRoutesForThisTest(QSeleniumJavalin qSeleniumJavalin) { + super.addJavalinRoutes(qSeleniumJavalin); qSeleniumJavalin.withRouteToFile("/data/person/count", "data/person/count.json"); qSeleniumJavalin.withRouteToFile("/data/person/query", "data/person/index.json"); + qSeleniumJavalin.withRouteToString("/processes/person.bulkEdit/74a03a7d-2f53-4784-9911-3a21f7646c43/records", "[]"); } From 5d42f2969642779320b19ca98c4bc19c5e625f72 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 2 Jun 2023 11:39:55 -0500 Subject: [PATCH 31/59] Remove redundant route --- .../kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java index b35a948..1252d6b 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java @@ -46,7 +46,6 @@ public class SavedFiltersTest extends QBaseSeleniumTest protected void addJavalinRoutes(QSeleniumJavalin qSeleniumJavalin) { addStandardRoutesForThisTest(qSeleniumJavalin); - qSeleniumJavalin.withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json"); } From 0eb7b9faa1aab6fbee5eda3f1523893fc35abfe2 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 2 Jun 2023 11:53:02 -0500 Subject: [PATCH 32/59] treat routes as map where paths are keys, and they can be overwritten - more flexibilty in setting up tests w/ custom override paths --- .../lib/javalin/QSeleniumJavalin.java | 27 ++++++++++--------- .../lib/javalin/RouteFromFileHandler.java | 7 +++-- .../lib/javalin/RouteFromStringHandler.java | 7 +++-- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/QSeleniumJavalin.java b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/QSeleniumJavalin.java index 65c62ec..455b34b 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/QSeleniumJavalin.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/QSeleniumJavalin.java @@ -3,10 +3,11 @@ package com.kingsrook.qqq.materialdashboard.lib.javalin; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import com.kingsrook.qqq.materialdashboard.lib.QSeleniumLib; import io.javalin.Javalin; -import org.apache.commons.lang3.tuple.Pair; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.eclipse.jetty.server.Connector; @@ -25,8 +26,8 @@ public class QSeleniumJavalin private long WAIT_SECONDS = 10; - private List> routesToFiles = new ArrayList<>(); - private List> routesToStrings = new ArrayList<>(); + private Map routesToFiles = new LinkedHashMap<>(); + private Map routesToStrings = new LinkedHashMap<>(); private Javalin javalin; @@ -71,9 +72,9 @@ public class QSeleniumJavalin { if(this.routesToFiles == null) { - this.routesToFiles = new ArrayList<>(); + this.routesToFiles = new LinkedHashMap<>(); } - this.routesToFiles.add(Pair.of(path, fixtureFilePath)); + this.routesToFiles.put(path, fixtureFilePath); return (this); } @@ -86,9 +87,9 @@ public class QSeleniumJavalin { if(this.routesToStrings == null) { - this.routesToStrings = new ArrayList<>(); + this.routesToStrings = new LinkedHashMap<>(); } - this.routesToStrings.add(Pair.of(path, responseString)); + this.routesToStrings.put(path, responseString); return (this); } @@ -105,11 +106,11 @@ public class QSeleniumJavalin { javalin.routes(() -> { - for(Pair routeToFile : routesToFiles) + for(Map.Entry routeToFile : routesToFiles.entrySet()) { LOG.debug("Setting up route for [" + routeToFile.getKey() + "] => [" + routeToFile.getValue() + "]"); - get(routeToFile.getKey(), new RouteFromFileHandler(this, routeToFile)); - post(routeToFile.getKey(), new RouteFromFileHandler(this, routeToFile)); + get(routeToFile.getKey(), new RouteFromFileHandler(this, routeToFile.getKey(), routeToFile.getValue())); + post(routeToFile.getKey(), new RouteFromFileHandler(this, routeToFile.getKey(), routeToFile.getValue())); } }); } @@ -118,11 +119,11 @@ public class QSeleniumJavalin { javalin.routes(() -> { - for(Pair routeToString : routesToStrings) + for(Map.Entry routeToString : routesToStrings.entrySet()) { LOG.debug("Setting up route for [" + routeToString.getKey() + "] => [" + routeToString.getValue() + "]"); - get(routeToString.getKey(), new RouteFromStringHandler(this, routeToString)); - post(routeToString.getKey(), new RouteFromStringHandler(this, routeToString)); + get(routeToString.getKey(), new RouteFromStringHandler(this, routeToString.getKey(), routeToString.getValue())); + post(routeToString.getKey(), new RouteFromStringHandler(this, routeToString.getKey(), routeToString.getValue())); } }); } diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromFileHandler.java b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromFileHandler.java index 364664c..3861623 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromFileHandler.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromFileHandler.java @@ -6,7 +6,6 @@ import java.util.List; import io.javalin.http.Context; import io.javalin.http.Handler; import org.apache.commons.io.IOUtils; -import org.apache.commons.lang3.tuple.Pair; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -28,11 +27,11 @@ public class RouteFromFileHandler implements Handler ** Constructor ** *******************************************************************************/ - public RouteFromFileHandler(QSeleniumJavalin qSeleniumJavalin, Pair routeToFilePath) + public RouteFromFileHandler(QSeleniumJavalin qSeleniumJavalin, String route, String filePath) { this.qSeleniumJavalin = qSeleniumJavalin; - this.route = routeToFilePath.getKey(); - this.filePath = routeToFilePath.getValue(); + this.route = route; + this.filePath = filePath; } diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromStringHandler.java b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromStringHandler.java index 3dc7e93..9de15a8 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromStringHandler.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromStringHandler.java @@ -3,7 +3,6 @@ package com.kingsrook.qqq.materialdashboard.lib.javalin; import io.javalin.http.Context; import io.javalin.http.Handler; -import org.apache.commons.lang3.tuple.Pair; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -25,11 +24,11 @@ public class RouteFromStringHandler implements Handler ** Constructor ** *******************************************************************************/ - public RouteFromStringHandler(QSeleniumJavalin qSeleniumJavalin, Pair routeToStringPath) + public RouteFromStringHandler(QSeleniumJavalin qSeleniumJavalin, String route, String responseString) { this.qSeleniumJavalin = qSeleniumJavalin; - this.route = routeToStringPath.getKey(); - this.responseString = routeToStringPath.getValue(); + this.route = route; + this.responseString = responseString; } From 1328597f700ec9801556822262e47788fd451a59 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 5 Jun 2023 08:08:22 -0500 Subject: [PATCH 33/59] Checkpoint --- .../components/query/CustomFilterPanel.tsx | 5 +- .../components/query/FilterCriteriaRow.tsx | 11 +++-- .../query/FilterCriteriaRowValues.tsx | 46 +++++++++++++++---- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/qqq/components/query/CustomFilterPanel.tsx b/src/qqq/components/query/CustomFilterPanel.tsx index aa768e2..6097326 100644 --- a/src/qqq/components/query/CustomFilterPanel.tsx +++ b/src/qqq/components/query/CustomFilterPanel.tsx @@ -31,10 +31,11 @@ import React, {forwardRef, useReducer} from "react"; import {FilterCriteriaRow} from "qqq/components/query/FilterCriteriaRow"; - - declare module "@mui/x-data-grid" { + /////////////////////////////////////////////////////////////////////// + // this lets these props be passed in via // + /////////////////////////////////////////////////////////////////////// interface FilterPanelPropsOverrides { tableMetaData: QTableMetaData; diff --git a/src/qqq/components/query/FilterCriteriaRow.tsx b/src/qqq/components/query/FilterCriteriaRow.tsx index 9aa95b2..216b2c9 100644 --- a/src/qqq/components/query/FilterCriteriaRow.tsx +++ b/src/qqq/components/query/FilterCriteriaRow.tsx @@ -308,13 +308,13 @@ export function FilterCriteriaRow({id, index, tableMetaData, criteria, booleanOp updateCriteria(criteria, false); }; - //////////////////////////////////////// - // event handler for value text field // - //////////////////////////////////////// + ////////////////////////////////////////////////// + // event handler for value field (of all types) // + ////////////////////////////////////////////////// const handleValueChange = (event: React.ChangeEvent | SyntheticEvent, valueIndex: number | "all" = 0, newValue?: any) => { // @ts-ignore - const value = newValue ? newValue : event.target.value + const value = newValue ? newValue : event ? event.target.value : null; if(!criteria.values) { @@ -489,7 +489,8 @@ export function FilterCriteriaRow({id, index, tableMetaData, criteria, booleanOp handleValueChange(event, valueIndex, newValue)} /> diff --git a/src/qqq/components/query/FilterCriteriaRowValues.tsx b/src/qqq/components/query/FilterCriteriaRowValues.tsx index e84c285..67c7642 100644 --- a/src/qqq/components/query/FilterCriteriaRowValues.tsx +++ b/src/qqq/components/query/FilterCriteriaRowValues.tsx @@ -20,12 +20,14 @@ */ +import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; -import {Chip} from "@mui/material"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import Autocomplete from "@mui/material/Autocomplete"; import Box from "@mui/material/Box"; import TextField from "@mui/material/TextField"; import React, {SyntheticEvent} from "react"; +import DynamicSelect from "qqq/components/forms/DynamicSelect"; import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; import {OperatorOption, ValueMode} from "qqq/components/query/FilterCriteriaRow"; @@ -33,14 +35,15 @@ interface Props { operatorOption: OperatorOption; criteria: QFilterCriteriaWithId; - fieldType?: QFieldType; + field: QFieldMetaData; + table: QTableMetaData; valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void; } FilterCriteriaRowValues.defaultProps = { }; -function FilterCriteriaRowValues({operatorOption, criteria, fieldType, valueChangeHandler}: Props): JSX.Element +function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler}: Props): JSX.Element { if(!operatorOption) { @@ -52,16 +55,16 @@ function FilterCriteriaRowValues({operatorOption, criteria, fieldType, valueChan let type = "search" const inputLabelProps: any = {}; - if(fieldType == QFieldType.INTEGER) + if(field.type == QFieldType.INTEGER) { type = "number"; } - else if(fieldType == QFieldType.DATE) + else if(field.type == QFieldType.DATE) { type = "date"; inputLabelProps.shrink = true; } - else if(fieldType == QFieldType.DATE_TIME) + else if(field.type == QFieldType.DATE_TIME) { type = "datetime-local"; inputLabelProps.shrink = true; @@ -117,10 +120,37 @@ function FilterCriteriaRowValues({operatorOption, criteria, fieldType, valueChan value={values} onChange={(event, value) => valueChangeHandler(event, "all", value)} /> + // todo - need the Paste button case ValueMode.PVS_SINGLE: - break; + let selectedPossibleValue = null; + if(criteria.values && criteria.values.length > 0) + { + selectedPossibleValue = criteria.values[0]; + } + return + valueChangeHandler(null, 0, value)} + /> + case ValueMode.PVS_MULTI: - break; + // todo - values not sticking when re-opening filter panel + return + valueChangeHandler(null, "all", value)} + /> + } return (
    ); From 5c274a0a8a279f63727065233a0a7e463c779032 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 5 Jun 2023 09:45:27 -0500 Subject: [PATCH 34/59] Initial checkin --- .../components/query/FilterCriteriaPaster.tsx | 440 ++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 src/qqq/components/query/FilterCriteriaPaster.tsx diff --git a/src/qqq/components/query/FilterCriteriaPaster.tsx b/src/qqq/components/query/FilterCriteriaPaster.tsx new file mode 100644 index 0000000..b594d02 --- /dev/null +++ b/src/qqq/components/query/FilterCriteriaPaster.tsx @@ -0,0 +1,440 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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 {FormControl, InputLabel, Select, SelectChangeEvent} from "@mui/material"; +import Box from "@mui/material/Box"; +import Card from "@mui/material/Card"; +import Grid from "@mui/material/Grid"; +import Icon from "@mui/material/Icon"; +import Modal from "@mui/material/Modal"; +import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import {GridFilterItem} from "@mui/x-data-grid-pro"; +import React, {useEffect, useState} from "react"; +import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; +import ChipTextField from "qqq/components/forms/ChipTextField"; + +interface Props +{ + type: string; +} + +FilterCriteriaPaster.defaultProps = {}; + +function FilterCriteriaPaster({type}: Props): JSX.Element +{ + enum Delimiter + { + DETECT_AUTOMATICALLY = "Detect Automatically", + COMMA = "Comma", + NEWLINE = "Newline", + PIPE = "Pipe", + SPACE = "Space", + TAB = "Tab", + CUSTOM = "Custom", + } + + const delimiterToCharacterMap: { [key: string]: string } = {}; + + delimiterToCharacterMap[Delimiter.COMMA] = "[,\n\r]"; + delimiterToCharacterMap[Delimiter.TAB] = "[\t,\n,\r]"; + delimiterToCharacterMap[Delimiter.NEWLINE] = "[\n\r]"; + delimiterToCharacterMap[Delimiter.PIPE] = "[\\|\r\n]"; + delimiterToCharacterMap[Delimiter.SPACE] = "[ \n\r]"; + + const delimiterDropdownOptions = Object.values(Delimiter); + + const mainCardStyles: any = {}; + mainCardStyles.width = "60%"; + mainCardStyles.minWidth = "500px"; + + //x const [gridFilterItem, setGridFilterItem] = useState(props.item); + const [pasteModalIsOpen, setPasteModalIsOpen] = useState(false); + const [inputText, setInputText] = useState(""); + const [delimiter, setDelimiter] = useState(""); + const [delimiterCharacter, setDelimiterCharacter] = useState(""); + const [customDelimiterValue, setCustomDelimiterValue] = useState(""); + const [chipData, setChipData] = useState(undefined); + const [detectedText, setDetectedText] = useState(""); + const [errorText, setErrorText] = useState(""); + + ////////////////////////////////////////////////////////////// + // handler for when paste icon is clicked in 'any' operator // + ////////////////////////////////////////////////////////////// + const handlePasteClick = (event: any) => + { + event.target.blur(); + setPasteModalIsOpen(true); + }; + + const applyValue = (item: GridFilterItem) => + { + console.log(`updating grid values: ${JSON.stringify(item.value)}`); + // todo! + // setGridFilterItem(item); + // props.applyValue(item); + }; + + const clearData = () => + { + setDelimiter(""); + setDelimiterCharacter(""); + setChipData([]); + setInputText(""); + setDetectedText(""); + setCustomDelimiterValue(""); + setPasteModalIsOpen(false); + }; + + const handleCancelClicked = () => + { + clearData(); + setPasteModalIsOpen(false); + }; + + const handleSaveClicked = () => + { + //x if (gridFilterItem) + /* todo + { + //////////////////////////////////////// + // if numeric remove any non-numerics // + //////////////////////////////////////// + let saveData = []; + for (let i = 0; i < chipData.length; i++) + { + if (type !== "number" || !Number.isNaN(Number(chipData[i]))) + { + saveData.push(chipData[i]); + } + } + + if (gridFilterItem.value) + { + gridFilterItem.value = [...gridFilterItem.value, ...saveData]; + } + else + { + gridFilterItem.value = saveData; + } + + setGridFilterItem(gridFilterItem); + props.applyValue(gridFilterItem); + } + */ + + clearData(); + setPasteModalIsOpen(false); + }; + + //////////////////////////////////////////////////////////////// + // when user selects a different delimiter on the parse modal // + //////////////////////////////////////////////////////////////// + const handleDelimiterChange = (event: SelectChangeEvent) => + { + const newDelimiter = event.target.value; + console.log(`Delimiter Changed to ${JSON.stringify(newDelimiter)}`); + + setDelimiter(newDelimiter); + if (newDelimiter === Delimiter.CUSTOM) + { + setDelimiterCharacter(customDelimiterValue); + } + else + { + setDelimiterCharacter(delimiterToCharacterMap[newDelimiter]); + } + }; + + const handleTextChange = (event: any) => + { + const inputText = event.target.value; + setInputText(inputText); + }; + + const handleCustomDelimiterChange = (event: any) => + { + let inputText = event.target.value; + setCustomDelimiterValue(inputText); + }; + + /////////////////////////////////////////////////////////////////////////////////////// + // iterate over each character, putting them into 'buckets' so that we can determine // + // a good default to use when data is pasted into the textarea // + /////////////////////////////////////////////////////////////////////////////////////// + const calculateAutomaticDelimiter = (text: string): string => + { + const buckets = new Map(); + for (let i = 0; i < text.length; i++) + { + let bucketName = ""; + + switch (text.charAt(i)) + { + case "\t": + bucketName = Delimiter.TAB; + break; + case "\n": + case "\r": + bucketName = Delimiter.NEWLINE; + break; + case "|": + bucketName = Delimiter.PIPE; + break; + case " ": + bucketName = Delimiter.SPACE; + break; + case ",": + bucketName = Delimiter.COMMA; + break; + } + + if (bucketName !== "") + { + let currentCount = (buckets.has(bucketName)) ? buckets.get(bucketName) : 0; + buckets.set(bucketName, currentCount + 1); + } + } + + /////////////////////// + // default is commas // + /////////////////////// + let highestCount = 0; + let delimiter = Delimiter.COMMA; + for (let j = 0; j < delimiterDropdownOptions.length; j++) + { + let bucketName = delimiterDropdownOptions[j]; + if (buckets.has(bucketName) && buckets.get(bucketName) > highestCount) + { + delimiter = bucketName; + highestCount = buckets.get(bucketName); + } + } + + setDetectedText(`${delimiter} Detected`); + return (delimiterToCharacterMap[delimiter]); + }; + + useEffect(() => + { + let currentDelimiter = delimiter; + let currentDelimiterCharacter = delimiterCharacter; + + ///////////////////////////////////////////////////////////////////////////// + // if no delimiter already set in the state, call function to determine it // + ///////////////////////////////////////////////////////////////////////////// + if (!currentDelimiter || currentDelimiter === Delimiter.DETECT_AUTOMATICALLY) + { + currentDelimiterCharacter = calculateAutomaticDelimiter(inputText); + if (!currentDelimiterCharacter) + { + return; + } + + currentDelimiter = Delimiter.DETECT_AUTOMATICALLY; + setDelimiter(Delimiter.DETECT_AUTOMATICALLY); + setDelimiterCharacter(currentDelimiterCharacter); + } + else if (currentDelimiter === Delimiter.CUSTOM) + { + //////////////////////////////////////////////////// + // if custom, make sure to split on new lines too // + //////////////////////////////////////////////////// + currentDelimiterCharacter = `[${customDelimiterValue}\r\n]`; + } + + console.log(`current delimiter is: ${currentDelimiter}, delimiting on: ${currentDelimiterCharacter}`); + + let regex = new RegExp(currentDelimiterCharacter); + let parts = inputText.split(regex); + let chipData = [] as string[]; + + /////////////////////////////////////////////////////// + // if delimiter is empty string, dont split anything // + /////////////////////////////////////////////////////// + setErrorText(""); + if (currentDelimiterCharacter !== "") + { + for (let i = 0; i < parts.length; i++) + { + let part = parts[i].trim(); + if (part !== "") + { + chipData.push(part); + + /////////////////////////////////////////////////////////// + // if numeric, check that first before pushing as a chip // + /////////////////////////////////////////////////////////// + if (type === "number" && Number.isNaN(Number(part))) + { + setErrorText("Some values are not numbers"); + } + } + } + } + + setChipData(chipData); + + }, [inputText, delimiterCharacter, customDelimiterValue, detectedText]); + + return ( + + + paste_content + + { + pasteModalIsOpen && + ( + + + + + + + + Bulk Add Filter Values + + Paste into the box on the left. + Review the filter values in the box on the right. + If the filter values are not what are expected, try changing the separator using the dropdown below. + + + + + + + + + + + + + + { + }} + chipData={chipData} + chipType={type} + multiline + fullWidth + variant="outlined" + id="tags" + rows={0} + name="tags" + label="FILTER VALUES REVIEW" + /> + + + + + + + + + SEPARATOR + + + + {delimiter === Delimiter.CUSTOM.valueOf() && ( + + + + + )} + {inputText && delimiter === Delimiter.DETECT_AUTOMATICALLY.valueOf() && ( + + + {detectedText} + + )} + + + + { + errorText && chipData.length > 0 && ( + + error + {errorText} + + ) + } + + + { + chipData && chipData.length > 0 && ( + {chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"} + ) + } + + + + + + + + + + + + + + ) + } + + ); +} + +export default FilterCriteriaPaster; From a6a9969cbaf78b38b19139768c17c2706c7a4bd2 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 5 Jun 2023 16:49:34 -0500 Subject: [PATCH 35/59] Post filter & fields as formParam instead of queryParam, to avoid 431 err --- src/qqq/pages/records/query/RecordQuery.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 86d2f7f..3269b4a 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -970,7 +970,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const d = new Date(); const dateString = `${d.getFullYear()}-${zp(d.getMonth() + 1)}-${zp(d.getDate())} ${zp(d.getHours())}${zp(d.getMinutes())}`; const filename = `${tableMetaData.label} Export ${dateString}.${format}`; - const url = `/data/${tableMetaData.name}/export/${filename}?filter=${encodeURIComponent(JSON.stringify(buildQFilter(tableMetaData, filterModel)))}&fields=${visibleFields.join(",")}`; + const url = `/data/${tableMetaData.name}/export/${filename}`; + + const encodedFilterJSON = encodeURIComponent(JSON.stringify(buildQFilter(tableMetaData, filterModel))); ////////////////////////////////////////////////////////////////////////////////////// // open a window (tab) with a little page that says the file is being generated. // @@ -987,6 +989,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element @@ -995,6 +1002,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element Generating file ${filename}${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}...
    + +
    `); From b7addd315bf4658491c69cb5073a6ca7f9a3c164 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 5 Jun 2023 16:49:51 -0500 Subject: [PATCH 36/59] Don't push onto history when launching process --- src/qqq/pages/records/view/RecordView.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/qqq/pages/records/view/RecordView.tsx b/src/qqq/pages/records/view/RecordView.tsx index 546648b..3b7b490 100644 --- a/src/qqq/pages/records/view/RecordView.tsx +++ b/src/qqq/pages/records/view/RecordView.tsx @@ -318,13 +318,16 @@ function RecordView({table, launchProcess}: Props): JSX.Element setPageHeader(record.recordLabel); - try + if(!launchingProcess) { - HistoryUtils.push({label: `${tableMetaData?.label}: ${record.recordLabel}`, path: location.pathname, iconName: table.iconName}); - } - catch(e) - { - console.error("Error pushing history: " + e); + try + { + HistoryUtils.push({label: `${tableMetaData?.label}: ${record.recordLabel}`, path: location.pathname, iconName: table.iconName}); + } + catch(e) + { + console.error("Error pushing history: " + e); + } } ///////////////////////////////////////////////// From 81e0170be7171f58cfaed0751e1e20cbb9c286a5 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 7 Jun 2023 11:10:29 -0500 Subject: [PATCH 37/59] Basic support for relative date-time expressions on Date fields --- src/qqq/utils/qqq/FilterUtils.ts | 47 ++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/qqq/utils/qqq/FilterUtils.ts b/src/qqq/utils/qqq/FilterUtils.ts index 0aebaf7..8c7f1d5 100644 --- a/src/qqq/utils/qqq/FilterUtils.ts +++ b/src/qqq/utils/qqq/FilterUtils.ts @@ -474,6 +474,53 @@ class FilterUtils } } + if (field && field.type == "DATE" && !values) + { + try + { + const criteria = filterJSON.criteria[i]; + if (criteria && criteria.expression) + { + let value = new Date(); + let amount = Number(criteria.expression.amount); + switch (criteria.expression.timeUnit) + { + case "MINUTES": + { + amount = amount * 60; + break; + } + case "HOURS": + { + amount = amount * 60 * 60; + break; + } + case "DAYS": + { + amount = amount * 60 * 60 * 24; + break; + } + default: + { + console.log("Unrecognized time unit: " + criteria.expression.timeUnit); + } + } + + if (criteria.expression.operator == "MINUS") + { + amount = -amount; + } + + value.setTime(value.getTime() + 1000 * amount); + values = [ValueUtils.formatDateISO8601(value)]; + } + } + catch (e) + { + console.log(e); + } + } + defaultFilter.items.push({ columnField: criteria.fieldName, operatorValue: FilterUtils.qqqCriteriaOperatorToGrid(criteria.operator, field, values), From 3dc9bc2702e0ffddb198e22b8edb006559371c9d Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 7 Jun 2023 11:10:39 -0500 Subject: [PATCH 38/59] Basic support for relative date-time expressions on Date fields --- src/qqq/utils/qqq/ValueUtils.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index 67cfbac..c680ceb 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -281,6 +281,16 @@ class ValueUtils return (`${date.toString("yyyy-MM-ddTHH:mm:ssZ")}`); } + public static formatDateISO8601(date: Date) + { + if (!(date instanceof Date)) + { + date = new Date(date); + } + // @ts-ignore + return (`${date.toString("yyyy-MM-dd")}`); + } + public static formatDateTimeForFileName(date: Date) { const zp = (value: number): string => (value < 10 ? `0${value}` : `${value}`); From dd3f126b69f2d06d6823d86967813a8a70f6c7ce Mon Sep 17 00:00:00 2001 From: Tim Chamberlain Date: Thu, 8 Jun 2023 14:39:44 -0500 Subject: [PATCH 39/59] Updated version --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 30d9dbd..22615eb 100644 --- a/pom.xml +++ b/pom.xml @@ -29,7 +29,7 @@ jar - 0.14.0-SNAPSHOT + 0.15.0-SNAPSHOT UTF-8 UTF-8 From 0c7330a01a9c34b97f0ea30f20cb9164a39c5f51 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 19 Jun 2023 08:33:23 -0500 Subject: [PATCH 40/59] Only show tables user has permission to; match search text against table name too --- .../components/query/CustomColumnsPanel.tsx | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/qqq/components/query/CustomColumnsPanel.tsx b/src/qqq/components/query/CustomColumnsPanel.tsx index b398dd1..44874e0 100644 --- a/src/qqq/components/query/CustomColumnsPanel.tsx +++ b/src/qqq/components/query/CustomColumnsPanel.tsx @@ -19,6 +19,7 @@ * 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, FormControlLabel, FormGroup} from "@mui/material"; import Button from "@mui/material/Button"; @@ -37,6 +38,7 @@ declare module "@mui/x-data-grid" interface ColumnsPanelPropsOverrides { tableMetaData: QTableMetaData; + metaData: QInstance; initialOpenedGroups: { [name: string]: boolean }; openGroupsChanger: (openedGroups: { [name: string]: boolean }) => void; initialFilterText: string; @@ -70,7 +72,11 @@ export const CustomColumnsPanel = forwardRef( { for (let i = 0; i < props.tableMetaData.exposedJoins.length; i++) { - tables.push(props.tableMetaData.exposedJoins[i].joinTable); + const exposedJoin = props.tableMetaData.exposedJoins[i]; + if (props.metaData.tables.has(exposedJoin.joinTable.name)) + { + tables.push(exposedJoin.joinTable); + } } } @@ -112,7 +118,7 @@ export const CustomColumnsPanel = forwardRef( return (true); } } - catch(e) + catch (e) { ////////////////////////////////////////////////////////////////////////////////// // in case text is an invalid regex... well, at least do a starts-with match... // @@ -123,6 +129,33 @@ export const CustomColumnsPanel = forwardRef( } } + const tableLabel = column.headerName.replace(/:.*/, ""); + if (tableLabel) + { + try + { + //////////////////////////////////////////////////////////// + // try to match word-boundary followed by the filter text // + // e.g., "name" would match "First Name" or "Last Name" // + //////////////////////////////////////////////////////////// + const re = new RegExp("\\b" + filterText.toLowerCase()); + if (tableLabel.toLowerCase().match(re)) + { + return (true); + } + } + catch (e) + { + ////////////////////////////////////////////////////////////////////////////////// + // in case text is an invalid regex... well, at least do a starts-with match... // + ////////////////////////////////////////////////////////////////////////////////// + if (tableLabel.toLowerCase().startsWith(filterText.toLowerCase())) + { + return (true); + } + } + } + return (false); }; From 50979a1ecc85178107331b1279d0886d268507ab Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 19 Jun 2023 08:40:47 -0500 Subject: [PATCH 41/59] Checkpoint; nearing completion of custom filter panel --- src/qqq/components/forms/DynamicSelect.tsx | 30 ++-- .../components/query/CustomFilterPanel.tsx | 30 +++- .../components/query/FilterCriteriaPaster.tsx | 46 ++---- .../components/query/FilterCriteriaRow.tsx | 86 +++++++--- .../query/FilterCriteriaRowValues.tsx | 147 ++++++++++++++---- src/qqq/pages/records/query/RecordQuery.tsx | 43 +++-- src/qqq/styles/qqq-override-styles.css | 27 ++++ src/qqq/utils/DataGridUtils.tsx | 38 ++++- src/qqq/utils/qqq/FilterUtils.ts | 67 ++++++-- 9 files changed, 380 insertions(+), 134 deletions(-) diff --git a/src/qqq/components/forms/DynamicSelect.tsx b/src/qqq/components/forms/DynamicSelect.tsx index 46ee247..ba22d4b 100644 --- a/src/qqq/components/forms/DynamicSelect.tsx +++ b/src/qqq/components/forms/DynamicSelect.tsx @@ -38,6 +38,7 @@ interface Props tableName?: string; processName?: string; fieldName: string; + overrideId?: string; fieldLabel: string; inForm: boolean; initialValue?: any; @@ -70,29 +71,34 @@ DynamicSelect.defaultProps = { const qController = Client.getInstance(); -function DynamicSelect({tableName, processName, fieldName, fieldLabel, inForm, initialValue, initialDisplayValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues}: Props) +function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabel, inForm, initialValue, initialDisplayValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues}: Props) { - const [ open, setOpen ] = useState(false); - const [ options, setOptions ] = useState([]); - const [ searchTerm, setSearchTerm ] = useState(null); - const [ firstRender, setFirstRender ] = useState(true); + const [open, setOpen] = useState(false); + const [options, setOptions] = useState([]); + const [searchTerm, setSearchTerm] = useState(null); + const [firstRender, setFirstRender] = useState(true); //////////////////////////////////////////////////////////////////////////////////////////////// // default value - needs to be an array (from initialValues (array) prop) for multiple mode - // // else non-multiple, assume we took in an initialValue (id) and initialDisplayValue (label), // // and build a little object that looks like a possibleValue out of those // //////////////////////////////////////////////////////////////////////////////////////////////// - const [defaultValue, _] = isMultiple ? useState(initialValues ?? undefined) + let [defaultValue, _] = isMultiple ? useState(initialValues ?? undefined) : useState(initialValue && initialDisplayValue ? [{id: initialValue, label: initialDisplayValue}] : null); + if (isMultiple && defaultValue === null) + { + defaultValue = []; + } + // const loading = open && options.length === 0; const [loading, setLoading] = useState(false); - const [ switchChecked, setSwitchChecked ] = useState(false); - const [ isDisabled, setIsDisabled ] = useState(!isEditable || bulkEditMode); + const [switchChecked, setSwitchChecked] = useState(false); + const [isDisabled, setIsDisabled] = useState(!isEditable || bulkEditMode); const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); let setFieldValueRef: (field: string, value: any, shouldValidate?: boolean) => void = null; - if(inForm) + if (inForm) { const {setFieldValue} = useFormikContext(); setFieldValueRef = setFieldValue; @@ -239,9 +245,11 @@ function DynamicSelect({tableName, processName, fieldName, fieldLabel, inForm, i bulkEditSwitchChangeHandler(fieldName, newSwitchValue); }; + // console.log(`default value: ${JSON.stringify(defaultValue)}`); + const autocomplete = ( ( . */ +import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; @@ -28,7 +29,7 @@ import Button from "@mui/material/Button/Button"; import Icon from "@mui/material/Icon/Icon"; import {GridFilterPanelProps, GridSlotsComponentsProps} from "@mui/x-data-grid-pro"; import React, {forwardRef, useReducer} from "react"; -import {FilterCriteriaRow} from "qqq/components/query/FilterCriteriaRow"; +import {FilterCriteriaRow, getDefaultCriteriaValue} from "qqq/components/query/FilterCriteriaRow"; declare module "@mui/x-data-grid" @@ -39,6 +40,7 @@ declare module "@mui/x-data-grid" interface FilterPanelPropsOverrides { tableMetaData: QTableMetaData; + metaData: QInstance; queryFilter: QQueryFilter; updateFilter: (newFilter: QQueryFilter) => void; } @@ -66,9 +68,9 @@ export const CustomFilterPanel = forwardRef( { setTimeout(() => { - console.log(`Try to focus ${criteriaId - 1}`); try { + // console.log(`Try to focus ${criteriaId - 1}`); document.getElementById(`field-${criteriaId - 1}`).focus(); } catch (e) @@ -80,7 +82,7 @@ export const CustomFilterPanel = forwardRef( const addCriteria = () => { - const qFilterCriteriaWithId = new QFilterCriteriaWithId(null, QCriteriaOperator.EQUALS, [""]); + const qFilterCriteriaWithId = new QFilterCriteriaWithId(null, QCriteriaOperator.EQUALS, getDefaultCriteriaValue()); qFilterCriteriaWithId.id = criteriaId++; console.log(`adding criteria id ${qFilterCriteriaWithId.id}`); queryFilter.criteria.push(qFilterCriteriaWithId); @@ -98,8 +100,29 @@ export const CustomFilterPanel = forwardRef( if (queryFilter.criteria.length == 0) { + ///////////////////////////////////////////// + // make sure there's at least one criteria // + ///////////////////////////////////////////// addCriteria(); } + else + { + //////////////////////////////////////////////////////////////////////////////////// + // make sure all criteria have an id on them (to be used as react component keys) // + //////////////////////////////////////////////////////////////////////////////////// + let updatedAny = false; + for (let i = 0; i < queryFilter.criteria.length; i++) + { + if (!queryFilter.criteria[i].id) + { + queryFilter.criteria[i].id = criteriaId++; + } + } + if (updatedAny) + { + props.updateFilter(queryFilter); + } + } if(queryFilter.criteria.length == 1 && !queryFilter.criteria[0].fieldName) { @@ -149,6 +172,7 @@ export const CustomFilterPanel = forwardRef( id={criteria.id} index={index} tableMetaData={props.tableMetaData} + metaData={props.metaData} criteria={criteria} booleanOperator={booleanOperator} updateCriteria={(newCriteria, needDebounce) => updateCriteria(newCriteria, index, needDebounce)} diff --git a/src/qqq/components/query/FilterCriteriaPaster.tsx b/src/qqq/components/query/FilterCriteriaPaster.tsx index b594d02..482332e 100644 --- a/src/qqq/components/query/FilterCriteriaPaster.tsx +++ b/src/qqq/components/query/FilterCriteriaPaster.tsx @@ -36,11 +36,12 @@ import ChipTextField from "qqq/components/forms/ChipTextField"; interface Props { type: string; + onSave: (newValues: any[]) => void; } FilterCriteriaPaster.defaultProps = {}; -function FilterCriteriaPaster({type}: Props): JSX.Element +function FilterCriteriaPaster({type, onSave}: Props): JSX.Element { enum Delimiter { @@ -86,14 +87,6 @@ function FilterCriteriaPaster({type}: Props): JSX.Element setPasteModalIsOpen(true); }; - const applyValue = (item: GridFilterItem) => - { - console.log(`updating grid values: ${JSON.stringify(item.value)}`); - // todo! - // setGridFilterItem(item); - // props.applyValue(item); - }; - const clearData = () => { setDelimiter(""); @@ -113,34 +106,19 @@ function FilterCriteriaPaster({type}: Props): JSX.Element const handleSaveClicked = () => { - //x if (gridFilterItem) - /* todo + //////////////////////////////////////// + // if numeric remove any non-numerics // + //////////////////////////////////////// + let saveData = []; + for (let i = 0; i < chipData.length; i++) { - //////////////////////////////////////// - // if numeric remove any non-numerics // - //////////////////////////////////////// - let saveData = []; - for (let i = 0; i < chipData.length; i++) + if (type !== "number" || !Number.isNaN(Number(chipData[i]))) { - if (type !== "number" || !Number.isNaN(Number(chipData[i]))) - { - saveData.push(chipData[i]); - } + saveData.push(chipData[i]); } - - if (gridFilterItem.value) - { - gridFilterItem.value = [...gridFilterItem.value, ...saveData]; - } - else - { - gridFilterItem.value = saveData; - } - - setGridFilterItem(gridFilterItem); - props.applyValue(gridFilterItem); } - */ + + onSave(saveData); clearData(); setPasteModalIsOpen(false); @@ -299,7 +277,7 @@ function FilterCriteriaPaster({type}: Props): JSX.Element return ( - paste_content + paste_content { pasteModalIsOpen && diff --git a/src/qqq/components/query/FilterCriteriaRow.tsx b/src/qqq/components/query/FilterCriteriaRow.tsx index 216b2c9..0e10549 100644 --- a/src/qqq/components/query/FilterCriteriaRow.tsx +++ b/src/qqq/components/query/FilterCriteriaRow.tsx @@ -20,6 +20,7 @@ */ 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 {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; @@ -57,12 +58,14 @@ export interface OperatorOption valueMode: ValueMode; } +export const getDefaultCriteriaValue = () => [""]; interface FilterCriteriaRowProps { id: number; index: number; tableMetaData: QTableMetaData; + metaData: QInstance; criteria: QFilterCriteria; booleanOperator: "AND" | "OR" | null; updateCriteria: (newCriteria: QFilterCriteria, needDebounce: boolean) => void; @@ -82,11 +85,11 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a } } -export function FilterCriteriaRow({id, index, tableMetaData, criteria, booleanOperator, updateCriteria, removeCriteria, updateBooleanOperator}: FilterCriteriaRowProps): JSX.Element +export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, booleanOperator, updateCriteria, removeCriteria, updateBooleanOperator}: FilterCriteriaRowProps): JSX.Element { // console.log(`FilterCriteriaRow: criteria: ${JSON.stringify(criteria)}`); const [operatorSelectedValue, setOperatorSelectedValue] = useState(null as OperatorOption); - const [operatorInputValue, setOperatorInputValue] = useState("") + const [operatorInputValue, setOperatorInputValue] = useState(""); /////////////////////////////////////////////////////////////// // set up the array of options for the fields Autocomplete // @@ -98,12 +101,14 @@ export function FilterCriteriaRow({id, index, tableMetaData, criteria, booleanOp if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0) { - fieldsGroupBy = (option: any) => `${option.table.label} Fields`; - for (let i = 0; i < tableMetaData.exposedJoins.length; i++) { const exposedJoin = tableMetaData.exposedJoins[i]; - makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true); + if (metaData.tables.has(exposedJoin.joinTable.name)) + { + fieldsGroupBy = (option: any) => `${option.table.label} fields`; + makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true); + } } } @@ -124,8 +129,8 @@ export function FilterCriteriaRow({id, index, tableMetaData, criteria, booleanOp ////////////////////////////////////////////////////// if (field.possibleValueSourceName) { - operatorOptions.push({label: "is", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.PVS_SINGLE}); - operatorOptions.push({label: "is not", value: QCriteriaOperator.NOT_EQUALS, valueMode: ValueMode.PVS_SINGLE}); + operatorOptions.push({label: "equals", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.PVS_SINGLE}); + operatorOptions.push({label: "does not equal", value: QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, valueMode: ValueMode.PVS_SINGLE}); operatorOptions.push({label: "is empty", value: QCriteriaOperator.IS_BLANK, valueMode: ValueMode.NONE}); operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE}); operatorOptions.push({label: "is any of", value: QCriteriaOperator.IN, valueMode: ValueMode.PVS_MULTI}); @@ -138,7 +143,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, criteria, booleanOp case QFieldType.DECIMAL: case QFieldType.INTEGER: operatorOptions.push({label: "equals", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE}); - operatorOptions.push({label: "not equals", value: QCriteriaOperator.NOT_EQUALS, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "does not equal", value: QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, valueMode: ValueMode.SINGLE}); operatorOptions.push({label: "greater than", value: QCriteriaOperator.GREATER_THAN, valueMode: ValueMode.SINGLE}); operatorOptions.push({label: "greater than or equals", value: QCriteriaOperator.GREATER_THAN_OR_EQUALS, valueMode: ValueMode.SINGLE}); operatorOptions.push({label: "less than", value: QCriteriaOperator.LESS_THAN, valueMode: ValueMode.SINGLE}); @@ -151,8 +156,8 @@ export function FilterCriteriaRow({id, index, tableMetaData, criteria, booleanOp operatorOptions.push({label: "is none of", value: QCriteriaOperator.NOT_IN, valueMode: ValueMode.MULTI}); break; case QFieldType.DATE: - operatorOptions.push({label: "is", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE_DATE}); - operatorOptions.push({label: "is not", value: QCriteriaOperator.NOT_EQUALS, valueMode: ValueMode.SINGLE_DATE}); + operatorOptions.push({label: "equals", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE_DATE}); + operatorOptions.push({label: "does not equal", value: QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, valueMode: ValueMode.SINGLE_DATE}); operatorOptions.push({label: "is after", value: QCriteriaOperator.GREATER_THAN, valueMode: ValueMode.SINGLE_DATE}); operatorOptions.push({label: "is before", value: QCriteriaOperator.LESS_THAN, valueMode: ValueMode.SINGLE_DATE}); operatorOptions.push({label: "is empty", value: QCriteriaOperator.IS_BLANK, valueMode: ValueMode.NONE}); @@ -163,8 +168,8 @@ export function FilterCriteriaRow({id, index, tableMetaData, criteria, booleanOp //? operatorOptions.push({label: "is none of", value: QCriteriaOperator.NOT_IN}); break; case QFieldType.DATE_TIME: - operatorOptions.push({label: "is", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE_DATE_TIME}); - operatorOptions.push({label: "is not", value: QCriteriaOperator.NOT_EQUALS, valueMode: ValueMode.SINGLE_DATE_TIME}); + operatorOptions.push({label: "equals", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE_DATE_TIME}); + operatorOptions.push({label: "does not equal", value: QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, valueMode: ValueMode.SINGLE_DATE_TIME}); operatorOptions.push({label: "is after", value: QCriteriaOperator.GREATER_THAN, valueMode: ValueMode.SINGLE_DATE_TIME}); operatorOptions.push({label: "is on or after", value: QCriteriaOperator.GREATER_THAN_OR_EQUALS, valueMode: ValueMode.SINGLE_DATE_TIME}); operatorOptions.push({label: "is before", value: QCriteriaOperator.LESS_THAN, valueMode: ValueMode.SINGLE_DATE_TIME}); @@ -175,8 +180,8 @@ export function FilterCriteriaRow({id, index, tableMetaData, criteria, booleanOp //? operatorOptions.push({label: "is not between", value: QCriteriaOperator.NOT_BETWEEN}); break; case QFieldType.BOOLEAN: - operatorOptions.push({label: "is yes", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.NONE, implicitValues: [true]}); - operatorOptions.push({label: "is no", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.NONE, implicitValues: [false]}); + operatorOptions.push({label: "equals yes", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.NONE, implicitValues: [true]}); + operatorOptions.push({label: "equals no", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.NONE, implicitValues: [false]}); operatorOptions.push({label: "is empty", value: QCriteriaOperator.IS_BLANK, valueMode: ValueMode.NONE}); operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE}); /* @@ -266,20 +271,39 @@ export function FilterCriteriaRow({id, index, tableMetaData, criteria, booleanOp ////////////////////////////////////////// const handleFieldChange = (event: any, newValue: any, reason: string) => { - criteria.fieldName = newValue ? newValue.fieldName : null; - updateCriteria(criteria, false); + const oldFieldName = criteria.fieldName; - setOperatorOptions(criteria.fieldName) - if(operatorOptions.length) + criteria.fieldName = newValue ? newValue.fieldName : null; + + ////////////////////////////////////////////////////// + // decide if we should clear out the values or not. // + ////////////////////////////////////////////////////// + if (criteria.fieldName == null || isFieldTypeDifferent(oldFieldName, criteria.fieldName)) { - setOperatorSelectedValue(operatorOptions[0]); - setOperatorInputValue(operatorOptions[0].label); + criteria.values = getDefaultCriteriaValue(); + } + + //////////////////////////////////////////////////////////////////// + // update the operator options, and the operator on this criteria // + //////////////////////////////////////////////////////////////////// + setOperatorOptions(criteria.fieldName); + if (operatorOptions.length) + { + if (isFieldTypeDifferent(oldFieldName, criteria.fieldName)) + { + criteria.operator = operatorOptions[0].value; + setOperatorSelectedValue(operatorOptions[0]); + setOperatorInputValue(operatorOptions[0].label); + } } else { + criteria.operator = null; setOperatorSelectedValue(null); setOperatorInputValue(""); } + + updateCriteria(criteria, false); }; ///////////////////////////////////////////// @@ -314,7 +338,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, criteria, booleanOp const handleValueChange = (event: React.ChangeEvent | SyntheticEvent, valueIndex: number | "all" = 0, newValue?: any) => { // @ts-ignore - const value = newValue ? newValue : event ? event.target.value : null; + const value = newValue !== undefined ? newValue : event ? event.target.value : null; if(!criteria.values) { @@ -323,7 +347,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, criteria, booleanOp if(valueIndex == "all") { - criteria.values= value; + criteria.values = value; } else { @@ -333,6 +357,22 @@ export function FilterCriteriaRow({id, index, tableMetaData, criteria, booleanOp updateCriteria(criteria, true); }; + const isFieldTypeDifferent = (fieldNameA: string, fieldNameB: string): boolean => + { + const [fieldA] = FilterUtils.getField(tableMetaData, fieldNameA); + const [fieldB] = FilterUtils.getField(tableMetaData, fieldNameB); + if (fieldA?.type !== fieldB.type) + { + return (true); + } + if (fieldA.possibleValueSourceName !== fieldB.possibleValueSourceName) + { + return (true); + } + + return (false); + }; + function isFieldOptionEqual(option: any, value: any) { return option.fieldName === value.fieldName; @@ -465,6 +505,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, criteria, booleanOp renderOption={(props, option, state) => renderFieldOption(props, option, state)} autoSelect={true} autoHighlight={true} + slotProps={{popper: {style: {padding: 0, width: "250px"}}}} /> @@ -481,6 +522,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, criteria, booleanOp getOptionLabel={(option: any) => option.label} autoSelect={true} autoHighlight={true} + slotProps={{popper: {style: {padding: 0, maxHeight: "unset", width: "200px"}}}} /*disabled={criteria.fieldName == null}*/ />
    diff --git a/src/qqq/components/query/FilterCriteriaRowValues.tsx b/src/qqq/components/query/FilterCriteriaRowValues.tsx index 67c7642..c8324cb 100644 --- a/src/qqq/components/query/FilterCriteriaRowValues.tsx +++ b/src/qqq/components/query/FilterCriteriaRowValues.tsx @@ -25,11 +25,16 @@ import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QField import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import Autocomplete from "@mui/material/Autocomplete"; import Box from "@mui/material/Box"; +import Icon from "@mui/material/Icon"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment/InputAdornment"; import TextField from "@mui/material/TextField"; -import React, {SyntheticEvent} from "react"; +import React, {SyntheticEvent, useReducer} from "react"; import DynamicSelect from "qqq/components/forms/DynamicSelect"; import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; +import FilterCriteriaPaster from "qqq/components/query/FilterCriteriaPaster"; import {OperatorOption, ValueMode} from "qqq/components/query/FilterCriteriaRow"; +import ValueUtils from "qqq/utils/qqq/ValueUtils"; interface Props { @@ -45,31 +50,64 @@ FilterCriteriaRowValues.defaultProps = { function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler}: Props): JSX.Element { - if(!operatorOption) + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + if (!operatorOption) { - return
    + return
    ; } - const makeTextField = (valueIndex: number = 0, label = "Value", idPrefix="value-") => + const getTypeForTextField = (): string => { - let type = "search" - const inputLabelProps: any = {}; + let type = "search"; - if(field.type == QFieldType.INTEGER) + if (field.type == QFieldType.INTEGER) { type = "number"; } - else if(field.type == QFieldType.DATE) + else if (field.type == QFieldType.DATE) { type = "date"; - inputLabelProps.shrink = true; } - else if(field.type == QFieldType.DATE_TIME) + else if (field.type == QFieldType.DATE_TIME) { type = "datetime-local"; + } + + return (type); + }; + + const makeTextField = (valueIndex: number = 0, label = "Value", idPrefix = "value-") => + { + let type = getTypeForTextField(); + const inputLabelProps: any = {}; + + if (field.type == QFieldType.DATE || field.type == QFieldType.DATE_TIME) + { inputLabelProps.shrink = true; } + let value = criteria.values[valueIndex]; + if (field.type == QFieldType.DATE_TIME && value && String(value).indexOf("Z") > -1) + { + value = ValueUtils.formatDateTimeValueForForm(value); + } + + const clearValue = (event: React.MouseEvent | React.MouseEvent, index: number) => + { + valueChangeHandler(event, index, ""); + document.getElementById(`${idPrefix}${criteria.id}`).focus(); + }; + + const inputProps: any = {}; + inputProps.endAdornment = ( + + clearValue(event, valueIndex)}> + close + + + ); + return valueChangeHandler(event, valueIndex)} - value={criteria.values[valueIndex]} + value={value} InputLabelProps={inputLabelProps} + InputProps={inputProps} fullWidth - // todo - x to clear value? - /> + />; + }; + + function saveNewPasterValues(newValues: any[]) + { + if (criteria.values) + { + criteria.values = [...criteria.values, ...newValues]; + } + else + { + criteria.values = newValues; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // we are somehow getting some empty-strings as first-value leaking through. they aren't cool, so, remove them if we find them // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if (criteria.values.length > 0 && criteria.values[0] == "") + { + criteria.values = criteria.values.splice(1); + } + + valueChangeHandler(null, "all", criteria.values); + forceUpdate(); } switch (operatorOption.valueMode) { case ValueMode.NONE: - return
    + return
    ; case ValueMode.SINGLE: return makeTextField(); case ValueMode.SINGLE_DATE: @@ -100,30 +161,36 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC { makeTextField(0, "From", "from-") } - { makeTextField(1, "To", "to-") } + {makeTextField(1, "To", "to-")} ; case ValueMode.MULTI: let values = criteria.values; - if(values && values.length == 1 && values[0] == "") + if (values && values.length == 1 && values[0] == "") { values = []; } - return ()} - options={[]} - multiple - freeSolo // todo - no debounce after enter? - selectOnFocus - clearOnBlur - limitTags={5} - value={values} - onChange={(event, value) => valueChangeHandler(event, "all", value)} - /> - // todo - need the Paste button + return + ()} + options={[]} + multiple + freeSolo // todo - no debounce after enter? + selectOnFocus + clearOnBlur + fullWidth + limitTags={5} + value={values} + onChange={(event, value) => valueChangeHandler(event, "all", value)} + /> + + saveNewPasterValues(newValues)} /> + + ; case ValueMode.PVS_SINGLE: + console.log("Doing pvs single: " + criteria.values); let selectedPossibleValue = null; - if(criteria.values && criteria.values.length > 0) + if (criteria.values && criteria.values.length > 0) { selectedPossibleValue = criteria.values[0]; } @@ -131,22 +198,38 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC valueChangeHandler(null, 0, value)} /> - + ; case ValueMode.PVS_MULTI: - // todo - values not sticking when re-opening filter panel + console.log("Doing pvs multi: " + criteria.values); + let initialValues: any[] = []; + if (criteria.values && criteria.values.length > 0) + { + if (criteria.values.length == 1 && criteria.values[0] == "") + { + // we never want a tag that's just ""... + } + else + { + initialValues = criteria.values; + } + } return valueChangeHandler(null, "all", value)} /> diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 4da63d7..072f177 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -350,7 +350,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ////////////////////////////////////////////////////////////////////////////////////////////////////// const buildQFilter = (tableMetaData: QTableMetaData, filterModel: GridFilterModel, limit?: number) => { - const filter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, limit); + let filter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, limit); + filter = FilterUtils.convertFilterPossibleValuesToIds(filter); setHasValidFilters(filter.criteria && filter.criteria.length > 0); return (filter); }; @@ -879,11 +880,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element console.log(columnOrderChangeParams); }; - const handleFilterChange = (filterModel: GridFilterModel, doSetQueryFilter = true) => + const handleFilterChange = (filterModel: GridFilterModel, doSetQueryFilter = true, isChangeFromDataGrid = false) => { setFilterModel(filterModel); - if(doSetQueryFilter) + if (doSetQueryFilter) { ////////////////////////////////////////////////////////////////////////////////// // someone might have already set the query filter, so, only set it if asked to // @@ -891,6 +892,18 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, rowsPerPage)); } + if (isChangeFromDataGrid) + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // this function is called by our code several times, but also from dataGridPro when its filter model changes. // + // in general, we don't want a "partial" criteria to be part of our query filter object (e.g., w/ no values) // + // BUT - for one use-case, when the user adds a "filter" (criteria) from column-header "..." menu, then dataGridPro // + // puts a partial item in its filter - so - in that case, we do like to get this partial criteria in our QFilter. // + // so far, not seeing any negatives to this being here, and it fixes that user experience, so keep this. // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, rowsPerPage, true)); + } + if (filterLocalStorageKey) { localStorage.setItem(filterLocalStorageKey, JSON.stringify(filterModel)); @@ -1700,7 +1713,6 @@ 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 // //////////////////////////////////////////////////////////////////////////////////////// - // console.log("calling update table for UE 1"); updateTable(); } }, [pageNumber, rowsPerPage, columnSortModel, currentSavedFilter]); @@ -1712,7 +1724,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { setTotalRecords(null); setDistinctRecords(null); - // console.log("calling update table for UE 2"); updateTable(); }, [columnsModel, tableState]); @@ -1722,19 +1733,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element currentQFilter.skip = pageNumber * rowsPerPage; const currentQFilterJSON = JSON.stringify(currentQFilter); - // console.log(`current ${currentQFilterJSON}`); - // console.log(`last... ${lastFetchedQFilterJSON}`); if(currentQFilterJSON !== lastFetchedQFilterJSON) { setTotalRecords(null); setDistinctRecords(null); - // console.log("calling update table for UE 3"); updateTable(); } - else - { - // console.log("NOT calling update table for UE 3!!"); - } }, [filterModel]); @@ -1744,12 +1748,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element document.scrollingElement.scrollTop = 0; }, [pageNumber, rowsPerPage]); - const updateFilter = (newFilter: QQueryFilter): void => + const updateFilterFromFilterPanel = (newFilter: QQueryFilter): void => { setQueryFilter(newFilter); const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); handleFilterChange(gridFilterModel, false); - } + }; if (tableMetaData && !tableMetaData.readPermission) { @@ -1813,7 +1817,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } - + { + metaData && metaData.processes.has("querySavedFilter") && + + } @@ -1840,6 +1847,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element columnsPanel: { tableMetaData: tableMetaData, + metaData: metaData, initialOpenedGroups: columnChooserOpenGroups, openGroupsChanger: setColumnChooserOpenGroups, initialFilterText: columnChooserFilterText, @@ -1848,8 +1856,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element filterPanel: { tableMetaData: tableMetaData, + metaData: metaData, queryFilter: queryFilter, - updateFilter: updateFilter + updateFilter: updateFilterFromFilterPanel } }} localeText={{ @@ -1880,7 +1889,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element density={density} loading={loading} filterModel={filterModel} - onFilterModelChange={(model) => handleFilterChange(model)} + onFilterModelChange={(model) => handleFilterChange(model, true, true)} columnVisibilityModel={columnVisibilityModel} onColumnVisibilityModelChange={handleColumnVisibilityChange} onColumnOrderChange={handleColumnOrderChange} diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index ce5a40d..cd3ed0b 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -421,42 +421,50 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } top: -60px !important; } +/* tighten the text in the field select dropdown in custom filters */ .customFilterPanel .MuiAutocomplete-paper { line-height: 1.375; } +/* tighten the text in the field select dropdown in custom filters */ .customFilterPanel .MuiAutocomplete-groupLabel { line-height: 1.75; } +/* taller list box */ .customFilterPanel .MuiAutocomplete-listbox { max-height: 60vh; } +/* shrink down-arrows in custom filters panel */ .customFilterPanel .booleanOperatorColumn .MuiSelect-iconStandard, .customFilterPanel .MuiSvgIcon-root { font-size: 14px !important; } +/* fix something in AND/OR dropdown in filters */ .customFilterPanel .booleanOperatorColumn .MuiSvgIcon-root { display: inline-block !important; } +/* adjust bottom of AND/OR dropdown in filters */ .customFilterPanel .booleanOperatorColumn .MuiInputBase-formControl { padding-bottom: calc(0.25rem + 1px); } +/* adjust down-arrow in AND/OR dropdown in filters */ .customFilterPanel .booleanOperatorColumn .MuiSelect-iconStandard { top: calc(50% - 0.75rem); } +/* change tags in any-of value fields to not be black bg with white text */ .customFilterPanel .filterValuesColumn .MuiChip-root { background: none; @@ -464,13 +472,32 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } border: 1px solid gray; } +/* change 'x' icon in tags in any-of value */ .customFilterPanel .filterValuesColumn .MuiChip-root .MuiChip-deleteIcon { color: gray; } +/* change tags in any-of value fields to not be black bg with white text */ .customFilterPanel .filterValuesColumn .MuiAutocomplete-tag { color: #191919; background: none; } + +/* default hover color for the 'x' to remove a tag from an 'any-of' value was white, which made it disappear */ +.customFilterPanel .filterValuesColumn .MuiAutocomplete-tag .MuiSvgIcon-root:hover +{ + color: lightgray; +} + +.DynamicSelectPopper ul +{ + padding: 0; +} + +.DynamicSelectPopper ul li.MuiAutocomplete-option +{ + padding-left: 0.25rem; + padding-right: 0.25rem; +} \ No newline at end of file diff --git a/src/qqq/utils/DataGridUtils.tsx b/src/qqq/utils/DataGridUtils.tsx index f43364f..489b793 100644 --- a/src/qqq/utils/DataGridUtils.tsx +++ b/src/qqq/utils/DataGridUtils.tsx @@ -25,13 +25,42 @@ import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QField import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; -import {getGridDateOperators, GridColDef, GridRowsProp} from "@mui/x-data-grid-pro"; +import {GridColDef, GridFilterItem, GridRowsProp} from "@mui/x-data-grid-pro"; import {GridFilterOperator} from "@mui/x-data-grid/models/gridFilterOperator"; import React from "react"; import {Link} from "react-router-dom"; import {buildQGridPvsOperators, QGridBooleanOperators, QGridNumericOperators, QGridStringOperators} from "qqq/pages/records/query/GridFilterOperators"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; + +const emptyApplyFilterFn = (filterItem: GridFilterItem, column: GridColDef): null => null; + +function NullInputComponent() +{ + return (); +} + +const makeGridFilterOperator = (value: string, label: string, takesValues: boolean = false): GridFilterOperator => +{ + const rs: GridFilterOperator = {value: value, label: label, getApplyFilterFn: emptyApplyFilterFn}; + if (takesValues) + { + rs.InputComponent = NullInputComponent; + } + return (rs); +}; + +const QGridDateOperators = [ + makeGridFilterOperator("equals", "equals", true), + makeGridFilterOperator("isNot", "not equals", true), + makeGridFilterOperator("after", "is after", true), + makeGridFilterOperator("onOrAfter", "is on or after", true), + makeGridFilterOperator("before", "is before", true), + makeGridFilterOperator("onOrBefore", "is on or before", true), + makeGridFilterOperator("isEmpty", "is empty"), + makeGridFilterOperator("isNotEmpty", "is not empty"), +]; + export default class DataGridUtils { @@ -40,7 +69,7 @@ export default class DataGridUtils *******************************************************************************/ public static makeRows = (results: QRecord[], tableMetaData: QTableMetaData): GridRowsProp[] => { - const fields = [ ...tableMetaData.fields.values() ]; + const fields = [...tableMetaData.fields.values()]; const rows = [] as any[]; let rowIndex = 0; results.forEach((record: QRecord) => @@ -188,6 +217,7 @@ export default class DataGridUtils }); } + /******************************************************************************* ** *******************************************************************************/ @@ -220,12 +250,12 @@ export default class DataGridUtils case QFieldType.DATE: columnType = "date"; columnWidth = 100; - filterOperators = getGridDateOperators(); + filterOperators = QGridDateOperators; break; case QFieldType.DATE_TIME: columnType = "dateTime"; columnWidth = 200; - filterOperators = getGridDateOperators(true); + filterOperators = QGridDateOperators; break; case QFieldType.BOOLEAN: columnType = "string"; // using boolean gives an odd 'no' for nulls. diff --git a/src/qqq/utils/qqq/FilterUtils.ts b/src/qqq/utils/qqq/FilterUtils.ts index 7830a8e..4102651 100644 --- a/src/qqq/utils/qqq/FilterUtils.ts +++ b/src/qqq/utils/qqq/FilterUtils.ts @@ -264,10 +264,10 @@ class FilterUtils ///////////////////////////////////////////////////////////////////////////////////////////////// return ([null, null]); } - return (FilterUtils.prepFilterValuesForBackend(value, fieldMetaData)); + return (FilterUtils.cleanseCriteriaValueForQQQ(value, fieldMetaData)); } - return (FilterUtils.prepFilterValuesForBackend([value], fieldMetaData)); + return (FilterUtils.cleanseCriteriaValueForQQQ([value], fieldMetaData)); }; @@ -278,7 +278,7 @@ class FilterUtils ** ** Or, if the values are date-times, convert them to UTC. *******************************************************************************/ - private static prepFilterValuesForBackend = (param: any[], fieldMetaData: QFieldMetaData): number[] | string[] => + private static cleanseCriteriaValueForQQQ = (param: any[], fieldMetaData: QFieldMetaData): number[] | string[] => { if (param === null || param === undefined) { @@ -291,10 +291,15 @@ class FilterUtils console.log(param[i]); if (param[i] && param[i].id && param[i].label) { - ///////////////////////////////////////////////////////////// - // if the param looks like a possible value, return its id // - ///////////////////////////////////////////////////////////// - rs.push(param[i].id); + ////////////////////////////////////////////////////////////////////////////////////////// + // if the param looks like a possible value, return its id // + // during build of new custom filter panel, this ended up causing us // + // problems (because we wanted the full PV object in the filter model for the frontend) // + // so, we can keep the PV as-is here, and see calls to convertFilterPossibleValuesToIds // + // to do what this used to do. // + ////////////////////////////////////////////////////////////////////////////////////////// + // rs.push(param[i].id); + rs.push(param[i]); } else { @@ -464,7 +469,16 @@ class FilterUtils amount = -amount; } + ///////////////////////////////////////////// + // shift the date/time by the input amount // + ///////////////////////////////////////////// value.setTime(value.getTime() + 1000 * amount); + + ///////////////////////////////////////////////// + // now also shift from local-timezone into UTC // + ///////////////////////////////////////////////// + value.setTime(value.getTime() + 1000 * 60 * value.getTimezoneOffset()); + values = [ValueUtils.formatDateTimeISO8601(value)]; } } @@ -598,7 +612,7 @@ class FilterUtils /******************************************************************************* ** build a qqq filter from a grid and column sort model *******************************************************************************/ - public static buildQFilterFromGridFilter(tableMetaData: QTableMetaData, filterModel: GridFilterModel, columnSortModel: GridSortItem[], limit?: number): QQueryFilter + public static buildQFilterFromGridFilter(tableMetaData: QTableMetaData, filterModel: GridFilterModel, columnSortModel: GridSortItem[], limit?: number, allowIncompleteCriteria = false): QQueryFilter { console.log("Building q filter with model:"); console.log(filterModel); @@ -638,13 +652,15 @@ class FilterUtils //////////////////////////////////////////////////////////////////////////////// // if no value set and not 'empty' or 'not empty' operators, skip this filter // //////////////////////////////////////////////////////////////////////////////// - if ((!item.value || item.value.length == 0) && item.operatorValue !== "isEmpty" && item.operatorValue !== "isNotEmpty") + if ((!item.value || item.value.length == 0 || (item.value.length == 1 && item.value[0] == "")) && item.operatorValue !== "isEmpty" && item.operatorValue !== "isNotEmpty") { - return; + if (!allowIncompleteCriteria) + { + return; + } } - var fieldMetadata = tableMetaData?.fields.get(item.columnField); - + const fieldMetadata = tableMetaData?.fields.get(item.columnField); const operator = FilterUtils.gridCriteriaOperatorToQQQ(item.operatorValue); const values = FilterUtils.gridCriteriaValueToQQQ(operator, item.value, item.operatorValue, fieldMetadata); qFilter.addCriteria(new QFilterCriteria(item.columnField, operator, values)); @@ -664,6 +680,33 @@ class FilterUtils return qFilter; }; + + public static convertFilterPossibleValuesToIds(inputFilter: QQueryFilter): QQueryFilter + { + const filter = Object.assign({}, inputFilter); + + if (filter.criteria) + { + for (let i = 0; i < filter.criteria.length; i++) + { + const criteria = filter.criteria[i]; + if (criteria.values) + { + for (let j = 0; j < criteria.values.length; j++) + { + let value = criteria.values[j]; + if (value && value.id && value.label) + { + criteria.values[j] = value.id; + } + } + } + } + } + + return (filter); + } + } export default FilterUtils; From db7cd1cc349e79989d802f69f064cd0156909e37 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 19 Jun 2023 12:07:18 -0500 Subject: [PATCH 42/59] Add posting 'other values' (e.g., for filtering) w/ possible values --- src/qqq/components/forms/EntityForm.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/qqq/components/forms/EntityForm.tsx b/src/qqq/components/forms/EntityForm.tsx index a20eb08..cb4bd87 100644 --- a/src/qqq/components/forms/EntityForm.tsx +++ b/src/qqq/components/forms/EntityForm.tsx @@ -133,6 +133,15 @@ function EntityForm(props: Props): JSX.Element for (let i = 0; i < formFields.length; i++) { formData.formFields[formFields[i].name] = formFields[i]; + + if (formFields[i].possibleValueProps) + { + formFields[i].possibleValueProps.otherValues = formFields[i].possibleValueProps.otherValues ?? new Map(); + Object.keys(formFields).forEach((otherKey) => + { + formFields[i].possibleValueProps.otherValues.set(otherKey, values[otherKey]); + }); + } } if (!Object.keys(formFields).length) From 8b38cf8fa3ae3bd5ef50b74fad1188f113515b6b Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 19 Jun 2023 19:00:20 -0500 Subject: [PATCH 43/59] Add optional fractions to a pattern in formatDateTimeValueForForm --- src/qqq/utils/qqq/ValueUtils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index d8c1332..79f182b 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -361,7 +361,7 @@ class ValueUtils ////////////////////////////////////////////////////////////////// return (value + "T00:00"); } - else if (value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?Z$/)) + else if (value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z$/)) { /////////////////////////////////////////////////////////////////////////////////////////////////////// // If the passed in string has a Z on the end (e.g., in UTC) - make a Date object - the browser will // From dc001442090449c2b45b04aa1d555e13988af0ee Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 20 Jun 2023 08:33:15 -0500 Subject: [PATCH 44/59] Update to pass selenium tests on custom filter panel --- .../components/query/FilterCriteriaRow.tsx | 6 +-- .../lib/QQQMaterialDashboardSelectors.java | 2 +- .../materialdashboard/lib/QSeleniumLib.java | 20 +++++++ .../tests/QueryScreenTest.java | 52 +++++++++++++------ .../tests/SavedFiltersTest.java | 2 +- 5 files changed, 61 insertions(+), 21 deletions(-) diff --git a/src/qqq/components/query/FilterCriteriaRow.tsx b/src/qqq/components/query/FilterCriteriaRow.tsx index 0e10549..c93142d 100644 --- a/src/qqq/components/query/FilterCriteriaRow.tsx +++ b/src/qqq/components/query/FilterCriteriaRow.tsx @@ -475,7 +475,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, } return ( - + close @@ -491,7 +491,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, : } - + ()} @@ -508,7 +508,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, slotProps={{popper: {style: {padding: 0, width: "250px"}}}} /> - + 0) { - qSeleniumLib.waitForSelectorContaining("BUTTON", "Add filter").click(); + qSeleniumLib.waitForSelectorContaining("BUTTON", "Add condition").click(); } - WebElement subFormForField = qSeleniumLib.waitForSelectorAll(".MuiDataGrid-filterForm", index + 1).get(index); + WebElement subFormForField = qSeleniumLib.waitForSelectorAll(".filterCriteriaRow", index + 1).get(index); if(index == 1) { - Select linkOperatorSelect = new Select(subFormForField.findElement(By.cssSelector(".MuiDataGrid-filterFormLinkOperatorInput SELECT"))); - linkOperatorSelect.selectByVisibleText(booleanOperator); + WebElement booleanOperatorInput = subFormForField.findElement(By.cssSelector(".booleanOperatorColumn .MuiInput-input")); + booleanOperatorInput.click(); + qSeleniumLib.waitForMillis(100); + + subFormForField.findElement(By.cssSelector(".booleanOperatorColumn .MuiInput-input")); + qSeleniumLib.waitForSelectorContaining("li", booleanOperator).click(); + qSeleniumLib.waitForMillis(100); } - Select fieldSelect = new Select(subFormForField.findElement(By.cssSelector(".MuiDataGrid-filterFormColumnInput SELECT"))); - fieldSelect.selectByVisibleText(fieldLabel); + WebElement fieldInput = subFormForField.findElement(By.cssSelector(".fieldColumn INPUT")); + fieldInput.click(); + qSeleniumLib.waitForMillis(100); + fieldInput.clear(); + fieldInput.sendKeys(fieldLabel); + qSeleniumLib.waitForMillis(100); + fieldInput.sendKeys("\n"); + qSeleniumLib.waitForMillis(100); - Select operatorSelect = new Select(subFormForField.findElement(By.cssSelector(".MuiDataGrid-filterFormOperatorInput SELECT"))); - operatorSelect.selectByVisibleText(operator); + WebElement operatorInput = subFormForField.findElement(By.cssSelector(".operatorColumn INPUT")); + operatorInput.click(); + qSeleniumLib.waitForMillis(100); + operatorInput.sendKeys(Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, operator); + qSeleniumLib.waitForMillis(100); + operatorInput.sendKeys("\n"); + qSeleniumLib.waitForMillis(100); - WebElement valueInput = subFormForField.findElement(By.cssSelector(".MuiDataGrid-filterFormValueInput INPUT")); + WebElement valueInput = subFormForField.findElement(By.cssSelector(".filterValuesColumn INPUT")); valueInput.click(); valueInput.sendKeys(value); - qSeleniumLib.waitForSeconds(1); + qSeleniumLib.waitForMillis(100); } } diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java index b35a948..0fe98fb 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java @@ -109,7 +109,7 @@ public class SavedFiltersTest extends QBaseSeleniumTest ////////////////////// // modify the query // ////////////////////// - qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filters").click(); + qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click(); addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or"); qSeleniumLib.waitForSelectorContaining("H5", "Person").click(); qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People") From 51406dbfe287b6af56ac16daea3b2f9dac82364f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 20 Jun 2023 10:14:57 -0500 Subject: [PATCH 45/59] Remove POC's --- src/App.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 129fc8a..8b5c118 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,8 +46,6 @@ import ReportRun from "qqq/pages/processes/ReportRun"; import EntityCreate from "qqq/pages/records/create/RecordCreate"; import TableDeveloperView from "qqq/pages/records/developer/TableDeveloperView"; import EntityEdit from "qqq/pages/records/edit/RecordEdit"; -import FilterPoc from "qqq/pages/records/FilterPoc"; -import IntersectionMatrix from "qqq/pages/records/IntersectionMatrix"; import RecordQuery from "qqq/pages/records/query/RecordQuery"; import RecordDeveloperView from "qqq/pages/records/view/RecordDeveloperView"; import RecordView from "qqq/pages/records/view/RecordView"; @@ -458,20 +456,6 @@ export default function App() }); } - appRoutesList.push({ - name: "Intersection Matrix", - key: "intersection-matrix", - route: "/intersection-matrix", - component: , - }); - - appRoutesList.push({ - name: "Filer POC", - key: "filter-poc", - route: "/filter-poc", - component: , - }); - const newSideNavRoutes = []; // @ts-ignore newSideNavRoutes.unshift(profileRoutes); From b010f10560c716356bf29dfa02d45303e38e44cb Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 20 Jun 2023 10:17:02 -0500 Subject: [PATCH 46/59] Add method comment to convertFilterPossibleValuesToIds --- src/qqq/utils/qqq/FilterUtils.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/qqq/utils/qqq/FilterUtils.ts b/src/qqq/utils/qqq/FilterUtils.ts index 4102651..5f940f0 100644 --- a/src/qqq/utils/qqq/FilterUtils.ts +++ b/src/qqq/utils/qqq/FilterUtils.ts @@ -681,6 +681,10 @@ class FilterUtils }; + /******************************************************************************* + ** edit the input filter object, replacing any values which have {id,label} attributes + ** to instead just have the id part. + *******************************************************************************/ public static convertFilterPossibleValuesToIds(inputFilter: QQueryFilter): QQueryFilter { const filter = Object.assign({}, inputFilter); From 5016d76b15dc2128dc06748af3fbfe91fbadd15a Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 21 Jun 2023 11:53:37 -0500 Subject: [PATCH 47/59] Fix dropdown flickering --- src/qqq/components/widgets/Widget.tsx | 301 +++++++++++++++----------- 1 file changed, 178 insertions(+), 123 deletions(-) diff --git a/src/qqq/components/widgets/Widget.tsx b/src/qqq/components/widgets/Widget.tsx index 6ad8a89..da59312 100644 --- a/src/qqq/components/widgets/Widget.tsx +++ b/src/qqq/components/widgets/Widget.tsx @@ -30,8 +30,7 @@ import Tooltip from "@mui/material/Tooltip/Tooltip"; import Typography from "@mui/material/Typography"; import parse from "html-react-parser"; import React, {useEffect, useState} from "react"; -import {Link, useNavigate, NavigateFunction} from "react-router-dom"; -import {bool} from "yup"; +import {Link, NavigateFunction, useNavigate} from "react-router-dom"; import colors from "qqq/components/legacy/colors"; import DropdownMenu, {DropdownOption} from "qqq/components/widgets/components/DropdownMenu"; @@ -94,7 +93,9 @@ export class LabelComponent } - +/******************************************************************************* + ** + *******************************************************************************/ export class HeaderLink extends LabelComponent { label: string; @@ -118,7 +119,9 @@ export class HeaderLink extends LabelComponent } - +/******************************************************************************* + ** + *******************************************************************************/ export class AddNewRecordButton extends LabelComponent { table: QTableMetaData; @@ -152,6 +155,9 @@ export class AddNewRecordButton extends LabelComponent } +/******************************************************************************* + ** + *******************************************************************************/ export class ExportDataButton extends LabelComponent { callbackToExport: any; @@ -177,26 +183,30 @@ export class ExportDataButton extends LabelComponent } +/******************************************************************************* + ** + *******************************************************************************/ export class Dropdown extends LabelComponent { label: string; options: DropdownOption[]; - onChangeCallback: any + dropdownName: string; + onChangeCallback: any; - constructor(label: string, options: DropdownOption[], onChangeCallback: any) + constructor(label: string, options: DropdownOption[], dropdownName: string, onChangeCallback: any) { super(); this.label = label; this.options = options; + this.dropdownName = dropdownName; this.onChangeCallback = onChangeCallback; } render = (args: LabelComponentRenderArgs): JSX.Element => { let defaultValue = null; - const dropdownName = args.widgetProps.widgetData.dropdownNameList[args.componentIndex]; - const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${args.widgetProps.widgetMetaData.name}.${dropdownName}`; - if(args.widgetProps.storeDropdownSelections) + const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${args.widgetProps.widgetMetaData.name}.${this.dropdownName}`; + if (args.widgetProps.storeDropdownSelections) { /////////////////////////////////////////////////////////////////////////////////////// // see if an existing value is stored in local storage, and if so set it in dropdown // @@ -208,7 +218,7 @@ export class Dropdown extends LabelComponent return ( void; @@ -235,7 +248,7 @@ export class ReloadControl extends LabelComponent { return ( - + ); } @@ -245,59 +258,101 @@ export class ReloadControl extends LabelComponent export const WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT = "qqq.widgets.dropdownData"; +/******************************************************************************* + ** + *******************************************************************************/ function Widget(props: React.PropsWithChildren): JSX.Element { const navigate = useNavigate(); const [dropdownData, setDropdownData] = useState([]); const [fullScreenWidgetClassName, setFullScreenWidgetClassName] = useState(""); const [reloading, setReloading] = useState(false); + const [dropdownDataJSON, setDropdownDataJSON] = useState(""); + const [labelComponentsLeft, setLabelComponentsLeft] = useState([] as LabelComponent[]); + const [labelComponentsRight, setLabelComponentsRight] = useState([] as LabelComponent[]); function renderComponent(component: LabelComponent, componentIndex: number) { - return component.render({navigate: navigate, widgetProps: props, dropdownData: dropdownData, componentIndex: componentIndex, reloadFunction: doReload}) + return component.render({navigate: navigate, widgetProps: props, dropdownData: dropdownData, componentIndex: componentIndex, reloadFunction: doReload}); } - - /////////////////////////////////////////////////////////////////// - // make dropdowns from the widgetData appear as label-components // - /////////////////////////////////////////////////////////////////// - const effectiveLabelAdditionalComponentsRight: LabelComponent[] = []; - if(props.labelAdditionalComponentsRight) + useEffect(() => { - props.labelAdditionalComponentsRight.map((component) => effectiveLabelAdditionalComponentsRight.push(component)); - } - if(props.widgetData && props.widgetData.dropdownDataList) - { - props.widgetData.dropdownDataList?.map((dropdownData: any, index: number) => + //////////////////////////////////////////////////////////////////////////////// + // for initial render, put left-components from props into the state variable // + // plus others we can infer from other props // + //////////////////////////////////////////////////////////////////////////////// + const stateLabelComponentsLeft: LabelComponent[] = []; + if (props.reloadWidgetCallback && props.widgetData && props.showReloadControl && props.widgetMetaData.showReloadButton) { - effectiveLabelAdditionalComponentsRight.push(new Dropdown(props.widgetData.dropdownLabelList[index], dropdownData, handleDataChange)) - }); + stateLabelComponentsLeft.push(new ReloadControl(doReload)); + } + if (props.labelAdditionalComponentsLeft) + { + props.labelAdditionalComponentsLeft.map((component) => stateLabelComponentsLeft.push(component)); + } + setLabelComponentsLeft(stateLabelComponentsLeft); + }, []); + + useEffect(() => + { + ///////////////////////////////////////////////////////////////////////////////// + // for initial render, put right-components from props into the state variable // + ///////////////////////////////////////////////////////////////////////////////// + const stateLabelComponentsRight = [] as LabelComponent[]; + // console.log(`${props.widgetMetaData.name} init'ing right-components`); + if (props.labelAdditionalComponentsRight) + { + props.labelAdditionalComponentsRight.map((component) => stateLabelComponentsRight.push(component)); + } + setLabelComponentsRight(stateLabelComponentsRight); + }, []); + + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if we have widgetData, and it has a dropdown list, capture that in a state variable, if it's changed // + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + if (props.widgetData && props.widgetData.dropdownDataList) + { + const currentDropdownDataJSON = JSON.stringify(props.widgetData.dropdownDataList); + if (currentDropdownDataJSON !== dropdownDataJSON) + { + // console.log(`${props.widgetMetaData.name} we have (new) dropdown data!!: ${currentDropdownDataJSON}`); + setDropdownDataJSON(currentDropdownDataJSON); + } } + useEffect(() => + { + /////////////////////////////////////////////////////////////////////////////////// + // if we've seen a change in the dropdown data, then update the right-components // + /////////////////////////////////////////////////////////////////////////////////// + // console.log(`${props.widgetMetaData.name} in useEffect post dropdownData change`); + if (props.widgetData && props.widgetData.dropdownDataList) + { + const updatedStateLabelComponentsRight = JSON.parse(JSON.stringify(labelComponentsRight)) as LabelComponent[]; + props.widgetData.dropdownDataList?.map((dropdownData: any, index: number) => + { + // console.log(`${props.widgetMetaData.name} building a Dropdown, data is: ${dropdownData}`); + updatedStateLabelComponentsRight.push(new Dropdown(props.widgetData.dropdownLabelList[index], dropdownData, props.widgetData.dropdownNameList[index], handleDataChange)); + }); + setLabelComponentsRight(updatedStateLabelComponentsRight); + } + }, [dropdownDataJSON]); + const doReload = () => { setReloading(true); reloadWidget(dropdownData); - } + }; useEffect(() => { setReloading(false); }, [props.widgetData]); - const effectiveLabelAdditionalComponentsLeft: LabelComponent[] = []; - if(props.reloadWidgetCallback && props.widgetData && props.showReloadControl && props.widgetMetaData.showReloadButton) - { - effectiveLabelAdditionalComponentsLeft.push(new ReloadControl(doReload)) - } - if(props.labelAdditionalComponentsLeft) - { - props.labelAdditionalComponentsLeft.map((component) => effectiveLabelAdditionalComponentsLeft.push(component)); - } - function handleDataChange(dropdownLabel: string, changedData: any) { - if(dropdownData) + if (dropdownData) { /////////////////////////////////////////// // find the index base on selected label // @@ -327,7 +382,7 @@ function Widget(props: React.PropsWithChildren): JSX.Element // if should store in local storage, do so now // // or remove if dropdown was cleared out // ///////////////////////////////////////////////// - if(props.storeDropdownSelections) + if (props.storeDropdownSelections) { if (changedData?.id) { @@ -371,7 +426,7 @@ function Widget(props: React.PropsWithChildren): JSX.Element const toggleFullScreenWidget = () => { - if(fullScreenWidgetClassName) + if (fullScreenWidgetClassName) { setFullScreenWidgetClassName(""); } @@ -385,17 +440,17 @@ function Widget(props: React.PropsWithChildren): JSX.Element const isSet = (v: any): boolean => { - return(v !== null && v !== undefined); + return (v !== null && v !== undefined); } ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // to avoid taking up the space of the Box with the label and icon and label-components (since it has a height), only output that box if we need any of the components // ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// let needLabelBox = false; - if(hasPermission) + if (hasPermission) { - needLabelBox ||= (effectiveLabelAdditionalComponentsLeft && effectiveLabelAdditionalComponentsLeft.length > 0); - needLabelBox ||= (effectiveLabelAdditionalComponentsRight && effectiveLabelAdditionalComponentsRight.length > 0); + needLabelBox ||= (labelComponentsLeft && labelComponentsLeft.length > 0); + needLabelBox ||= (labelComponentsRight && labelComponentsRight.length > 0); needLabelBox ||= isSet(props.widgetMetaData?.icon); needLabelBox ||= isSet(props.widgetData?.label); needLabelBox ||= isSet(props.widgetMetaData?.label); @@ -406,90 +461,90 @@ function Widget(props: React.PropsWithChildren): JSX.Element { needLabelBox && - - - { - hasPermission ? - props.widgetMetaData?.icon && ( - - - {props.widgetMetaData.icon} - - - - ) : ( - - lock - - ) - } - { - ////////////////////////////////////////////////////////////////////////////////////////// - // first look for a label in the widget data, which would override that in the metadata // - ////////////////////////////////////////////////////////////////////////////////////////// - hasPermission && props.widgetData?.label? ( - - {props.widgetData.label} - - ) : ( - hasPermission && props.widgetMetaData?.label && ( - - {props.widgetMetaData.label} + + + { + hasPermission ? + props.widgetMetaData?.icon && ( + + + {props.widgetMetaData.icon} + + + ) : + ( + + lock + + ) + } + { + ////////////////////////////////////////////////////////////////////////////////////////// + // first look for a label in the widget data, which would override that in the metadata // + ////////////////////////////////////////////////////////////////////////////////////////// + hasPermission && props.widgetData?.label ? ( + + {props.widgetData.label} + ) : ( + hasPermission && props.widgetMetaData?.label && ( + + {props.widgetMetaData.label} + + ) ) - ) - } - { - hasPermission && ( - effectiveLabelAdditionalComponentsLeft.map((component, i) => - { - return ({renderComponent(component, i)}); - }) - ) - } + } + { + hasPermission && ( + labelComponentsLeft.map((component, i) => + { + return ({renderComponent(component, i)}); + }) + ) + } + + + { + hasPermission && ( + labelComponentsRight.map((component, i) => + { + return ({renderComponent(component, i)}); + }) + ) + } + - - { - hasPermission && ( - effectiveLabelAdditionalComponentsRight.map((component, i) => - { - return ({renderComponent(component, i)}); - }) - ) - } - - } { - props.widgetMetaData?.isCard && (reloading ? : ) + props.widgetMetaData?.isCard && (reloading ? : ) } { errorLoading ? ( @@ -514,7 +569,7 @@ function Widget(props: React.PropsWithChildren): JSX.Element ) } { - ! errorLoading && props?.footerHTML && ( + !errorLoading && props?.footerHTML && ( {parse(props.footerHTML)} ) } From 9cbf9f4dcf260faddf7d48571627ce0adcc1de3a Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 21 Jun 2023 11:54:00 -0500 Subject: [PATCH 48/59] add ability to (poorly) format SQL --- src/qqq/utils/qqq/ValueUtils.tsx | 42 ++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index 857d71a..81a8657 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -38,6 +38,8 @@ import {Link} from "react-router-dom"; import HtmlUtils from "qqq/utils/HtmlUtils"; import Client from "qqq/utils/qqq/Client"; +import "ace-builds/src-noconflict/mode-sql"; + /******************************************************************************* ** Utility class for working with QQQ Values ** @@ -462,7 +464,12 @@ function CodeViewer({name, mode, code}: {name: string; mode: string; code: strin const [errorMessage, setErrorMessage] = useState(null as string); const [resetErrorTimeout, setResetErrorTimeout] = useState(null as any); - const formatJson = () => + const isFormattable = (mode: string): boolean => + { + return (mode === "json" || mode === "sql"); + }; + + const formatCode = () => { if (isFormatted) { @@ -473,19 +480,44 @@ function CodeViewer({name, mode, code}: {name: string; mode: string; code: strin { try { - let formatted = JSON.stringify(JSON.parse(activeCode), null, 3); + let formatted = activeCode; + + if (mode === "json") + { + formatted = JSON.stringify(JSON.parse(activeCode), null, 3); + } + else if (mode === "sql") + { + formatted = code; + if (formatted.match(/(^|\s)SELECT\s.*\sFROM\s/i)) + { + const beforeAndAfterFrom = formatted.split(/\sFROM\s/, 2); + let beforeFrom = beforeAndAfterFrom[0]; + beforeFrom = beforeFrom.replaceAll(/,\s*/gi, ",\n "); + const afterFrom = beforeAndAfterFrom[1]; + formatted = beforeFrom + " FROM " + afterFrom; + } + formatted = formatted.replaceAll(/(\s*\b(SELECT|SELECT DISTINCT|FROM|WHERE|ORDER BY|GROUP BY|HAVING|INNER JOIN|LEFT JOIN|RIGHT JOIN)\b\s*)/gi, "\n$2\n "); + formatted = formatted.replaceAll(/(\s*\b(AND|OR)\b\s*)/gi, "\n $2 "); + formatted = formatted.replaceAll(/^\s*/g, ""); + } + else + { + console.log(`Unsupported mode for formatting [${mode}]`); + } + setActiveCode(formatted); setIsFormatted(true); } catch (e) { - setErrorMessage("Error formatting json: " + e); + setErrorMessage("Error formatting code: " + e); clearTimeout(resetErrorTimeout); setResetErrorTimeout(setTimeout(() => { setErrorMessage(null); }, 5000)); - console.log("Error formatting json: " + e); + console.log("Error formatting code: " + e); } } }; @@ -497,7 +529,7 @@ function CodeViewer({name, mode, code}: {name: string; mode: string; code: strin return ( - {mode == "json" && code && } + {isFormattable(mode) && code && } {code && } {errorMessage}
    From 97f1ac2263f605013432301ffb1bb8e3af13e64f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 21 Jun 2023 12:05:03 -0500 Subject: [PATCH 49/59] Store in local storage what api & version were selected (and re-select them0 --- .../records/developer/TableDeveloperView.tsx | 74 +++++++++++++++---- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/src/qqq/pages/records/developer/TableDeveloperView.tsx b/src/qqq/pages/records/developer/TableDeveloperView.tsx index 2f128a6..7294d43 100644 --- a/src/qqq/pages/records/developer/TableDeveloperView.tsx +++ b/src/qqq/pages/records/developer/TableDeveloperView.tsx @@ -47,8 +47,6 @@ TableDeveloperView.defaultProps = function TableDeveloperView({table}: Props): JSX.Element { - const {id} = useParams(); - const {getAccessTokenSilently} = useAuth0(); const [accessToken, setAccessToken] = useState(null as string); @@ -70,6 +68,33 @@ function TableDeveloperView({table}: Props): JSX.Element setAccessToken(accessToken); })(); + const LAST_API_NAME_LS_KEY = "qqq.tableDeveloperView.lastApiName"; + const LAST_API_VERSION_LS_KEY = "qqq.tableDeveloperView.lastApiVersion"; + + const lastSelectedApiName = localStorage.getItem(LAST_API_NAME_LS_KEY); + const lastSelectedApiVersion = localStorage.getItem(LAST_API_VERSION_LS_KEY); + + function selectVersionAfterApiIsChanged(versionsJson: any) + { + if (versionsJson.currentVersion) + { + setSelectedVersion(versionsJson.currentVersion); + localStorage.setItem(LAST_API_VERSION_LS_KEY, versionsJson.currentVersion); + } + + if (lastSelectedApiVersion) + { + for (let i = 0; i < versionsJson.supportedVersions.length; i++) + { + if (versionsJson.supportedVersions[i] == lastSelectedApiVersion) + { + setSelectedVersion(lastSelectedApiVersion); + localStorage.setItem(LAST_API_VERSION_LS_KEY, lastSelectedApiVersion); + } + } + } + } + if (!asyncLoadInited) { setAsyncLoadInited(true); @@ -90,11 +115,14 @@ function TableDeveloperView({table}: Props): JSX.Element setPageHeader(tableMetaData.label + " Developer Mode"); + /////////////////////////////// + // fetch apis for this table // + /////////////////////////////// const apisResponse = await fetch("/apis.json?tableName=" + tableName); const apisJson = await apisResponse.json(); console.log(apisJson); - if(!apisJson["apis"] || apisJson["apis"].length == 0) + if (!apisJson["apis"] || apisJson["apis"].length == 0) { setNoApis(true); return; @@ -102,18 +130,36 @@ function TableDeveloperView({table}: Props): JSX.Element setSupportedApis(apisJson["apis"]); - const selectedApi = apisJson["apis"][0]; + //////////////////////////////////////////////////////////////////////////////////////////////////////// + // either select the 0th api, or, if there was one previously stored in local storage, use it instead // + //////////////////////////////////////////////////////////////////////////////////////////////////////// + let selectedApi = apisJson["apis"][0]; + if (lastSelectedApiName) + { + for (let i = 0; i < apisJson["apis"].length; i++) + { + if (apisJson["apis"][i].name == lastSelectedApiName) + { + selectedApi = apisJson["apis"][i]; + break; + } + } + } + localStorage.setItem(LAST_API_NAME_LS_KEY, selectedApi.name); setSelectedApi(selectedApi); + //////////////////////////////// + // fetch versions for ths api // + //////////////////////////////// const versionsResponse = await fetch(selectedApi["path"] + "versions.json"); const versionsJson = await versionsResponse.json(); console.log(versionsJson); - setSupportedVersions(versionsJson.supportedVersions); - if (versionsJson.currentVersion) - { - setSelectedVersion(versionsJson.currentVersion); - } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // set the selected version, either to current, or to one from local storage, if still valid // + /////////////////////////////////////////////////////////////////////////////////////////////// + selectVersionAfterApiIsChanged(versionsJson); })(); } @@ -129,16 +175,15 @@ function TableDeveloperView({table}: Props): JSX.Element { const selectedApi = supportedApis[i]; setSelectedApi(selectedApi); + localStorage.setItem(LAST_API_NAME_LS_KEY, selectedApi.name); const versionsResponse = await fetch(selectedApi["path"] + "versions.json"); const versionsJson = await versionsResponse.json(); console.log(versionsJson); setSupportedVersions(versionsJson.supportedVersions); - if (versionsJson.currentVersion) - { - setSelectedVersion(versionsJson.currentVersion); - } + + selectVersionAfterApiIsChanged(versionsJson); break; } } @@ -147,6 +192,7 @@ function TableDeveloperView({table}: Props): JSX.Element const selectVersion = (event: SelectChangeEvent) => { setSelectedVersion(event.target.value); + localStorage.setItem(LAST_API_VERSION_LS_KEY, event.target.value); }; return ( @@ -207,7 +253,7 @@ function TableDeveloperView({table}: Props): JSX.Element persist-auth={true} allow-server-selection={false} allow-spec-file-download={true} - sort-endpoints-by="method" + sort-endpoints-by="none" schema-description-expanded={true} css-file={"/api/rapi-doc.css"} css-classes={"qqq-rapi-doc"} From 22caabb952a480f7ebbdf580d288fc9692976ddc Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 21 Jun 2023 12:10:19 -0500 Subject: [PATCH 50/59] ADd 'api.runProcess(' --- src/qqq/components/scripts/ScriptEditor.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/qqq/components/scripts/ScriptEditor.tsx b/src/qqq/components/scripts/ScriptEditor.tsx index d67ffa3..c50b08a 100644 --- a/src/qqq/components/scripts/ScriptEditor.tsx +++ b/src/qqq/components/scripts/ScriptEditor.tsx @@ -101,6 +101,7 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab completions.push({value: "api.bulkInsert(", meta: "Create multiple records in a table."}); completions.push({value: "api.bulkUpdate(", meta: "Update multiple records in a table."}); completions.push({value: "api.bulkDelete(", meta: "Remove multiple records from a table."}); + completions.push({value: "api.runProcess(", meta: "Run a process"}); // completions.push({value: "api.newRecord(", meta: "Create a new QRecord object."}); // completions.push({value: "api.newQueryInput(", meta: "Create a new QueryInput object."}); // completions.push({value: "api.newQueryFilter(", meta: "Create a new QueryFilter object."}); From 45063ac4166e4290c9a67a1360ab29e9b284fe76 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 21 Jun 2023 12:14:39 -0500 Subject: [PATCH 51/59] Add Duplicate! --- src/qqq/components/forms/EntityForm.tsx | 82 ++++++++++++++++------- src/qqq/pages/records/edit/RecordEdit.tsx | 14 ++-- src/qqq/pages/records/view/RecordView.tsx | 7 ++ 3 files changed, 73 insertions(+), 30 deletions(-) diff --git a/src/qqq/components/forms/EntityForm.tsx b/src/qqq/components/forms/EntityForm.tsx index cb4bd87..4719711 100644 --- a/src/qqq/components/forms/EntityForm.tsx +++ b/src/qqq/components/forms/EntityForm.tsx @@ -54,6 +54,7 @@ interface Props closeModalHandler?: (event: object, reason: string) => void; defaultValues: { [key: string]: string }; disabledFields: { [key: string]: boolean } | string[]; + isDuplicate?: boolean; } EntityForm.defaultProps = { @@ -63,6 +64,7 @@ EntityForm.defaultProps = { closeModalHandler: null, defaultValues: {}, disabledFields: {}, + isDuplicate: false }; function EntityForm(props: Props): JSX.Element @@ -173,24 +175,30 @@ function EntityForm(props: Props): JSX.Element fieldArray.push(fieldMetaData); }); - ///////////////////////////////////////////////////////////////////////////////// - // if doing an edit, fetch the record and pre-populate the form values from it // - ///////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////// + // if doing an edit or duplicate, fetch the record and pre-populate the form values from it // + ////////////////////////////////////////////////////////////////////////////////////////////// let record: QRecord = null; let defaultDisplayValues = new Map(); if (props.id !== null) { record = await qController.get(tableName, props.id); setRecord(record); - setFormTitle(`Edit ${tableMetaData?.label}: ${record?.recordLabel}`); + + const titleVerb = props.isDuplicate ? "Duplicate" : "Edit"; + setFormTitle(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`); if (!props.isModal) { - setPageHeader(`Edit ${tableMetaData?.label}: ${record?.recordLabel}`); + setPageHeader(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`); } tableMetaData.fields.forEach((fieldMetaData, key) => { + if (props.isDuplicate && fieldMetaData.name == tableMetaData.primaryKeyField) + { + return; + } initialValues[key] = record.values.get(key); }); @@ -215,15 +223,6 @@ function EntityForm(props: Props): JSX.Element setPageHeader(`Creating New ${tableMetaData?.label}`); } - if (!tableMetaData.capabilities.has(Capability.TABLE_INSERT)) - { - setNotAllowedError("Records may not be created in this table"); - } - else if (!tableMetaData.insertPermission) - { - setNotAllowedError(`You do not have permission to create ${tableMetaData.label} records`); - } - //////////////////////////////////////////////////////////////////////////////////////////////// // if default values were supplied for a new record, then populate initialValues, for formik. // //////////////////////////////////////////////////////////////////////////////////////////////// @@ -254,6 +253,32 @@ function EntityForm(props: Props): JSX.Element } } + ////////////////////////////////////// + // check capabilities & permissions // + ////////////////////////////////////// + if (props.isDuplicate || !props.id) + { + if (!tableMetaData.capabilities.has(Capability.TABLE_INSERT)) + { + setNotAllowedError("Records may not be created in this table"); + } + else if (!tableMetaData.insertPermission) + { + setNotAllowedError(`You do not have permission to create ${tableMetaData.label} records`); + } + } + else + { + if (!tableMetaData.capabilities.has(Capability.TABLE_UPDATE)) + { + setNotAllowedError("Records may not be edited in this table"); + } + else if (!tableMetaData.editPermission) + { + setNotAllowedError(`You do not have permission to edit ${tableMetaData.label} records`); + } + } + ///////////////////////////////////////////////////////////////////// // make sure all initialValues are properly formatted for the form // ///////////////////////////////////////////////////////////////////// @@ -316,11 +341,11 @@ function EntityForm(props: Props): JSX.Element const fieldName = section.fieldNames[j]; const field = tableMetaData.fields.get(fieldName); - //////////////////////////////////////////////////////////////////////////////////////////// - // if id !== null - means we're on the edit screen -- show all fields on the edit screen. // - // || (or) we're on the insert screen in which case, only show editable fields. // - //////////////////////////////////////////////////////////////////////////////////////////// - if (props.id !== null || field.isEditable) + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if id !== null (and we're not duplicating) - means we're on the edit screen -- show all fields on the edit screen. // + // || (or) we're on the insert screen in which case, only show editable fields. // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if ((props.id !== null && !props.isDuplicate) || field.isEditable) { sectionDynamicFormFields.push(dynamicFormFields[fieldName]); } @@ -368,7 +393,12 @@ function EntityForm(props: Props): JSX.Element // but if the user used the anchors on the page, this doesn't effectively cancel... // // what we have here pushed a new history entry (I think?), so could be better // /////////////////////////////////////////////////////////////////////////////////////// - if (props.id !== null) + if (props.id !== null && props.isDuplicate) + { + const path = `${location.pathname.replace(/\/duplicate$/, "")}`; + navigate(path, {replace: true}); + } + else if (props.id !== null) { const path = `${location.pathname.replace(/\/edit$/, "")}`; navigate(path, {replace: true}); @@ -428,8 +458,9 @@ function EntityForm(props: Props): JSX.Element } } - if (props.id !== null) + if (props.id !== null && !props.isDuplicate) { + // todo - audit that it's a dupe await qController .update(tableName, props.id, values) .then((record) => @@ -473,7 +504,9 @@ function EntityForm(props: Props): JSX.Element } else { - const path = location.pathname.replace(/create$/, record.values.get(tableMetaData.primaryKeyField)); + const path = props.isDuplicate ? + location.pathname.replace(new RegExp(`/${props.id}/duplicate$`), "/" + record.values.get(tableMetaData.primaryKeyField)) + : location.pathname.replace(/create$/, record.values.get(tableMetaData.primaryKeyField)); navigate(path, {state: {createSuccess: true}}); } }) @@ -481,8 +514,9 @@ function EntityForm(props: Props): JSX.Element { if(error.message.toLowerCase().startsWith("warning")) { - const path = location.pathname.replace(/create$/, record.values.get(tableMetaData.primaryKeyField)); - navigate(path); + const path = props.isDuplicate ? + location.pathname.replace(new RegExp(`/${props.id}/duplicate$`), "/" + record.values.get(tableMetaData.primaryKeyField)) + : location.pathname.replace(/create$/, record.values.get(tableMetaData.primaryKeyField)); navigate(path, {state: {createSuccess: true, warning: error.message}}); } else diff --git a/src/qqq/pages/records/edit/RecordEdit.tsx b/src/qqq/pages/records/edit/RecordEdit.tsx index 55de7db..66dd34d 100644 --- a/src/qqq/pages/records/edit/RecordEdit.tsx +++ b/src/qqq/pages/records/edit/RecordEdit.tsx @@ -29,9 +29,15 @@ import BaseLayout from "qqq/layouts/BaseLayout"; interface Props { table?: QTableMetaData; + isDuplicate?: boolean } -function EntityEdit({table}: Props): JSX.Element +EntityEdit.defaultProps = { + table: null, + isDuplicate: false +}; + +function EntityEdit({table, isDuplicate}: Props): JSX.Element { const {id} = useParams(); @@ -43,7 +49,7 @@ function EntityEdit({table}: Props): JSX.Element - + @@ -54,8 +60,4 @@ function EntityEdit({table}: Props): JSX.Element ); } -EntityEdit.defaultProps = { - table: null, -}; - export default EntityEdit; diff --git a/src/qqq/pages/records/view/RecordView.tsx b/src/qqq/pages/records/view/RecordView.tsx index 3b7b490..9861535 100644 --- a/src/qqq/pages/records/view/RecordView.tsx +++ b/src/qqq/pages/records/view/RecordView.tsx @@ -534,6 +534,13 @@ function RecordView({table, launchProcess}: Props): JSX.Element Create New } + { + table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission && + navigate("duplicate")}> + copy + Create Duplicate + + } { table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission && navigate("edit")}> From 7650d32ea50e35ce9889b5cb73af328786ca40bb Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 21 Jun 2023 12:15:12 -0500 Subject: [PATCH 52/59] CSS for height of autocomplete in custom filter panel --- src/qqq/components/query/FilterCriteriaRow.tsx | 2 +- src/qqq/styles/qqq-override-styles.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/qqq/components/query/FilterCriteriaRow.tsx b/src/qqq/components/query/FilterCriteriaRow.tsx index c93142d..850a065 100644 --- a/src/qqq/components/query/FilterCriteriaRow.tsx +++ b/src/qqq/components/query/FilterCriteriaRow.tsx @@ -505,7 +505,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, renderOption={(props, option, state) => renderFieldOption(props, option, state)} autoSelect={true} autoHighlight={true} - slotProps={{popper: {style: {padding: 0, width: "250px"}}}} + slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}} />
    diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index 8282f58..d4f76e9 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -434,7 +434,7 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } } /* taller list box */ -.customFilterPanel .MuiAutocomplete-listbox +.filterCriteriaRowColumnPopper .MuiAutocomplete-listbox { max-height: 60vh; } From 15b295f62d28b5482fe6fa0e81782569931d5fda Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 21 Jun 2023 15:55:02 -0500 Subject: [PATCH 53/59] CSS for these chips in filter panel bulk-paste --- src/qqq/components/forms/ChipTextField.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/qqq/components/forms/ChipTextField.tsx b/src/qqq/components/forms/ChipTextField.tsx index 5c2e9b0..ed79d99 100644 --- a/src/qqq/components/forms/ChipTextField.tsx +++ b/src/qqq/components/forms/ChipTextField.tsx @@ -118,12 +118,12 @@ function ChipTextField({...props}) return (
    +
    { chips.map((item, i) => ( Date: Wed, 21 Jun 2023 16:14:14 -0500 Subject: [PATCH 54/59] CSS adjustments for multi-value filter --- .../query/FilterCriteriaRowValues.tsx | 2 +- src/qqq/styles/qqq-override-styles.css | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/qqq/components/query/FilterCriteriaRowValues.tsx b/src/qqq/components/query/FilterCriteriaRowValues.tsx index c8324cb..3361f2c 100644 --- a/src/qqq/components/query/FilterCriteriaRowValues.tsx +++ b/src/qqq/components/query/FilterCriteriaRowValues.tsx @@ -170,7 +170,7 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC { values = []; } - return + return ()} options={[]} diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index d4f76e9..b645a8f 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -491,6 +491,27 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } color: lightgray; } +/* make the blue active-bottom-border not scroll in multi-value filter value panel */ +/* also prevent that box from getting stupidly large; scroll well. */ +.filterValuesColumn .multiValue .Mui-focused:after +{ + border-bottom: none !important; +} + +.filterValuesColumn .multiValue .Mui-focused .MuiAutocomplete-inputRoot:before +{ + border-bottom: none !important; +} + +.filterValuesColumn .multiValue .MuiAutocomplete-inputRoot.Mui-focused +{ + border-bottom: 2px solid #0062FF; + max-height: 150px; + overflow-x: hidden; + overflow-y: auto; +} + + .DynamicSelectPopper ul { padding: 0; From 0c0a0353a0e89a5e98e803c50ffa7b266c1b6a14 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 22 Jun 2023 09:20:35 -0500 Subject: [PATCH 55/59] Add Duplicate --- src/App.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 8b5c118..147526b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -308,7 +308,14 @@ export default function App() name: `${app.label}`, key: `${app.name}.edit`, route: `${path}/:id/edit`, - component: , + component: , + }); + + routeList.push({ + name: `${app.label}`, + key: `${app.name}.duplicate`, + route: `${path}/:id/duplicate`, + component: , }); routeList.push({ From 37eb7f073b5e3fda360722ca529067ccc24f88c6 Mon Sep 17 00:00:00 2001 From: Tim Chamberlain Date: Thu, 22 Jun 2023 10:16:40 -0500 Subject: [PATCH 56/59] uppped pom version to 0.16.0-SNAPSHOT --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 22615eb..e8cb365 100644 --- a/pom.xml +++ b/pom.xml @@ -29,7 +29,7 @@ jar - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT UTF-8 UTF-8 From 11a4ca256a1414b98fd90f0a1cd4fb9be1783a92 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 22 Jun 2023 16:24:26 -0500 Subject: [PATCH 57/59] Fix how filter models get set when a saved filter is loaded --- src/qqq/pages/records/query/RecordQuery.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index cb47647..689782d 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -929,12 +929,21 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }; - const handleSortChange = (gridSort: GridSortModel) => + const handleSortChangeForDataGrid = (gridSort: GridSortModel) => + { + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + // this method just wraps handleSortChange, but w/o the optional 2nd param, so we can use it in data grid // + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + handleSortChange(gridSort); + } + + const handleSortChange = (gridSort: GridSortModel, overrideFilterModel?: GridFilterModel) => { if (gridSort && gridSort.length > 0) { setColumnSortModel(gridSort); - setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, gridSort, rowsPerPage)); + const gridFilterModelToUse = overrideFilterModel ?? filterModel; + setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, gridFilterModelToUse, gridSort, rowsPerPage)); localStorage.setItem(sortLocalStorageKey, JSON.stringify(gridSort)); } }; @@ -1286,7 +1295,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, qRecord.values.get("filterJson"), null, null, null); handleFilterChange(models.filter); - handleSortChange(models.sort); + handleSortChange(models.sort, models.filter); localStorage.setItem(currentSavedFilterLocalStorageKey, selectedSavedFilterId.toString()); } else @@ -1919,7 +1928,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element onColumnVisibilityModelChange={handleColumnVisibilityChange} onColumnOrderChange={handleColumnOrderChange} onSelectionModelChange={selectionChanged} - onSortModelChange={handleSortChange} + onSortModelChange={handleSortChangeForDataGrid} sortingOrder={["asc", "desc"]} sortModel={columnSortModel} getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")} From 13cfd29d8ce1bd66cb86f6f3818b84a2f36dacd3 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 22 Jun 2023 16:33:50 -0500 Subject: [PATCH 58/59] Same fix as previous for when clearing filter --- src/qqq/pages/records/query/RecordQuery.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 689782d..aad82a4 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -1301,7 +1301,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element else { handleFilterChange({items: []} as GridFilterModel); - handleSortChange([{field: tableMetaData.primaryKeyField, sort: "desc"}]); + handleSortChange([{field: tableMetaData.primaryKeyField, sort: "desc"}], {items: []} as GridFilterModel); localStorage.removeItem(currentSavedFilterLocalStorageKey); } } From 5ce5f8475261e6e44fbadea23a860684900baf07 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 22 Jun 2023 18:43:36 -0500 Subject: [PATCH 59/59] Add call to convertFilterPossibleValuesToIds now that buildQFilterFromGridFilter doesn't do that... --- src/qqq/components/misc/SavedFilters.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qqq/components/misc/SavedFilters.tsx b/src/qqq/components/misc/SavedFilters.tsx index ef52bc6..eead528 100644 --- a/src/qqq/components/misc/SavedFilters.tsx +++ b/src/qqq/components/misc/SavedFilters.tsx @@ -200,7 +200,7 @@ function SavedFilters({qController, metaData, tableMetaData, currentSavedFilter, else { formData.append("tableName", tableMetaData.name); - formData.append("filterJson", JSON.stringify(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel))); + formData.append("filterJson", JSON.stringify(FilterUtils.convertFilterPossibleValuesToIds(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel)))); if (isSaveFilterAs || isRenameFilter || currentSavedFilter == null) {