mirror of
https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
synced 2025-07-18 05:10:45 +00:00
Merge branch 'dev' into feature/breadcrumb-labels
This commit is contained in:
23
.circleci/adjust-pom-version.sh
Executable file
23
.circleci/adjust-pom-version.sh
Executable file
@ -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 <revision> to: $SLUG-SNAPSHOT"
|
||||||
|
sed -i "s/<revision>.*/<revision>$SLUG-SNAPSHOT<\/revision>/" $POM
|
||||||
|
git diff $POM
|
@ -71,6 +71,10 @@ commands:
|
|||||||
mvn_deploy:
|
mvn_deploy:
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
|
- run:
|
||||||
|
name: Adjust pom version
|
||||||
|
command: |
|
||||||
|
.circleci/adjust-pom-version.sh
|
||||||
- restore_cache:
|
- restore_cache:
|
||||||
keys:
|
keys:
|
||||||
- v1-dependencies-{{ checksum "pom.xml" }}
|
- v1-dependencies-{{ checksum "pom.xml" }}
|
||||||
|
4891
package-lock.json
generated
4891
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,7 @@
|
|||||||
"@auth0/auth0-react": "1.10.2",
|
"@auth0/auth0-react": "1.10.2",
|
||||||
"@emotion/react": "11.7.1",
|
"@emotion/react": "11.7.1",
|
||||||
"@emotion/styled": "11.6.0",
|
"@emotion/styled": "11.6.0",
|
||||||
"@kingsrook/qqq-frontend-core": "1.0.61",
|
"@kingsrook/qqq-frontend-core": "1.0.68",
|
||||||
"@mui/icons-material": "5.4.1",
|
"@mui/icons-material": "5.4.1",
|
||||||
"@mui/material": "5.11.1",
|
"@mui/material": "5.11.1",
|
||||||
"@mui/styles": "5.11.1",
|
"@mui/styles": "5.11.1",
|
||||||
@ -30,6 +30,7 @@
|
|||||||
"form-data": "4.0.0",
|
"form-data": "4.0.0",
|
||||||
"formik": "2.2.9",
|
"formik": "2.2.9",
|
||||||
"html-react-parser": "1.4.8",
|
"html-react-parser": "1.4.8",
|
||||||
|
"html-to-text": "^9.0.5",
|
||||||
"http-proxy-middleware": "2.0.6",
|
"http-proxy-middleware": "2.0.6",
|
||||||
"rapidoc": "9.3.4",
|
"rapidoc": "9.3.4",
|
||||||
"react": "17.0.2",
|
"react": "17.0.2",
|
||||||
|
2
pom.xml
2
pom.xml
@ -29,7 +29,7 @@
|
|||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<revision>0.14.0-SNAPSHOT</revision>
|
<revision>0.16.0-SNAPSHOT</revision>
|
||||||
|
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||||
|
10
src/App.tsx
10
src/App.tsx
@ -309,7 +309,14 @@ export default function App()
|
|||||||
name: `${app.label}`,
|
name: `${app.label}`,
|
||||||
key: `${app.name}.edit`,
|
key: `${app.name}.edit`,
|
||||||
route: `${path}/:id/edit`,
|
route: `${path}/:id/edit`,
|
||||||
component: <EntityEdit table={table} />,
|
component: <EntityEdit table={table} isDuplicate={false} />,
|
||||||
|
});
|
||||||
|
|
||||||
|
routeList.push({
|
||||||
|
name: `${app.label}`,
|
||||||
|
key: `${app.name}.duplicate`,
|
||||||
|
route: `${path}/:id/duplicate`,
|
||||||
|
component: <EntityEdit table={table} isDuplicate={true} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
routeList.push({
|
routeList.push({
|
||||||
@ -578,6 +585,7 @@ export default function App()
|
|||||||
icon={branding.icon}
|
icon={branding.icon}
|
||||||
logo={branding.logo}
|
logo={branding.logo}
|
||||||
appName={branding.appName}
|
appName={branding.appName}
|
||||||
|
branding={branding}
|
||||||
routes={sideNavRoutes}
|
routes={sideNavRoutes}
|
||||||
pathToLabelMap={pathToLabelMap}
|
pathToLabelMap={pathToLabelMap}
|
||||||
onMouseEnter={handleOnMouseEnter}
|
onMouseEnter={handleOnMouseEnter}
|
||||||
|
@ -108,6 +108,10 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
|
|||||||
{
|
{
|
||||||
return (<>{fieldLabel}: Removed value {(oldValue)}</>);
|
return (<>{fieldLabel}: Removed value {(oldValue)}</>);
|
||||||
}
|
}
|
||||||
|
else if(message)
|
||||||
|
{
|
||||||
|
return (<>{message}</>);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
const fieldLabel = <span style={{fontWeight: "700", color: "rgb(52, 71, 103)"}}>{tableMetaData?.fields?.get(fieldName)?.label ?? fieldName}</span>;
|
const fieldLabel = <span style={{fontWeight: "700", color: "rgb(52, 71, 103)"}}>{tableMetaData?.fields?.get(fieldName)?.label ?? fieldName}</span>;
|
||||||
|
@ -118,12 +118,12 @@ function ChipTextField({...props})
|
|||||||
return (
|
return (
|
||||||
<div id="chip-text-field-container" style={{flexWrap: "wrap", display:"flex"}}>
|
<div id="chip-text-field-container" style={{flexWrap: "wrap", display:"flex"}}>
|
||||||
<TextField
|
<TextField
|
||||||
sx={{width: "100%"}}
|
sx={{width: "99%"}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label={label}
|
label={label}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment:
|
startAdornment:
|
||||||
<div>
|
<div style={{overflowY: "auto", overflowX: "hidden", margin: "-10px", width: "calc(100% + 20px)", padding: "10px", marginBottom: "-20px", height: "calc(100% + 10px)"}}>
|
||||||
{
|
{
|
||||||
chips.map((item, i) => (
|
chips.map((item, i) => (
|
||||||
<Chip
|
<Chip
|
||||||
|
@ -19,15 +19,20 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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 Box from "@mui/material/Box";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Grid from "@mui/material/Grid";
|
import Grid from "@mui/material/Grid";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import {useFormikContext} from "formik";
|
import {useFormikContext} from "formik";
|
||||||
import React, {useState} from "react";
|
import React, {useState} from "react";
|
||||||
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
|
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
|
||||||
import DynamicSelect from "qqq/components/forms/DynamicSelect";
|
import DynamicSelect from "qqq/components/forms/DynamicSelect";
|
||||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||||
|
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||||
|
|
||||||
interface Props
|
interface Props
|
||||||
{
|
{
|
||||||
@ -35,6 +40,7 @@ interface Props
|
|||||||
formData: any;
|
formData: any;
|
||||||
bulkEditMode?: boolean;
|
bulkEditMode?: boolean;
|
||||||
bulkEditSwitchChangeHandler?: any;
|
bulkEditSwitchChangeHandler?: any;
|
||||||
|
record?: QRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
function QDynamicForm(props: Props): JSX.Element
|
function QDynamicForm(props: Props): JSX.Element
|
||||||
@ -60,6 +66,14 @@ function QDynamicForm(props: Props): JSX.Element
|
|||||||
formikProps.setFieldValue(field.name, event.currentTarget.files[0]);
|
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) =>
|
const bulkEditSwitchChanged = (name: string, value: boolean) =>
|
||||||
{
|
{
|
||||||
bulkEditSwitchChangeHandler(name, value);
|
bulkEditSwitchChangeHandler(name, value);
|
||||||
@ -94,10 +108,23 @@ function QDynamicForm(props: Props): JSX.Element
|
|||||||
|
|
||||||
if (field.type === "file")
|
if (field.type === "file")
|
||||||
{
|
{
|
||||||
|
const pseudoField = new QFieldMetaData({name: fieldName, type: QFieldType.BLOB});
|
||||||
return (
|
return (
|
||||||
<Grid item xs={12} sm={6} key={fieldName}>
|
<Grid item xs={12} sm={6} key={fieldName}>
|
||||||
<Box mb={1.5}>
|
<Box mb={1.5}>
|
||||||
|
|
||||||
|
<InputLabel shrink={true}>{field.label}</InputLabel>
|
||||||
|
{
|
||||||
|
props.record && props.record.values.get(fieldName) && <Box fontSize="0.875rem" pb={1}>
|
||||||
|
Current File:
|
||||||
|
<Box display="inline-flex" pl={1}>
|
||||||
|
{ValueUtils.getDisplayValue(pseudoField, props.record, "view")}
|
||||||
|
<Tooltip placement="bottom" title="Remove current file">
|
||||||
|
<Icon className="blobIcon" fontSize="small" onClick={(e) => removeFile(fieldName)}>delete</Icon>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
<Box display="flex" alignItems="center">
|
<Box display="flex" alignItems="center">
|
||||||
<Button variant="outlined" component="label">
|
<Button variant="outlined" component="label">
|
||||||
<span style={{color: colors.lightBlue[500]}}>Choose file to upload</span>
|
<span style={{color: colors.lightBlue[500]}}>Choose file to upload</span>
|
||||||
|
@ -38,6 +38,7 @@ interface Props
|
|||||||
tableName?: string;
|
tableName?: string;
|
||||||
processName?: string;
|
processName?: string;
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
|
overrideId?: string;
|
||||||
fieldLabel: string;
|
fieldLabel: string;
|
||||||
inForm: boolean;
|
inForm: boolean;
|
||||||
initialValue?: any;
|
initialValue?: any;
|
||||||
@ -70,7 +71,7 @@ DynamicSelect.defaultProps = {
|
|||||||
|
|
||||||
const qController = Client.getInstance();
|
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 [open, setOpen] = useState(false);
|
||||||
const [options, setOptions] = useState<readonly QPossibleValue[]>([]);
|
const [options, setOptions] = useState<readonly QPossibleValue[]>([]);
|
||||||
@ -82,9 +83,14 @@ function DynamicSelect({tableName, processName, fieldName, fieldLabel, inForm, i
|
|||||||
// else non-multiple, assume we took in an initialValue (id) and initialDisplayValue (label), //
|
// 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 //
|
// 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);
|
: useState(initialValue && initialDisplayValue ? [{id: initialValue, label: initialDisplayValue}] : null);
|
||||||
|
|
||||||
|
if (isMultiple && defaultValue === null)
|
||||||
|
{
|
||||||
|
defaultValue = [];
|
||||||
|
}
|
||||||
|
|
||||||
// const loading = open && options.length === 0;
|
// const loading = open && options.length === 0;
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [switchChecked, setSwitchChecked] = useState(false);
|
const [switchChecked, setSwitchChecked] = useState(false);
|
||||||
@ -239,9 +245,11 @@ function DynamicSelect({tableName, processName, fieldName, fieldLabel, inForm, i
|
|||||||
bulkEditSwitchChangeHandler(fieldName, newSwitchValue);
|
bulkEditSwitchChangeHandler(fieldName, newSwitchValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// console.log(`default value: ${JSON.stringify(defaultValue)}`);
|
||||||
|
|
||||||
const autocomplete = (
|
const autocomplete = (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
id={fieldName}
|
id={overrideId ?? fieldName}
|
||||||
sx={{background: isDisabled ? "#f0f2f5!important" : "initial"}}
|
sx={{background: isDisabled ? "#f0f2f5!important" : "initial"}}
|
||||||
open={open}
|
open={open}
|
||||||
fullWidth
|
fullWidth
|
||||||
@ -291,6 +299,8 @@ function DynamicSelect({tableName, processName, fieldName, fieldLabel, inForm, i
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
multiple={isMultiple}
|
multiple={isMultiple}
|
||||||
disableCloseOnSelect={isMultiple}
|
disableCloseOnSelect={isMultiple}
|
||||||
|
limitTags={5}
|
||||||
|
slotProps={{popper: {className: "DynamicSelectPopper"}}}
|
||||||
renderInput={(params) => (
|
renderInput={(params) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...params}
|
{...params}
|
||||||
|
@ -41,6 +41,7 @@ import QDynamicForm from "qqq/components/forms/DynamicForm";
|
|||||||
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
|
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
|
||||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||||
import QRecordSidebar from "qqq/components/misc/RecordSidebar";
|
import QRecordSidebar from "qqq/components/misc/RecordSidebar";
|
||||||
|
import HtmlUtils from "qqq/utils/HtmlUtils";
|
||||||
import Client from "qqq/utils/qqq/Client";
|
import Client from "qqq/utils/qqq/Client";
|
||||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||||
@ -53,6 +54,7 @@ interface Props
|
|||||||
closeModalHandler?: (event: object, reason: string) => void;
|
closeModalHandler?: (event: object, reason: string) => void;
|
||||||
defaultValues: { [key: string]: string };
|
defaultValues: { [key: string]: string };
|
||||||
disabledFields: { [key: string]: boolean } | string[];
|
disabledFields: { [key: string]: boolean } | string[];
|
||||||
|
isDuplicate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
EntityForm.defaultProps = {
|
EntityForm.defaultProps = {
|
||||||
@ -62,6 +64,7 @@ EntityForm.defaultProps = {
|
|||||||
closeModalHandler: null,
|
closeModalHandler: null,
|
||||||
defaultValues: {},
|
defaultValues: {},
|
||||||
disabledFields: {},
|
disabledFields: {},
|
||||||
|
isDuplicate: false
|
||||||
};
|
};
|
||||||
|
|
||||||
function EntityForm(props: Props): JSX.Element
|
function EntityForm(props: Props): JSX.Element
|
||||||
@ -82,7 +85,6 @@ function EntityForm(props: Props): JSX.Element
|
|||||||
const [warningContent, setWarningContent] = useState("");
|
const [warningContent, setWarningContent] = useState("");
|
||||||
|
|
||||||
const [asyncLoadInited, setAsyncLoadInited] = useState(false);
|
const [asyncLoadInited, setAsyncLoadInited] = useState(false);
|
||||||
const [formValues, setFormValues] = useState({} as { [key: string]: string });
|
|
||||||
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
|
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
|
||||||
const [record, setRecord] = useState(null as QRecord);
|
const [record, setRecord] = useState(null as QRecord);
|
||||||
const [tableSections, setTableSections] = useState(null as QTableSection[]);
|
const [tableSections, setTableSections] = useState(null as QTableSection[]);
|
||||||
@ -133,13 +135,22 @@ function EntityForm(props: Props): JSX.Element
|
|||||||
for (let i = 0; i < formFields.length; i++)
|
for (let i = 0; i < formFields.length; i++)
|
||||||
{
|
{
|
||||||
formData.formFields[formFields[i].name] = formFields[i];
|
formData.formFields[formFields[i].name] = formFields[i];
|
||||||
|
|
||||||
|
if (formFields[i].possibleValueProps)
|
||||||
|
{
|
||||||
|
formFields[i].possibleValueProps.otherValues = formFields[i].possibleValueProps.otherValues ?? new Map<string, any>();
|
||||||
|
Object.keys(formFields).forEach((otherKey) =>
|
||||||
|
{
|
||||||
|
formFields[i].possibleValueProps.otherValues.set(otherKey, values[otherKey]);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Object.keys(formFields).length)
|
if (!Object.keys(formFields).length)
|
||||||
{
|
{
|
||||||
return <div>Loading...</div>;
|
return <div>Loading...</div>;
|
||||||
}
|
}
|
||||||
return <QDynamicForm formData={formData} />;
|
return <QDynamicForm formData={formData} record={record} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!asyncLoadInited)
|
if (!asyncLoadInited)
|
||||||
@ -164,29 +175,33 @@ function EntityForm(props: Props): JSX.Element
|
|||||||
fieldArray.push(fieldMetaData);
|
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 record: QRecord = null;
|
||||||
let defaultDisplayValues = new Map<string, string>();
|
let defaultDisplayValues = new Map<string, string>();
|
||||||
if (props.id !== null)
|
if (props.id !== null)
|
||||||
{
|
{
|
||||||
record = await qController.get(tableName, props.id);
|
record = await qController.get(tableName, props.id);
|
||||||
setRecord(record);
|
setRecord(record);
|
||||||
setFormTitle(`Edit ${tableMetaData?.label}: ${record?.recordLabel}`);
|
|
||||||
|
const titleVerb = props.isDuplicate ? "Duplicate" : "Edit";
|
||||||
|
setFormTitle(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`);
|
||||||
|
|
||||||
if (!props.isModal)
|
if (!props.isModal)
|
||||||
{
|
{
|
||||||
setPageHeader(`Edit ${tableMetaData?.label}: ${record?.recordLabel}`);
|
setPageHeader(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
tableMetaData.fields.forEach((fieldMetaData, key) =>
|
tableMetaData.fields.forEach((fieldMetaData, key) =>
|
||||||
{
|
{
|
||||||
|
if (props.isDuplicate && fieldMetaData.name == tableMetaData.primaryKeyField)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
initialValues[key] = record.values.get(key);
|
initialValues[key] = record.values.get(key);
|
||||||
});
|
});
|
||||||
|
|
||||||
//? safe to delete? setFormValues(formValues);
|
|
||||||
|
|
||||||
if (!tableMetaData.capabilities.has(Capability.TABLE_UPDATE))
|
if (!tableMetaData.capabilities.has(Capability.TABLE_UPDATE))
|
||||||
{
|
{
|
||||||
setNotAllowedError("Records may not be edited in this table");
|
setNotAllowedError("Records may not be edited in this table");
|
||||||
@ -208,15 +223,6 @@ function EntityForm(props: Props): JSX.Element
|
|||||||
setPageHeader(`Creating New ${tableMetaData?.label}`);
|
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. //
|
// if default values were supplied for a new record, then populate initialValues, for formik. //
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
@ -247,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 //
|
// make sure all initialValues are properly formatted for the form //
|
||||||
/////////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////////
|
||||||
@ -309,11 +341,11 @@ function EntityForm(props: Props): JSX.Element
|
|||||||
const fieldName = section.fieldNames[j];
|
const fieldName = section.fieldNames[j];
|
||||||
const field = tableMetaData.fields.get(fieldName);
|
const field = tableMetaData.fields.get(fieldName);
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
// if id !== null - means we're on the edit screen -- show all fields on the edit screen. //
|
// 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. //
|
// || (or) we're on the insert screen in which case, only show editable fields. //
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
if (props.id !== null || field.isEditable)
|
if ((props.id !== null && !props.isDuplicate) || field.isEditable)
|
||||||
{
|
{
|
||||||
sectionDynamicFormFields.push(dynamicFormFields[fieldName]);
|
sectionDynamicFormFields.push(dynamicFormFields[fieldName]);
|
||||||
}
|
}
|
||||||
@ -361,7 +393,12 @@ function EntityForm(props: Props): JSX.Element
|
|||||||
// but if the user used the anchors on the page, this doesn't effectively cancel... //
|
// 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 //
|
// 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$/, "")}`;
|
const path = `${location.pathname.replace(/\/edit$/, "")}`;
|
||||||
navigate(path, {replace: true});
|
navigate(path, {replace: true});
|
||||||
@ -378,6 +415,10 @@ function EntityForm(props: Props): JSX.Element
|
|||||||
actions.setSubmitting(true);
|
actions.setSubmitting(true);
|
||||||
await (async () =>
|
await (async () =>
|
||||||
{
|
{
|
||||||
|
for(let fieldName of tableMetaData.fields.keys())
|
||||||
|
{
|
||||||
|
const fieldMetaData = tableMetaData.fields.get(fieldName);
|
||||||
|
|
||||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
// (1) convert date-time fields from user's time-zone into UTC //
|
// (1) convert date-time fields from user's time-zone into UTC //
|
||||||
// (2) if there's an initial value which matches the value (e.g., from the form), then remove that field //
|
// (2) if there's an initial value which matches the value (e.g., from the form), then remove that field //
|
||||||
@ -386,9 +427,6 @@ function EntityForm(props: Props): JSX.Element
|
|||||||
// changing from, say, 12:15:30 to just 12:15:00... this seems to get around that, for cases when the //
|
// changing from, say, 12:15:30 to just 12:15:00... this seems to get around that, for cases when the //
|
||||||
// user didn't change the value in the field (but if the user did change the value, then we will submit it) //
|
// user didn't change the value in the field (but if the user did change the value, then we will submit it) //
|
||||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
for(let fieldName of tableMetaData.fields.keys())
|
|
||||||
{
|
|
||||||
const fieldMetaData = tableMetaData.fields.get(fieldName);
|
|
||||||
if(fieldMetaData.type === QFieldType.DATE_TIME && values[fieldName])
|
if(fieldMetaData.type === QFieldType.DATE_TIME && values[fieldName])
|
||||||
{
|
{
|
||||||
console.log(`DateTime ${fieldName}: Initial value: [${initialValues[fieldName]}] -> [${values[fieldName]}]`)
|
console.log(`DateTime ${fieldName}: Initial value: [${initialValues[fieldName]}] -> [${values[fieldName]}]`)
|
||||||
@ -402,10 +440,27 @@ function EntityForm(props: Props): JSX.Element
|
|||||||
values[fieldName] = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(values[fieldName]);
|
values[fieldName] = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(values[fieldName]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// for BLOB fields, there are 3 possible cases: //
|
||||||
|
// 1) they are a File object - in which case, cool, send them through to the backend to have bytes stored. //
|
||||||
|
// 2) they are null - in which case, cool, send them through to the backend to be set to null. //
|
||||||
|
// 3) they are a String, which is their URL path to download them... in that case, don't submit them to //
|
||||||
|
// the backend at all, so they'll stay what they were. do that by deleting them from the values object here. //
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
if(fieldMetaData.type === QFieldType.BLOB)
|
||||||
|
{
|
||||||
|
if(typeof values[fieldName] === "string")
|
||||||
|
{
|
||||||
|
console.log(`${fieldName} value was a string, so, we're deleting it from the values array, to not submit it to the backend, to not change it.`);
|
||||||
|
delete(values[fieldName]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.id !== null)
|
if (props.id !== null && !props.isDuplicate)
|
||||||
{
|
{
|
||||||
|
// todo - audit that it's a dupe
|
||||||
await qController
|
await qController
|
||||||
.update(tableName, props.id, values)
|
.update(tableName, props.id, values)
|
||||||
.then((record) =>
|
.then((record) =>
|
||||||
@ -416,8 +471,8 @@ function EntityForm(props: Props): JSX.Element
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
const path = `${location.pathname.replace(/\/edit$/, "")}?updateSuccess=true`;
|
const path = location.pathname.replace(/\/edit$/, "");
|
||||||
navigate(path);
|
navigate(path, {state: {updateSuccess: true}});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) =>
|
.catch((error) =>
|
||||||
@ -427,12 +482,13 @@ function EntityForm(props: Props): JSX.Element
|
|||||||
|
|
||||||
if(error.message.toLowerCase().startsWith("warning"))
|
if(error.message.toLowerCase().startsWith("warning"))
|
||||||
{
|
{
|
||||||
const path = `${location.pathname.replace(/\/edit$/, "")}?updateSuccess=true&warning=${encodeURIComponent(error.message)}`;
|
const path = location.pathname.replace(/\/edit$/, "");
|
||||||
navigate(path);
|
navigate(path, {state: {updateSuccess: true, warning: error.message}});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
setAlertContent(error.message);
|
setAlertContent(error.message);
|
||||||
|
HtmlUtils.autoScroll(0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -448,20 +504,25 @@ function EntityForm(props: Props): JSX.Element
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
const path = `${location.pathname.replace(/create$/, record.values.get(tableMetaData.primaryKeyField))}?createSuccess=true`;
|
const path = props.isDuplicate ?
|
||||||
navigate(path);
|
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}});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) =>
|
.catch((error) =>
|
||||||
{
|
{
|
||||||
if(error.message.toLowerCase().startsWith("warning"))
|
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 = props.isDuplicate ?
|
||||||
navigate(path);
|
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
|
else
|
||||||
{
|
{
|
||||||
setAlertContent(error.message);
|
setAlertContent(error.message);
|
||||||
|
HtmlUtils.autoScroll(0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -499,12 +560,12 @@ function EntityForm(props: Props): JSX.Element
|
|||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
{alertContent ? (
|
{alertContent ? (
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Alert severity="error">{alertContent}</Alert>
|
<Alert severity="error" onClose={() => setAlertContent(null)}>{alertContent}</Alert>
|
||||||
</Box>
|
</Box>
|
||||||
) : ("")}
|
) : ("")}
|
||||||
{warningContent ? (
|
{warningContent ? (
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Alert severity="warning">{warningContent}</Alert>
|
<Alert severity="warning" onClose={() => setWarningContent(null)}>{warningContent}</Alert>
|
||||||
</Box>
|
</Box>
|
||||||
) : ("")}
|
) : ("")}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Divider from "@mui/material/Divider";
|
import Divider from "@mui/material/Divider";
|
||||||
import Icon from "@mui/material/Icon";
|
import Icon from "@mui/material/Icon";
|
||||||
@ -42,6 +43,7 @@ interface Props
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
logo?: string;
|
logo?: string;
|
||||||
appName?: string;
|
appName?: string;
|
||||||
|
branding?: QBrandingMetaData;
|
||||||
routes: {
|
routes: {
|
||||||
[key: string]:
|
[key: string]:
|
||||||
| ReactNode
|
| ReactNode
|
||||||
@ -64,7 +66,7 @@ interface Props
|
|||||||
[key: string]: any;
|
[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<boolean | string>(false);
|
const [openCollapse, setOpenCollapse] = useState<boolean | string>(false);
|
||||||
const [openNestedCollapse, setOpenNestedCollapse] = useState<boolean | string>(false);
|
const [openNestedCollapse, setOpenNestedCollapse] = useState<boolean | string>(false);
|
||||||
@ -328,6 +330,12 @@ function Sidenav({color, icon, logo, appName, routes, ...rest}: Props): JSX.Elem
|
|||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
</Box>
|
</Box>
|
||||||
|
{
|
||||||
|
branding && branding.environmentBannerText &&
|
||||||
|
<Box mt={2} bgcolor={branding.environmentBannerColor} borderRadius={2}>
|
||||||
|
{branding.environmentBannerText}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
</Box>
|
</Box>
|
||||||
<Divider
|
<Divider
|
||||||
light={
|
light={
|
||||||
|
@ -200,7 +200,7 @@ function SavedFilters({qController, metaData, tableMetaData, currentSavedFilter,
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
formData.append("tableName", tableMetaData.name);
|
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)
|
if (isSaveFilterAs || isRenameFilter || currentSavedFilter == null)
|
||||||
{
|
{
|
||||||
|
432
src/qqq/components/query/CustomColumnsPanel.tsx
Normal file
432
src/qqq/components/query/CustomColumnsPanel.tsx
Normal file
@ -0,0 +1,432 @@
|
|||||||
|
/*
|
||||||
|
* 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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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";
|
||||||
|
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;
|
||||||
|
metaData: QInstance;
|
||||||
|
initialOpenedGroups: { [name: string]: boolean };
|
||||||
|
openGroupsChanger: (openedGroups: { [name: string]: boolean }) => void;
|
||||||
|
initialFilterText: string;
|
||||||
|
filterTextChanger: (filterText: string) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomColumnsPanel = forwardRef<any, GridColumnsPanelProps>(
|
||||||
|
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++)
|
||||||
|
{
|
||||||
|
const exposedJoin = props.tableMetaData.exposedJoins[i];
|
||||||
|
if (props.metaData.tables.has(exposedJoin.joinTable.name))
|
||||||
|
{
|
||||||
|
tables.push(exposedJoin.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// 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<HTMLButtonElement>, 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<HTMLTextAreaElement | HTMLInputElement>) =>
|
||||||
|
{
|
||||||
|
changeFilterText(event.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="custom-columns-panel" style={{width: "350px", height: "450px"}}>
|
||||||
|
<Box height="55px" padding="5px" display="flex">
|
||||||
|
<TextField id="findColumn" label="Find column" placeholder="Column title" variant="standard" fullWidth={true}
|
||||||
|
value={filterText}
|
||||||
|
onChange={(event) => filterTextChanged(event)}
|
||||||
|
></TextField>
|
||||||
|
{
|
||||||
|
filterText != "" && <IconButton sx={{position: "absolute", right: "0", top: "1rem"}} onClick={() =>
|
||||||
|
{
|
||||||
|
changeFilterText("");
|
||||||
|
document.getElementById("findColumn").focus();
|
||||||
|
}}><Icon fontSize="small">close</Icon></IconButton>
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
<Box ref={someRef} overflow="auto" height="calc( 100% - 105px )">
|
||||||
|
|
||||||
|
<Stack direction="column" spacing={1} pl="0.5rem">
|
||||||
|
<FormGroup>
|
||||||
|
{tables.map((table: QTableMetaData) =>
|
||||||
|
(
|
||||||
|
<React.Fragment key={table.name}>
|
||||||
|
<IconButton
|
||||||
|
key={table.name}
|
||||||
|
size="small"
|
||||||
|
onClick={() => toggleColumnGroupOpen(table.name)}
|
||||||
|
sx={{width: "100%", justifyContent: "flex-start", fontSize: "0.875rem", pt: 0.5}}
|
||||||
|
disableRipple={true}
|
||||||
|
>
|
||||||
|
<Icon>{filterText != "" ? "horizontal_rule" : openGroups[table.name] ? "expand_more" : "expand_less"}</Icon>
|
||||||
|
<Box pl={"4px"} position="relative" top="-2px">
|
||||||
|
<Switch
|
||||||
|
checked={noOfVisibleColumnsByTable[table.name] == noOfColumnsByTable[table.name] && noOfVisibleColumnsByTable[table.name] > 0}
|
||||||
|
onClick={(event) => onTableVisibilityClick(event, table.name)}
|
||||||
|
size="small" />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{pl: "0.125rem", fontWeight: "bold"}} textAlign="left">
|
||||||
|
{table.label} fields
|
||||||
|
<Box display="inline" fontWeight="200">({noOfVisibleColumnsByTable[table.name]} / {noOfColumnsByTable[table.name]})</Box>
|
||||||
|
</Box>
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
{(openGroups[table.name] || openGroupsBecauseOfFilter[table.name]) && tableFields[table.name].map((gridColumn: any) =>
|
||||||
|
{
|
||||||
|
if (doesColumnMatchFilterText(gridColumn))
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
<Box key={gridColumn.field} pl={6}>
|
||||||
|
<FormControlLabel
|
||||||
|
sx={{fontWeight: "500 !important", display: "flex", paddingBottom: "0.25rem", alignItems: "flex-start"}}
|
||||||
|
control={<Switch
|
||||||
|
checked={columnVisibilityModel[gridColumn.field] !== false}
|
||||||
|
onChange={() => onColumnVisibilityChange(gridColumn.field)}
|
||||||
|
size="small" />}
|
||||||
|
label={<Box pt="0.25rem" lineHeight="1.4">{gridColumn.headerName.replace(/.*: /, "")}</Box>} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</FormGroup>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<Box height="50px" padding="5px" display="flex" justifyContent="space-between">
|
||||||
|
<Button onClick={resetClicked}>Reset</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
192
src/qqq/components/query/CustomFilterPanel.tsx
Normal file
192
src/qqq/components/query/CustomFilterPanel.tsx
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
/*
|
||||||
|
* 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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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";
|
||||||
|
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
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, getDefaultCriteriaValue} from "qqq/components/query/FilterCriteriaRow";
|
||||||
|
|
||||||
|
|
||||||
|
declare module "@mui/x-data-grid"
|
||||||
|
{
|
||||||
|
///////////////////////////////////////////////////////////////////////
|
||||||
|
// this lets these props be passed in via <DataGrid componentsProps> //
|
||||||
|
///////////////////////////////////////////////////////////////////////
|
||||||
|
interface FilterPanelPropsOverrides
|
||||||
|
{
|
||||||
|
tableMetaData: QTableMetaData;
|
||||||
|
metaData: QInstance;
|
||||||
|
queryFilter: QQueryFilter;
|
||||||
|
updateFilter: (newFilter: QQueryFilter) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class QFilterCriteriaWithId extends QFilterCriteria
|
||||||
|
{
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let debounceTimeout: string | number | NodeJS.Timeout;
|
||||||
|
let criteriaId = (new Date().getTime()) + 1000;
|
||||||
|
|
||||||
|
export const CustomFilterPanel = forwardRef<any, GridFilterPanelProps>(
|
||||||
|
function MyCustomFilterPanel(props: GridSlotsComponentsProps["filterPanel"], ref)
|
||||||
|
{
|
||||||
|
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
|
const queryFilter = props.queryFilter;
|
||||||
|
// console.log(`CustomFilterPanel: filter: ${JSON.stringify(queryFilter)}`);
|
||||||
|
|
||||||
|
function focusLastField()
|
||||||
|
{
|
||||||
|
setTimeout(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// console.log(`Try to focus ${criteriaId - 1}`);
|
||||||
|
document.getElementById(`field-${criteriaId - 1}`).focus();
|
||||||
|
}
|
||||||
|
catch (e)
|
||||||
|
{
|
||||||
|
console.log("Error trying to focus field ...", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCriteria = () =>
|
||||||
|
{
|
||||||
|
const qFilterCriteriaWithId = new QFilterCriteriaWithId(null, QCriteriaOperator.EQUALS, getDefaultCriteriaValue());
|
||||||
|
qFilterCriteriaWithId.id = criteriaId++;
|
||||||
|
console.log(`adding criteria id ${qFilterCriteriaWithId.id}`);
|
||||||
|
queryFilter.criteria.push(qFilterCriteriaWithId);
|
||||||
|
props.updateFilter(queryFilter);
|
||||||
|
forceUpdate();
|
||||||
|
|
||||||
|
focusLastField();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!queryFilter.criteria)
|
||||||
|
{
|
||||||
|
queryFilter.criteria = [];
|
||||||
|
addCriteria();
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
focusLastField();
|
||||||
|
}
|
||||||
|
|
||||||
|
let booleanOperator: "AND" | "OR" | null = null;
|
||||||
|
if (queryFilter.criteria.length > 1)
|
||||||
|
{
|
||||||
|
booleanOperator = queryFilter.booleanOperator;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// needDebounce param - things like typing in a text field DO need debounce, but changing an operator doesn't //
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
const updateCriteria = (newCriteria: QFilterCriteria, index: number, needDebounce = false) =>
|
||||||
|
{
|
||||||
|
queryFilter.criteria[index] = newCriteria;
|
||||||
|
|
||||||
|
clearTimeout(debounceTimeout)
|
||||||
|
debounceTimeout = setTimeout(() => props.updateFilter(queryFilter), needDebounce ? 500 : 1);
|
||||||
|
|
||||||
|
forceUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBooleanOperator = (newValue: string) =>
|
||||||
|
{
|
||||||
|
queryFilter.booleanOperator = newValue;
|
||||||
|
props.updateFilter(queryFilter);
|
||||||
|
forceUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCriteria = (index: number) =>
|
||||||
|
{
|
||||||
|
queryFilter.criteria.splice(index, 1);
|
||||||
|
props.updateFilter(queryFilter);
|
||||||
|
forceUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="customFilterPanel">
|
||||||
|
{
|
||||||
|
queryFilter.criteria.map((criteria: QFilterCriteriaWithId, index: number) =>
|
||||||
|
(
|
||||||
|
<Box key={criteria.id}>
|
||||||
|
<FilterCriteriaRow
|
||||||
|
id={criteria.id}
|
||||||
|
index={index}
|
||||||
|
tableMetaData={props.tableMetaData}
|
||||||
|
metaData={props.metaData}
|
||||||
|
criteria={criteria}
|
||||||
|
booleanOperator={booleanOperator}
|
||||||
|
updateCriteria={(newCriteria, needDebounce) => updateCriteria(newCriteria, index, needDebounce)}
|
||||||
|
removeCriteria={() => removeCriteria(index)}
|
||||||
|
updateBooleanOperator={(newValue) => updateBooleanOperator(newValue)}
|
||||||
|
/>
|
||||||
|
{/*JSON.stringify(criteria)*/}
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
<Box p={1}>
|
||||||
|
<Button onClick={() => addCriteria()} startIcon={<Icon>add</Icon>} size="medium" sx={{px: 0.75}}>Add Condition</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
418
src/qqq/components/query/FilterCriteriaPaster.tsx
Normal file
418
src/qqq/components/query/FilterCriteriaPaster.tsx
Normal file
@ -0,0 +1,418 @@
|
|||||||
|
/*
|
||||||
|
* 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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
onSave: (newValues: any[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
FilterCriteriaPaster.defaultProps = {};
|
||||||
|
|
||||||
|
function FilterCriteriaPaster({type, onSave}: 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 clearData = () =>
|
||||||
|
{
|
||||||
|
setDelimiter("");
|
||||||
|
setDelimiterCharacter("");
|
||||||
|
setChipData([]);
|
||||||
|
setInputText("");
|
||||||
|
setDetectedText("");
|
||||||
|
setCustomDelimiterValue("");
|
||||||
|
setPasteModalIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelClicked = () =>
|
||||||
|
{
|
||||||
|
clearData();
|
||||||
|
setPasteModalIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveClicked = () =>
|
||||||
|
{
|
||||||
|
////////////////////////////////////////
|
||||||
|
// 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave(saveData);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box>
|
||||||
|
<Tooltip title="Quickly add many values to your filter by pasting them from a spreadsheet or any other data source.">
|
||||||
|
<Icon onClick={handlePasteClick} fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer"}}>paste_content</Icon>
|
||||||
|
</Tooltip>
|
||||||
|
{
|
||||||
|
pasteModalIsOpen &&
|
||||||
|
(
|
||||||
|
<Modal open={pasteModalIsOpen}>
|
||||||
|
<Box sx={{position: "absolute", overflowY: "auto", width: "100%"}}>
|
||||||
|
<Box py={3} justifyContent="center" sx={{display: "flex", mt: 8}}>
|
||||||
|
<Card sx={mainCardStyles}>
|
||||||
|
<Box p={4} pb={2}>
|
||||||
|
<Grid container>
|
||||||
|
<Grid item pr={3} xs={12} lg={12}>
|
||||||
|
<Typography variant="h5">Bulk Add Filter Values</Typography>
|
||||||
|
<Typography sx={{display: "flex", lineHeight: "1.7", textTransform: "revert"}} variant="button">
|
||||||
|
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.
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
<Grid container pl={3} pr={3} justifyContent="center" alignItems="stretch" sx={{display: "flex", height: "100%"}}>
|
||||||
|
<Grid item pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
|
||||||
|
<FormControl sx={{m: 1, width: "100%"}}>
|
||||||
|
<TextField
|
||||||
|
id="outlined-multiline-static"
|
||||||
|
label="PASTE TEXT"
|
||||||
|
multiline
|
||||||
|
onChange={handleTextChange}
|
||||||
|
rows={16}
|
||||||
|
value={inputText}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} lg={6} sx={{display: "flex", flexGrow: 1}}>
|
||||||
|
<FormControl sx={{m: 1, width: "100%"}}>
|
||||||
|
<ChipTextField
|
||||||
|
handleChipChange={() =>
|
||||||
|
{
|
||||||
|
}}
|
||||||
|
chipData={chipData}
|
||||||
|
chipType={type}
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
id="tags"
|
||||||
|
rows={0}
|
||||||
|
name="tags"
|
||||||
|
label="FILTER VALUES REVIEW"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<Grid container pl={3} pr={3} justifyContent="center" alignItems="stretch" sx={{display: "flex", height: "100%"}}>
|
||||||
|
<Grid item pl={1} pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
|
||||||
|
<Box sx={{display: "inline-flex", alignItems: "baseline"}}>
|
||||||
|
<FormControl sx={{mt: 2, width: "50%"}}>
|
||||||
|
<InputLabel htmlFor="select-native">
|
||||||
|
SEPARATOR
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
multiline
|
||||||
|
native
|
||||||
|
value={delimiter}
|
||||||
|
onChange={handleDelimiterChange}
|
||||||
|
label="SEPARATOR"
|
||||||
|
size="medium"
|
||||||
|
inputProps={{
|
||||||
|
id: "select-native",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{delimiterDropdownOptions.map((delimiter) => (
|
||||||
|
<option key={delimiter} value={delimiter}>
|
||||||
|
{delimiter}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
{delimiter === Delimiter.CUSTOM.valueOf() && (
|
||||||
|
|
||||||
|
<FormControl sx={{pl: 2, top: 5, width: "50%"}}>
|
||||||
|
<TextField
|
||||||
|
name="custom-delimiter-value"
|
||||||
|
placeholder="Custom Separator"
|
||||||
|
label="Custom Separator"
|
||||||
|
variant="standard"
|
||||||
|
value={customDelimiterValue}
|
||||||
|
onChange={handleCustomDelimiterChange}
|
||||||
|
inputProps={{maxLength: 1}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
{inputText && delimiter === Delimiter.DETECT_AUTOMATICALLY.valueOf() && (
|
||||||
|
|
||||||
|
<Typography pl={2} variant="button" sx={{top: "1px", textTransform: "revert"}}>
|
||||||
|
<i>{detectedText}</i>
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}} item pl={1} xs={3} lg={3}>
|
||||||
|
{
|
||||||
|
errorText && chipData.length > 0 && (
|
||||||
|
<Box sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}}>
|
||||||
|
<Icon color="error">error</Icon>
|
||||||
|
<Typography sx={{paddingLeft: "4px", textTransform: "revert"}} variant="button">{errorText}</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Grid>
|
||||||
|
<Grid sx={{display: "flex", justifyContent: "flex-end", alignItems: "flex-start"}} item pr={1} xs={3} lg={3}>
|
||||||
|
{
|
||||||
|
chipData && chipData.length > 0 && (
|
||||||
|
<Typography sx={{textTransform: "revert"}} variant="button">{chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"}</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<Box p={3} pt={0}>
|
||||||
|
<Grid container pl={1} pr={1} justifyContent="right" alignItems="stretch" sx={{display: "flex-inline "}}>
|
||||||
|
<QCancelButton
|
||||||
|
onClickHandler={handleCancelClicked}
|
||||||
|
iconName="cancel"
|
||||||
|
disabled={false} />
|
||||||
|
<QSaveButton onClickHandler={handleSaveClicked} label="Add Filters" disabled={false} />
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilterCriteriaPaster;
|
550
src/qqq/components/query/FilterCriteriaRow.tsx
Normal file
550
src/qqq/components/query/FilterCriteriaRow.tsx
Normal file
@ -0,0 +1,550 @@
|
|||||||
|
/*
|
||||||
|
* 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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
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, 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("");
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
|
||||||
|
{
|
||||||
|
const exposedJoin = tableMetaData.exposedJoins[i];
|
||||||
|
if (metaData.tables.has(exposedJoin.joinTable.name))
|
||||||
|
{
|
||||||
|
fieldsGroupBy = (option: any) => `${option.table.label} fields`;
|
||||||
|
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: "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});
|
||||||
|
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: "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});
|
||||||
|
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: "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});
|
||||||
|
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: "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});
|
||||||
|
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: "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});
|
||||||
|
/*
|
||||||
|
? 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) =>
|
||||||
|
{
|
||||||
|
const oldFieldName = criteria.fieldName;
|
||||||
|
|
||||||
|
criteria.fieldName = newValue ? newValue.fieldName : null;
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////////
|
||||||
|
// decide if we should clear out the values or not. //
|
||||||
|
//////////////////////////////////////////////////////
|
||||||
|
if (criteria.fieldName == null || isFieldTypeDifferent(oldFieldName, criteria.fieldName))
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
/////////////////////////////////////////////
|
||||||
|
// 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 field (of all types) //
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
const handleValueChange = (event: React.ChangeEvent | SyntheticEvent, valueIndex: number | "all" = 0, newValue?: any) =>
|
||||||
|
{
|
||||||
|
// @ts-ignore
|
||||||
|
const value = newValue !== undefined ? newValue : event ? event.target.value : null;
|
||||||
|
|
||||||
|
if(!criteria.values)
|
||||||
|
{
|
||||||
|
criteria.values = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if(valueIndex == "all")
|
||||||
|
{
|
||||||
|
criteria.values = value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
criteria.values[valueIndex] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLLIElement>, option: any, state: AutocompleteRenderOptionState): ReactNode
|
||||||
|
{
|
||||||
|
let label = ""
|
||||||
|
if(option && option.field)
|
||||||
|
{
|
||||||
|
label = (option.field.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (<li {...props}>{label}</li>);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box className="filterCriteriaRow" pt={0.5} display="flex" alignItems="flex-end">
|
||||||
|
<Box display="inline-block">
|
||||||
|
<Tooltip title="Remove this condition from your filter" enterDelay={750} placement="left">
|
||||||
|
<IconButton onClick={removeCriteria}><Icon fontSize="small">close</Icon></IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Box display="inline-block" width={55} className="booleanOperatorColumn">
|
||||||
|
{booleanOperator && index > 0 ?
|
||||||
|
<FormControl variant="standard" sx={{verticalAlign: "bottom"}} fullWidth>
|
||||||
|
<Select value={booleanOperator} disabled={index > 1} onChange={handleBooleanOperatorChange}>
|
||||||
|
<MenuItem value="AND">And</MenuItem>
|
||||||
|
<MenuItem value="OR">Or</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
: <span />}
|
||||||
|
</Box>
|
||||||
|
<Box display="inline-block" width={250} className="fieldColumn">
|
||||||
|
<Autocomplete
|
||||||
|
id={`field-${id}`}
|
||||||
|
renderInput={(params) => (<TextField {...params} label={"Field"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
|
||||||
|
// @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}
|
||||||
|
slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box display="inline-block" width={200} className="operatorColumn">
|
||||||
|
<Tooltip title={criteria.fieldName == null ? "You must select a field before you can select an operator" : null} enterDelay={750}>
|
||||||
|
<Autocomplete
|
||||||
|
id={"criteriaOperator"}
|
||||||
|
renderInput={(params) => (<TextField {...params} label={"Operator"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
|
||||||
|
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}
|
||||||
|
slotProps={{popper: {style: {padding: 0, maxHeight: "unset", width: "200px"}}}}
|
||||||
|
/*disabled={criteria.fieldName == null}*/
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Box display="inline-block" width={300} className="filterValuesColumn">
|
||||||
|
<FilterCriteriaRowValues
|
||||||
|
operatorOption={operatorSelectedValue}
|
||||||
|
criteria={{id: id, ...criteria}}
|
||||||
|
field={field}
|
||||||
|
table={fieldTable}
|
||||||
|
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box display="inline-block" pl={0.5} pr={1}>
|
||||||
|
<Tooltip title={criteriaStatusTooltip} enterDelay={750} placement="right">
|
||||||
|
{
|
||||||
|
criteriaIsValid
|
||||||
|
? <Icon color="success">check</Icon>
|
||||||
|
: <Icon color="disabled">pending</Icon>
|
||||||
|
}
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
242
src/qqq/components/query/FilterCriteriaRowValues.tsx
Normal file
242
src/qqq/components/query/FilterCriteriaRowValues.tsx
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
/*
|
||||||
|
* 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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||||
|
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||||
|
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||||
|
import 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, 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
|
||||||
|
{
|
||||||
|
operatorOption: OperatorOption;
|
||||||
|
criteria: QFilterCriteriaWithId;
|
||||||
|
field: QFieldMetaData;
|
||||||
|
table: QTableMetaData;
|
||||||
|
valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
FilterCriteriaRowValues.defaultProps = {
|
||||||
|
};
|
||||||
|
|
||||||
|
function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler}: Props): JSX.Element
|
||||||
|
{
|
||||||
|
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
|
if (!operatorOption)
|
||||||
|
{
|
||||||
|
return <br />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeForTextField = (): string =>
|
||||||
|
{
|
||||||
|
let type = "search";
|
||||||
|
|
||||||
|
if (field.type == QFieldType.INTEGER)
|
||||||
|
{
|
||||||
|
type = "number";
|
||||||
|
}
|
||||||
|
else if (field.type == QFieldType.DATE)
|
||||||
|
{
|
||||||
|
type = "date";
|
||||||
|
}
|
||||||
|
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<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) =>
|
||||||
|
{
|
||||||
|
valueChangeHandler(event, index, "");
|
||||||
|
document.getElementById(`${idPrefix}${criteria.id}`).focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputProps: any = {};
|
||||||
|
inputProps.endAdornment = (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton sx={{visibility: value ? "visible" : "hidden"}} onClick={(event) => clearValue(event, valueIndex)}>
|
||||||
|
<Icon>close</Icon>
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <TextField
|
||||||
|
id={`${idPrefix}${criteria.id}`}
|
||||||
|
label={label}
|
||||||
|
variant="standard"
|
||||||
|
autoComplete="off"
|
||||||
|
type={type}
|
||||||
|
onChange={(event) => valueChangeHandler(event, valueIndex)}
|
||||||
|
value={value}
|
||||||
|
InputLabelProps={inputLabelProps}
|
||||||
|
InputProps={inputProps}
|
||||||
|
fullWidth
|
||||||
|
/>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 <br />;
|
||||||
|
case ValueMode.SINGLE:
|
||||||
|
return makeTextField();
|
||||||
|
case ValueMode.SINGLE_DATE:
|
||||||
|
return makeTextField();
|
||||||
|
case ValueMode.SINGLE_DATE_TIME:
|
||||||
|
return makeTextField();
|
||||||
|
case ValueMode.DOUBLE:
|
||||||
|
return <Box>
|
||||||
|
<Box width="50%" display="inline-block">
|
||||||
|
{ makeTextField(0, "From", "from-") }
|
||||||
|
</Box>
|
||||||
|
<Box width="50%" display="inline-block">
|
||||||
|
{makeTextField(1, "To", "to-")}
|
||||||
|
</Box>
|
||||||
|
</Box>;
|
||||||
|
case ValueMode.MULTI:
|
||||||
|
let values = criteria.values;
|
||||||
|
if (values && values.length == 1 && values[0] == "")
|
||||||
|
{
|
||||||
|
values = [];
|
||||||
|
}
|
||||||
|
return <Box display="flex" alignItems="flex-end" className="multiValue">
|
||||||
|
<Autocomplete
|
||||||
|
renderInput={(params) => (<TextField {...params} variant="standard" label="Values" />)}
|
||||||
|
options={[]}
|
||||||
|
multiple
|
||||||
|
freeSolo // todo - no debounce after enter?
|
||||||
|
selectOnFocus
|
||||||
|
clearOnBlur
|
||||||
|
fullWidth
|
||||||
|
limitTags={5}
|
||||||
|
value={values}
|
||||||
|
onChange={(event, value) => valueChangeHandler(event, "all", value)}
|
||||||
|
/>
|
||||||
|
<Box>
|
||||||
|
<FilterCriteriaPaster type={getTypeForTextField()} onSave={(newValues: any[]) => saveNewPasterValues(newValues)} />
|
||||||
|
</Box>
|
||||||
|
</Box>;
|
||||||
|
case ValueMode.PVS_SINGLE:
|
||||||
|
console.log("Doing pvs single: " + criteria.values);
|
||||||
|
let selectedPossibleValue = null;
|
||||||
|
if (criteria.values && criteria.values.length > 0)
|
||||||
|
{
|
||||||
|
selectedPossibleValue = criteria.values[0];
|
||||||
|
}
|
||||||
|
return <Box mb={-1.5}>
|
||||||
|
<DynamicSelect
|
||||||
|
tableName={table.name}
|
||||||
|
fieldName={field.name}
|
||||||
|
overrideId={field.name + "-single-" + criteria.id}
|
||||||
|
key={field.name + "-single-" + criteria.id}
|
||||||
|
fieldLabel="Value"
|
||||||
|
initialValue={selectedPossibleValue?.id}
|
||||||
|
initialDisplayValue={selectedPossibleValue?.label}
|
||||||
|
inForm={false}
|
||||||
|
onChange={(value: any) => valueChangeHandler(null, 0, value)}
|
||||||
|
/>
|
||||||
|
</Box>;
|
||||||
|
case ValueMode.PVS_MULTI:
|
||||||
|
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 <Box mb={-1.5}>
|
||||||
|
<DynamicSelect
|
||||||
|
tableName={table.name}
|
||||||
|
fieldName={field.name}
|
||||||
|
overrideId={field.name + "-multi-" + criteria.id}
|
||||||
|
key={field.name + "-multi-" + criteria.id}
|
||||||
|
isMultiple
|
||||||
|
fieldLabel="Values"
|
||||||
|
initialValues={initialValues}
|
||||||
|
inForm={false}
|
||||||
|
onChange={(value: any) => valueChangeHandler(null, "all", value)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (<br />);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilterCriteriaRowValues;
|
@ -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.bulkInsert(", meta: "Create multiple records in a table."});
|
||||||
completions.push({value: "api.bulkUpdate(", meta: "Update 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.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.newRecord(", meta: "Create a new QRecord object."});
|
||||||
// completions.push({value: "api.newQueryInput(", meta: "Create a new QueryInput object."});
|
// completions.push({value: "api.newQueryInput(", meta: "Create a new QueryInput object."});
|
||||||
// completions.push({value: "api.newQueryFilter(", meta: "Create a new QueryFilter object."});
|
// completions.push({value: "api.newQueryFilter(", meta: "Create a new QueryFilter object."});
|
||||||
|
@ -44,10 +44,10 @@ import USMapWidget from "qqq/components/widgets/misc/USMapWidget";
|
|||||||
import ParentWidget from "qqq/components/widgets/ParentWidget";
|
import ParentWidget from "qqq/components/widgets/ParentWidget";
|
||||||
import MultiStatisticsCard from "qqq/components/widgets/statistics/MultiStatisticsCard";
|
import MultiStatisticsCard from "qqq/components/widgets/statistics/MultiStatisticsCard";
|
||||||
import StatisticsCard from "qqq/components/widgets/statistics/StatisticsCard";
|
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 Widget, {WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT} from "qqq/components/widgets/Widget";
|
||||||
import ProcessRun from "qqq/pages/processes/ProcessRun";
|
import ProcessRun from "qqq/pages/processes/ProcessRun";
|
||||||
import Client from "qqq/utils/qqq/Client";
|
import Client from "qqq/utils/qqq/Client";
|
||||||
|
import TableWidget from "./tables/TableWidget";
|
||||||
|
|
||||||
|
|
||||||
const qController = Client.getInstance();
|
const qController = Client.getInstance();
|
||||||
@ -96,10 +96,26 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
|
|||||||
|
|
||||||
widgetData[i] = {};
|
widgetData[i] = {};
|
||||||
(async () =>
|
(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
widgetData[i] = await qController.widget(widgetMetaData.name, urlParams);
|
widgetData[i] = await qController.widget(widgetMetaData.name, urlParams);
|
||||||
setWidgetData(widgetData);
|
setWidgetData(widgetData);
|
||||||
setWidgetCounter(widgetCounter + 1);
|
setWidgetCounter(widgetCounter + 1);
|
||||||
|
if(widgetData[i])
|
||||||
|
{
|
||||||
|
widgetData[i]["errorLoading"] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(e)
|
||||||
|
{
|
||||||
|
console.error(e);
|
||||||
|
if(widgetData[i])
|
||||||
|
{
|
||||||
|
widgetData[i]["errorLoading"] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
@ -111,13 +127,31 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
|
|||||||
{
|
{
|
||||||
const urlParams = getQueryParams(widgetMetaDataList[index], data);
|
const urlParams = getQueryParams(widgetMetaDataList[index], data);
|
||||||
setCurrentUrlParams(urlParams);
|
setCurrentUrlParams(urlParams);
|
||||||
|
widgetData[index] = {};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
widgetData[index] = await qController.widget(widgetMetaDataList[index].name, urlParams);
|
widgetData[index] = await qController.widget(widgetMetaDataList[index].name, urlParams);
|
||||||
setWidgetCounter(widgetCounter + 1);
|
setWidgetCounter(widgetCounter + 1);
|
||||||
setWidgetData(widgetData);
|
setWidgetData(widgetData);
|
||||||
|
|
||||||
|
if (widgetData[index])
|
||||||
|
{
|
||||||
|
widgetData[index]["errorLoading"] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(e)
|
||||||
|
{
|
||||||
|
console.error(e);
|
||||||
|
if (widgetData[index])
|
||||||
|
{
|
||||||
|
widgetData[index]["errorLoading"] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
})();
|
})();
|
||||||
};
|
}
|
||||||
|
|
||||||
function getQueryParams(widgetMetaData: QWidgetMetaData, extraParams: string): string
|
function getQueryParams(widgetMetaData: QWidgetMetaData, extraParams: string): string
|
||||||
{
|
{
|
||||||
@ -221,20 +255,12 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
|
|||||||
}
|
}
|
||||||
{
|
{
|
||||||
widgetMetaData.type === "table" && (
|
widgetMetaData.type === "table" && (
|
||||||
<Widget
|
<TableWidget
|
||||||
widgetMetaData={widgetMetaData}
|
widgetMetaData={widgetMetaData}
|
||||||
widgetData={widgetData[i]}
|
widgetData={widgetData[i]}
|
||||||
reloadWidgetCallback={(data) => reloadWidget(i, data)}
|
reloadWidgetCallback={(data) => reloadWidget(i, data)}
|
||||||
footerHTML={widgetData[i]?.footerHTML}
|
|
||||||
isChild={areChildren}
|
isChild={areChildren}
|
||||||
>
|
|
||||||
<TableCard
|
|
||||||
noRowsFoundHTML={widgetData[i]?.noRowsFoundHTML}
|
|
||||||
rowsPerPage={widgetData[i]?.rowsPerPage}
|
|
||||||
hidePaginationDropdown={widgetData[i]?.hidePaginationDropdown}
|
|
||||||
data={widgetData[i]}
|
|
||||||
/>
|
/>
|
||||||
</Widget>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@ -254,7 +280,9 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
|
|||||||
<Widget
|
<Widget
|
||||||
widgetMetaData={widgetMetaData}
|
widgetMetaData={widgetMetaData}
|
||||||
widgetData={widgetData[i]}
|
widgetData={widgetData[i]}
|
||||||
reloadWidgetCallback={(data) => reloadWidget(i, data)}>
|
reloadWidgetCallback={(data) => reloadWidget(i, data)}
|
||||||
|
showReloadControl={false}
|
||||||
|
>
|
||||||
<div className="widgetProcessMidDiv" style={{height: "100%"}}>
|
<div className="widgetProcessMidDiv" style={{height: "100%"}}>
|
||||||
<ProcessRun process={widgetData[i]?.processMetaData} defaultProcessValues={widgetData[i]?.defaultValues} isWidget={true} forceReInit={widgetCounter} />
|
<ProcessRun process={widgetData[i]?.processMetaData} defaultProcessValues={widgetData[i]?.defaultValues} isWidget={true} forceReInit={widgetCounter} />
|
||||||
</div>
|
</div>
|
||||||
@ -265,7 +293,9 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
|
|||||||
widgetMetaData.type === "stepper" && (
|
widgetMetaData.type === "stepper" && (
|
||||||
<Widget
|
<Widget
|
||||||
widgetMetaData={widgetMetaData}
|
widgetMetaData={widgetMetaData}
|
||||||
widgetData={widgetData[i]}>
|
widgetData={widgetData[i]}
|
||||||
|
reloadWidgetCallback={(data) => reloadWidget(i, data)}
|
||||||
|
>
|
||||||
<Box sx={{alignItems: "stretch", flexGrow: 1, display: "flex", marginTop: "0px", paddingTop: "0px"}}>
|
<Box sx={{alignItems: "stretch", flexGrow: 1, display: "flex", marginTop: "0px", paddingTop: "0px"}}>
|
||||||
<Box padding="1rem" sx={{width: "100%"}}>
|
<Box padding="1rem" sx={{width: "100%"}}>
|
||||||
<StepperCard data={widgetData[i]} />
|
<StepperCard data={widgetData[i]} />
|
||||||
@ -276,7 +306,11 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
|
|||||||
}
|
}
|
||||||
{
|
{
|
||||||
widgetMetaData.type === "html" && (
|
widgetMetaData.type === "html" && (
|
||||||
<Widget widgetMetaData={widgetMetaData}>
|
<Widget
|
||||||
|
widgetMetaData={widgetMetaData}
|
||||||
|
reloadWidgetCallback={(data) => reloadWidget(i, data)}
|
||||||
|
widgetData={widgetData[i]}
|
||||||
|
>
|
||||||
<Box px={3} pt={0} pb={2}>
|
<Box px={3} pt={0} pb={2}>
|
||||||
<MDTypography component="div" variant="button" color="text" fontWeight="light">
|
<MDTypography component="div" variant="button" color="text" fontWeight="light">
|
||||||
{
|
{
|
||||||
@ -306,8 +340,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
|
|||||||
widgetMetaData={widgetMetaData}
|
widgetMetaData={widgetMetaData}
|
||||||
widgetData={widgetData[i]}
|
widgetData={widgetData[i]}
|
||||||
isChild={areChildren}
|
isChild={areChildren}
|
||||||
|
reloadWidgetCallback={(data) => reloadWidget(i, data)}
|
||||||
// reloadWidgetCallback={(data) => reloadWidget(i, data)}
|
|
||||||
>
|
>
|
||||||
<StatisticsCard
|
<StatisticsCard
|
||||||
data={widgetData[i]}
|
data={widgetData[i]}
|
||||||
@ -346,6 +379,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
|
|||||||
<Widget
|
<Widget
|
||||||
widgetMetaData={widgetMetaData}
|
widgetMetaData={widgetMetaData}
|
||||||
widgetData={widgetData[i]}
|
widgetData={widgetData[i]}
|
||||||
|
reloadWidgetCallback={(data) => reloadWidget(i, data)}
|
||||||
isChild={areChildren}
|
isChild={areChildren}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
@ -379,7 +413,9 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
|
|||||||
<Widget
|
<Widget
|
||||||
widgetMetaData={widgetMetaData}
|
widgetMetaData={widgetMetaData}
|
||||||
widgetData={widgetData[i]}
|
widgetData={widgetData[i]}
|
||||||
isChild={areChildren}>
|
reloadWidgetCallback={(data) => reloadWidget(i, data)}
|
||||||
|
isChild={areChildren}
|
||||||
|
>
|
||||||
<DefaultLineChart sx={{alignItems: "center"}}
|
<DefaultLineChart sx={{alignItems: "center"}}
|
||||||
data={widgetData[i]?.chartData}
|
data={widgetData[i]?.chartData}
|
||||||
isYAxisCurrency={widgetData[i]?.isYAxisCurrency}
|
isYAxisCurrency={widgetData[i]?.isYAxisCurrency}
|
||||||
|
@ -25,10 +25,12 @@ import Box from "@mui/material/Box";
|
|||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Card from "@mui/material/Card";
|
import Card from "@mui/material/Card";
|
||||||
import Icon from "@mui/material/Icon";
|
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 Typography from "@mui/material/Typography";
|
||||||
import parse from "html-react-parser";
|
import parse from "html-react-parser";
|
||||||
import React, {useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {Link, useNavigate} from "react-router-dom";
|
import {Link, NavigateFunction, useNavigate} from "react-router-dom";
|
||||||
import colors from "qqq/components/legacy/colors";
|
import colors from "qqq/components/legacy/colors";
|
||||||
import DropdownMenu, {DropdownOption} from "qqq/components/widgets/components/DropdownMenu";
|
import DropdownMenu, {DropdownOption} from "qqq/components/widgets/components/DropdownMenu";
|
||||||
|
|
||||||
@ -43,6 +45,8 @@ export interface WidgetData
|
|||||||
}[][];
|
}[][];
|
||||||
dropdownNeedsSelectedText?: string;
|
dropdownNeedsSelectedText?: string;
|
||||||
hasPermission?: boolean;
|
hasPermission?: boolean;
|
||||||
|
errorLoading?: boolean;
|
||||||
|
[other: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -54,6 +58,7 @@ interface Props
|
|||||||
widgetData?: WidgetData;
|
widgetData?: WidgetData;
|
||||||
children: JSX.Element;
|
children: JSX.Element;
|
||||||
reloadWidgetCallback?: (params: string) => void;
|
reloadWidgetCallback?: (params: string) => void;
|
||||||
|
showReloadControl: boolean;
|
||||||
isChild?: boolean;
|
isChild?: boolean;
|
||||||
footerHTML?: string;
|
footerHTML?: string;
|
||||||
storeDropdownSelections?: boolean;
|
storeDropdownSelections?: boolean;
|
||||||
@ -61,6 +66,7 @@ interface Props
|
|||||||
|
|
||||||
Widget.defaultProps = {
|
Widget.defaultProps = {
|
||||||
isChild: false,
|
isChild: false,
|
||||||
|
showReloadControl: true,
|
||||||
widgetMetaData: {},
|
widgetMetaData: {},
|
||||||
widgetData: {},
|
widgetData: {},
|
||||||
labelAdditionalComponentsLeft: [],
|
labelAdditionalComponentsLeft: [],
|
||||||
@ -68,13 +74,28 @@ Widget.defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export class LabelComponent
|
interface LabelComponentRenderArgs
|
||||||
{
|
{
|
||||||
|
navigate: NavigateFunction;
|
||||||
|
widgetProps: Props;
|
||||||
|
dropdownData: any[];
|
||||||
|
componentIndex: number;
|
||||||
|
reloadFunction: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class LabelComponent
|
||||||
|
{
|
||||||
|
render = (args: LabelComponentRenderArgs): JSX.Element =>
|
||||||
|
{
|
||||||
|
return (<div>Unsupported component type</div>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
export class HeaderLink extends LabelComponent
|
export class HeaderLink extends LabelComponent
|
||||||
{
|
{
|
||||||
label: string;
|
label: string;
|
||||||
@ -86,10 +107,21 @@ export class HeaderLink extends LabelComponent
|
|||||||
this.label = label;
|
this.label = label;
|
||||||
this.to = to;
|
this.to = to;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
render = (args: LabelComponentRenderArgs): JSX.Element =>
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
<Typography variant="body2" p={2} display="inline" fontSize=".875rem" pt="0" position="relative" top="-0.25rem">
|
||||||
|
{this.to ? <Link to={this.to}>{this.label}</Link> : null}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
export class AddNewRecordButton extends LabelComponent
|
export class AddNewRecordButton extends LabelComponent
|
||||||
{
|
{
|
||||||
table: QTableMetaData;
|
table: QTableMetaData;
|
||||||
@ -97,6 +129,7 @@ export class AddNewRecordButton extends LabelComponent
|
|||||||
defaultValues: any;
|
defaultValues: any;
|
||||||
disabledFields: any;
|
disabledFields: any;
|
||||||
|
|
||||||
|
|
||||||
constructor(table: QTableMetaData, defaultValues: any, label: string = "Add new", disabledFields: any = defaultValues)
|
constructor(table: QTableMetaData, defaultValues: any, label: string = "Add new", disabledFields: any = defaultValues)
|
||||||
{
|
{
|
||||||
super();
|
super();
|
||||||
@ -105,110 +138,217 @@ export class AddNewRecordButton extends LabelComponent
|
|||||||
this.defaultValues = defaultValues;
|
this.defaultValues = defaultValues;
|
||||||
this.disabledFields = disabledFields;
|
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 (
|
||||||
|
<Typography variant="body2" p={2} pr={0} display="inline" position="relative" top="0.25rem">
|
||||||
|
<Button sx={{mt: 0.75}} onClick={() => this.openEditForm(args.navigate, this.table, null, this.defaultValues, this.disabledFields)}>{this.label}</Button>
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
export class ExportDataButton extends LabelComponent
|
||||||
|
{
|
||||||
|
callbackToExport: any;
|
||||||
|
tooltipTitle: string;
|
||||||
|
isDisabled: boolean;
|
||||||
|
|
||||||
|
constructor(callbackToExport: any, isDisabled = false, tooltipTitle: string = "Export")
|
||||||
|
{
|
||||||
|
super();
|
||||||
|
this.callbackToExport = callbackToExport;
|
||||||
|
this.isDisabled = isDisabled;
|
||||||
|
this.tooltipTitle = tooltipTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
render = (args: LabelComponentRenderArgs): JSX.Element =>
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
<Typography variant="body2" py={2} px={0} display="inline" position="relative" top="-0.375rem">
|
||||||
|
<Tooltip title={this.tooltipTitle}><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={() => this.callbackToExport()} disabled={this.isDisabled}><Icon>save_alt</Icon></Button></Tooltip>
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
export class Dropdown extends LabelComponent
|
export class Dropdown extends LabelComponent
|
||||||
{
|
{
|
||||||
label: string;
|
label: string;
|
||||||
options: DropdownOption[];
|
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();
|
super();
|
||||||
this.label = label;
|
this.label = label;
|
||||||
this.options = options;
|
this.options = options;
|
||||||
|
this.dropdownName = dropdownName;
|
||||||
this.onChangeCallback = onChangeCallback;
|
this.onChangeCallback = onChangeCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
render = (args: LabelComponentRenderArgs): JSX.Element =>
|
||||||
|
{
|
||||||
|
let defaultValue = null;
|
||||||
|
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 //
|
||||||
|
///////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
defaultValue = JSON.parse(localStorage.getItem(localStorageKey));
|
||||||
|
args.dropdownData[args.componentIndex] = defaultValue?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box my={2} sx={{float: "right"}}>
|
||||||
|
<DropdownMenu
|
||||||
|
name={this.dropdownName}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
sx={{width: 200, marginLeft: "15px"}}
|
||||||
|
label={`Select ${this.label}`}
|
||||||
|
dropdownOptions={this.options}
|
||||||
|
onChangeCallback={this.onChangeCallback}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
export class ReloadControl extends LabelComponent
|
||||||
|
{
|
||||||
|
callback: () => void;
|
||||||
|
|
||||||
|
constructor(callback: () => void)
|
||||||
|
{
|
||||||
|
super();
|
||||||
|
this.callback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
render = (args: LabelComponentRenderArgs): JSX.Element =>
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
<Typography variant="body2" py={2} px={0} display="inline" position="relative" top="-0.375rem">
|
||||||
|
<Tooltip title="Refresh"><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={() => this.callback()}><Icon>refresh</Icon></Button></Tooltip>
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT = "qqq.widgets.dropdownData";
|
export const WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT = "qqq.widgets.dropdownData";
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
function Widget(props: React.PropsWithChildren<Props>): JSX.Element
|
function Widget(props: React.PropsWithChildren<Props>): JSX.Element
|
||||||
{
|
{
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [dropdownData, setDropdownData] = useState([]);
|
const [dropdownData, setDropdownData] = useState([]);
|
||||||
const [fullScreenWidgetClassName, setFullScreenWidgetClassName] = 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 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)}`)
|
return component.render({navigate: navigate, widgetProps: props, dropdownData: dropdownData, componentIndex: componentIndex, reloadFunction: doReload});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderComponent(component: LabelComponent, index: number)
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
if(component instanceof HeaderLink)
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// 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)
|
||||||
{
|
{
|
||||||
const link = component as HeaderLink
|
stateLabelComponentsLeft.push(new ReloadControl(doReload));
|
||||||
return (
|
|
||||||
<Typography variant="body2" p={2} display="inline" fontSize=".875rem" pt="0" position="relative" top="-0.25rem">
|
|
||||||
{link.to ? <Link to={link.to}>{link.label}</Link> : null}
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
if (props.labelAdditionalComponentsLeft)
|
||||||
if (component instanceof AddNewRecordButton)
|
|
||||||
{
|
{
|
||||||
const addNewRecordButton = component as AddNewRecordButton
|
props.labelAdditionalComponentsLeft.map((component) => stateLabelComponentsLeft.push(component));
|
||||||
return (
|
|
||||||
<Typography variant="body2" p={2} pr={0} display="inline" position="relative" top="0.25rem">
|
|
||||||
<Button sx={{mt: 0.75}} onClick={() => openEditForm(addNewRecordButton.table, null, addNewRecordButton.defaultValues, addNewRecordButton.disabledFields)}>{addNewRecordButton.label}</Button>
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
setLabelComponentsLeft(stateLabelComponentsLeft);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (component instanceof Dropdown)
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
let defaultValue = null;
|
/////////////////////////////////////////////////////////////////////////////////
|
||||||
const dropdownName = props.widgetData.dropdownNameList[index];
|
// for initial render, put right-components from props into the state variable //
|
||||||
const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${props.widgetMetaData.name}.${dropdownName}`;
|
/////////////////////////////////////////////////////////////////////////////////
|
||||||
if(props.storeDropdownSelections)
|
const stateLabelComponentsRight = [] as LabelComponent[];
|
||||||
{
|
// console.log(`${props.widgetMetaData.name} init'ing right-components`);
|
||||||
///////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
// 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 (
|
|
||||||
<Box my={2} sx={{float: "right"}}>
|
|
||||||
<DropdownMenu
|
|
||||||
name={dropdownName}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
sx={{width: 200, marginLeft: "15px"}}
|
|
||||||
label={`Select ${dropdown.label}`}
|
|
||||||
dropdownOptions={dropdown.options}
|
|
||||||
onChangeCallback={dropdown.onChangeCallback}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (<div>Unsupported component type.</div>)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////
|
|
||||||
// make dropdowns from the widgetData appear as label-components //
|
|
||||||
///////////////////////////////////////////////////////////////////
|
|
||||||
const effectiveLabelAdditionalComponentsRight: LabelComponent[] = [];
|
|
||||||
if (props.labelAdditionalComponentsRight)
|
if (props.labelAdditionalComponentsRight)
|
||||||
{
|
{
|
||||||
props.labelAdditionalComponentsRight.map((component) => effectiveLabelAdditionalComponentsRight.push(component));
|
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)
|
if (props.widgetData && props.widgetData.dropdownDataList)
|
||||||
{
|
{
|
||||||
props.widgetData.dropdownDataList?.map((dropdownData: any, index: number) =>
|
const currentDropdownDataJSON = JSON.stringify(props.widgetData.dropdownDataList);
|
||||||
|
if (currentDropdownDataJSON !== dropdownDataJSON)
|
||||||
{
|
{
|
||||||
effectiveLabelAdditionalComponentsRight.push(new Dropdown(props.widgetData.dropdownLabelList[index], dropdownData, handleDataChange))
|
// 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]);
|
||||||
|
|
||||||
function handleDataChange(dropdownLabel: string, changedData: any)
|
function handleDataChange(dropdownLabel: string, changedData: any)
|
||||||
{
|
{
|
||||||
@ -297,9 +437,31 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasPermission = props.widgetData?.hasPermission === undefined || props.widgetData?.hasPermission === true;
|
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 ||= (labelComponentsLeft && labelComponentsLeft.length > 0);
|
||||||
|
needLabelBox ||= (labelComponentsRight && labelComponentsRight.length > 0);
|
||||||
|
needLabelBox ||= isSet(props.widgetMetaData?.icon);
|
||||||
|
needLabelBox ||= isSet(props.widgetData?.label);
|
||||||
|
needLabelBox ||= isSet(props.widgetMetaData?.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorLoading = props.widgetData?.errorLoading !== undefined && props.widgetData?.errorLoading === true;
|
||||||
const widgetContent =
|
const widgetContent =
|
||||||
<Box sx={{width: "100%", height: "100%", minHeight: props.widgetMetaData?.minHeight ? props.widgetMetaData?.minHeight : "initial"}}>
|
<Box sx={{width: "100%", height: "100%", minHeight: props.widgetMetaData?.minHeight ? props.widgetMetaData?.minHeight : "initial"}}>
|
||||||
<Box pr={2} display="flex" justifyContent="space-between" alignItems="flex-start" sx={{width: "100%"}}>
|
{
|
||||||
|
needLabelBox &&
|
||||||
|
<Box pr={2} display="flex" justifyContent="space-between" alignItems="flex-start" sx={{width: "100%"}} height={"3.5rem"}>
|
||||||
<Box pt={2} pb={1}>
|
<Box pt={2} pb={1}>
|
||||||
{
|
{
|
||||||
hasPermission ?
|
hasPermission ?
|
||||||
@ -323,8 +485,8 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
|
|||||||
{props.widgetMetaData.icon}
|
{props.widgetMetaData.icon}
|
||||||
</Icon>
|
</Icon>
|
||||||
</Box>
|
</Box>
|
||||||
|
) :
|
||||||
) : (
|
(
|
||||||
<Box
|
<Box
|
||||||
ml={3}
|
ml={3}
|
||||||
mt={-4}
|
mt={-4}
|
||||||
@ -360,14 +522,9 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
{/*
|
|
||||||
<Button onClick={() => toggleFullScreenWidget()}>
|
|
||||||
{fullScreenWidgetClassName ? "-" : "+"}
|
|
||||||
</Button>
|
|
||||||
*/}
|
|
||||||
{
|
{
|
||||||
hasPermission && (
|
hasPermission && (
|
||||||
props.labelAdditionalComponentsLeft.map((component, i) =>
|
labelComponentsLeft.map((component, i) =>
|
||||||
{
|
{
|
||||||
return (<span key={i}>{renderComponent(component, i)}</span>);
|
return (<span key={i}>{renderComponent(component, i)}</span>);
|
||||||
})
|
})
|
||||||
@ -377,7 +534,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
|
|||||||
<Box>
|
<Box>
|
||||||
{
|
{
|
||||||
hasPermission && (
|
hasPermission && (
|
||||||
effectiveLabelAdditionalComponentsRight.map((component, i) =>
|
labelComponentsRight.map((component, i) =>
|
||||||
{
|
{
|
||||||
return (<span key={i}>{renderComponent(component, i)}</span>);
|
return (<span key={i}>{renderComponent(component, i)}</span>);
|
||||||
})
|
})
|
||||||
@ -385,7 +542,17 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
|
|||||||
}
|
}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
}
|
||||||
{
|
{
|
||||||
|
props.widgetMetaData?.isCard && (reloading ? <LinearProgress color="info" sx={{overflow: "hidden", borderRadius: "0"}} /> : <Box height="0.375rem" />)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
errorLoading ? (
|
||||||
|
<Box pb={3} sx={{display: "flex", justifyContent: "center", alignItems: "flex-start"}}>
|
||||||
|
<Icon color="error">error</Icon>
|
||||||
|
<Typography sx={{paddingLeft: "4px", textTransform: "revert"}} variant="button">An error occurred loading widget content.</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
hasPermission && props.widgetData?.dropdownNeedsSelectedText ? (
|
hasPermission && props.widgetData?.dropdownNeedsSelectedText ? (
|
||||||
<Box pb={3} pr={3} sx={{width: "100%", textAlign: "right"}}>
|
<Box pb={3} pr={3} sx={{width: "100%", textAlign: "right"}}>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
@ -399,15 +566,20 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
|
|||||||
<Box mt={2} mb={5} sx={{display: "flex", justifyContent: "center"}}><Typography variant="body2">You do not have permission to view this data.</Typography></Box>
|
<Box mt={2} mb={5} sx={{display: "flex", justifyContent: "center"}}><Typography variant="body2">You do not have permission to view this data.</Typography></Box>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
props?.footerHTML && (
|
!errorLoading && props?.footerHTML && (
|
||||||
<Box mt={1} ml={3} mr={3} mb={2} sx={{fontWeight: 300, color: "#7b809a", display: "flex", alignContent: "flex-end", fontSize: "14px"}}>{parse(props.footerHTML)}</Box>
|
<Box mt={1} ml={3} mr={3} mb={2} sx={{fontWeight: 300, color: "#7b809a", display: "flex", alignContent: "flex-end", fontSize: "14px"}}>{parse(props.footerHTML)}</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</Box>;
|
</Box>;
|
||||||
|
|
||||||
return props.widgetMetaData?.isCard ? <Card sx={{marginTop: props.widgetMetaData?.icon ? 2 : 0, width: "100%"}} className={fullScreenWidgetClassName}>{widgetContent}</Card> : widgetContent;
|
return props.widgetMetaData?.isCard
|
||||||
|
? <Card sx={{marginTop: props.widgetMetaData?.icon ? 2 : 0, width: "100%"}} className={fullScreenWidgetClassName}>
|
||||||
|
{widgetContent}
|
||||||
|
</Card>
|
||||||
|
: widgetContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Widget;
|
export default Widget;
|
||||||
|
@ -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 {DataGridPro, GridCallbackDetails, GridRowParams, MuiEvent} from "@mui/x-data-grid-pro";
|
||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {useNavigate} from "react-router-dom";
|
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 DataGridUtils from "qqq/utils/DataGridUtils";
|
||||||
|
import HtmlUtils from "qqq/utils/HtmlUtils";
|
||||||
import Client from "qqq/utils/qqq/Client";
|
import Client from "qqq/utils/qqq/Client";
|
||||||
|
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||||
|
|
||||||
interface Props
|
interface Props
|
||||||
{
|
{
|
||||||
@ -42,7 +44,9 @@ const qController = Client.getInstance();
|
|||||||
function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
|
function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
|
||||||
{
|
{
|
||||||
const [rows, setRows] = useState([]);
|
const [rows, setRows] = useState([]);
|
||||||
|
const [records, setRecords] = useState([] as QRecord[])
|
||||||
const [columns, setColumns] = useState([]);
|
const [columns, setColumns] = useState([]);
|
||||||
|
const [allColumns, setAllColumns] = useState([])
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
@ -68,6 +72,11 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
|
|||||||
const childTablePath = data.tablePath ? data.tablePath + (data.tablePath.endsWith("/") ? "" : "/") : data.tablePath;
|
const childTablePath = data.tablePath ? data.tablePath + (data.tablePath.endsWith("/") ? "" : "/") : data.tablePath;
|
||||||
const columns = DataGridUtils.setupGridColumns(tableMetaData, childTablePath, null, "bySection");
|
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 //
|
// 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);
|
setRows(rows);
|
||||||
|
setRecords(records)
|
||||||
setColumns(columns);
|
setColumns(columns);
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [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[] = []
|
const labelAdditionalComponentsLeft: LabelComponent[] = []
|
||||||
if(data && data.viewAllLink)
|
if(data && data.viewAllLink)
|
||||||
{
|
{
|
||||||
labelAdditionalComponentsLeft.push(new HeaderLink("View All", 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[] = []
|
const labelAdditionalComponentsRight: LabelComponent[] = []
|
||||||
if(data && data.canAddChildRecord)
|
if(data && data.canAddChildRecord)
|
||||||
{
|
{
|
||||||
@ -123,6 +183,7 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
|
|||||||
return (
|
return (
|
||||||
<Widget
|
<Widget
|
||||||
widgetMetaData={widgetMetaData}
|
widgetMetaData={widgetMetaData}
|
||||||
|
widgetData={data}
|
||||||
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
|
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
|
||||||
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
|
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
|
||||||
>
|
>
|
||||||
|
131
src/qqq/components/widgets/tables/TableWidget.tsx
Normal file
131
src/qqq/components/widgets/tables/TableWidget.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
/*
|
||||||
|
* 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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
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 HtmlUtils from "qqq/utils/HtmlUtils";
|
||||||
|
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||||
|
|
||||||
|
interface Props
|
||||||
|
{
|
||||||
|
widgetMetaData?: QWidgetMetaData;
|
||||||
|
widgetData?: WidgetData;
|
||||||
|
reloadWidgetCallback?: (params: string) => void;
|
||||||
|
isChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
TableWidget.defaultProps = {
|
||||||
|
};
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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 += `"${ValueUtils.cleanForCsv(text)}"`;
|
||||||
|
}
|
||||||
|
csv += "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(csv);
|
||||||
|
|
||||||
|
const fileName = (props.widgetData.label ?? props.widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv";
|
||||||
|
HtmlUtils.download(fileName, csv);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
alert("There is no data available to export.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget
|
||||||
|
widgetMetaData={props.widgetMetaData}
|
||||||
|
widgetData={props.widgetData}
|
||||||
|
reloadWidgetCallback={(data) => props.reloadWidgetCallback(data)}
|
||||||
|
footerHTML={props.widgetData?.footerHTML}
|
||||||
|
isChild={props.isChild}
|
||||||
|
labelAdditionalComponentsLeft={props.widgetMetaData?.showExportButton ? [new ExportDataButton(() => exportCallback(), isExportDisabled)] : []}
|
||||||
|
>
|
||||||
|
<TableCard
|
||||||
|
noRowsFoundHTML={props.widgetData?.noRowsFoundHTML}
|
||||||
|
rowsPerPage={props.widgetData?.rowsPerPage}
|
||||||
|
hidePaginationDropdown={props.widgetData?.hidePaginationDropdown}
|
||||||
|
data={{columns: props.widgetData?.columns, rows: props.widgetData?.rows}}
|
||||||
|
/>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TableWidget;
|
@ -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 {QReportMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QReportMetaData";
|
||||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||||
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
|
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 Card from "@mui/material/Card";
|
||||||
import Divider from "@mui/material/Divider";
|
import Divider from "@mui/material/Divider";
|
||||||
import Grid from "@mui/material/Grid";
|
import Grid from "@mui/material/Grid";
|
||||||
@ -316,7 +317,7 @@ function AppHome({app}: Props): JSX.Element
|
|||||||
<Grid key={table.name} item xs={12} md={12} lg={tileSizeLg}>
|
<Grid key={table.name} item xs={12} md={12} lg={tileSizeLg}>
|
||||||
{hasTablePermission(tableName) ?
|
{hasTablePermission(tableName) ?
|
||||||
<Link to={table.name}>
|
<Link to={table.name}>
|
||||||
<Box mb={3}>
|
<Box className="big-icon" mb={3}>
|
||||||
<MiniStatisticsCard
|
<MiniStatisticsCard
|
||||||
title={{fontWeight: "bold", text: table.label}}
|
title={{fontWeight: "bold", text: table.label}}
|
||||||
count={!tableCounts.has(table.name) || tableCounts.get(table.name).isLoading ? "..." : (tableCountNumbers.get(table.name))}
|
count={!tableCounts.has(table.name) || tableCounts.get(table.name).isLoading ? "..." : (tableCountNumbers.get(table.name))}
|
||||||
|
75
src/qqq/pages/records/FilterPoc.tsx
Normal file
75
src/qqq/pages/records/FilterPoc.tsx
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<BaseLayout>
|
||||||
|
{
|
||||||
|
tableMetaData &&
|
||||||
|
<Box>
|
||||||
|
<Box sx={{background: "white"}} border="1px solid gray">
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<CustomFilterPanel tableMetaData={tableMetaData} queryFilter={queryFilter} updateFilter={updateFilter} />
|
||||||
|
</Box>
|
||||||
|
<pre style={{fontSize: "12px"}}>
|
||||||
|
{JSON.stringify(queryFilter, null, 3)})
|
||||||
|
</pre>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
</BaseLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilterPoc;
|
123
src/qqq/pages/records/IntersectionMatrix.tsx
Normal file
123
src/qqq/pages/records/IntersectionMatrix.tsx
Normal file
File diff suppressed because one or more lines are too long
@ -47,8 +47,6 @@ TableDeveloperView.defaultProps =
|
|||||||
|
|
||||||
function TableDeveloperView({table}: Props): JSX.Element
|
function TableDeveloperView({table}: Props): JSX.Element
|
||||||
{
|
{
|
||||||
const {id} = useParams();
|
|
||||||
|
|
||||||
const {getAccessTokenSilently} = useAuth0();
|
const {getAccessTokenSilently} = useAuth0();
|
||||||
const [accessToken, setAccessToken] = useState(null as string);
|
const [accessToken, setAccessToken] = useState(null as string);
|
||||||
|
|
||||||
@ -70,6 +68,33 @@ function TableDeveloperView({table}: Props): JSX.Element
|
|||||||
setAccessToken(accessToken);
|
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)
|
if (!asyncLoadInited)
|
||||||
{
|
{
|
||||||
setAsyncLoadInited(true);
|
setAsyncLoadInited(true);
|
||||||
@ -90,6 +115,9 @@ function TableDeveloperView({table}: Props): JSX.Element
|
|||||||
|
|
||||||
setPageHeader(tableMetaData.label + " Developer Mode");
|
setPageHeader(tableMetaData.label + " Developer Mode");
|
||||||
|
|
||||||
|
///////////////////////////////
|
||||||
|
// fetch apis for this table //
|
||||||
|
///////////////////////////////
|
||||||
const apisResponse = await fetch("/apis.json?tableName=" + tableName);
|
const apisResponse = await fetch("/apis.json?tableName=" + tableName);
|
||||||
const apisJson = await apisResponse.json();
|
const apisJson = await apisResponse.json();
|
||||||
console.log(apisJson);
|
console.log(apisJson);
|
||||||
@ -102,18 +130,36 @@ function TableDeveloperView({table}: Props): JSX.Element
|
|||||||
|
|
||||||
setSupportedApis(apisJson["apis"]);
|
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);
|
setSelectedApi(selectedApi);
|
||||||
|
|
||||||
|
////////////////////////////////
|
||||||
|
// fetch versions for ths api //
|
||||||
|
////////////////////////////////
|
||||||
const versionsResponse = await fetch(selectedApi["path"] + "versions.json");
|
const versionsResponse = await fetch(selectedApi["path"] + "versions.json");
|
||||||
const versionsJson = await versionsResponse.json();
|
const versionsJson = await versionsResponse.json();
|
||||||
console.log(versionsJson);
|
console.log(versionsJson);
|
||||||
|
|
||||||
setSupportedVersions(versionsJson.supportedVersions);
|
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];
|
const selectedApi = supportedApis[i];
|
||||||
setSelectedApi(selectedApi);
|
setSelectedApi(selectedApi);
|
||||||
|
localStorage.setItem(LAST_API_NAME_LS_KEY, selectedApi.name);
|
||||||
|
|
||||||
const versionsResponse = await fetch(selectedApi["path"] + "versions.json");
|
const versionsResponse = await fetch(selectedApi["path"] + "versions.json");
|
||||||
const versionsJson = await versionsResponse.json();
|
const versionsJson = await versionsResponse.json();
|
||||||
console.log(versionsJson);
|
console.log(versionsJson);
|
||||||
|
|
||||||
setSupportedVersions(versionsJson.supportedVersions);
|
setSupportedVersions(versionsJson.supportedVersions);
|
||||||
if (versionsJson.currentVersion)
|
|
||||||
{
|
selectVersionAfterApiIsChanged(versionsJson);
|
||||||
setSelectedVersion(versionsJson.currentVersion);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -147,6 +192,7 @@ function TableDeveloperView({table}: Props): JSX.Element
|
|||||||
const selectVersion = (event: SelectChangeEvent) =>
|
const selectVersion = (event: SelectChangeEvent) =>
|
||||||
{
|
{
|
||||||
setSelectedVersion(event.target.value);
|
setSelectedVersion(event.target.value);
|
||||||
|
localStorage.setItem(LAST_API_VERSION_LS_KEY, event.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -207,7 +253,7 @@ function TableDeveloperView({table}: Props): JSX.Element
|
|||||||
persist-auth={true}
|
persist-auth={true}
|
||||||
allow-server-selection={false}
|
allow-server-selection={false}
|
||||||
allow-spec-file-download={true}
|
allow-spec-file-download={true}
|
||||||
sort-endpoints-by="method"
|
sort-endpoints-by="none"
|
||||||
schema-description-expanded={true}
|
schema-description-expanded={true}
|
||||||
css-file={"/api/rapi-doc.css"}
|
css-file={"/api/rapi-doc.css"}
|
||||||
css-classes={"qqq-rapi-doc"}
|
css-classes={"qqq-rapi-doc"}
|
||||||
|
@ -29,9 +29,15 @@ import BaseLayout from "qqq/layouts/BaseLayout";
|
|||||||
interface Props
|
interface Props
|
||||||
{
|
{
|
||||||
table?: QTableMetaData;
|
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();
|
const {id} = useParams();
|
||||||
|
|
||||||
@ -43,7 +49,7 @@ function EntityEdit({table}: Props): JSX.Element
|
|||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<EntityForm table={table} id={id} />
|
<EntityForm table={table} id={id} isDuplicate={isDuplicate} />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
@ -54,8 +60,4 @@ function EntityEdit({table}: Props): JSX.Element
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
EntityEdit.defaultProps = {
|
|
||||||
table: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EntityEdit;
|
export default EntityEdit;
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
|
||||||
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||||
import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection";
|
import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection";
|
||||||
@ -26,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 {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
|
||||||
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
||||||
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
|
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
|
||||||
import {TablePagination} from "@mui/material";
|
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Grid from "@mui/material/Grid";
|
import Grid from "@mui/material/Grid";
|
||||||
@ -37,6 +37,7 @@ import {DataGridPro, GridSortModel} from "@mui/x-data-grid-pro";
|
|||||||
import FormData from "form-data";
|
import FormData from "form-data";
|
||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import DataGridUtils from "qqq/utils/DataGridUtils";
|
import DataGridUtils from "qqq/utils/DataGridUtils";
|
||||||
|
import HtmlUtils from "qqq/utils/HtmlUtils";
|
||||||
import Client from "qqq/utils/qqq/Client";
|
import Client from "qqq/utils/qqq/Client";
|
||||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||||
|
|
||||||
@ -80,6 +81,7 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro
|
|||||||
formData.append("tableName", tableMetaData.name);
|
formData.append("tableName", tableMetaData.name);
|
||||||
formData.append("fieldName", fullFieldName);
|
formData.append("fieldName", fullFieldName);
|
||||||
formData.append("filterJSON", JSON.stringify(filter));
|
formData.append("filterJSON", JSON.stringify(filter));
|
||||||
|
formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 300 * 1000);
|
||||||
if(orderBy)
|
if(orderBy)
|
||||||
{
|
{
|
||||||
formData.append("orderBy", orderBy);
|
formData.append("orderBy", orderBy);
|
||||||
@ -95,6 +97,8 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// todo - job running!
|
||||||
|
|
||||||
const result = processResult as QJobComplete;
|
const result = processResult as QJobComplete;
|
||||||
|
|
||||||
const statFieldObjects = result.values.statsFields;
|
const statFieldObjects = result.values.statsFields;
|
||||||
@ -172,6 +176,20 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro
|
|||||||
setStatusString("Refreshing...")
|
setStatusString("Refreshing...")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const doExport = () =>
|
||||||
|
{
|
||||||
|
let csv = `"${ValueUtils.cleanForCsv(fieldMetaData.label)}","Count"\n`;
|
||||||
|
for (let i = 0; i < valueCounts.length; i++)
|
||||||
|
{
|
||||||
|
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";
|
||||||
|
HtmlUtils.download(fileName, csv);
|
||||||
|
}
|
||||||
|
|
||||||
function Loading()
|
function Loading()
|
||||||
{
|
{
|
||||||
return (
|
return (
|
||||||
@ -198,9 +216,14 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro
|
|||||||
{statusString ?? <> </>}
|
{statusString ?? <> </>}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Box>
|
||||||
<Button onClick={() => refresh()} startIcon={<Icon>refresh</Icon>}>
|
<Button onClick={() => refresh()} startIcon={<Icon>refresh</Icon>}>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={() => doExport()} startIcon={<Icon>save_alt</Icon>} disabled={valueCounts == null || valueCounts.length == 0}>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
<Grid item xs={8}>
|
<Grid item xs={8}>
|
||||||
|
@ -707,6 +707,20 @@ const booleanNotEmptyOperator: GridFilterOperator = {
|
|||||||
|
|
||||||
export const QGridBooleanOperators = [booleanTrueOperator, booleanFalseOperator, booleanEmptyOperator, booleanNotEmptyOperator];
|
export const QGridBooleanOperators = [booleanTrueOperator, booleanFalseOperator, booleanEmptyOperator, booleanNotEmptyOperator];
|
||||||
|
|
||||||
|
const blobEmptyOperator: GridFilterOperator = {
|
||||||
|
label: "is empty",
|
||||||
|
value: "isEmpty",
|
||||||
|
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
|
||||||
|
};
|
||||||
|
|
||||||
|
const blobNotEmptyOperator: GridFilterOperator = {
|
||||||
|
label: "is not empty",
|
||||||
|
value: "isNotEmpty",
|
||||||
|
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QGridBlobOperators = [blobNotEmptyOperator, blobEmptyOperator];
|
||||||
|
|
||||||
|
|
||||||
///////////////////////////////////////
|
///////////////////////////////////////
|
||||||
// input element for possible values //
|
// input element for possible values //
|
||||||
|
@ -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 {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
||||||
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
|
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
|
||||||
import {QueryJoin} from "@kingsrook/qqq-frontend-core/lib/model/query/QueryJoin";
|
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 Button from "@mui/material/Button";
|
||||||
import Card from "@mui/material/Card";
|
import Card from "@mui/material/Card";
|
||||||
import Dialog from "@mui/material/Dialog";
|
import Dialog from "@mui/material/Dialog";
|
||||||
@ -46,12 +47,9 @@ import ListItemIcon from "@mui/material/ListItemIcon";
|
|||||||
import Menu from "@mui/material/Menu";
|
import Menu from "@mui/material/Menu";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import Modal from "@mui/material/Modal";
|
import Modal from "@mui/material/Modal";
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
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 {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 {GridRowModel} from "@mui/x-data-grid/models/gridRows";
|
||||||
import FormData from "form-data";
|
import FormData from "form-data";
|
||||||
import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react";
|
import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react";
|
||||||
@ -60,6 +58,8 @@ import QContext from "QContext";
|
|||||||
import {QActionsMenuButton, QCancelButton, QCreateNewButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
import {QActionsMenuButton, QCancelButton, QCreateNewButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
||||||
import MenuButton from "qqq/components/buttons/MenuButton";
|
import MenuButton from "qqq/components/buttons/MenuButton";
|
||||||
import SavedFilters from "qqq/components/misc/SavedFilters";
|
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 CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip";
|
||||||
import BaseLayout from "qqq/layouts/BaseLayout";
|
import BaseLayout from "qqq/layouts/BaseLayout";
|
||||||
import ProcessRun from "qqq/pages/processes/ProcessRun";
|
import ProcessRun from "qqq/pages/processes/ProcessRun";
|
||||||
@ -97,12 +97,31 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
const tableName = table.name;
|
const tableName = table.name;
|
||||||
const [searchParams] = useSearchParams();
|
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 [successAlert, setSuccessAlert] = useState(null as string);
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
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("/");
|
const pathParts = location.pathname.replace(/\/+$/, "").split("/");
|
||||||
|
|
||||||
////////////////////////////////////////////
|
////////////////////////////////////////////
|
||||||
@ -155,7 +174,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [filterModel, setFilterModel] = useState({items: []} as GridFilterModel);
|
const [filterModel, setFilterModel] = useState({items: []} as GridFilterModel);
|
||||||
|
const [lastFetchedQFilterJSON, setLastFetchedQFilterJSON] = useState("");
|
||||||
const [columnSortModel, setColumnSortModel] = useState(defaultSort);
|
const [columnSortModel, setColumnSortModel] = useState(defaultSort);
|
||||||
|
const [queryFilter, setQueryFilter] = useState(new QQueryFilter());
|
||||||
|
|
||||||
const [columnVisibilityModel, setColumnVisibilityModel] = useState(defaultVisibility);
|
const [columnVisibilityModel, setColumnVisibilityModel] = useState(defaultVisibility);
|
||||||
const [shouldSetAllNewJoinFieldsToHidden, setShouldSetAllNewJoinFieldsToHidden] = useState(!didDefaultVisibilityComeFromLocalStorage)
|
const [shouldSetAllNewJoinFieldsToHidden, setShouldSetAllNewJoinFieldsToHidden] = useState(!didDefaultVisibilityComeFromLocalStorage)
|
||||||
const [visibleJoinTables, setVisibleJoinTables] = useState(new Set<string>());
|
const [visibleJoinTables, setVisibleJoinTables] = useState(new Set<string>());
|
||||||
@ -163,6 +185,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
const [density, setDensity] = useState(defaultDensity);
|
const [density, setDensity] = useState(defaultDensity);
|
||||||
const [pinnedColumns, setPinnedColumns] = useState(defaultPinnedColumns);
|
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 [tableState, setTableState] = useState("");
|
||||||
const [metaData, setMetaData] = useState(null as QInstance);
|
const [metaData, setMetaData] = useState(null as QInstance);
|
||||||
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
|
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
|
||||||
@ -213,7 +240,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
const [queryErrors, setQueryErrors] = useState({} as any);
|
const [queryErrors, setQueryErrors] = useState({} as any);
|
||||||
const [receivedQueryErrorTimestamp, setReceivedQueryErrorTimestamp] = useState(new Date());
|
const [receivedQueryErrorTimestamp, setReceivedQueryErrorTimestamp] = useState(new Date());
|
||||||
|
|
||||||
|
|
||||||
const {setPageHeader} = useContext(QContext);
|
const {setPageHeader} = useContext(QContext);
|
||||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
@ -311,17 +337,27 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
|
|
||||||
}, [location, tableMetaData]);
|
}, [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 //
|
// any time these are out of sync, it means we need to reload things //
|
||||||
///////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////
|
||||||
if (tableMetaData && tableMetaData.name !== tableName)
|
if (tableMetaData && tableMetaData.name !== tableName)
|
||||||
{
|
{
|
||||||
console.log(" it looks like we changed tables - try to reload the things");
|
|
||||||
setTableMetaData(null);
|
setTableMetaData(null);
|
||||||
setColumnSortModel([]);
|
setColumnSortModel([]);
|
||||||
setColumnVisibilityModel({});
|
updateColumnVisibilityModel();
|
||||||
setColumnsModel([]);
|
setColumnsModel([]);
|
||||||
setFilterModel({items: []});
|
setFilterModel({items: []});
|
||||||
|
setQueryFilter(new QQueryFilter());
|
||||||
setDefaultFilterLoaded(false);
|
setDefaultFilterLoaded(false);
|
||||||
setRows([]);
|
setRows([]);
|
||||||
}
|
}
|
||||||
@ -333,7 +369,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
const buildQFilter = (tableMetaData: QTableMetaData, filterModel: GridFilterModel, limit?: number) =>
|
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);
|
setHasValidFilters(filter.criteria && filter.criteria.length > 0);
|
||||||
return (filter);
|
return (filter);
|
||||||
};
|
};
|
||||||
@ -499,6 +536,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
let models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, null, searchParams, filterLocalStorageKey, sortLocalStorageKey);
|
let models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, null, searchParams, filterLocalStorageKey, sortLocalStorageKey);
|
||||||
setFilterModel(models.filter);
|
setFilterModel(models.filter);
|
||||||
setColumnSortModel(models.sort);
|
setColumnSortModel(models.sort);
|
||||||
|
setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, models.filter, models.sort, rowsPerPage));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -532,6 +570,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
{
|
{
|
||||||
columnSortModel.splice(i, 1);
|
columnSortModel.splice(i, 1);
|
||||||
setColumnSortModel(columnSortModel);
|
setColumnSortModel(columnSortModel);
|
||||||
|
// todo - need to setQueryFilter?
|
||||||
resetColumnSortModel = true;
|
resetColumnSortModel = true;
|
||||||
i--;
|
i--;
|
||||||
}
|
}
|
||||||
@ -548,6 +587,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
sort: "desc",
|
sort: "desc",
|
||||||
});
|
});
|
||||||
setColumnSortModel(columnSortModel);
|
setColumnSortModel(columnSortModel);
|
||||||
|
// todo - need to setQueryFilter?
|
||||||
resetColumnSortModel = true;
|
resetColumnSortModel = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -604,6 +644,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLastFetchedQFilterJSON(JSON.stringify(qFilter));
|
||||||
qController.query(tableName, qFilter, queryJoins).then((results) =>
|
qController.query(tableName, qFilter, queryJoins).then((results) =>
|
||||||
{
|
{
|
||||||
console.log(`Received results for query ${thisQueryId}`);
|
console.log(`Received results for query ${thisQueryId}`);
|
||||||
@ -833,10 +874,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
setColumnVisibilityModel(columnVisibilityModel);
|
setColumnVisibilityModel(columnVisibilityModel);
|
||||||
if (columnVisibilityLocalStorageKey)
|
if (columnVisibilityLocalStorageKey)
|
||||||
{
|
{
|
||||||
localStorage.setItem(
|
localStorage.setItem(columnVisibilityLocalStorageKey, JSON.stringify(columnVisibilityModel));
|
||||||
columnVisibilityLocalStorageKey,
|
|
||||||
JSON.stringify(columnVisibilityModel),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -849,6 +887,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
const newVisibleJoinTables = getVisibleJoinTables();
|
const newVisibleJoinTables = getVisibleJoinTables();
|
||||||
if (JSON.stringify([...newVisibleJoinTables.keys()]) != JSON.stringify([...visibleJoinTables.keys()]))
|
if (JSON.stringify([...newVisibleJoinTables.keys()]) != JSON.stringify([...visibleJoinTables.keys()]))
|
||||||
{
|
{
|
||||||
|
console.log("calling update table for visible join table change");
|
||||||
updateTable();
|
updateTable();
|
||||||
setVisibleJoinTables(newVisibleJoinTables);
|
setVisibleJoinTables(newVisibleJoinTables);
|
||||||
}
|
}
|
||||||
@ -860,20 +899,51 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
console.log(columnOrderChangeParams);
|
console.log(columnOrderChangeParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFilterChange = (filterModel: GridFilterModel) =>
|
const handleFilterChange = (filterModel: GridFilterModel, doSetQueryFilter = true, isChangeFromDataGrid = false) =>
|
||||||
{
|
{
|
||||||
setFilterModel(filterModel);
|
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 (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)
|
if (filterLocalStorageKey)
|
||||||
{
|
{
|
||||||
localStorage.setItem(filterLocalStorageKey, JSON.stringify(filterModel));
|
localStorage.setItem(filterLocalStorageKey, JSON.stringify(filterModel));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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)
|
if (gridSort && gridSort.length > 0)
|
||||||
{
|
{
|
||||||
setColumnSortModel(gridSort);
|
setColumnSortModel(gridSort);
|
||||||
|
const gridFilterModelToUse = overrideFilterModel ?? filterModel;
|
||||||
|
setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, gridFilterModelToUse, gridSort, rowsPerPage));
|
||||||
localStorage.setItem(sortLocalStorageKey, JSON.stringify(gridSort));
|
localStorage.setItem(sortLocalStorageKey, JSON.stringify(gridSort));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -938,10 +1008,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
//////////////////////////////////////
|
//////////////////////////////////////
|
||||||
// construct the url for the export //
|
// construct the url for the export //
|
||||||
//////////////////////////////////////
|
//////////////////////////////////////
|
||||||
const d = new Date();
|
const dateString = ValueUtils.formatDateTimeForFileName(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 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. //
|
// open a window (tab) with a little page that says the file is being generated. //
|
||||||
@ -958,6 +1029,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
<script>
|
<script>
|
||||||
setTimeout(() =>
|
setTimeout(() =>
|
||||||
{
|
{
|
||||||
|
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// need to encode and decode this value, so set it in the form here, instead of literally below //
|
||||||
|
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
document.getElementById("filter").value = decodeURIComponent("${encodedFilterJSON}");
|
||||||
|
|
||||||
document.getElementById("exportForm").submit();
|
document.getElementById("exportForm").submit();
|
||||||
}, 1);
|
}, 1);
|
||||||
</script>
|
</script>
|
||||||
@ -966,6 +1042,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
Generating file <u>${filename}</u>${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}...
|
Generating file <u>${filename}</u>${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}...
|
||||||
<form id="exportForm" method="post" action="${url}" >
|
<form id="exportForm" method="post" action="${url}" >
|
||||||
<input type="hidden" name="Authorization" value="${qController.getAuthorizationHeaderValue()}">
|
<input type="hidden" name="Authorization" value="${qController.getAuthorizationHeaderValue()}">
|
||||||
|
<input type="hidden" name="fields" value="${visibleFields.join(",")}">
|
||||||
|
<input type="hidden" name="filter" id="filter">
|
||||||
</form>
|
</form>
|
||||||
</body>
|
</body>
|
||||||
</html>`);
|
</html>`);
|
||||||
@ -1077,6 +1155,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
newPath.pop();
|
newPath.pop();
|
||||||
navigate(newPath.join("/"));
|
navigate(newPath.join("/"));
|
||||||
|
|
||||||
|
console.log("calling update table for close modal");
|
||||||
updateTable();
|
updateTable();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1186,6 +1265,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
return (
|
return (
|
||||||
<TablePagination
|
<TablePagination
|
||||||
component="div"
|
component="div"
|
||||||
|
sx={{minWidth: "450px"}}
|
||||||
// note - passing null here makes the 'to' param in the defaultLabelDisplayedRows also be null,
|
// note - passing null here makes the 'to' param in the defaultLabelDisplayedRows also be null,
|
||||||
// so pass a sentinel value of -1...
|
// so pass a sentinel value of -1...
|
||||||
count={totalRecords === null || totalRecords === undefined ? -1 : totalRecords}
|
count={totalRecords === null || totalRecords === undefined ? -1 : totalRecords}
|
||||||
@ -1215,13 +1295,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
|
|
||||||
const models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, qRecord.values.get("filterJson"), null, null, null);
|
const models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, qRecord.values.get("filterJson"), null, null, null);
|
||||||
handleFilterChange(models.filter);
|
handleFilterChange(models.filter);
|
||||||
handleSortChange(models.sort);
|
handleSortChange(models.sort, models.filter);
|
||||||
localStorage.setItem(currentSavedFilterLocalStorageKey, selectedSavedFilterId.toString());
|
localStorage.setItem(currentSavedFilterLocalStorageKey, selectedSavedFilterId.toString());
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
handleFilterChange({items: []} as GridFilterModel);
|
handleFilterChange({items: []} as GridFilterModel);
|
||||||
handleSortChange([{field: tableMetaData.primaryKeyField, sort: "desc"}]);
|
handleSortChange([{field: tableMetaData.primaryKeyField, sort: "desc"}], {items: []} as GridFilterModel);
|
||||||
localStorage.removeItem(currentSavedFilterLocalStorageKey);
|
localStorage.removeItem(currentSavedFilterLocalStorageKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1247,17 +1327,39 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
return (qRecord);
|
return (qRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getFieldAndTable = (fieldName: string): [QFieldMetaData, QTableMetaData] =>
|
||||||
|
{
|
||||||
|
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) =>
|
const copyColumnValues = async (column: GridColDef) =>
|
||||||
{
|
{
|
||||||
let data = "";
|
let data = "";
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
if (latestQueryResults && latestQueryResults.length)
|
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++)
|
for (let i = 0; i < latestQueryResults.length; i++)
|
||||||
{
|
{
|
||||||
let record = latestQueryResults[i] as QRecord;
|
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) !== "")
|
if (value !== null && value !== undefined && String(value) !== "")
|
||||||
{
|
{
|
||||||
data += value + "\n";
|
data += value + "\n";
|
||||||
@ -1283,24 +1385,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
setFilterForColumnStats(buildQFilter(tableMetaData, filterModel));
|
setFilterForColumnStats(buildQFilter(tableMetaData, filterModel));
|
||||||
setColumnStatsFieldName(column.field);
|
setColumnStatsFieldName(column.field);
|
||||||
|
|
||||||
if(column.field.indexOf(".") > -1)
|
const [field, fieldTable] = getFieldAndTable(column.field);
|
||||||
{
|
setColumnStatsField(field);
|
||||||
const nameParts = column.field.split(".", 2);
|
setColumnStatsFieldTableName(fieldTable.name);
|
||||||
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 CustomColumnMenu = forwardRef<HTMLUListElement, GridColumnMenuProps>(
|
const CustomColumnMenu = forwardRef<HTMLUListElement, GridColumnMenuProps>(
|
||||||
@ -1357,95 +1444,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<any, GridColumnsPanelProps>(
|
|
||||||
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 (
|
|
||||||
<div ref={ref} className="custom-columns-panel" style={{width: "350px", height: "450px"}}>
|
|
||||||
<Box height="55px" padding="5px">
|
|
||||||
<TextField label="Find column" placeholder="Column title" variant="standard" fullWidth={true}></TextField>
|
|
||||||
</Box>
|
|
||||||
<Box overflow="auto" height="calc( 100% - 105px )">
|
|
||||||
|
|
||||||
<Stack direction="column" spacing={1} pl="0.5rem">
|
|
||||||
|
|
||||||
{groups.map((groupName: string) =>
|
|
||||||
(
|
|
||||||
<>
|
|
||||||
<IconButton
|
|
||||||
key={groupName}
|
|
||||||
size="small"
|
|
||||||
onClick={() => toggleColumnGroup(groupName)}
|
|
||||||
sx={{width: "100%", justifyContent: "flex-start", fontSize: "0.875rem"}}
|
|
||||||
disableRipple={true}
|
|
||||||
>
|
|
||||||
<Icon>{openGroups[groupName] === false ? "expand_less" : "expand_more"}</Icon>
|
|
||||||
<Box sx={{pl: "0.25rem", fontWeight: "bold"}} textAlign="left">{groupName} fields</Box>
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
{openGroups[groupName] !== false && columnsModel.map((gridColumn: any) => (
|
|
||||||
<IconButton
|
|
||||||
key={gridColumn.field}
|
|
||||||
size="small"
|
|
||||||
onClick={() => onColumnVisibilityChange(gridColumn.field)}
|
|
||||||
sx={{width: "100%", justifyContent: "flex-start", fontSize: "0.875rem", pl: "1.375rem"}}
|
|
||||||
disableRipple={true}
|
|
||||||
>
|
|
||||||
<Icon>{columnVisibilityModel[gridColumn.field] === false ? "visibility_off" : "visibility"}</Icon>
|
|
||||||
<Box sx={{pl: "0.25rem"}} textAlign="left">{gridColumn.headerName}</Box>
|
|
||||||
</IconButton>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
<Box height="50px" padding="5px" display="flex" justifyContent="space-between">
|
|
||||||
<Button>hide all</Button>
|
|
||||||
<Button>show all</Button>
|
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const safeToLocaleString = (n: Number): string =>
|
const safeToLocaleString = (n: Number): string =>
|
||||||
{
|
{
|
||||||
@ -1553,6 +1551,15 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const doClearFilter = (event: React.KeyboardEvent<HTMLDivElement>, isYesButton: boolean = false) =>
|
||||||
|
{
|
||||||
|
if (isYesButton|| event.key == "Enter")
|
||||||
|
{
|
||||||
|
setShowClearFiltersWarning(false);
|
||||||
|
handleFilterChange({items: []} as GridFilterModel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GridToolbarContainer>
|
<GridToolbarContainer>
|
||||||
<div>
|
<div>
|
||||||
@ -1567,30 +1574,18 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
<GridToolbarFilterButton nonce={undefined} />
|
<GridToolbarFilterButton nonce={undefined} />
|
||||||
{
|
{
|
||||||
hasValidFilters && (
|
hasValidFilters && (
|
||||||
|
<div id="clearFiltersButton" style={{display: "inline-block", position: "relative", top: "2px", left: "-0.75rem", width: "1rem"}}>
|
||||||
<div id="clearFiltersButton" style={{position: "absolute", left: "84px", top: "6px"}}>
|
<Tooltip title="Clear Filter">
|
||||||
<Tooltip title="Clear All Filters">
|
|
||||||
<Icon sx={{cursor: "pointer"}} onClick={() => setShowClearFiltersWarning(true)}>clear</Icon>
|
<Icon sx={{cursor: "pointer"}} onClick={() => setShowClearFiltersWarning(true)}>clear</Icon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Dialog open={showClearFiltersWarning} onClose={() => setShowClearFiltersWarning(false)} onKeyPress={(e) =>
|
<Dialog open={showClearFiltersWarning} onClose={() => setShowClearFiltersWarning(false)} onKeyPress={(e) => doClearFilter(e)}>
|
||||||
{
|
|
||||||
if (e.key == "Enter")
|
|
||||||
{
|
|
||||||
setShowClearFiltersWarning(false)
|
|
||||||
handleFilterChange({items: []} as GridFilterModel);
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<DialogTitle id="alert-dialog-title">Confirm</DialogTitle>
|
<DialogTitle id="alert-dialog-title">Confirm</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>Are you sure you want to clear all filters?</DialogContentText>
|
<DialogContentText>Are you sure you want to remove all conditions from the current filter?</DialogContentText>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<QCancelButton label="No" disabled={false} onClickHandler={() => setShowClearFiltersWarning(false)} />
|
<QCancelButton label="No" disabled={false} onClickHandler={() => setShowClearFiltersWarning(false)} />
|
||||||
<QSaveButton label="Yes" iconName="check" disabled={false} onClickHandler={() =>
|
<QSaveButton label="Yes" iconName="check" disabled={false} onClickHandler={() => doClearFilter(null, true)}/>
|
||||||
{
|
|
||||||
setShowClearFiltersWarning(false);
|
|
||||||
handleFilterChange({items: []} as GridFilterModel);
|
|
||||||
}}/>
|
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
@ -1606,7 +1601,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
</GridToolbarExportContainer>
|
</GridToolbarExportContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div style={{zIndex: 10}}>
|
||||||
<MenuButton label="Selection" iconName={selectedIds.length == 0 ? "check_box_outline_blank" : "check_box"} disabled={totalRecords == 0} options={selectionMenuOptions} callback={selectionMenuCallback}/>
|
<MenuButton label="Selection" iconName={selectedIds.length == 0 ? "check_box_outline_blank" : "check_box"} disabled={totalRecords == 0} options={selectionMenuOptions} callback={selectionMenuCallback}/>
|
||||||
<SelectionSubsetDialog isOpen={selectionSubsetSizePromptOpen} initialValue={selectionSubsetSize} closeHandler={(value) =>
|
<SelectionSubsetDialog isOpen={selectionSubsetSizePromptOpen} initialValue={selectionSubsetSize} closeHandler={(value) =>
|
||||||
{
|
{
|
||||||
@ -1767,7 +1762,22 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
setTotalRecords(null);
|
setTotalRecords(null);
|
||||||
setDistinctRecords(null);
|
setDistinctRecords(null);
|
||||||
updateTable();
|
updateTable();
|
||||||
}, [columnsModel, tableState, filterModel]);
|
}, [columnsModel, tableState]);
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
const currentQFilter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, rowsPerPage);
|
||||||
|
currentQFilter.skip = pageNumber * rowsPerPage;
|
||||||
|
const currentQFilterJSON = JSON.stringify(currentQFilter);
|
||||||
|
|
||||||
|
if(currentQFilterJSON !== lastFetchedQFilterJSON)
|
||||||
|
{
|
||||||
|
setTotalRecords(null);
|
||||||
|
setDistinctRecords(null);
|
||||||
|
updateTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [filterModel]);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
@ -1775,6 +1785,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
document.scrollingElement.scrollTop = 0;
|
document.scrollingElement.scrollTop = 0;
|
||||||
}, [pageNumber, rowsPerPage]);
|
}, [pageNumber, rowsPerPage]);
|
||||||
|
|
||||||
|
const updateFilterFromFilterPanel = (newFilter: QQueryFilter): void =>
|
||||||
|
{
|
||||||
|
setQueryFilter(newFilter);
|
||||||
|
const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter);
|
||||||
|
handleFilterChange(gridFilterModel, false);
|
||||||
|
};
|
||||||
|
|
||||||
if (tableMetaData && !tableMetaData.readPermission)
|
if (tableMetaData && !tableMetaData.readPermission)
|
||||||
{
|
{
|
||||||
return (
|
return (
|
||||||
@ -1815,29 +1832,29 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
)}
|
)}
|
||||||
{
|
{
|
||||||
(tableLabel && showSuccessfullyDeletedAlert) ? (
|
(tableLabel && showSuccessfullyDeletedAlert) ? (
|
||||||
<Alert color="success" sx={{mb: 3}} onClose={() =>
|
<Alert color="success" sx={{mb: 3}} onClose={() => setShowSuccessfullyDeletedAlert(false)}>{`${tableLabel} successfully deleted`}</Alert>
|
||||||
{
|
|
||||||
setShowSuccessfullyDeletedAlert(false);
|
|
||||||
}}>
|
|
||||||
{`${tableLabel} successfully deleted`}
|
|
||||||
</Alert>
|
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
(successAlert) ? (
|
(successAlert) ? (
|
||||||
<Collapse in={Boolean(successAlert)}>
|
<Collapse in={Boolean(successAlert)}>
|
||||||
<Alert color="success" sx={{mb: 3}} onClose={() =>
|
<Alert color="success" sx={{mb: 3}} onClose={() => setSuccessAlert(null)}>{successAlert}</Alert>
|
||||||
|
</Collapse>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
{
|
{
|
||||||
setSuccessAlert(null);
|
(warningAlert) ? (
|
||||||
}}>
|
<Collapse in={Boolean(warningAlert)}>
|
||||||
{successAlert}
|
<Alert color="warning" sx={{mb: 3}} onClose={() => setWarningAlert(null)}>{warningAlert}</Alert>
|
||||||
</Alert>
|
|
||||||
</Collapse>
|
</Collapse>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
<Box display="flex" justifyContent="flex-end" alignItems="flex-start" mb={2}>
|
<Box display="flex" justifyContent="flex-end" alignItems="flex-start" mb={2}>
|
||||||
<Box display="flex" marginRight="auto">
|
<Box display="flex" marginRight="auto">
|
||||||
|
{
|
||||||
|
metaData && metaData.processes.has("querySavedFilter") &&
|
||||||
<SavedFilters qController={qController} metaData={metaData} tableMetaData={tableMetaData} currentSavedFilter={currentSavedFilter} filterModel={filterModel} columnSortModel={columnSortModel} filterOnChangeCallback={handleSavedFilterChange} />
|
<SavedFilters qController={qController} metaData={metaData} tableMetaData={tableMetaData} currentSavedFilter={currentSavedFilter} filterModel={filterModel} columnSortModel={columnSortModel} filterOnChangeCallback={handleSavedFilterChange} />
|
||||||
|
}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box display="flex" width="150px">
|
<Box display="flex" width="150px">
|
||||||
@ -1848,12 +1865,43 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission &&
|
table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission &&
|
||||||
<QCreateNewButton tablePath={metaData?.getTablePathByName(tableName)} />
|
<QCreateNewButton tablePath={metaData?.getTablePathByName(tableName)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
<Card>
|
<Card>
|
||||||
<Box height="100%">
|
<Box height="100%">
|
||||||
<DataGridPro
|
<DataGridPro
|
||||||
components={{Toolbar: CustomToolbar, Pagination: CustomPagination, LoadingOverlay: Loading, ColumnMenu: CustomColumnMenu/*, ColumnsPanel: CustomColumnsPanel*/}}
|
components={{
|
||||||
|
Toolbar: CustomToolbar,
|
||||||
|
Pagination: CustomPagination,
|
||||||
|
LoadingOverlay: Loading,
|
||||||
|
ColumnMenu: CustomColumnMenu,
|
||||||
|
ColumnsPanel: CustomColumnsPanel,
|
||||||
|
FilterPanel: CustomFilterPanel
|
||||||
|
}}
|
||||||
|
componentsProps={{
|
||||||
|
columnsPanel:
|
||||||
|
{
|
||||||
|
tableMetaData: tableMetaData,
|
||||||
|
metaData: metaData,
|
||||||
|
initialOpenedGroups: columnChooserOpenGroups,
|
||||||
|
openGroupsChanger: setColumnChooserOpenGroups,
|
||||||
|
initialFilterText: columnChooserFilterText,
|
||||||
|
filterTextChanger: setColumnChooserFilterText
|
||||||
|
},
|
||||||
|
filterPanel:
|
||||||
|
{
|
||||||
|
tableMetaData: tableMetaData,
|
||||||
|
metaData: metaData,
|
||||||
|
queryFilter: queryFilter,
|
||||||
|
updateFilter: updateFilterFromFilterPanel
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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}
|
pinnedColumns={pinnedColumns}
|
||||||
onPinnedColumnsChange={handlePinnedColumnsChange}
|
onPinnedColumnsChange={handlePinnedColumnsChange}
|
||||||
pagination
|
pagination
|
||||||
@ -1875,12 +1923,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
|||||||
density={density}
|
density={density}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
filterModel={filterModel}
|
filterModel={filterModel}
|
||||||
onFilterModelChange={handleFilterChange}
|
onFilterModelChange={(model) => handleFilterChange(model, true, true)}
|
||||||
columnVisibilityModel={columnVisibilityModel}
|
columnVisibilityModel={columnVisibilityModel}
|
||||||
onColumnVisibilityModelChange={handleColumnVisibilityChange}
|
onColumnVisibilityModelChange={handleColumnVisibilityChange}
|
||||||
onColumnOrderChange={handleColumnOrderChange}
|
onColumnOrderChange={handleColumnOrderChange}
|
||||||
onSelectionModelChange={selectionChanged}
|
onSelectionModelChange={selectionChanged}
|
||||||
onSortModelChange={handleSortChange}
|
onSortModelChange={handleSortChangeForDataGrid}
|
||||||
sortingOrder={["asc", "desc"]}
|
sortingOrder={["asc", "desc"]}
|
||||||
sortModel={columnSortModel}
|
sortModel={columnSortModel}
|
||||||
getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")}
|
getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")}
|
||||||
|
@ -43,7 +43,7 @@ import Menu from "@mui/material/Menu";
|
|||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import Modal from "@mui/material/Modal";
|
import Modal from "@mui/material/Modal";
|
||||||
import React, {useContext, useEffect, useState} from "react";
|
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 QContext from "QContext";
|
||||||
import AuditBody from "qqq/components/audits/AuditBody";
|
import AuditBody from "qqq/components/audits/AuditBody";
|
||||||
import {QActionsMenuButton, QCancelButton, QDeleteButton, QEditButton} from "qqq/components/buttons/DefaultButtons";
|
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 BaseLayout from "qqq/layouts/BaseLayout";
|
||||||
import ProcessRun from "qqq/pages/processes/ProcessRun";
|
import ProcessRun from "qqq/pages/processes/ProcessRun";
|
||||||
import HistoryUtils from "qqq/utils/HistoryUtils";
|
import HistoryUtils from "qqq/utils/HistoryUtils";
|
||||||
|
import HtmlUtils from "qqq/utils/HtmlUtils";
|
||||||
import Client from "qqq/utils/qqq/Client";
|
import Client from "qqq/utils/qqq/Client";
|
||||||
import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
|
import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
|
||||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
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 [tableProcesses, setTableProcesses] = useState([] as QProcessMetaData[]);
|
||||||
const [allTableProcesses, setAllTableProcesses] = useState([] as QProcessMetaData[]);
|
const [allTableProcesses, setAllTableProcesses] = useState([] as QProcessMetaData[]);
|
||||||
const [actionsMenu, setActionsMenu] = useState(null);
|
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 [successMessage, setSuccessMessage] = useState(null as string);
|
||||||
const [warningMessage, setWarningMessage] = useState(null as string);
|
const [warningMessage, setWarningMessage] = useState(null as string);
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const {setPageHeader} = useContext(QContext);
|
const {setPageHeader} = useContext(QContext);
|
||||||
const [activeModalProcess, setActiveModalProcess] = useState(null as QProcessMetaData);
|
const [activeModalProcess, setActiveModalProcess] = useState(null as QProcessMetaData);
|
||||||
const [reloadCounter, setReloadCounter] = useState(0);
|
const [reloadCounter, setReloadCounter] = useState(0);
|
||||||
@ -116,6 +117,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
|||||||
{
|
{
|
||||||
setSuccessMessage(null);
|
setSuccessMessage(null);
|
||||||
setNotFoundMessage(null);
|
setNotFoundMessage(null);
|
||||||
|
setErrorMessage(null);
|
||||||
setAsyncLoadInited(false);
|
setAsyncLoadInited(false);
|
||||||
setTableMetaData(null);
|
setTableMetaData(null);
|
||||||
setRecord(null);
|
setRecord(null);
|
||||||
@ -316,6 +318,8 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
|||||||
|
|
||||||
setPageHeader(record.recordLabel);
|
setPageHeader(record.recordLabel);
|
||||||
|
|
||||||
|
if(!launchingProcess)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
HistoryUtils.push({label: `${tableMetaData?.label}: ${record.recordLabel}`, path: location.pathname, iconName: table.iconName});
|
HistoryUtils.push({label: `${tableMetaData?.label}: ${record.recordLabel}`, path: location.pathname, iconName: table.iconName});
|
||||||
@ -324,6 +328,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
|||||||
{
|
{
|
||||||
console.error("Error pushing history: " + e);
|
console.error("Error pushing history: " + e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/////////////////////////////////////////////////
|
/////////////////////////////////////////////////
|
||||||
// define the sections, e.g., for the left-bar //
|
// define the sections, e.g., for the left-bar //
|
||||||
@ -423,14 +428,26 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
|||||||
setSectionFieldElements(sectionFieldElements);
|
setSectionFieldElements(sectionFieldElements);
|
||||||
setNonT1TableSections(nonT1TableSections);
|
setNonT1TableSections(nonT1TableSections);
|
||||||
|
|
||||||
if (searchParams.get("createSuccess") || searchParams.get("updateSuccess"))
|
if(location.state)
|
||||||
{
|
{
|
||||||
setSuccessMessage(`${tableMetaData.label} successfully ${searchParams.get("createSuccess") ? "created" : "updated"}`);
|
let state: any = location.state;
|
||||||
}
|
if (state["createSuccess"] || state["updateSuccess"])
|
||||||
if (searchParams.get("warning"))
|
|
||||||
{
|
{
|
||||||
setWarningMessage(searchParams.get("warning"));
|
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 +469,25 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
|||||||
await qController.delete(tableName, id)
|
await qController.delete(tableName, id)
|
||||||
.then(() =>
|
.then(() =>
|
||||||
{
|
{
|
||||||
const path = `${pathParts.slice(0, -1).join("/")}?deleteSuccess=true`;
|
const path = pathParts.slice(0, -1).join("/");
|
||||||
navigate(path);
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
@ -500,6 +534,13 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
|||||||
Create New
|
Create New
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission &&
|
||||||
|
<MenuItem onClick={() => navigate("duplicate")}>
|
||||||
|
<ListItemIcon><Icon>copy</Icon></ListItemIcon>
|
||||||
|
Create Duplicate
|
||||||
|
</MenuItem>
|
||||||
|
}
|
||||||
{
|
{
|
||||||
table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission &&
|
table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission &&
|
||||||
<MenuItem onClick={() => navigate("edit")}>
|
<MenuItem onClick={() => navigate("edit")}>
|
||||||
@ -666,6 +707,16 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
|||||||
</Alert>
|
</Alert>
|
||||||
: ("")
|
: ("")
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
errorMessage ?
|
||||||
|
<Alert color="error" sx={{mb: 3}} onClose={() =>
|
||||||
|
{
|
||||||
|
setErrorMessage(null);
|
||||||
|
}}>
|
||||||
|
{errorMessage}
|
||||||
|
</Alert>
|
||||||
|
: ("")
|
||||||
|
}
|
||||||
|
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
<Grid item xs={12} lg={3}>
|
<Grid item xs={12} lg={3}>
|
||||||
|
@ -386,8 +386,140 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.MuiGrid-root > .MuiBox-root > .material-icons-round,
|
.big-icon .material-icons-round
|
||||||
.MuiBox-root > .MuiBox-root > .material-icons-round
|
|
||||||
{
|
{
|
||||||
font-size: 2rem !important;
|
font-size: 2rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-schedule-icon
|
||||||
|
{
|
||||||
|
font-size: 1.1rem !important;
|
||||||
|
position: relative;
|
||||||
|
top: -5px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-columns-panel .MuiSwitch-thumb
|
||||||
|
{
|
||||||
|
width: 15px !important;
|
||||||
|
height: 15px !important;
|
||||||
|
position: relative;
|
||||||
|
top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 */
|
||||||
|
.filterCriteriaRowColumnPopper .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;
|
||||||
|
color: black;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.DynamicSelectPopper ul li.MuiAutocomplete-option
|
||||||
|
{
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -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 {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||||
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
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 {GridFilterOperator} from "@mui/x-data-grid/models/gridFilterOperator";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {Link} from "react-router-dom";
|
import {Link} from "react-router-dom";
|
||||||
import {buildQGridPvsOperators, QGridBooleanOperators, QGridNumericOperators, QGridStringOperators} from "qqq/pages/records/query/GridFilterOperators";
|
import {buildQGridPvsOperators, QGridBlobOperators, QGridBooleanOperators, QGridNumericOperators, QGridStringOperators} from "qqq/pages/records/query/GridFilterOperators";
|
||||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||||
|
|
||||||
|
|
||||||
|
const emptyApplyFilterFn = (filterItem: GridFilterItem, column: GridColDef): null => null;
|
||||||
|
|
||||||
|
function NullInputComponent()
|
||||||
|
{
|
||||||
|
return (<React.Fragment />);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
export default class DataGridUtils
|
||||||
{
|
{
|
||||||
|
|
||||||
@ -97,22 +126,28 @@ export default class DataGridUtils
|
|||||||
const columns = [] as GridColDef[];
|
const columns = [] as GridColDef[];
|
||||||
this.addColumnsForTable(tableMetaData, linkBase, columns, columnSort, null, null);
|
this.addColumnsForTable(tableMetaData, linkBase, columns, columnSort, null, null);
|
||||||
|
|
||||||
|
if(metaData)
|
||||||
|
{
|
||||||
if(tableMetaData.exposedJoins)
|
if(tableMetaData.exposedJoins)
|
||||||
{
|
{
|
||||||
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
|
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
|
||||||
{
|
{
|
||||||
const join = tableMetaData.exposedJoins[i];
|
const join = tableMetaData.exposedJoins[i];
|
||||||
|
let joinTableName = join.joinTable.name;
|
||||||
let joinLinkBase = null;
|
if(metaData.tables.has(joinTableName) && metaData.tables.get(joinTableName).readPermission)
|
||||||
if(metaData)
|
|
||||||
{
|
{
|
||||||
|
let joinLinkBase = null;
|
||||||
joinLinkBase = metaData.getTablePath(join.joinTable);
|
joinLinkBase = metaData.getTablePath(join.joinTable);
|
||||||
|
if(joinLinkBase)
|
||||||
|
{
|
||||||
joinLinkBase += joinLinkBase.endsWith("/") ? "" : "/";
|
joinLinkBase += joinLinkBase.endsWith("/") ? "" : "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
if(join?.joinTable?.fields?.values())
|
if(join?.joinTable?.fields?.values())
|
||||||
{
|
{
|
||||||
this.addColumnsForTable(join.joinTable, joinLinkBase, columns, columnSort, join.joinTable.name + ".", join.label + ": ");
|
this.addColumnsForTable(join.joinTable, joinLinkBase, columns, columnSort, joinTableName + ".", join.label + ": ");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -162,6 +197,23 @@ export default class DataGridUtils
|
|||||||
sortedKeys.forEach((key) =>
|
sortedKeys.forEach((key) =>
|
||||||
{
|
{
|
||||||
const field = tableMetaData.fields.get(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);
|
const column = this.makeColumnFromField(field, tableMetaData, namePrefix, labelPrefix);
|
||||||
|
|
||||||
if(key === tableMetaData.primaryKeyField && linkBase && namePrefix == null)
|
if(key === tableMetaData.primaryKeyField && linkBase && namePrefix == null)
|
||||||
@ -182,6 +234,7 @@ export default class DataGridUtils
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
**
|
**
|
||||||
*******************************************************************************/
|
*******************************************************************************/
|
||||||
@ -214,18 +267,21 @@ export default class DataGridUtils
|
|||||||
case QFieldType.DATE:
|
case QFieldType.DATE:
|
||||||
columnType = "date";
|
columnType = "date";
|
||||||
columnWidth = 100;
|
columnWidth = 100;
|
||||||
filterOperators = getGridDateOperators();
|
filterOperators = QGridDateOperators;
|
||||||
break;
|
break;
|
||||||
case QFieldType.DATE_TIME:
|
case QFieldType.DATE_TIME:
|
||||||
columnType = "dateTime";
|
columnType = "dateTime";
|
||||||
columnWidth = 200;
|
columnWidth = 200;
|
||||||
filterOperators = getGridDateOperators(true);
|
filterOperators = QGridDateOperators;
|
||||||
break;
|
break;
|
||||||
case QFieldType.BOOLEAN:
|
case QFieldType.BOOLEAN:
|
||||||
columnType = "string"; // using boolean gives an odd 'no' for nulls.
|
columnType = "string"; // using boolean gives an odd 'no' for nulls.
|
||||||
columnWidth = 75;
|
columnWidth = 75;
|
||||||
filterOperators = QGridBooleanOperators;
|
filterOperators = QGridBooleanOperators;
|
||||||
break;
|
break;
|
||||||
|
case QFieldType.BLOB:
|
||||||
|
filterOperators = QGridBlobOperators;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// noop - leave as string
|
// noop - leave as string
|
||||||
}
|
}
|
||||||
@ -238,6 +294,7 @@ export default class DataGridUtils
|
|||||||
const widths: Map<string, number> = new Map<string, number>([
|
const widths: Map<string, number> = new Map<string, number>([
|
||||||
["small", 100],
|
["small", 100],
|
||||||
["medium", 200],
|
["medium", 200],
|
||||||
|
["medlarge", 300],
|
||||||
["large", 400],
|
["large", 400],
|
||||||
["xlarge", 600]
|
["xlarge", 600]
|
||||||
]);
|
]);
|
||||||
@ -254,7 +311,7 @@ export default class DataGridUtils
|
|||||||
let headerName = labelPrefix ? labelPrefix + field.label : field.label;
|
let headerName = labelPrefix ? labelPrefix + field.label : field.label;
|
||||||
let fieldName = namePrefix ? namePrefix + field.name : field.name;
|
let fieldName = namePrefix ? namePrefix + field.name : field.name;
|
||||||
|
|
||||||
const column = {
|
const column: GridColDef = {
|
||||||
field: fieldName,
|
field: fieldName,
|
||||||
type: columnType,
|
type: columnType,
|
||||||
headerName: headerName,
|
headerName: headerName,
|
||||||
|
132
src/qqq/utils/HtmlUtils.ts
Normal file
132
src/qqq/utils/HtmlUtils.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
/*
|
||||||
|
* 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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Client from "qqq/utils/qqq/Client";
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** 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(`<html lang="en">
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
* { font-family: "Roboto","Helvetica","Arial",sans-serif; }
|
||||||
|
</style>
|
||||||
|
<title>${filename}</title>
|
||||||
|
<script>
|
||||||
|
setTimeout(() =>
|
||||||
|
{
|
||||||
|
document.getElementById("exportForm").submit();
|
||||||
|
}, 1);
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
Opening ${filename}...
|
||||||
|
<form id="exportForm" method="post" action="${url}" >
|
||||||
|
<input type="hidden" name="Authorization" value="${Client.getInstance().getAuthorizationHeaderValue()}">
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>`);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
}
|
@ -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 {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
|
||||||
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
|
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
|
||||||
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
|
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";
|
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||||
|
|
||||||
const CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedFilterId";
|
const CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedFilterId";
|
||||||
@ -65,7 +65,7 @@ class FilterUtils
|
|||||||
return QCriteriaOperator.EQUALS;
|
return QCriteriaOperator.EQUALS;
|
||||||
case "isNot":
|
case "isNot":
|
||||||
case "!=":
|
case "!=":
|
||||||
return QCriteriaOperator.NOT_EQUALS;
|
return QCriteriaOperator.NOT_EQUALS_OR_IS_NULL;
|
||||||
case "after":
|
case "after":
|
||||||
case ">":
|
case ">":
|
||||||
return QCriteriaOperator.GREATER_THAN;
|
return QCriteriaOperator.GREATER_THAN;
|
||||||
@ -138,6 +138,7 @@ class FilterUtils
|
|||||||
return ("is");
|
return ("is");
|
||||||
}
|
}
|
||||||
case QCriteriaOperator.NOT_EQUALS:
|
case QCriteriaOperator.NOT_EQUALS:
|
||||||
|
case QCriteriaOperator.NOT_EQUALS_OR_IS_NULL:
|
||||||
|
|
||||||
if (field.possibleValueSourceName)
|
if (field.possibleValueSourceName)
|
||||||
{
|
{
|
||||||
@ -255,7 +256,7 @@ class FilterUtils
|
|||||||
}
|
}
|
||||||
else if (operator === QCriteriaOperator.IN || operator === QCriteriaOperator.NOT_IN || operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN)
|
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 //
|
// if we send back null, we get a 500 - bad look every time you try to set up a BETWEEN filter //
|
||||||
@ -263,10 +264,10 @@ class FilterUtils
|
|||||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
return ([null, null]);
|
return ([null, null]);
|
||||||
}
|
}
|
||||||
return (FilterUtils.prepFilterValuesForBackend(value, fieldMetaData));
|
return (FilterUtils.cleanseCriteriaValueForQQQ(value, fieldMetaData));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (FilterUtils.prepFilterValuesForBackend([value], fieldMetaData));
|
return (FilterUtils.cleanseCriteriaValueForQQQ([value], fieldMetaData));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -277,7 +278,7 @@ class FilterUtils
|
|||||||
**
|
**
|
||||||
** Or, if the values are date-times, convert them to UTC.
|
** 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)
|
if (param === null || param === undefined)
|
||||||
{
|
{
|
||||||
@ -290,10 +291,15 @@ class FilterUtils
|
|||||||
console.log(param[i]);
|
console.log(param[i]);
|
||||||
if (param[i] && param[i].id && param[i].label)
|
if (param[i] && param[i].id && param[i].label)
|
||||||
{
|
{
|
||||||
/////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////////////////////////
|
||||||
// if the param looks like a possible value, return its id //
|
// if the param looks like a possible value, return its id //
|
||||||
/////////////////////////////////////////////////////////////
|
// during build of new custom filter panel, this ended up causing us //
|
||||||
rs.push(param[i].id);
|
// 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
|
else
|
||||||
{
|
{
|
||||||
@ -463,7 +469,16 @@ class FilterUtils
|
|||||||
amount = -amount;
|
amount = -amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/////////////////////////////////////////////
|
||||||
|
// shift the date/time by the input amount //
|
||||||
|
/////////////////////////////////////////////
|
||||||
value.setTime(value.getTime() + 1000 * 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)];
|
values = [ValueUtils.formatDateTimeISO8601(value)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -473,6 +488,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({
|
defaultFilter.items.push({
|
||||||
columnField: criteria.fieldName,
|
columnField: criteria.fieldName,
|
||||||
operatorValue: FilterUtils.qqqCriteriaOperatorToGrid(criteria.operator, field, values),
|
operatorValue: FilterUtils.qqqCriteriaOperatorToGrid(criteria.operator, field, values),
|
||||||
@ -537,10 +599,67 @@ 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
|
** 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("Building q filter with model:");
|
||||||
console.log(filterModel);
|
console.log(filterModel);
|
||||||
@ -580,13 +699,15 @@ class FilterUtils
|
|||||||
////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
// if no value set and not 'empty' or 'not empty' operators, skip this filter //
|
// 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")
|
||||||
|
{
|
||||||
|
if (!allowIncompleteCriteria)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var fieldMetadata = tableMetaData?.fields.get(item.columnField);
|
const fieldMetadata = tableMetaData?.fields.get(item.columnField);
|
||||||
|
|
||||||
const operator = FilterUtils.gridCriteriaOperatorToQQQ(item.operatorValue);
|
const operator = FilterUtils.gridCriteriaOperatorToQQQ(item.operatorValue);
|
||||||
const values = FilterUtils.gridCriteriaValueToQQQ(operator, item.value, item.operatorValue, fieldMetadata);
|
const values = FilterUtils.gridCriteriaValueToQQQ(operator, item.value, item.operatorValue, fieldMetadata);
|
||||||
qFilter.addCriteria(new QFilterCriteria(item.columnField, operator, values));
|
qFilter.addCriteria(new QFilterCriteria(item.columnField, operator, values));
|
||||||
@ -606,6 +727,37 @@ class FilterUtils
|
|||||||
return qFilter;
|
return qFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** 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);
|
||||||
|
|
||||||
|
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;
|
export default FilterUtils;
|
||||||
|
@ -25,15 +25,21 @@ import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QField
|
|||||||
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
|
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||||
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
||||||
import "datejs"; // https://github.com/datejs/Datejs
|
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 Button from "@mui/material/Button";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
|
import {makeStyles} from "@mui/styles";
|
||||||
import parse from "html-react-parser";
|
import parse from "html-react-parser";
|
||||||
import React, {Fragment, useReducer, useState} from "react";
|
import React, {Fragment, useReducer, useState} from "react";
|
||||||
import AceEditor from "react-ace";
|
import AceEditor from "react-ace";
|
||||||
import {Link} from "react-router-dom";
|
import {Link} from "react-router-dom";
|
||||||
|
import HtmlUtils from "qqq/utils/HtmlUtils";
|
||||||
import Client from "qqq/utils/qqq/Client";
|
import Client from "qqq/utils/qqq/Client";
|
||||||
|
|
||||||
|
import "ace-builds/src-noconflict/mode-sql";
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
** Utility class for working with QQQ Values
|
** Utility class for working with QQQ Values
|
||||||
**
|
**
|
||||||
@ -191,6 +197,11 @@ class ValueUtils
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field.type == QFieldType.BLOB)
|
||||||
|
{
|
||||||
|
return (<BlobComponent field={field} url={rawValue} filename={displayValue} usage={usage} />);
|
||||||
|
}
|
||||||
|
|
||||||
return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue));
|
return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -272,6 +283,24 @@ class ValueUtils
|
|||||||
return (`${date.toString("yyyy-MM-ddTHH:mm:ssZ")}`);
|
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}`);
|
||||||
|
const d = new Date();
|
||||||
|
const dateString = `${d.getFullYear()}-${zp(d.getMonth() + 1)}-${zp(d.getDate())} ${zp(d.getHours())}${zp(d.getMinutes())}`;
|
||||||
|
return (dateString);
|
||||||
|
}
|
||||||
|
|
||||||
public static getFullWeekday(date: Date)
|
public static getFullWeekday(date: Date)
|
||||||
{
|
{
|
||||||
if (!(date instanceof Date))
|
if (!(date instanceof Date))
|
||||||
@ -352,7 +381,7 @@ class ValueUtils
|
|||||||
//////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////
|
||||||
return (value + "T00:00");
|
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 //
|
// If the passed in string has a Z on the end (e.g., in UTC) - make a Date object - the browser will //
|
||||||
@ -409,6 +438,19 @@ class ValueUtils
|
|||||||
return toPush;
|
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, "\"\""));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
@ -422,7 +464,12 @@ function CodeViewer({name, mode, code}: {name: string; mode: string; code: strin
|
|||||||
const [errorMessage, setErrorMessage] = useState(null as string);
|
const [errorMessage, setErrorMessage] = useState(null as string);
|
||||||
const [resetErrorTimeout, setResetErrorTimeout] = useState(null as any);
|
const [resetErrorTimeout, setResetErrorTimeout] = useState(null as any);
|
||||||
|
|
||||||
const formatJson = () =>
|
const isFormattable = (mode: string): boolean =>
|
||||||
|
{
|
||||||
|
return (mode === "json" || mode === "sql");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCode = () =>
|
||||||
{
|
{
|
||||||
if (isFormatted)
|
if (isFormatted)
|
||||||
{
|
{
|
||||||
@ -433,19 +480,44 @@ function CodeViewer({name, mode, code}: {name: string; mode: string; code: strin
|
|||||||
{
|
{
|
||||||
try
|
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);
|
setActiveCode(formatted);
|
||||||
setIsFormatted(true);
|
setIsFormatted(true);
|
||||||
}
|
}
|
||||||
catch (e)
|
catch (e)
|
||||||
{
|
{
|
||||||
setErrorMessage("Error formatting json: " + e);
|
setErrorMessage("Error formatting code: " + e);
|
||||||
clearTimeout(resetErrorTimeout);
|
clearTimeout(resetErrorTimeout);
|
||||||
setResetErrorTimeout(setTimeout(() =>
|
setResetErrorTimeout(setTimeout(() =>
|
||||||
{
|
{
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
}, 5000));
|
}, 5000));
|
||||||
console.log("Error formatting json: " + e);
|
console.log("Error formatting code: " + e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -457,7 +529,7 @@ function CodeViewer({name, mode, code}: {name: string; mode: string; code: strin
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box component="span">
|
<Box component="span">
|
||||||
{mode == "json" && code && <Button onClick={() => formatJson()}>{isFormatted ? "Reset Format" : "Format JSON"}</Button>}
|
{isFormattable(mode) && code && <Button onClick={() => formatCode()}>{isFormatted ? "Reset Format" : `Format ${mode.toUpperCase()}`}</Button>}
|
||||||
{code && <Button onClick={() => toggleSize()}>{isExpanded ? "Collapse" : "Expand"}</Button>}
|
{code && <Button onClick={() => toggleSize()}>{isExpanded ? "Collapse" : "Expand"}</Button>}
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
<br />
|
<br />
|
||||||
@ -478,9 +550,9 @@ function CodeViewer({name, mode, code}: {name: string; mode: string; code: strin
|
|||||||
</Box>);
|
</Box>);
|
||||||
}
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
// 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
|
function RevealComponent({fieldName, value, usage}: {fieldName: string, value: string, usage: string;}): JSX.Element
|
||||||
{
|
{
|
||||||
const [adornmentFieldsMap, setAdornmentFieldsMap] = useState(new Map<string, boolean>);
|
const [adornmentFieldsMap, setAdornmentFieldsMap] = useState(new Map<string, boolean>);
|
||||||
@ -520,20 +592,18 @@ function RevealComponent({fieldName, value, usage}: {fieldName: string, value: s
|
|||||||
{
|
{
|
||||||
displayValue && (
|
displayValue && (
|
||||||
adornmentFieldsMap.get(fieldName) === true ? (
|
adornmentFieldsMap.get(fieldName) === true ? (
|
||||||
<Box>
|
<Box component="span">
|
||||||
<Icon onClick={(e) => handleRevealIconClick(e, fieldName)} sx={{cursor: "pointer", fontSize: "15px !important", position: "relative", top: "3px", marginRight: "5px"}}>visibility_on</Icon>
|
<Icon onClick={(e) => handleRevealIconClick(e, fieldName)} sx={{cursor: "pointer", fontSize: "15px !important", position: "relative", top: "3px", marginRight: "5px"}}>visibility_on</Icon>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
<ClickAwayListener onClickAway={handleTooltipClose}>
|
<ClickAwayListener onClickAway={handleTooltipClose}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
sx={{zIndex: 1000}}
|
sx={{zIndex: 1000, border: "1px solid red"}}
|
||||||
PopperProps={{
|
|
||||||
disablePortal: true,
|
|
||||||
}}
|
|
||||||
onClose={handleTooltipClose}
|
onClose={handleTooltipClose}
|
||||||
open={tooltipOpen}
|
open={tooltipOpen}
|
||||||
disableFocusListener
|
disableFocusListener
|
||||||
disableHoverListener
|
disableHoverListener
|
||||||
disableTouchListener
|
disableTouchListener
|
||||||
|
placement="right"
|
||||||
title="Copied To Clipboard"
|
title="Copied To Clipboard"
|
||||||
>
|
>
|
||||||
<Icon onClick={(e) => copyToClipboard(e, value)} sx={{cursor: "pointer", fontSize: "15px !important", position: "relative", top: "3px", marginLeft: "5px"}}>copy</Icon>
|
<Icon onClick={(e) => copyToClipboard(e, value)} sx={{cursor: "pointer", fontSize: "15px !important", position: "relative", top: "3px", marginLeft: "5px"}}>copy</Icon>
|
||||||
@ -541,7 +611,7 @@ function RevealComponent({fieldName, value, usage}: {fieldName: string, value: s
|
|||||||
</ClickAwayListener>
|
</ClickAwayListener>
|
||||||
</Box>
|
</Box>
|
||||||
):(
|
):(
|
||||||
<Box><Icon onClick={(e) => handleRevealIconClick(e, fieldName)} sx={{cursor: "pointer", fontSize: "15px !important", position: "relative", top: "3px", marginRight: "5px"}}>visibility_off</Icon>{displayValue}</Box>
|
<Box display="inline"><Icon onClick={(e) => handleRevealIconClick(e, fieldName)} sx={{cursor: "pointer", fontSize: "15px !important", position: "relative", top: "3px", marginRight: "5px"}}>visibility_off</Icon>{displayValue}</Box>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -550,5 +620,59 @@ function RevealComponent({fieldName, value, usage}: {fieldName: string, value: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface BlobComponentProps
|
||||||
|
{
|
||||||
|
field: QFieldMetaData;
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
usage: "view" | "query";
|
||||||
|
}
|
||||||
|
|
||||||
|
BlobComponent.defaultProps = {
|
||||||
|
usage: "view",
|
||||||
|
};
|
||||||
|
|
||||||
|
function BlobComponent({field, url, filename, usage}: BlobComponentProps): JSX.Element
|
||||||
|
{
|
||||||
|
const download = (event: React.MouseEvent<HTMLSpanElement>) =>
|
||||||
|
{
|
||||||
|
event.stopPropagation();
|
||||||
|
HtmlUtils.downloadUrlViaIFrame(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = (event: React.MouseEvent<HTMLSpanElement>) =>
|
||||||
|
{
|
||||||
|
event.stopPropagation();
|
||||||
|
HtmlUtils.openInNewWindow(url, filename);
|
||||||
|
};
|
||||||
|
|
||||||
|
if(!filename || !url)
|
||||||
|
{
|
||||||
|
return (<React.Fragment />);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipPlacement = usage == "view" ? "bottom" : "right";
|
||||||
|
|
||||||
|
// todo - thumbnails if adorned?
|
||||||
|
// challenge is - must post (for auth header)...
|
||||||
|
return (
|
||||||
|
<Box display="inline-flex">
|
||||||
|
{
|
||||||
|
usage == "view" && filename
|
||||||
|
}
|
||||||
|
<Tooltip placement={tooltipPlacement} title="Open file">
|
||||||
|
<Icon className={"blobIcon"} fontSize="small" onClick={(e) => open(e)}>open_in_new</Icon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip placement={tooltipPlacement} title="Download file">
|
||||||
|
<Icon className={"blobIcon"} fontSize="small" onClick={(e) => download(e)}>save_alt</Icon>
|
||||||
|
</Tooltip>
|
||||||
|
{
|
||||||
|
usage == "query" && filename
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default ValueUtils;
|
export default ValueUtils;
|
||||||
|
@ -74,7 +74,8 @@ public class QBaseSeleniumTest
|
|||||||
.withRouteToFile("/metaData", "metaData/index.json")
|
.withRouteToFile("/metaData", "metaData/index.json")
|
||||||
.withRouteToFile("/metaData/authentication", "metaData/authentication.json")
|
.withRouteToFile("/metaData/authentication", "metaData/authentication.json")
|
||||||
.withRouteToFile("/metaData/table/person", "metaData/table/person.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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -10,5 +10,5 @@ public interface QQQMaterialDashboardSelectors
|
|||||||
String BREADCRUMB_HEADER = ".MuiToolbar-root h5";
|
String BREADCRUMB_HEADER = ".MuiToolbar-root h5";
|
||||||
|
|
||||||
String QUERY_GRID_CELL = ".MuiDataGrid-root .MuiDataGrid-cellContent";
|
String QUERY_GRID_CELL = ".MuiDataGrid-root .MuiDataGrid-cellContent";
|
||||||
String QUERY_FILTER_INPUT = ".MuiDataGrid-filterForm input.MuiInput-input";
|
String QUERY_FILTER_INPUT = ".customFilterPanel input.MuiInput-input";
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import org.apache.commons.io.FileUtils;
|
|||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
import org.openqa.selenium.By;
|
import org.openqa.selenium.By;
|
||||||
|
import org.openqa.selenium.JavascriptExecutor;
|
||||||
import org.openqa.selenium.OutputType;
|
import org.openqa.selenium.OutputType;
|
||||||
import org.openqa.selenium.StaleElementReferenceException;
|
import org.openqa.selenium.StaleElementReferenceException;
|
||||||
import org.openqa.selenium.WebDriver;
|
import org.openqa.selenium.WebDriver;
|
||||||
@ -115,6 +116,26 @@ public class QSeleniumLib
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
public void waitForMillis(int n)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
new WebDriverWait(driver, Duration.ofMillis(n))
|
||||||
|
.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(".wontEverBePresent")));
|
||||||
|
}
|
||||||
|
catch(Exception e)
|
||||||
|
{
|
||||||
|
///////////////////
|
||||||
|
// okay, resume. //
|
||||||
|
///////////////////
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
**
|
**
|
||||||
*******************************************************************************/
|
*******************************************************************************/
|
||||||
@ -261,6 +282,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
|
@FunctionalInterface
|
||||||
public interface Code<T>
|
public interface Code<T>
|
||||||
{
|
{
|
||||||
|
@ -3,10 +3,11 @@ package com.kingsrook.qqq.materialdashboard.lib.javalin;
|
|||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import com.kingsrook.qqq.materialdashboard.lib.QSeleniumLib;
|
import com.kingsrook.qqq.materialdashboard.lib.QSeleniumLib;
|
||||||
import io.javalin.Javalin;
|
import io.javalin.Javalin;
|
||||||
import org.apache.commons.lang3.tuple.Pair;
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
import org.eclipse.jetty.server.Connector;
|
import org.eclipse.jetty.server.Connector;
|
||||||
@ -25,8 +26,8 @@ public class QSeleniumJavalin
|
|||||||
|
|
||||||
private long WAIT_SECONDS = 10;
|
private long WAIT_SECONDS = 10;
|
||||||
|
|
||||||
private List<Pair<String, String>> routesToFiles = new ArrayList<>();
|
private Map<String, String> routesToFiles = new LinkedHashMap<>();
|
||||||
private List<Pair<String, String>> routesToStrings = new ArrayList<>();
|
private Map<String, String> routesToStrings = new LinkedHashMap<>();
|
||||||
|
|
||||||
private Javalin javalin;
|
private Javalin javalin;
|
||||||
|
|
||||||
@ -71,9 +72,9 @@ public class QSeleniumJavalin
|
|||||||
{
|
{
|
||||||
if(this.routesToFiles == null)
|
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);
|
return (this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,9 +87,9 @@ public class QSeleniumJavalin
|
|||||||
{
|
{
|
||||||
if(this.routesToStrings == null)
|
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);
|
return (this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,11 +106,11 @@ public class QSeleniumJavalin
|
|||||||
{
|
{
|
||||||
javalin.routes(() ->
|
javalin.routes(() ->
|
||||||
{
|
{
|
||||||
for(Pair<String, String> routeToFile : routesToFiles)
|
for(Map.Entry<String, String> routeToFile : routesToFiles.entrySet())
|
||||||
{
|
{
|
||||||
LOG.debug("Setting up route for [" + routeToFile.getKey() + "] => [" + routeToFile.getValue() + "]");
|
LOG.debug("Setting up route for [" + routeToFile.getKey() + "] => [" + routeToFile.getValue() + "]");
|
||||||
get(routeToFile.getKey(), new RouteFromFileHandler(this, routeToFile));
|
get(routeToFile.getKey(), new RouteFromFileHandler(this, routeToFile.getKey(), routeToFile.getValue()));
|
||||||
post(routeToFile.getKey(), new RouteFromFileHandler(this, routeToFile));
|
post(routeToFile.getKey(), new RouteFromFileHandler(this, routeToFile.getKey(), routeToFile.getValue()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -118,11 +119,11 @@ public class QSeleniumJavalin
|
|||||||
{
|
{
|
||||||
javalin.routes(() ->
|
javalin.routes(() ->
|
||||||
{
|
{
|
||||||
for(Pair<String, String> routeToString : routesToStrings)
|
for(Map.Entry<String, String> routeToString : routesToStrings.entrySet())
|
||||||
{
|
{
|
||||||
LOG.debug("Setting up route for [" + routeToString.getKey() + "] => [" + routeToString.getValue() + "]");
|
LOG.debug("Setting up route for [" + routeToString.getKey() + "] => [" + routeToString.getValue() + "]");
|
||||||
get(routeToString.getKey(), new RouteFromStringHandler(this, routeToString));
|
get(routeToString.getKey(), new RouteFromStringHandler(this, routeToString.getKey(), routeToString.getValue()));
|
||||||
post(routeToString.getKey(), new RouteFromStringHandler(this, routeToString));
|
post(routeToString.getKey(), new RouteFromStringHandler(this, routeToString.getKey(), routeToString.getValue()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,6 @@ import java.util.List;
|
|||||||
import io.javalin.http.Context;
|
import io.javalin.http.Context;
|
||||||
import io.javalin.http.Handler;
|
import io.javalin.http.Handler;
|
||||||
import org.apache.commons.io.IOUtils;
|
import org.apache.commons.io.IOUtils;
|
||||||
import org.apache.commons.lang3.tuple.Pair;
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
@ -28,11 +27,11 @@ public class RouteFromFileHandler implements Handler
|
|||||||
** Constructor
|
** Constructor
|
||||||
**
|
**
|
||||||
*******************************************************************************/
|
*******************************************************************************/
|
||||||
public RouteFromFileHandler(QSeleniumJavalin qSeleniumJavalin, Pair<String, String> routeToFilePath)
|
public RouteFromFileHandler(QSeleniumJavalin qSeleniumJavalin, String route, String filePath)
|
||||||
{
|
{
|
||||||
this.qSeleniumJavalin = qSeleniumJavalin;
|
this.qSeleniumJavalin = qSeleniumJavalin;
|
||||||
this.route = routeToFilePath.getKey();
|
this.route = route;
|
||||||
this.filePath = routeToFilePath.getValue();
|
this.filePath = filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -3,7 +3,6 @@ package com.kingsrook.qqq.materialdashboard.lib.javalin;
|
|||||||
|
|
||||||
import io.javalin.http.Context;
|
import io.javalin.http.Context;
|
||||||
import io.javalin.http.Handler;
|
import io.javalin.http.Handler;
|
||||||
import org.apache.commons.lang3.tuple.Pair;
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
@ -25,11 +24,11 @@ public class RouteFromStringHandler implements Handler
|
|||||||
** Constructor
|
** Constructor
|
||||||
**
|
**
|
||||||
*******************************************************************************/
|
*******************************************************************************/
|
||||||
public RouteFromStringHandler(QSeleniumJavalin qSeleniumJavalin, Pair<String, String> routeToStringPath)
|
public RouteFromStringHandler(QSeleniumJavalin qSeleniumJavalin, String route, String responseString)
|
||||||
{
|
{
|
||||||
this.qSeleniumJavalin = qSeleniumJavalin;
|
this.qSeleniumJavalin = qSeleniumJavalin;
|
||||||
this.route = routeToStringPath.getKey();
|
this.route = route;
|
||||||
this.responseString = routeToStringPath.getValue();
|
this.responseString = responseString;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
115
src/test/java/com/kingsrook/qqq/materialdashboard/tests/BulkEditTest.java
Executable file
115
src/test/java/com/kingsrook/qqq/materialdashboard/tests/BulkEditTest.java
Executable file
@ -0,0 +1,115 @@
|
|||||||
|
/*
|
||||||
|
* 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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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", "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -29,8 +29,8 @@ import com.kingsrook.qqq.materialdashboard.lib.javalin.CapturedContext;
|
|||||||
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
|
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.openqa.selenium.By;
|
import org.openqa.selenium.By;
|
||||||
|
import org.openqa.selenium.Keys;
|
||||||
import org.openqa.selenium.WebElement;
|
import org.openqa.selenium.WebElement;
|
||||||
import org.openqa.selenium.support.ui.Select;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
|
||||||
@ -49,7 +49,8 @@ public class QueryScreenTest extends QBaseSeleniumTest
|
|||||||
super.addJavalinRoutes(qSeleniumJavalin);
|
super.addJavalinRoutes(qSeleniumJavalin);
|
||||||
qSeleniumJavalin
|
qSeleniumJavalin
|
||||||
.withRouteToFile("/data/person/count", "data/person/count.json")
|
.withRouteToFile("/data/person/count", "data/person/count.json")
|
||||||
.withRouteToFile("/data/person/query", "data/person/index.json");
|
.withRouteToFile("/data/person/query", "data/person/index.json")
|
||||||
|
.withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -62,15 +63,18 @@ public class QueryScreenTest extends QBaseSeleniumTest
|
|||||||
{
|
{
|
||||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
|
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
|
||||||
qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL);
|
qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL);
|
||||||
qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filters").click();
|
qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click();
|
||||||
|
|
||||||
/////////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////////
|
||||||
// open the filter window, enter a value, wait for query to re-run //
|
// open the filter window, enter a value, wait for query to re-run //
|
||||||
/////////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////////
|
||||||
WebElement filterInput = qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_FILTER_INPUT);
|
WebElement filterInput = qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_FILTER_INPUT);
|
||||||
qSeleniumLib.waitForElementToHaveFocus(filterInput);
|
qSeleniumLib.waitForElementToHaveFocus(filterInput);
|
||||||
|
filterInput.sendKeys("id");
|
||||||
|
filterInput.sendKeys("\t");
|
||||||
|
driver.switchTo().activeElement().sendKeys("\t");
|
||||||
qSeleniumJavalin.beginCapture();
|
qSeleniumJavalin.beginCapture();
|
||||||
filterInput.sendKeys("1");
|
driver.switchTo().activeElement().sendKeys("1");
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////
|
||||||
// assert that query & count both have the expected filter value //
|
// assert that query & count both have the expected filter value //
|
||||||
@ -117,10 +121,10 @@ public class QueryScreenTest extends QBaseSeleniumTest
|
|||||||
{
|
{
|
||||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
|
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
|
||||||
qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL);
|
qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL);
|
||||||
qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filters").click();
|
qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click();
|
||||||
|
|
||||||
addQueryFilterInput(qSeleniumLib, 0, "First Name", "contains", "Dar", "Or");
|
|
||||||
qSeleniumJavalin.beginCapture();
|
qSeleniumJavalin.beginCapture();
|
||||||
|
addQueryFilterInput(qSeleniumLib, 0, "First Name", "contains", "Dar", "Or");
|
||||||
addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or");
|
addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or");
|
||||||
|
|
||||||
String expectedFilterContents0 = """
|
String expectedFilterContents0 = """
|
||||||
@ -145,27 +149,43 @@ public class QueryScreenTest extends QBaseSeleniumTest
|
|||||||
{
|
{
|
||||||
if(index > 0)
|
if(index > 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)
|
if(index == 1)
|
||||||
{
|
{
|
||||||
Select linkOperatorSelect = new Select(subFormForField.findElement(By.cssSelector(".MuiDataGrid-filterFormLinkOperatorInput SELECT")));
|
WebElement booleanOperatorInput = subFormForField.findElement(By.cssSelector(".booleanOperatorColumn .MuiInput-input"));
|
||||||
linkOperatorSelect.selectByVisibleText(booleanOperator);
|
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")));
|
WebElement fieldInput = subFormForField.findElement(By.cssSelector(".fieldColumn INPUT"));
|
||||||
fieldSelect.selectByVisibleText(fieldLabel);
|
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")));
|
WebElement operatorInput = subFormForField.findElement(By.cssSelector(".operatorColumn INPUT"));
|
||||||
operatorSelect.selectByVisibleText(operator);
|
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.click();
|
||||||
valueInput.sendKeys(value);
|
valueInput.sendKeys(value);
|
||||||
qSeleniumLib.waitForSeconds(1);
|
qSeleniumLib.waitForMillis(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -27,7 +27,6 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
|
import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
|
||||||
import com.kingsrook.qqq.materialdashboard.lib.javalin.CapturedContext;
|
import com.kingsrook.qqq.materialdashboard.lib.javalin.CapturedContext;
|
||||||
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
|
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
|
||||||
import org.junit.jupiter.api.Disabled;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.openqa.selenium.By;
|
import org.openqa.selenium.By;
|
||||||
import static com.kingsrook.qqq.materialdashboard.tests.QueryScreenTest.addQueryFilterInput;
|
import static com.kingsrook.qqq.materialdashboard.tests.QueryScreenTest.addQueryFilterInput;
|
||||||
@ -47,7 +46,6 @@ public class SavedFiltersTest extends QBaseSeleniumTest
|
|||||||
protected void addJavalinRoutes(QSeleniumJavalin qSeleniumJavalin)
|
protected void addJavalinRoutes(QSeleniumJavalin qSeleniumJavalin)
|
||||||
{
|
{
|
||||||
addStandardRoutesForThisTest(qSeleniumJavalin);
|
addStandardRoutesForThisTest(qSeleniumJavalin);
|
||||||
qSeleniumJavalin.withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -69,7 +67,6 @@ public class SavedFiltersTest extends QBaseSeleniumTest
|
|||||||
**
|
**
|
||||||
*******************************************************************************/
|
*******************************************************************************/
|
||||||
@Test
|
@Test
|
||||||
@Disabled
|
|
||||||
void testNavigatingBackAndForth()
|
void testNavigatingBackAndForth()
|
||||||
{
|
{
|
||||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
|
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
|
||||||
@ -95,10 +92,8 @@ public class SavedFiltersTest extends QBaseSeleniumTest
|
|||||||
//////////////////////////////
|
//////////////////////////////
|
||||||
// click into a view screen //
|
// 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...
|
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");
|
qSeleniumLib.waitForSelectorContaining("H5", "Viewing Person: John Doe");
|
||||||
|
|
||||||
/////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////
|
||||||
@ -113,7 +108,7 @@ public class SavedFiltersTest extends QBaseSeleniumTest
|
|||||||
//////////////////////
|
//////////////////////
|
||||||
// modify the query //
|
// 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");
|
addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or");
|
||||||
qSeleniumLib.waitForSelectorContaining("H5", "Person").click();
|
qSeleniumLib.waitForSelectorContaining("H5", "Person").click();
|
||||||
qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People")
|
qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People")
|
||||||
@ -123,7 +118,7 @@ public class SavedFiltersTest extends QBaseSeleniumTest
|
|||||||
//////////////////////////////
|
//////////////////////////////
|
||||||
// click into a view screen //
|
// 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");
|
qSeleniumLib.waitForSelectorContaining("H5", "Viewing Person: John Doe");
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
@ -162,7 +157,7 @@ public class SavedFiltersTest extends QBaseSeleniumTest
|
|||||||
//////////////////////////////
|
//////////////////////////////
|
||||||
// click into a view screen //
|
// 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");
|
qSeleniumLib.waitForSelectorContaining("H5", "Viewing Person: John Doe");
|
||||||
|
|
||||||
/////////////////////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////////////////////
|
||||||
|
@ -1,11 +1,23 @@
|
|||||||
{
|
{
|
||||||
"values": {
|
"values": {
|
||||||
"firstName": "Kahhhhn",
|
"transactionLevel": "process",
|
||||||
"valuesBeingUpdated": "First Name will be set to: Kahhhhn",
|
"tableName": "person",
|
||||||
"bulkEditEnabledFields": "firstName",
|
|
||||||
"recordsParam": "recordIds",
|
"recordsParam": "recordIds",
|
||||||
|
"supportsFullValidation": true,
|
||||||
"recordIds": "1,2,3,4,5",
|
"recordIds": "1,2,3,4,5",
|
||||||
"queryFilterJSON": "{\"criteria\":[{\"fieldName\":\"id\",\"operator\":\"IN\",\"values\":[\"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",
|
"processUUID": "74a03a7d-2f53-4784-9911-3a21f7646c43",
|
||||||
"nextStep": "review"
|
"nextStep": "review"
|
||||||
|
@ -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"
|
||||||
|
}
|
@ -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"
|
||||||
|
}
|
Reference in New Issue
Block a user