Merge branch 'dev' into feature/breadcrumb-labels

This commit is contained in:
2023-06-23 16:52:06 -05:00
49 changed files with 6928 additions and 2750 deletions

23
.circleci/adjust-pom-version.sh Executable file
View 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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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>

View File

@ -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}

View File

@ -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>;

View File

@ -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

View File

@ -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>

View File

@ -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}

View File

@ -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>

View File

@ -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={

View File

@ -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)
{ {

View 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&nbsp;
<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>
);
}
);

View 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>
);
}
);

View 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;

View 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>
);
}

View 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;

View File

@ -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."});

View File

@ -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}

View File

@ -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;

View File

@ -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}
> >

View 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;

View File

@ -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))}

View 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;

File diff suppressed because one or more lines are too long

View File

@ -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"}

View File

@ -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;

View File

@ -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 ?? <>&nbsp;</>} {statusString ?? <>&nbsp;</>}
</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}>

View File

@ -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 //

View File

@ -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")}

View File

@ -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}>

View File

@ -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;
}

View File

@ -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
View 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>`);
};
}

View File

@ -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;

View File

@ -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;

View File

@ -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");
} }

View File

@ -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";
} }

View File

@ -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>
{ {

View File

@ -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()));
} }
}); });
} }

View File

@ -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;
} }

View File

@ -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;
} }

View 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();
}
}

View File

@ -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);
} }
} }

View File

@ -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");
///////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////

View File

@ -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"

View File

@ -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"
}

View File

@ -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"
}