From cc324fd76da85062a0e3886fc2c722dedc4a13fb Mon Sep 17 00:00:00 2001 From: Tim Chamberlain Date: Tue, 12 Jul 2022 10:28:58 -0500 Subject: [PATCH] ONE-39: added filters, order by, pagination --- .prettierrc.json | 1 - package-lock.json | 60 ++++- package.json | 2 +- src/App.tsx | 9 +- .../DashboardLayout/index.tsx | 18 +- src/layouts/dashboards/analytics/index.tsx | 2 + src/qqq/components/EntityForm/index.tsx | 118 ++++++---- src/qqq/pages/entity-create/index.tsx | 10 +- src/qqq/pages/entity-list/index.tsx | 213 ++++++++++++++---- src/qqq/utils/QClient.ts | 9 +- 10 files changed, 321 insertions(+), 121 deletions(-) diff --git a/.prettierrc.json b/.prettierrc.json index 40fa8e5..f553e50 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,7 +1,6 @@ { "printWidth": 100, "trailingComma": "es5", - "tabWidth": 2, "semi": true, "singleQuote": false, "endOfLine": "auto" diff --git a/package-lock.json b/package-lock.json index 95485ca..dbf124e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@mui/icons-material": "5.4.1", "@mui/material": "5.4.1", "@mui/styled-engine": "5.4.1", + "@mui/x-data-grid": "5.13.0", "@react-jvectormap/core": "1.0.1", "@react-jvectormap/world": "1.0.0", "@testing-library/jest-dom": "5.16.2", @@ -3291,6 +3292,31 @@ "react": "^17.0.0 || ^18.0.0" } }, + "node_modules/@mui/x-data-grid": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-5.13.0.tgz", + "integrity": "sha512-x310qsOJFIT0JuqnDusM6DC4hlX6eeL9biEveb/y+hdeLa+VHSwrclS0M7e9UUwmh1MPza6VT4ceEWJjbySf3A==", + "dependencies": { + "@babel/runtime": "^7.17.2", + "@mui/utils": "^5.4.1", + "clsx": "^1.2.1", + "prop-types": "^15.8.1", + "reselect": "^4.1.6" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@mui/material": "^5.4.1", + "@mui/system": "^5.4.1", + "react": "^17.0.2 || ^18.0.0", + "react-dom": "^17.0.2 || ^18.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5934,9 +5960,9 @@ } }, "node_modules/clsx": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", - "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", "engines": { "node": ">=6" } @@ -14466,6 +14492,11 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, + "node_modules/reselect": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.6.tgz", + "integrity": "sha512-ZovIuXqto7elwnxyXbBtCPo9YFEr3uJqj2rRbcOOog1bmu2Ag85M4hixSwFWyaBMKXNgvPaJ9OSu9SkBPIeJHQ==" + }, "node_modules/resolve": { "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", @@ -19213,6 +19244,18 @@ "react-is": "^17.0.2" } }, + "@mui/x-data-grid": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-5.13.0.tgz", + "integrity": "sha512-x310qsOJFIT0JuqnDusM6DC4hlX6eeL9biEveb/y+hdeLa+VHSwrclS0M7e9UUwmh1MPza6VT4ceEWJjbySf3A==", + "requires": { + "@babel/runtime": "^7.17.2", + "@mui/utils": "^5.4.1", + "clsx": "^1.2.1", + "prop-types": "^15.8.1", + "reselect": "^4.1.6" + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -21178,9 +21221,9 @@ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==" }, "clsx": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", - "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==" }, "co": { "version": "4.6.0", @@ -27233,6 +27276,11 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, + "reselect": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.6.tgz", + "integrity": "sha512-ZovIuXqto7elwnxyXbBtCPo9YFEr3uJqj2rRbcOOog1bmu2Ag85M4hixSwFWyaBMKXNgvPaJ9OSu9SkBPIeJHQ==" + }, "resolve": { "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", diff --git a/package.json b/package.json index b8327ad..f0d6140 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@fullcalendar/interaction": "5.10.0", "@fullcalendar/react": "5.10.0", "@fullcalendar/timegrid": "5.10.0", - "@kingsrook/qqq-frontend-core": "1.0.1", + "@kingsrook/qqq-frontend-core": "file:.yalc/@kingsrook/qqq-frontend-core", "@mui/icons-material": "5.4.1", "@mui/material": "5.4.1", "@mui/styled-engine": "5.4.1", diff --git a/src/App.tsx b/src/App.tsx index d8c5d34..f37268e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,14 @@ Coded by www.creative-tim.com * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. */ -import React, { useState, useEffect, JSXElementConstructor, Key, ReactElement } from "react"; +import React, { + useState, + useEffect, + JSXElementConstructor, + Key, + ReactElement, + useReducer, +} from "react"; // react-router components import { Routes, Route, Navigate, useLocation } from "react-router-dom"; diff --git a/src/examples/LayoutContainers/DashboardLayout/index.tsx b/src/examples/LayoutContainers/DashboardLayout/index.tsx index 3f92daf..fb53257 100644 --- a/src/examples/LayoutContainers/DashboardLayout/index.tsx +++ b/src/examples/LayoutContainers/DashboardLayout/index.tsx @@ -1,19 +1,19 @@ /** -========================================================= -* Material Dashboard 2 PRO React TS - v1.0.0 -========================================================= + ========================================================= + * Material Dashboard 2 PRO React TS - v1.0.0 + ========================================================= -* Product Page: https://www.creative-tim.com/product/material-dashboard-2-pro-react-ts -* Copyright 2022 Creative Tim (https://www.creative-tim.com) + * Product Page: https://www.creative-tim.com/product/material-dashboard-2-pro-react-ts + * Copyright 2022 Creative Tim (https://www.creative-tim.com) -Coded by www.creative-tim.com + Coded by www.creative-tim.com ========================================================= -* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -*/ + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + */ -import { useEffect, ReactNode } from "react"; +import { useEffect, ReactNode, useReducer, useState } from "react"; // react-router-dom components import { useLocation } from "react-router-dom"; diff --git a/src/layouts/dashboards/analytics/index.tsx b/src/layouts/dashboards/analytics/index.tsx index 8cba5b1..31e8fbd 100644 --- a/src/layouts/dashboards/analytics/index.tsx +++ b/src/layouts/dashboards/analytics/index.tsx @@ -42,9 +42,11 @@ import reportsLineChartData from "layouts/dashboards/analytics/data/reportsLineC import booking1 from "assets/images/products/product-1-min.jpg"; import booking2 from "assets/images/products/product-2-min.jpg"; import booking3 from "assets/images/products/product-3-min.jpg"; +import { useReducer } from "react"; function Analytics(): JSX.Element { const { sales, tasks } = reportsLineChartData; + const [, forceUpdate] = useReducer((x) => x + 1, 0); // Action buttons for the BookingCard const actionButtons = ( diff --git a/src/qqq/components/EntityForm/index.tsx b/src/qqq/components/EntityForm/index.tsx index dee41fc..383a4ae 100644 --- a/src/qqq/components/EntityForm/index.tsx +++ b/src/qqq/components/EntityForm/index.tsx @@ -15,8 +15,10 @@ import { QFieldMetaData } from "@kingsrook/qqq-frontend-core/lib/model/metaData/ // @material-ui core components import Card from "@mui/material/Card"; import Grid from "@mui/material/Grid"; +import { Alert } from "@mui/material"; // Material Dashboard 2 PRO React TS components +import MDAlert from "components/MDAlert"; import MDBox from "components/MDBox"; import MDTypography from "components/MDTypography"; import MDButton from "../../../components/MDButton"; @@ -33,6 +35,7 @@ function EntityForm({ id }: Props): JSX.Element { const [validations, setValidations] = useState({}); const [initialValues, setInitialValues] = useState({} as { [key: string]: string }); const [formFields, setFormFields] = useState({}); + const [alertContent, setAlertContent] = useState(""); const [asyncLoadInited, setAsyncLoadInited] = useState(false); const [formValues, setFormValues] = useState({} as { [key: string]: string }); @@ -85,17 +88,27 @@ function EntityForm({ id }: Props): JSX.Element { actions.setSubmitting(true); await (async () => { if (id !== null) { - await qController.update(tableName, id, values).then((record) => { - window.location.href = `/${tableName}/${record.values.get( - tableMetaData.primaryKeyField - )}`; - }); + await qController + .update(tableName, id, values) + .then((record) => { + window.location.href = `/${tableName}/${record.values.get( + tableMetaData.primaryKeyField + )}`; + }) + .catch((error) => { + setAlertContent(error.response.data.error); + }); } else { - await qController.create(tableName, values).then((record) => { - window.location.href = `/${tableName}/${record.values.get( - tableMetaData.primaryKeyField - )}`; - }); + await qController + .create(tableName, values) + .then((record) => { + window.location.href = `/${tableName}/${record.values.get( + tableMetaData.primaryKeyField + )}`; + }) + .catch((error) => { + setAlertContent(error.response.data.error); + }); } })(); }; @@ -106,43 +119,56 @@ function EntityForm({ id }: Props): JSX.Element { id != null ? `edit-${tableMetaData?.label}-form` : `create-${tableMetaData?.label}-form`; return ( - - - {formTitle} - - - - - {({ values, errors, touched, isSubmitting }) => ( -
- - {/*************************************************************************** - ** step content - e.g., the appropriate form or other screen for the step ** - ***************************************************************************/} - {getDynamicStepContent({ - values, - touched, - formFields, - errors, - })} - - - - save {tableMetaData?.label} - - - - -
- )} -
+ + + + {alertContent ? ( + + {alertContent} + + ) : ( + "" + )} + + + {formTitle} + + + + + {({ values, errors, touched, isSubmitting }) => ( +
+ + {/*************************************************************************** + ** step content - e.g., the appropriate form or other screen for the step ** + ***************************************************************************/} + {getDynamicStepContent({ + values, + touched, + formFields, + errors, + })} + + + + save {tableMetaData?.label} + + + + +
+ )} +
+
+
+
-
-
+ + ); } diff --git a/src/qqq/pages/entity-create/index.tsx b/src/qqq/pages/entity-create/index.tsx index c8796b4..633e666 100644 --- a/src/qqq/pages/entity-create/index.tsx +++ b/src/qqq/pages/entity-create/index.tsx @@ -20,7 +20,7 @@ import Grid from "@mui/material/Grid"; import MDBox from "components/MDBox"; // Settings page components -import CreateForm from "qqq/components/EntityForm"; +import EntityForm from "qqq/components/EntityForm"; import BaseLayout from "qqq/components/BaseLayout"; function EntityCreate(): JSX.Element { @@ -29,13 +29,7 @@ function EntityCreate(): JSX.Element { - - - - - - - + diff --git a/src/qqq/pages/entity-list/index.tsx b/src/qqq/pages/entity-list/index.tsx index c086991..82892a7 100644 --- a/src/qqq/pages/entity-list/index.tsx +++ b/src/qqq/pages/entity-list/index.tsx @@ -15,6 +15,7 @@ /* eslint-disable react/no-unstable-nested-components */ import React, { useEffect, useReducer, useState } from "react"; +import { useParams } from "react-router-dom"; // @mui material components import Card from "@mui/material/Card"; @@ -23,32 +24,43 @@ import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Divider from "@mui/material/Divider"; import Link from "@mui/material/Link"; -import { makeStyles } from "@mui/material"; +import { makeStyles, Alert } from "@mui/material"; import { DataGrid, GridCallbackDetails, GridColDef, + GridColumnVisibilityModel, + GridFilterModel, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, + GridSortItem, + GridSortModel, + GridToolbar, } from "@mui/x-data-grid"; // Material Dashboard 2 PRO React TS components import DashboardLayout from "examples/LayoutContainers/DashboardLayout"; import DashboardNavbar from "examples/Navbars/DashboardNavbar"; import MDBox from "components/MDBox"; -import MDTypography from "components/MDTypography"; import MDButton from "components/MDButton"; // QQQ import { QProcessMetaData } from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData"; import { QTableMetaData } from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; -import { useParams } from "react-router-dom"; +import { QQueryFilter } from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import { QFilterOrderBy } from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; +import { QFilterCriteria } from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; +import { QCriteriaOperator } from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; +import { QFieldType } from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; import QClient from "qqq/utils/QClient"; import Footer from "../../components/Footer"; import QProcessUtils from "../../utils/QProcessUtils"; +const COLUMN_VISIBILITY_LOCAL_STORAGE_KEY = "qqq.columnVisibility"; +const COLUMN_SORT_LOCAL_STORAGE_KEY = "qqq.columnSort"; + // Declaring props types for DefaultCell interface Props { table?: QTableMetaData; @@ -69,6 +81,10 @@ function EntityList({ table }: Props): JSX.Element { const [columns, setColumns] = useState([] as GridColDef[]); const [rows, setRows] = useState([] as GridRowsProp[]); const [loading, setLoading] = useState(true); + const [columnVisibilityModel, setColumnVisibilityModel] = useState({}); + const [sortModel, setSortModel] = useState([] as GridSortItem[]); + const [filterModel, setFilterModel] = useState(null as GridFilterModel); + const [alertContent, setAlertContent] = useState(""); const [, forceUpdate] = useReducer((x) => x + 1, 0); @@ -77,14 +93,89 @@ function EntityList({ table }: Props): JSX.Element { const openFiltersMenu = (event: any) => setFiltersMenu(event.currentTarget); const closeFiltersMenu = () => setFiltersMenu(null); + const translateCriteriaOperator = (operator: string) => { + switch (operator) { + case "contains": + return QCriteriaOperator.CONTAINS; + case "starts with": + return QCriteriaOperator.STARTS_WITH; + case "ends with": + return QCriteriaOperator.STARTS_WITH; + case "is": + case "equals": + case "=": + return QCriteriaOperator.EQUALS; + case "is not": + case "!=": + return QCriteriaOperator.NOT_EQUALS; + case "is after": + case ">": + return QCriteriaOperator.GREATER_THAN; + case "is on or after": + case ">=": + return QCriteriaOperator.GREATER_THAN_OR_EQUALS; + case "is before": + case "<": + return QCriteriaOperator.LESS_THAN; + case "is on or before": + case "<=": + return QCriteriaOperator.LESS_THAN_OR_EQUALS; + case "is empty": + return QCriteriaOperator.IS_BLANK; + case "is not empty": + return QCriteriaOperator.IS_NOT_BLANK; + // case "is any of": + // TODO: handle this case + default: + return QCriteriaOperator.EQUALS; + } + }; + + const buildQFilter = () => { + const qFilter = new QQueryFilter(); + sortModel.forEach((gridSortItem) => { + qFilter.addOrderBy(new QFilterOrderBy(gridSortItem.field, gridSortItem.sort === "asc")); + }); + if (filterModel) { + filterModel.items.forEach((item) => { + qFilter.addCriteria( + new QFilterCriteria(item.columnField, translateCriteriaOperator(item.operatorValue), [ + item.value, + ]) + ); + }); + } + + return qFilter; + }; + const updateTable = () => { (async () => { const tableMetaData = await QClient.loadTableMetaData(tableName); const count = await QClient.count(tableName); setTotalRecords(count); + if (sortModel.length === 0) { + sortModel.push({ field: tableMetaData.primaryKeyField, sort: "desc" }); + setSortModel(sortModel); + } + + const qFilter = buildQFilter(); const columns = [] as GridColDef[]; - const results = await QClient.query(tableName, rowsPerPage, pageNumber * rowsPerPage); + + const results = await QClient.query( + tableName, + qFilter, + rowsPerPage, + pageNumber * rowsPerPage + ).catch((error) => { + if (error.message) { + setAlertContent(error.message); + } else { + setAlertContent(error.response.data.error); + } + throw error; + }); const rows = [] as any[]; results.forEach((record) => { @@ -95,19 +186,46 @@ function EntityList({ table }: Props): JSX.Element { sortedKeys.forEach((key) => { const field = tableMetaData.fields.get(key); + let columnType = "string"; + switch (field.type) { + case QFieldType.DECIMAL: + case QFieldType.INTEGER: + columnType = "number"; + break; + case QFieldType.DATE: + columnType = "date"; + break; + case QFieldType.DATE_TIME: + columnType = "dateTime"; + break; + case QFieldType.BOOLEAN: + columnType = "boolean"; + break; + default: + // noop + } + const column = { field: field.name, + type: columnType, headerName: field.label, width: 200, }; if (key === tableMetaData.primaryKeyField) { + column.width = 75; columns.splice(0, 0, column); } else { columns.push(column); } }); + const columnVisibilityModel = localStorage.getItem(COLUMN_VISIBILITY_LOCAL_STORAGE_KEY); + if (columnVisibilityModel) { + setColumnVisibilityModel( + JSON.parse(localStorage.getItem(COLUMN_VISIBILITY_LOCAL_STORAGE_KEY)) + ); + } setColumns(columns); setRows(rows); setLoading(false); @@ -127,6 +245,10 @@ function EntityList({ table }: Props): JSX.Element { document.location.href = `/${tableName}/${params.id}`; }; + const handleFilterChange = (filterModel: GridFilterModel) => { + setFilterModel(filterModel); + }; + const selectionChanged = (selectionModel: GridSelectionModel, details: GridCallbackDetails) => { const newSelectedIds: string[] = []; selectionModel.forEach((value: GridRowId) => { @@ -135,6 +257,19 @@ function EntityList({ table }: Props): JSX.Element { setSelectedIds(newSelectedIds); }; + const handleColumnVisibilityChange = (columnVisibilityModel: GridColumnVisibilityModel) => { + setColumnVisibilityModel(columnVisibilityModel); + localStorage.setItem( + COLUMN_VISIBILITY_LOCAL_STORAGE_KEY, + JSON.stringify(columnVisibilityModel) + ); + }; + + const handleSortChange = (gridSort: GridSortModel) => { + setSortModel(gridSort); + localStorage.setItem(COLUMN_SORT_LOCAL_STORAGE_KEY, JSON.stringify(gridSort)); + }; + if (tableName !== tableState) { (async () => { setTableState(tableName); @@ -171,39 +306,28 @@ function EntityList({ table }: Props): JSX.Element { ); - const renderFiltersMenu = ( - - Status: Paid - Status: Refunded - Status: Canceled - - - - Remove Filter - - - - ); - useEffect(() => { updateTable(); - }, [pageNumber, rowsPerPage, tableState]); + }, [pageNumber, rowsPerPage, tableState, sortModel, filterModel]); return ( + {alertContent ? ( + + {alertContent} + + ) : ( + "" + )} - - new {tableName} - + + + new {tableName} + + + {tableProcesses.length > 0 && ( )} {renderActionsMenu} - - - filters  - keyboard_arrow_down - - {renderFiltersMenu} - - - - description -  export csv - - + params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd" + } /> diff --git a/src/qqq/utils/QClient.ts b/src/qqq/utils/QClient.ts index a32fe8a..4853720 100644 --- a/src/qqq/utils/QClient.ts +++ b/src/qqq/utils/QClient.ts @@ -21,6 +21,7 @@ import { QFieldMetaData } from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; import { QController } from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; +import { QQueryFilter } from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; /******************************************************************************* ** client wrapper of qqq backend @@ -45,8 +46,12 @@ class QClient { return this.getInstance().loadMetaData(); } - public static query(tableName: string, limit: number, skip: number) { - return this.getInstance().query(tableName, limit, skip); + public static query(tableName: string, filter: QQueryFilter, limit: number, skip: number) { + return this.getInstance() + .query(tableName, filter, limit, skip) + .catch((error) => { + throw error; + }); } public static count(tableName: string) {