diff --git a/pom.xml b/pom.xml index 22615eb..e8cb365 100644 --- a/pom.xml +++ b/pom.xml @@ -29,7 +29,7 @@ jar - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT UTF-8 UTF-8 diff --git a/src/App.tsx b/src/App.tsx index ffcd159..b73376c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -309,7 +309,14 @@ export default function App() name: `${app.label}`, key: `${app.name}.edit`, route: `${path}/:id/edit`, - component: , + component: , + }); + + routeList.push({ + name: `${app.label}`, + key: `${app.name}.duplicate`, + route: `${path}/:id/duplicate`, + component: , }); routeList.push({ diff --git a/src/qqq/components/forms/ChipTextField.tsx b/src/qqq/components/forms/ChipTextField.tsx index 5c2e9b0..ed79d99 100644 --- a/src/qqq/components/forms/ChipTextField.tsx +++ b/src/qqq/components/forms/ChipTextField.tsx @@ -118,12 +118,12 @@ function ChipTextField({...props}) return (
+
{ chips.map((item, i) => ( ([]); - const [ searchTerm, setSearchTerm ] = useState(null); - const [ firstRender, setFirstRender ] = useState(true); + const [open, setOpen] = useState(false); + const [options, setOptions] = useState([]); + const [searchTerm, setSearchTerm] = useState(null); + const [firstRender, setFirstRender] = useState(true); //////////////////////////////////////////////////////////////////////////////////////////////// // default value - needs to be an array (from initialValues (array) prop) for multiple mode - // // else non-multiple, assume we took in an initialValue (id) and initialDisplayValue (label), // // and build a little object that looks like a possibleValue out of those // //////////////////////////////////////////////////////////////////////////////////////////////// - const [defaultValue, _] = isMultiple ? useState(initialValues ?? undefined) + let [defaultValue, _] = isMultiple ? useState(initialValues ?? undefined) : useState(initialValue && initialDisplayValue ? [{id: initialValue, label: initialDisplayValue}] : null); + if (isMultiple && defaultValue === null) + { + defaultValue = []; + } + // const loading = open && options.length === 0; const [loading, setLoading] = useState(false); - const [ switchChecked, setSwitchChecked ] = useState(false); - const [ isDisabled, setIsDisabled ] = useState(!isEditable || bulkEditMode); + const [switchChecked, setSwitchChecked] = useState(false); + const [isDisabled, setIsDisabled] = useState(!isEditable || bulkEditMode); const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); let setFieldValueRef: (field: string, value: any, shouldValidate?: boolean) => void = null; - if(inForm) + if (inForm) { const {setFieldValue} = useFormikContext(); setFieldValueRef = setFieldValue; @@ -239,9 +245,11 @@ function DynamicSelect({tableName, processName, fieldName, fieldLabel, inForm, i bulkEditSwitchChangeHandler(fieldName, newSwitchValue); }; + // console.log(`default value: ${JSON.stringify(defaultValue)}`); + const autocomplete = ( ( void; defaultValues: { [key: string]: string }; disabledFields: { [key: string]: boolean } | string[]; + isDuplicate?: boolean; } EntityForm.defaultProps = { @@ -63,6 +64,7 @@ EntityForm.defaultProps = { closeModalHandler: null, defaultValues: {}, disabledFields: {}, + isDuplicate: false }; function EntityForm(props: Props): JSX.Element @@ -133,6 +135,15 @@ function EntityForm(props: Props): JSX.Element for (let i = 0; i < formFields.length; i++) { formData.formFields[formFields[i].name] = formFields[i]; + + if (formFields[i].possibleValueProps) + { + formFields[i].possibleValueProps.otherValues = formFields[i].possibleValueProps.otherValues ?? new Map(); + Object.keys(formFields).forEach((otherKey) => + { + formFields[i].possibleValueProps.otherValues.set(otherKey, values[otherKey]); + }); + } } if (!Object.keys(formFields).length) @@ -164,24 +175,30 @@ function EntityForm(props: Props): JSX.Element fieldArray.push(fieldMetaData); }); - ///////////////////////////////////////////////////////////////////////////////// - // if doing an edit, fetch the record and pre-populate the form values from it // - ///////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////// + // if doing an edit or duplicate, fetch the record and pre-populate the form values from it // + ////////////////////////////////////////////////////////////////////////////////////////////// let record: QRecord = null; let defaultDisplayValues = new Map(); if (props.id !== null) { record = await qController.get(tableName, props.id); setRecord(record); - setFormTitle(`Edit ${tableMetaData?.label}: ${record?.recordLabel}`); + + const titleVerb = props.isDuplicate ? "Duplicate" : "Edit"; + setFormTitle(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`); if (!props.isModal) { - setPageHeader(`Edit ${tableMetaData?.label}: ${record?.recordLabel}`); + setPageHeader(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`); } tableMetaData.fields.forEach((fieldMetaData, key) => { + if (props.isDuplicate && fieldMetaData.name == tableMetaData.primaryKeyField) + { + return; + } initialValues[key] = record.values.get(key); }); @@ -206,15 +223,6 @@ function EntityForm(props: Props): JSX.Element setPageHeader(`Creating New ${tableMetaData?.label}`); } - if (!tableMetaData.capabilities.has(Capability.TABLE_INSERT)) - { - setNotAllowedError("Records may not be created in this table"); - } - else if (!tableMetaData.insertPermission) - { - setNotAllowedError(`You do not have permission to create ${tableMetaData.label} records`); - } - //////////////////////////////////////////////////////////////////////////////////////////////// // if default values were supplied for a new record, then populate initialValues, for formik. // //////////////////////////////////////////////////////////////////////////////////////////////// @@ -245,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 // ///////////////////////////////////////////////////////////////////// @@ -307,11 +341,11 @@ function EntityForm(props: Props): JSX.Element const fieldName = section.fieldNames[j]; const field = tableMetaData.fields.get(fieldName); - //////////////////////////////////////////////////////////////////////////////////////////// - // if id !== null - means we're on the edit screen -- show all fields on the edit screen. // - // || (or) we're on the insert screen in which case, only show editable fields. // - //////////////////////////////////////////////////////////////////////////////////////////// - if (props.id !== null || field.isEditable) + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if id !== null (and we're not duplicating) - means we're on the edit screen -- show all fields on the edit screen. // + // || (or) we're on the insert screen in which case, only show editable fields. // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if ((props.id !== null && !props.isDuplicate) || field.isEditable) { sectionDynamicFormFields.push(dynamicFormFields[fieldName]); } @@ -359,7 +393,12 @@ function EntityForm(props: Props): JSX.Element // but if the user used the anchors on the page, this doesn't effectively cancel... // // what we have here pushed a new history entry (I think?), so could be better // /////////////////////////////////////////////////////////////////////////////////////// - if (props.id !== null) + if (props.id !== null && props.isDuplicate) + { + const path = `${location.pathname.replace(/\/duplicate$/, "")}`; + navigate(path, {replace: true}); + } + else if (props.id !== null) { const path = `${location.pathname.replace(/\/edit$/, "")}`; navigate(path, {replace: true}); @@ -419,8 +458,9 @@ function EntityForm(props: Props): JSX.Element } } - if (props.id !== null) + if (props.id !== null && !props.isDuplicate) { + // todo - audit that it's a dupe await qController .update(tableName, props.id, values) .then((record) => @@ -464,7 +504,9 @@ function EntityForm(props: Props): JSX.Element } else { - const path = location.pathname.replace(/create$/, record.values.get(tableMetaData.primaryKeyField)); + const path = props.isDuplicate ? + location.pathname.replace(new RegExp(`/${props.id}/duplicate$`), "/" + record.values.get(tableMetaData.primaryKeyField)) + : location.pathname.replace(/create$/, record.values.get(tableMetaData.primaryKeyField)); navigate(path, {state: {createSuccess: true}}); } }) @@ -472,8 +514,9 @@ function EntityForm(props: Props): JSX.Element { if(error.message.toLowerCase().startsWith("warning")) { - const path = location.pathname.replace(/create$/, record.values.get(tableMetaData.primaryKeyField)); - navigate(path); + const path = props.isDuplicate ? + location.pathname.replace(new RegExp(`/${props.id}/duplicate$`), "/" + record.values.get(tableMetaData.primaryKeyField)) + : location.pathname.replace(/create$/, record.values.get(tableMetaData.primaryKeyField)); navigate(path, {state: {createSuccess: true, warning: error.message}}); } else diff --git a/src/qqq/components/query/CustomColumnsPanel.tsx b/src/qqq/components/query/CustomColumnsPanel.tsx index b398dd1..44874e0 100644 --- a/src/qqq/components/query/CustomColumnsPanel.tsx +++ b/src/qqq/components/query/CustomColumnsPanel.tsx @@ -19,6 +19,7 @@ * along with this program. If not, see . */ +import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {Box, FormControlLabel, FormGroup} from "@mui/material"; import Button from "@mui/material/Button"; @@ -37,6 +38,7 @@ declare module "@mui/x-data-grid" interface ColumnsPanelPropsOverrides { tableMetaData: QTableMetaData; + metaData: QInstance; initialOpenedGroups: { [name: string]: boolean }; openGroupsChanger: (openedGroups: { [name: string]: boolean }) => void; initialFilterText: string; @@ -70,7 +72,11 @@ export const CustomColumnsPanel = forwardRef( { for (let i = 0; i < props.tableMetaData.exposedJoins.length; i++) { - tables.push(props.tableMetaData.exposedJoins[i].joinTable); + const exposedJoin = props.tableMetaData.exposedJoins[i]; + if (props.metaData.tables.has(exposedJoin.joinTable.name)) + { + tables.push(exposedJoin.joinTable); + } } } @@ -112,7 +118,7 @@ export const CustomColumnsPanel = forwardRef( return (true); } } - catch(e) + catch (e) { ////////////////////////////////////////////////////////////////////////////////// // in case text is an invalid regex... well, at least do a starts-with match... // @@ -123,6 +129,33 @@ export const CustomColumnsPanel = forwardRef( } } + const tableLabel = column.headerName.replace(/:.*/, ""); + if (tableLabel) + { + try + { + //////////////////////////////////////////////////////////// + // try to match word-boundary followed by the filter text // + // e.g., "name" would match "First Name" or "Last Name" // + //////////////////////////////////////////////////////////// + const re = new RegExp("\\b" + filterText.toLowerCase()); + if (tableLabel.toLowerCase().match(re)) + { + return (true); + } + } + catch (e) + { + ////////////////////////////////////////////////////////////////////////////////// + // in case text is an invalid regex... well, at least do a starts-with match... // + ////////////////////////////////////////////////////////////////////////////////// + if (tableLabel.toLowerCase().startsWith(filterText.toLowerCase())) + { + return (true); + } + } + } + return (false); }; diff --git a/src/qqq/components/query/CustomFilterPanel.tsx b/src/qqq/components/query/CustomFilterPanel.tsx new file mode 100644 index 0000000..03dfee3 --- /dev/null +++ b/src/qqq/components/query/CustomFilterPanel.tsx @@ -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 . + */ + +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 // + /////////////////////////////////////////////////////////////////////// + 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( + 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 ( + + { + queryFilter.criteria.map((criteria: QFilterCriteriaWithId, index: number) => + ( + + updateCriteria(newCriteria, index, needDebounce)} + removeCriteria={() => removeCriteria(index)} + updateBooleanOperator={(newValue) => updateBooleanOperator(newValue)} + /> + {/*JSON.stringify(criteria)*/} + + )) + } + + + + + ); + } +); diff --git a/src/qqq/components/query/FilterCriteriaPaster.tsx b/src/qqq/components/query/FilterCriteriaPaster.tsx new file mode 100644 index 0000000..482332e --- /dev/null +++ b/src/qqq/components/query/FilterCriteriaPaster.tsx @@ -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 . + */ + +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 ( + + + paste_content + + { + pasteModalIsOpen && + ( + + + + + + + + Bulk Add Filter Values + + Paste into the box on the left. + Review the filter values in the box on the right. + If the filter values are not what are expected, try changing the separator using the dropdown below. + + + + + + + + + + + + + + { + }} + chipData={chipData} + chipType={type} + multiline + fullWidth + variant="outlined" + id="tags" + rows={0} + name="tags" + label="FILTER VALUES REVIEW" + /> + + + + + + + + + SEPARATOR + + + + {delimiter === Delimiter.CUSTOM.valueOf() && ( + + + + + )} + {inputText && delimiter === Delimiter.DETECT_AUTOMATICALLY.valueOf() && ( + + + {detectedText} + + )} + + + + { + errorText && chipData.length > 0 && ( + + error + {errorText} + + ) + } + + + { + chipData && chipData.length > 0 && ( + {chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"} + ) + } + + + + + + + + + + + + + + ) + } + + ); +} + +export default FilterCriteriaPaster; diff --git a/src/qqq/components/query/FilterCriteriaRow.tsx b/src/qqq/components/query/FilterCriteriaRow.tsx new file mode 100644 index 0000000..850a065 --- /dev/null +++ b/src/qqq/components/query/FilterCriteriaRow.tsx @@ -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 . + */ + +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, option: any, state: AutocompleteRenderOptionState): ReactNode + { + let label = "" + if(option && option.field) + { + label = (option.field.label); + } + + return (
  • {label}
  • ); + } + + function isOperatorOptionEqual(option: OperatorOption, value: OperatorOption) + { + return (option?.value == value?.value && JSON.stringify(option?.implicitValues) == JSON.stringify(value?.implicitValues)); + } + + let criteriaIsValid = true; + let criteriaStatusTooltip = "This condition is fully defined and is part of your filter."; + + function isNotSet(value: any) + { + return (value === null || value == undefined || String(value).trim() === ""); + } + + if(!criteria.fieldName) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must select a field to begin to define this condition."; + } + else if(!criteria.operator) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must select an operator to continue to define this condition."; + } + else + { + if(operatorSelectedValue) + { + if (operatorSelectedValue.valueMode == ValueMode.NONE || operatorSelectedValue.implicitValues) + { + ////////////////////////////////// + // don't need to look at values // + ////////////////////////////////// + } + else if(operatorSelectedValue.valueMode == ValueMode.DOUBLE) + { + if(criteria.values.length < 2) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must enter two values to complete the definition of this condition."; + } + } + else if(operatorSelectedValue.valueMode == ValueMode.MULTI || operatorSelectedValue.valueMode == ValueMode.PVS_MULTI) + { + if(criteria.values.length < 1 || isNotSet(criteria.values[0])) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must enter one or more values complete the definition of this condition."; + } + } + else + { + if(isNotSet(criteria.values[0])) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must enter a value to complete the definition of this condition."; + } + } + } + } + + return ( + + + + close + + + + {booleanOperator && index > 0 ? + + + + : } + + + ()} + // @ts-ignore + defaultValue={defaultFieldValue} + options={fieldOptions} + onChange={handleFieldChange} + isOptionEqualToValue={(option, value) => isFieldOptionEqual(option, value)} + groupBy={fieldsGroupBy} + getOptionLabel={(option) => getFieldOptionLabel(option)} + renderOption={(props, option, state) => renderFieldOption(props, option, state)} + autoSelect={true} + autoHighlight={true} + slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}} + /> + + + + ()} + 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}*/ + /> + + + + handleValueChange(event, valueIndex, newValue)} + /> + + + + { + criteriaIsValid + ? check + : pending + } + + + + ); +} diff --git a/src/qqq/components/query/FilterCriteriaRowValues.tsx b/src/qqq/components/query/FilterCriteriaRowValues.tsx new file mode 100644 index 0000000..3361f2c --- /dev/null +++ b/src/qqq/components/query/FilterCriteriaRowValues.tsx @@ -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 . + */ + + +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
    ; + } + + 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 | React.MouseEvent, index: number) => + { + valueChangeHandler(event, index, ""); + document.getElementById(`${idPrefix}${criteria.id}`).focus(); + }; + + const inputProps: any = {}; + inputProps.endAdornment = ( + + clearValue(event, valueIndex)}> + close + + + ); + + return valueChangeHandler(event, valueIndex)} + value={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
    ; + case ValueMode.SINGLE: + return makeTextField(); + case ValueMode.SINGLE_DATE: + return makeTextField(); + case ValueMode.SINGLE_DATE_TIME: + return makeTextField(); + case ValueMode.DOUBLE: + return + + { makeTextField(0, "From", "from-") } + + + {makeTextField(1, "To", "to-")} + + ; + case ValueMode.MULTI: + let values = criteria.values; + if (values && values.length == 1 && values[0] == "") + { + values = []; + } + return + ()} + options={[]} + multiple + freeSolo // todo - no debounce after enter? + selectOnFocus + clearOnBlur + fullWidth + limitTags={5} + value={values} + onChange={(event, value) => valueChangeHandler(event, "all", value)} + /> + + saveNewPasterValues(newValues)} /> + + ; + case ValueMode.PVS_SINGLE: + console.log("Doing pvs single: " + criteria.values); + let selectedPossibleValue = null; + if (criteria.values && criteria.values.length > 0) + { + selectedPossibleValue = criteria.values[0]; + } + return + valueChangeHandler(null, 0, value)} + /> + ; + 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 + valueChangeHandler(null, "all", value)} + /> + + } + + return (
    ); +} + +export default FilterCriteriaRowValues; \ No newline at end of file diff --git a/src/qqq/components/scripts/ScriptEditor.tsx b/src/qqq/components/scripts/ScriptEditor.tsx index d67ffa3..c50b08a 100644 --- a/src/qqq/components/scripts/ScriptEditor.tsx +++ b/src/qqq/components/scripts/ScriptEditor.tsx @@ -101,6 +101,7 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab completions.push({value: "api.bulkInsert(", meta: "Create multiple records in a table."}); completions.push({value: "api.bulkUpdate(", meta: "Update multiple records in a table."}); completions.push({value: "api.bulkDelete(", meta: "Remove multiple records from a table."}); + completions.push({value: "api.runProcess(", meta: "Run a process"}); // completions.push({value: "api.newRecord(", meta: "Create a new QRecord object."}); // completions.push({value: "api.newQueryInput(", meta: "Create a new QueryInput object."}); // completions.push({value: "api.newQueryFilter(", meta: "Create a new QueryFilter object."}); diff --git a/src/qqq/components/widgets/Widget.tsx b/src/qqq/components/widgets/Widget.tsx index 6ad8a89..da59312 100644 --- a/src/qqq/components/widgets/Widget.tsx +++ b/src/qqq/components/widgets/Widget.tsx @@ -30,8 +30,7 @@ import Tooltip from "@mui/material/Tooltip/Tooltip"; import Typography from "@mui/material/Typography"; import parse from "html-react-parser"; import React, {useEffect, useState} from "react"; -import {Link, useNavigate, NavigateFunction} from "react-router-dom"; -import {bool} from "yup"; +import {Link, NavigateFunction, useNavigate} from "react-router-dom"; import colors from "qqq/components/legacy/colors"; import DropdownMenu, {DropdownOption} from "qqq/components/widgets/components/DropdownMenu"; @@ -94,7 +93,9 @@ export class LabelComponent } - +/******************************************************************************* + ** + *******************************************************************************/ export class HeaderLink extends LabelComponent { label: string; @@ -118,7 +119,9 @@ export class HeaderLink extends LabelComponent } - +/******************************************************************************* + ** + *******************************************************************************/ export class AddNewRecordButton extends LabelComponent { table: QTableMetaData; @@ -152,6 +155,9 @@ export class AddNewRecordButton extends LabelComponent } +/******************************************************************************* + ** + *******************************************************************************/ export class ExportDataButton extends LabelComponent { callbackToExport: any; @@ -177,26 +183,30 @@ export class ExportDataButton extends LabelComponent } +/******************************************************************************* + ** + *******************************************************************************/ export class Dropdown extends LabelComponent { label: string; options: DropdownOption[]; - onChangeCallback: any + dropdownName: string; + onChangeCallback: any; - constructor(label: string, options: DropdownOption[], onChangeCallback: any) + constructor(label: string, options: DropdownOption[], dropdownName: string, onChangeCallback: any) { super(); this.label = label; this.options = options; + this.dropdownName = dropdownName; this.onChangeCallback = onChangeCallback; } render = (args: LabelComponentRenderArgs): JSX.Element => { let defaultValue = null; - const dropdownName = args.widgetProps.widgetData.dropdownNameList[args.componentIndex]; - const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${args.widgetProps.widgetMetaData.name}.${dropdownName}`; - if(args.widgetProps.storeDropdownSelections) + const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${args.widgetProps.widgetMetaData.name}.${this.dropdownName}`; + if (args.widgetProps.storeDropdownSelections) { /////////////////////////////////////////////////////////////////////////////////////// // see if an existing value is stored in local storage, and if so set it in dropdown // @@ -208,7 +218,7 @@ export class Dropdown extends LabelComponent return ( void; @@ -235,7 +248,7 @@ export class ReloadControl extends LabelComponent { return ( - + ); } @@ -245,59 +258,101 @@ export class ReloadControl extends LabelComponent export const WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT = "qqq.widgets.dropdownData"; +/******************************************************************************* + ** + *******************************************************************************/ function Widget(props: React.PropsWithChildren): JSX.Element { const navigate = useNavigate(); const [dropdownData, setDropdownData] = useState([]); const [fullScreenWidgetClassName, setFullScreenWidgetClassName] = useState(""); const [reloading, setReloading] = useState(false); + const [dropdownDataJSON, setDropdownDataJSON] = useState(""); + const [labelComponentsLeft, setLabelComponentsLeft] = useState([] as LabelComponent[]); + const [labelComponentsRight, setLabelComponentsRight] = useState([] as LabelComponent[]); function renderComponent(component: LabelComponent, componentIndex: number) { - return component.render({navigate: navigate, widgetProps: props, dropdownData: dropdownData, componentIndex: componentIndex, reloadFunction: doReload}) + return component.render({navigate: navigate, widgetProps: props, dropdownData: dropdownData, componentIndex: componentIndex, reloadFunction: doReload}); } - - /////////////////////////////////////////////////////////////////// - // make dropdowns from the widgetData appear as label-components // - /////////////////////////////////////////////////////////////////// - const effectiveLabelAdditionalComponentsRight: LabelComponent[] = []; - if(props.labelAdditionalComponentsRight) + useEffect(() => { - props.labelAdditionalComponentsRight.map((component) => effectiveLabelAdditionalComponentsRight.push(component)); - } - if(props.widgetData && props.widgetData.dropdownDataList) - { - props.widgetData.dropdownDataList?.map((dropdownData: any, index: number) => + //////////////////////////////////////////////////////////////////////////////// + // for initial render, put left-components from props into the state variable // + // plus others we can infer from other props // + //////////////////////////////////////////////////////////////////////////////// + const stateLabelComponentsLeft: LabelComponent[] = []; + if (props.reloadWidgetCallback && props.widgetData && props.showReloadControl && props.widgetMetaData.showReloadButton) { - effectiveLabelAdditionalComponentsRight.push(new Dropdown(props.widgetData.dropdownLabelList[index], dropdownData, handleDataChange)) - }); + stateLabelComponentsLeft.push(new ReloadControl(doReload)); + } + if (props.labelAdditionalComponentsLeft) + { + props.labelAdditionalComponentsLeft.map((component) => stateLabelComponentsLeft.push(component)); + } + setLabelComponentsLeft(stateLabelComponentsLeft); + }, []); + + useEffect(() => + { + ///////////////////////////////////////////////////////////////////////////////// + // for initial render, put right-components from props into the state variable // + ///////////////////////////////////////////////////////////////////////////////// + const stateLabelComponentsRight = [] as LabelComponent[]; + // console.log(`${props.widgetMetaData.name} init'ing right-components`); + if (props.labelAdditionalComponentsRight) + { + props.labelAdditionalComponentsRight.map((component) => stateLabelComponentsRight.push(component)); + } + setLabelComponentsRight(stateLabelComponentsRight); + }, []); + + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if we have widgetData, and it has a dropdown list, capture that in a state variable, if it's changed // + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + if (props.widgetData && props.widgetData.dropdownDataList) + { + const currentDropdownDataJSON = JSON.stringify(props.widgetData.dropdownDataList); + if (currentDropdownDataJSON !== dropdownDataJSON) + { + // console.log(`${props.widgetMetaData.name} we have (new) dropdown data!!: ${currentDropdownDataJSON}`); + setDropdownDataJSON(currentDropdownDataJSON); + } } + useEffect(() => + { + /////////////////////////////////////////////////////////////////////////////////// + // if we've seen a change in the dropdown data, then update the right-components // + /////////////////////////////////////////////////////////////////////////////////// + // console.log(`${props.widgetMetaData.name} in useEffect post dropdownData change`); + if (props.widgetData && props.widgetData.dropdownDataList) + { + const updatedStateLabelComponentsRight = JSON.parse(JSON.stringify(labelComponentsRight)) as LabelComponent[]; + props.widgetData.dropdownDataList?.map((dropdownData: any, index: number) => + { + // console.log(`${props.widgetMetaData.name} building a Dropdown, data is: ${dropdownData}`); + updatedStateLabelComponentsRight.push(new Dropdown(props.widgetData.dropdownLabelList[index], dropdownData, props.widgetData.dropdownNameList[index], handleDataChange)); + }); + setLabelComponentsRight(updatedStateLabelComponentsRight); + } + }, [dropdownDataJSON]); + const doReload = () => { setReloading(true); reloadWidget(dropdownData); - } + }; useEffect(() => { setReloading(false); }, [props.widgetData]); - const effectiveLabelAdditionalComponentsLeft: LabelComponent[] = []; - if(props.reloadWidgetCallback && props.widgetData && props.showReloadControl && props.widgetMetaData.showReloadButton) - { - effectiveLabelAdditionalComponentsLeft.push(new ReloadControl(doReload)) - } - if(props.labelAdditionalComponentsLeft) - { - props.labelAdditionalComponentsLeft.map((component) => effectiveLabelAdditionalComponentsLeft.push(component)); - } - function handleDataChange(dropdownLabel: string, changedData: any) { - if(dropdownData) + if (dropdownData) { /////////////////////////////////////////// // find the index base on selected label // @@ -327,7 +382,7 @@ function Widget(props: React.PropsWithChildren): JSX.Element // if should store in local storage, do so now // // or remove if dropdown was cleared out // ///////////////////////////////////////////////// - if(props.storeDropdownSelections) + if (props.storeDropdownSelections) { if (changedData?.id) { @@ -371,7 +426,7 @@ function Widget(props: React.PropsWithChildren): JSX.Element const toggleFullScreenWidget = () => { - if(fullScreenWidgetClassName) + if (fullScreenWidgetClassName) { setFullScreenWidgetClassName(""); } @@ -385,17 +440,17 @@ function Widget(props: React.PropsWithChildren): JSX.Element const isSet = (v: any): boolean => { - return(v !== null && v !== undefined); + return (v !== null && v !== undefined); } ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // to avoid taking up the space of the Box with the label and icon and label-components (since it has a height), only output that box if we need any of the components // ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// let needLabelBox = false; - if(hasPermission) + if (hasPermission) { - needLabelBox ||= (effectiveLabelAdditionalComponentsLeft && effectiveLabelAdditionalComponentsLeft.length > 0); - needLabelBox ||= (effectiveLabelAdditionalComponentsRight && effectiveLabelAdditionalComponentsRight.length > 0); + needLabelBox ||= (labelComponentsLeft && labelComponentsLeft.length > 0); + needLabelBox ||= (labelComponentsRight && labelComponentsRight.length > 0); needLabelBox ||= isSet(props.widgetMetaData?.icon); needLabelBox ||= isSet(props.widgetData?.label); needLabelBox ||= isSet(props.widgetMetaData?.label); @@ -406,90 +461,90 @@ function Widget(props: React.PropsWithChildren): JSX.Element { needLabelBox && - - - { - hasPermission ? - props.widgetMetaData?.icon && ( - - - {props.widgetMetaData.icon} - - - - ) : ( - - lock - - ) - } - { - ////////////////////////////////////////////////////////////////////////////////////////// - // first look for a label in the widget data, which would override that in the metadata // - ////////////////////////////////////////////////////////////////////////////////////////// - hasPermission && props.widgetData?.label? ( - - {props.widgetData.label} - - ) : ( - hasPermission && props.widgetMetaData?.label && ( - - {props.widgetMetaData.label} + + + { + hasPermission ? + props.widgetMetaData?.icon && ( + + + {props.widgetMetaData.icon} + + + ) : + ( + + lock + + ) + } + { + ////////////////////////////////////////////////////////////////////////////////////////// + // first look for a label in the widget data, which would override that in the metadata // + ////////////////////////////////////////////////////////////////////////////////////////// + hasPermission && props.widgetData?.label ? ( + + {props.widgetData.label} + ) : ( + hasPermission && props.widgetMetaData?.label && ( + + {props.widgetMetaData.label} + + ) ) - ) - } - { - hasPermission && ( - effectiveLabelAdditionalComponentsLeft.map((component, i) => - { - return ({renderComponent(component, i)}); - }) - ) - } + } + { + hasPermission && ( + labelComponentsLeft.map((component, i) => + { + return ({renderComponent(component, i)}); + }) + ) + } + + + { + hasPermission && ( + labelComponentsRight.map((component, i) => + { + return ({renderComponent(component, i)}); + }) + ) + } + - - { - hasPermission && ( - effectiveLabelAdditionalComponentsRight.map((component, i) => - { - return ({renderComponent(component, i)}); - }) - ) - } - - } { - props.widgetMetaData?.isCard && (reloading ? : ) + props.widgetMetaData?.isCard && (reloading ? : ) } { errorLoading ? ( @@ -514,7 +569,7 @@ function Widget(props: React.PropsWithChildren): JSX.Element ) } { - ! errorLoading && props?.footerHTML && ( + !errorLoading && props?.footerHTML && ( {parse(props.footerHTML)} ) } diff --git a/src/qqq/pages/records/FilterPoc.tsx b/src/qqq/pages/records/FilterPoc.tsx new file mode 100644 index 0000000..a2ea88a --- /dev/null +++ b/src/qqq/pages/records/FilterPoc.tsx @@ -0,0 +1,75 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import Box from "@mui/material/Box"; +import {useEffect, useState} from "react"; +import {CustomFilterPanel} from "qqq/components/query/CustomFilterPanel"; +import BaseLayout from "qqq/layouts/BaseLayout"; +import Client from "qqq/utils/qqq/Client"; + + +interface Props +{ +} + +FilterPoc.defaultProps = {}; + +function FilterPoc({}: Props): JSX.Element +{ + const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData) + const [queryFilter, setQueryFilter] = useState(new QQueryFilter()) + + const updateFilter = (newFilter: QQueryFilter) => + { + setQueryFilter(JSON.parse(JSON.stringify(newFilter))); + } + + useEffect(() => + { + (async () => + { + const table = await Client.getInstance().loadTableMetaData("order") + setTableMetaData(table); + })(); + }, []); + + return ( + + { + tableMetaData && + + + {/* @ts-ignore */} + + +
    +                  {JSON.stringify(queryFilter, null, 3)})
    +               
    +
    + } +
    + ); +} + +export default FilterPoc; diff --git a/src/qqq/pages/records/IntersectionMatrix.tsx b/src/qqq/pages/records/IntersectionMatrix.tsx new file mode 100644 index 0000000..54657c1 --- /dev/null +++ b/src/qqq/pages/records/IntersectionMatrix.tsx @@ -0,0 +1,123 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import Box from "@mui/material/Box"; +import Checkbox from "@mui/material/Checkbox/Checkbox"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import {makeStyles} from "@mui/styles"; +import {useState} from "react"; +import BaseLayout from "qqq/layouts/BaseLayout"; + + +interface Props +{ + foo: string; +} + +IntersectionMatrix.defaultProps = { + foo: null, +}; + +const useStyles = makeStyles({ + sticky: { + position: "sticky", + left: 0, + top: 0, + background: "white", + boxShadow: "2px 2px 2px grey", + borderRight: "2px solid grey", + zIndex: 1 + } +}); + +function IntersectionMatrix({foo}: Props): JSX.Element +{ + const permissions = ["apiLog.delete", "apiLog.edit", "apiLog.insert", "apiLog.read", "apiLogUser.delete", "apiLogUser.edit", "apiLogUser.insert", "apiLogUser.read", "audit.delete", "audit.edit", "audit.insert", "audit.read", "auditDetail.delete", "auditDetail.edit", "auditDetail.insert", "auditDetail.read", "auditTable.delete", "auditTable.edit", "auditTable.insert", "auditTable.read", "auditUser.delete", "auditUser.edit", "auditUser.insert", "auditUser.read", "availableInventoryIndex.delete", "availableInventoryIndex.edit", "availableInventoryIndex.insert", "availableInventoryIndex.read", "availablePermission.delete", "availablePermission.edit", "availablePermission.insert", "availablePermission.read", "billing.hasAccess", "billingActivity.delete", "billingActivity.edit", "billingActivity.insert", "billingActivity.read", "billingDashboard.hasAccess", "billingWorksheet.delete", "billingWorksheet.edit", "billingWorksheet.insert", "billingWorksheet.read", "billingWorksheetLine.delete", "billingWorksheetLine.edit", "billingWorksheetLine.insert", "billingWorksheetLine.read", "billingWorksheetLineDetail.hasAccess", "billingWorksheetRevenueReport.hasAccess", "billingWorksheetSummary.hasAccess", "blackboxCartonization.delete", "blackboxCartonization.edit", "blackboxCartonization.insert", "blackboxCartonization.read", "blackboxStatus.delete", "blackboxStatus.edit", "blackboxStatus.insert", "blackboxStatus.read", "cancelBillingWorksheet.hasAccess", "carrier.delete", "carrier.edit", "carrier.insert", "carrier.read", "carrierAccount.delete", "carrierAccount.edit", "carrierAccount.insert", "carrierAccount.read", "carrierInvoicing.hasAccess", "carrierPerformance.hasAccess", "carrierPerformanceDashboard.hasAccess", "carrierRevenueReport.hasAccess", "carrierService.delete", "carrierService.edit", "carrierService.insert", "carrierService.read", "carrierServiceSlaExclusionDate.delete", "carrierServiceSlaExclusionDate.edit", "carrierServiceSlaExclusionDate.insert", "carrierServiceSlaExclusionDate.read", "cartonType.delete", "cartonType.edit", "cartonType.insert", "cartonType.read", "cartonization.hasAccess", "client.delete", "client.edit", "client.insert", "client.read", "clientAlias.delete", "clientAlias.edit", "clientAlias.insert", "clientAlias.read", "clientAuth0Application.delete", "clientAuth0Application.edit", "clientAuth0Application.insert", "clientAuth0Application.read", "clientAuth0ApplicationApiKey.delete", "clientAuth0ApplicationApiKey.edit", "clientAuth0ApplicationApiKey.insert", "clientAuth0ApplicationApiKey.read", "clientBillingKey.delete", "clientBillingKey.edit", "clientBillingKey.insert", "clientBillingKey.read", "clientFeeKey.delete", "clientFeeKey.edit", "clientFeeKey.insert", "clientFeeKey.read", "clientShipStationStore.delete", "clientShipStationStore.edit", "clientShipStationStore.insert", "clientShipStationStore.read", "closeBillingWorksheet.hasAccess", "connection.delete", "connection.edit", "connection.insert", "connection.read", "createBillingWorksheet.hasAccess", "createTestOrdersProcess.hasAccess", "dashboard.hasAccess", "dashboards.hasAccess", "dataBag.delete", "dataBag.edit", "dataBag.insert", "dataBag.read", "dataBagVersion.delete", "dataBagVersion.edit", "dataBagVersion.insert", "dataBagVersion.read", "dataHealthDashboard.hasAccess", "dataIndex.delete", "dataIndex.edit", "dataIndex.insert", "dataIndex.read", "deleteSavedFilter.hasAccess", "deposcoCreateTestOrdersJob.delete", "deposcoCreateTestOrdersJob.edit", "deposcoCreateTestOrdersJob.insert", "deposcoCreateTestOrdersJob.read", "deposcoCreateTestOrdersProcess.hasAccess", "deposcoCurrentExceptionsWidget.hasAccess", "deposcoCurrentStatusWidget.hasAccess", "deposcoCustomerOrder.delete", "deposcoCustomerOrder.edit", "deposcoCustomerOrder.insert", "deposcoCustomerOrder.read", "deposcoEnterpriseInventory.delete", "deposcoEnterpriseInventory.edit", "deposcoEnterpriseInventory.insert", "deposcoEnterpriseInventory.read", "deposcoItem.delete", "deposcoItem.edit", "deposcoItem.insert", "deposcoItem.read", "deposcoOrder.delete", "deposcoOrder.edit", "deposcoOrder.insert", "deposcoOrder.read", "deposcoOrderToOrder.hasAccess", "deposcoOrdersApp.hasAccess", "deposcoOrdersByClientPieChart.hasAccess", "deposcoPollForCustomerOrders.hasAccess", "deposcoPollForOrders.hasAccess", "deposcoRecentDataParentWidget.hasAccess", "deposcoReplaceLineItemProcess.hasAccess", "deposcoSalesOrder.delete", "deposcoSalesOrder.edit", "deposcoSalesOrder.insert", "deposcoSalesOrder.read", "deposcoSalesOrderLine.delete", "deposcoSalesOrderLine.edit", "deposcoSalesOrderLine.insert", "deposcoSalesOrderLine.read", "deposcoSalesOrdersBarChart.hasAccess", "deposcoSentOrder.delete", "deposcoSentOrder.edit", "deposcoSentOrder.insert", "deposcoSentOrder.read", "deposcoShipment.delete", "deposcoShipment.edit", "deposcoShipment.insert", "deposcoShipment.read", "deposcoShipmentToSystemGeneratedTrackingNo.hasAccess", "deposcoTradingPartner.delete", "deposcoTradingPartner.edit", "deposcoTradingPartner.insert", "deposcoTradingPartner.read", "developer.hasAccess", "easypostTracker.delete", "easypostTracker.edit", "easypostTracker.insert", "easypostTracker.read", "extensivOrder.delete", "extensivOrder.edit", "extensivOrder.insert", "extensivOrder.read", "extensivOrderToOrder.hasAccess", "fedexTntCache.delete", "fedexTntCache.edit", "fedexTntCache.insert", "fedexTntCache.read", "freightStudy.delete", "freightStudy.edit", "freightStudy.insert", "freightStudy.read", "freightStudyActualShipment.delete", "freightStudyActualShipment.edit", "freightStudyActualShipment.insert", "freightStudyActualShipment.read", "freightStudyAllShipmentsReport.hasAccess", "freightStudyAllShipmentsReportProcess.hasAccess", "freightStudyApp.hasAccess", "freightStudyEstimateShipments.hasAccess", "freightStudyEstimatedShipment.delete", "freightStudyEstimatedShipment.edit", "freightStudyEstimatedShipment.insert", "freightStudyEstimatedShipment.read", "freightStudyScenario.delete", "freightStudyScenario.edit", "freightStudyScenario.insert", "freightStudyScenario.read", "fuelSurcharge.delete", "fuelSurcharge.edit", "fuelSurcharge.insert", "fuelSurcharge.read", "fulfillment.hasAccess", "generateBillingActivityFromBillingWorksheet.hasAccess", "generateBillingWorksheetDocuments.hasAccess", "generateParcelInvoiceLineFromRawAxleHire.hasAccess", "generateParcelInvoiceLineFromRawCdl.hasAccess", "generateParcelInvoiceLineFromRawFedEx.hasAccess", "generateParcelInvoiceLineFromRawLso.hasAccess", "generateParcelInvoiceLineFromRawOntrac.hasAccess", "generateParcelInvoiceLineFromRawUps.hasAccess", "graceDiscountAuditReport.hasAccess", "infoplusLOB.delete", "infoplusLOB.edit", "infoplusLOB.insert", "infoplusLOB.read", "infoplusOrder.delete", "infoplusOrder.edit", "infoplusOrder.insert", "infoplusOrder.read", "infoplusOrderToOrder.hasAccess", "infoplusShipment.delete", "infoplusShipment.edit", "infoplusShipment.insert", "infoplusShipment.read", "infoplusShipmentToSystemGeneratedTrackingNumber.hasAccess", "infoplusWarehouse.delete", "infoplusWarehouse.edit", "infoplusWarehouse.insert", "infoplusWarehouse.read", "initParcelSlaStatus.hasAccess", "integrations.hasAccess", "lineItem.delete", "lineItem.edit", "lineItem.insert", "lineItem.read", "manualUpdateInvoiceLineFromRaw.hasAccess", "markBillingActivityAsException.hasAccess", "markParcelInvoiceLineAsOrphan.hasAccess", "mergeDuplicatedParcels.hasAccess", "omsOperationsDashboard.hasAccess", "optimization.hasAccess", "optimizationCarrierServiceRulesChecker.hasAccess", "optimizationCarrierServiceStateRule.delete", "optimizationCarrierServiceStateRule.edit", "optimizationCarrierServiceStateRule.insert", "optimizationCarrierServiceStateRule.read", "optimizationCarrierServiceTNTRule.delete", "optimizationCarrierServiceTNTRule.edit", "optimizationCarrierServiceTNTRule.insert", "optimizationCarrierServiceTNTRule.read", "optimizationCarrierServiceZipCodeRule.delete", "optimizationCarrierServiceZipCodeRule.edit", "optimizationCarrierServiceZipCodeRule.insert", "optimizationCarrierServiceZipCodeRule.read", "optimizationConfig.delete", "optimizationConfig.edit", "optimizationConfig.insert", "optimizationConfig.read", "optimizationConfigApp.hasAccess", "optimizationDashboard.hasAccess", "optimizationRateChecker.hasAccess", "optimizationRulesChecker.hasAccess", "optimizationStateRule.delete", "optimizationStateRule.edit", "optimizationStateRule.insert", "optimizationStateRule.read", "optimizationTNTRule.delete", "optimizationTNTRule.edit", "optimizationTNTRule.insert", "optimizationTNTRule.read", "optimizationWarehouseRoutingStateRule.delete", "optimizationWarehouseRoutingStateRule.edit", "optimizationWarehouseRoutingStateRule.insert", "optimizationWarehouseRoutingStateRule.read", "optimizationWarehouseRoutingZipCodeRule.delete", "optimizationWarehouseRoutingZipCodeRule.edit", "optimizationWarehouseRoutingZipCodeRule.insert", "optimizationWarehouseRoutingZipCodeRule.read", "optimizationZipCodeRule.delete", "optimizationZipCodeRule.edit", "optimizationZipCodeRule.insert", "optimizationZipCodeRule.read", "order.delete", "order.edit", "order.insert", "order.read", "orderAndShipmentPerformanceDashboard.hasAccess", "orderCarton.delete", "orderCarton.edit", "orderCarton.insert", "orderCarton.read", "orderCartonization.delete", "orderCartonization.edit", "orderCartonization.insert", "orderCartonization.read", "orderExtrinsic.delete", "orderExtrinsic.edit", "orderExtrinsic.insert", "orderExtrinsic.read", "orderOptimization.hasAccess", "orders.hasAccess", "ordersAndShipmentsReport.hasAccess", "outboundApiLog.delete", "outboundApiLog.edit", "outboundApiLog.insert", "outboundApiLog.read", "outboundScannedTrackingNumber.delete", "outboundScannedTrackingNumber.edit", "outboundScannedTrackingNumber.insert", "outboundScannedTrackingNumber.read", "outboundScannedTrackingNumberToParcel.hasAccess", "overview.hasAccess", "overviewDashboard.hasAccess", "parcel.delete", "parcel.edit", "parcel.insert", "parcel.read", "parcelHealthApp.hasAccess", "parcelInvoice.delete", "parcelInvoice.edit", "parcelInvoice.insert", "parcelInvoice.read", "parcelInvoiceLine.delete", "parcelInvoiceLine.edit", "parcelInvoiceLine.insert", "parcelInvoiceLine.read", "parcelInvoiceLineChargeMappingRule.delete", "parcelInvoiceLineChargeMappingRule.edit", "parcelInvoiceLineChargeMappingRule.insert", "parcelInvoiceLineChargeMappingRule.read", "parcelInvoiceLineChargeRollupRule.read", "parcelInvoiceLineToParcel.hasAccess", "parcelInvoiceRawETLAxleHire.hasAccess", "parcelInvoiceRawETLCdl.hasAccess", "parcelInvoiceRawETLFedEx.hasAccess", "parcelInvoiceRawETLLso.hasAccess", "parcelInvoiceRawETLOntrac.hasAccess", "parcelInvoiceRawETLUps.hasAccess", "parcelInvoiceShiplabsSyncAxleHire.hasAccess", "parcelInvoiceShiplabsSyncCdl.hasAccess", "parcelInvoiceShiplabsSyncFedEx.hasAccess", "parcelInvoiceShiplabsSyncLso.hasAccess", "parcelInvoiceShiplabsSyncOntrac.hasAccess", "parcelInvoiceShiplabsSyncUps.hasAccess", "parcelSlaStatus.delete", "parcelSlaStatus.edit", "parcelSlaStatus.insert", "parcelSlaStatus.read", "parcelTrackingDetail.delete", "parcelTrackingDetail.edit", "parcelTrackingDetail.insert", "parcelTrackingDetail.read", "parcels.hasAccess", "pollExtensiveForOrders.hasAccess", "pushDeposcoSalesOrders.hasAccess", "querySavedFilter.hasAccess", "rawParcelInvoiceLineAxleHire.delete", "rawParcelInvoiceLineAxleHire.edit", "rawParcelInvoiceLineAxleHire.insert", "rawParcelInvoiceLineAxleHire.read", "rawParcelInvoiceLineCdl.delete", "rawParcelInvoiceLineCdl.edit", "rawParcelInvoiceLineCdl.insert", "rawParcelInvoiceLineCdl.read", "rawParcelInvoiceLineFedEx.delete", "rawParcelInvoiceLineFedEx.edit", "rawParcelInvoiceLineFedEx.insert", "rawParcelInvoiceLineFedEx.read", "rawParcelInvoiceLineLso.delete", "rawParcelInvoiceLineLso.edit", "rawParcelInvoiceLineLso.insert", "rawParcelInvoiceLineLso.read", "rawParcelInvoiceLineOntrac.delete", "rawParcelInvoiceLineOntrac.edit", "rawParcelInvoiceLineOntrac.insert", "rawParcelInvoiceLineOntrac.read", "rawParcelInvoiceLineUps.delete", "rawParcelInvoiceLineUps.edit", "rawParcelInvoiceLineUps.insert", "rawParcelInvoiceLineUps.read", "receiveEasypostTrackerWebhook.hasAccess", "reconcileClientsOnParcelInvoiceLine.hasAccess", "reconcileClientsOnParcelInvoiceLineFromBillingWorksheet.hasAccess", "reevaluateParcelSlaStatus.hasAccess", "registerParcelAsEasypostTracker.hasAccess", "releaseOrderToWmsProcess.hasAccess", "releaseOrdersJob.delete", "releaseOrdersJob.edit", "releaseOrdersJob.insert", "releaseOrdersJob.read", "releaseOrdersJobOrder.delete", "releaseOrdersJobOrder.edit", "releaseOrdersJobOrder.insert", "releaseOrdersJobOrder.read", "releaseOrdersToWmsProcess.hasAccess", "replaceLineItem.hasAccess", "resyncOrderFromSource.hasAccess", "resyncParcelTrackingStatus.hasAccess", "resyncSystemGeneratedTrackingNumberFromSource.hasAccess", "retrySendingReleaseOrdersJob.hasAccess", "runBillingWorksheetRevenueReport.hasAccess", "runRecordScript.hasAccess", "salesOrderAutomation.hasAccess", "savedFilter.delete", "savedFilter.edit", "savedFilter.insert", "savedFilter.read", "script.delete", "script.edit", "script.insert", "script.read", "scriptLog.delete", "scriptLog.edit", "scriptLog.insert", "scriptLog.read", "scriptLogLine.delete", "scriptLogLine.edit", "scriptLogLine.insert", "scriptLogLine.read", "scriptRevision.delete", "scriptRevision.edit", "scriptRevision.insert", "scriptRevision.read", "scriptType.delete", "scriptType.edit", "scriptType.insert", "scriptType.read", "setup.hasAccess", "shipStationOrder0.delete", "shipStationOrder0.edit", "shipStationOrder0.insert", "shipStationOrder0.read", "shipStationOrderToOrder0.hasAccess", "shipStationShipment0.delete", "shipStationShipment0.edit", "shipStationShipment0.insert", "shipStationShipment0.read", "shipStationShipmentToSystemGeneratedTrackingNumber0.hasAccess", "shipStationShipmentToSystemGeneratedTrackingNumber1.hasAccess", "shipStationShipmentToSystemGeneratedTrackingNumber2.hasAccess", "shipStationShipmentToSystemGeneratedTrackingNumber3.hasAccess", "shipStationShipmentToSystemGeneratedTrackingNumber4.hasAccess", "shipStationStore0.delete", "shipStationStore0.edit", "shipStationStore0.insert", "shipStationStore0.read", "shipStationWarehouse0.delete", "shipStationWarehouse0.edit", "shipStationWarehouse0.insert", "shipStationWarehouse0.read", "shippedOrderToExtensivOrder.hasAccess", "shipping.hasAccess", "shippingDashboard.hasAccess", "storeDataBagVersion.hasAccess", "storeSavedFilter.hasAccess", "storeScriptRevision.hasAccess", "systemGeneratedTrackingNumber.delete", "systemGeneratedTrackingNumber.edit", "systemGeneratedTrackingNumber.insert", "systemGeneratedTrackingNumber.read", "systemGeneratedTrackingNumberToParcel.hasAccess", "tableTrigger.delete", "tableTrigger.edit", "tableTrigger.insert", "tableTrigger.read", "testScript.hasAccess", "totalDeposcoOrdersImported.hasAccess", "uploadFileArchive.delete", "uploadFileArchive.edit", "uploadFileArchive.insert", "uploadFileArchive.read", "warehouse.delete", "warehouse.edit", "warehouse.insert", "warehouse.read", "warehouseClientInt.delete", "warehouseClientInt.edit", "warehouseClientInt.insert", "warehouseClientInt.read", "warehouseShipStationWarehouse.delete", "warehouseShipStationWarehouse.edit", "warehouseShipStationWarehouse.insert", "warehouseShipStationWarehouse.read", "zipZone.delete", "zipZone.edit", "zipZone.insert", "zipZone.read", "zipZoneCdl.delete", "zipZoneCdl.edit", "zipZoneCdl.insert", "zipZoneCdl.read"]; + permissions.splice(50) + const roles = ["External - Customer - OMS API User", "External - Customer - Reports API", "External - Customer - Viewer", "External - Deposco - Cartonization API", "External - Optimization - Viewer", "Internal - Carrier Invoicing - Admin", "Internal - Carrier Invoicing - User", "Internal - Carrier Invoicing - Viewer", "Internal - Developer - Admin", "Internal - Engineering Team - Admin", "Internal - Executive Team", "Internal - Freight Study - Admin", "Internal - Freight Study - User", "Internal - Freight Study - Viewer", "Internal - Integrations - Viewer", "Internal - Optimization - Admin", "Internal - Optimization - User", "Internal - Optimization - Viewer", "Internal - Orders & Parcels - Admin", "Internal - Orders & Parcels - User"]; + + const classes = useStyles(); + + return ( + + + + {/* display: fixes apparent bug in mui? */} + + + { + roles.map((name) => ( + + {name} + + )) + } + + + + { + permissions.map((name) => ( + + + {name.split(/(?=[A-Z.])/).map((part, index) => ( + {part} + ))} + + { + roles.map((role) => ( + + + + )) + } + + )) + } + +
    +
    +
    + ); + + return ( + + + { + permissions.map((name) => + { + return ( + + {name} + + ) + }) + } + + + ); +} + +export default IntersectionMatrix; diff --git a/src/qqq/pages/records/developer/TableDeveloperView.tsx b/src/qqq/pages/records/developer/TableDeveloperView.tsx index 2f128a6..7294d43 100644 --- a/src/qqq/pages/records/developer/TableDeveloperView.tsx +++ b/src/qqq/pages/records/developer/TableDeveloperView.tsx @@ -47,8 +47,6 @@ TableDeveloperView.defaultProps = function TableDeveloperView({table}: Props): JSX.Element { - const {id} = useParams(); - const {getAccessTokenSilently} = useAuth0(); const [accessToken, setAccessToken] = useState(null as string); @@ -70,6 +68,33 @@ function TableDeveloperView({table}: Props): JSX.Element setAccessToken(accessToken); })(); + const LAST_API_NAME_LS_KEY = "qqq.tableDeveloperView.lastApiName"; + const LAST_API_VERSION_LS_KEY = "qqq.tableDeveloperView.lastApiVersion"; + + const lastSelectedApiName = localStorage.getItem(LAST_API_NAME_LS_KEY); + const lastSelectedApiVersion = localStorage.getItem(LAST_API_VERSION_LS_KEY); + + function selectVersionAfterApiIsChanged(versionsJson: any) + { + if (versionsJson.currentVersion) + { + setSelectedVersion(versionsJson.currentVersion); + localStorage.setItem(LAST_API_VERSION_LS_KEY, versionsJson.currentVersion); + } + + if (lastSelectedApiVersion) + { + for (let i = 0; i < versionsJson.supportedVersions.length; i++) + { + if (versionsJson.supportedVersions[i] == lastSelectedApiVersion) + { + setSelectedVersion(lastSelectedApiVersion); + localStorage.setItem(LAST_API_VERSION_LS_KEY, lastSelectedApiVersion); + } + } + } + } + if (!asyncLoadInited) { setAsyncLoadInited(true); @@ -90,11 +115,14 @@ function TableDeveloperView({table}: Props): JSX.Element setPageHeader(tableMetaData.label + " Developer Mode"); + /////////////////////////////// + // fetch apis for this table // + /////////////////////////////// const apisResponse = await fetch("/apis.json?tableName=" + tableName); const apisJson = await apisResponse.json(); console.log(apisJson); - if(!apisJson["apis"] || apisJson["apis"].length == 0) + if (!apisJson["apis"] || apisJson["apis"].length == 0) { setNoApis(true); return; @@ -102,18 +130,36 @@ function TableDeveloperView({table}: Props): JSX.Element setSupportedApis(apisJson["apis"]); - const selectedApi = apisJson["apis"][0]; + //////////////////////////////////////////////////////////////////////////////////////////////////////// + // either select the 0th api, or, if there was one previously stored in local storage, use it instead // + //////////////////////////////////////////////////////////////////////////////////////////////////////// + let selectedApi = apisJson["apis"][0]; + if (lastSelectedApiName) + { + for (let i = 0; i < apisJson["apis"].length; i++) + { + if (apisJson["apis"][i].name == lastSelectedApiName) + { + selectedApi = apisJson["apis"][i]; + break; + } + } + } + localStorage.setItem(LAST_API_NAME_LS_KEY, selectedApi.name); setSelectedApi(selectedApi); + //////////////////////////////// + // fetch versions for ths api // + //////////////////////////////// const versionsResponse = await fetch(selectedApi["path"] + "versions.json"); const versionsJson = await versionsResponse.json(); console.log(versionsJson); - setSupportedVersions(versionsJson.supportedVersions); - if (versionsJson.currentVersion) - { - setSelectedVersion(versionsJson.currentVersion); - } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // set the selected version, either to current, or to one from local storage, if still valid // + /////////////////////////////////////////////////////////////////////////////////////////////// + selectVersionAfterApiIsChanged(versionsJson); })(); } @@ -129,16 +175,15 @@ function TableDeveloperView({table}: Props): JSX.Element { const selectedApi = supportedApis[i]; setSelectedApi(selectedApi); + localStorage.setItem(LAST_API_NAME_LS_KEY, selectedApi.name); const versionsResponse = await fetch(selectedApi["path"] + "versions.json"); const versionsJson = await versionsResponse.json(); console.log(versionsJson); setSupportedVersions(versionsJson.supportedVersions); - if (versionsJson.currentVersion) - { - setSelectedVersion(versionsJson.currentVersion); - } + + selectVersionAfterApiIsChanged(versionsJson); break; } } @@ -147,6 +192,7 @@ function TableDeveloperView({table}: Props): JSX.Element const selectVersion = (event: SelectChangeEvent) => { setSelectedVersion(event.target.value); + localStorage.setItem(LAST_API_VERSION_LS_KEY, event.target.value); }; return ( @@ -207,7 +253,7 @@ function TableDeveloperView({table}: Props): JSX.Element persist-auth={true} allow-server-selection={false} allow-spec-file-download={true} - sort-endpoints-by="method" + sort-endpoints-by="none" schema-description-expanded={true} css-file={"/api/rapi-doc.css"} css-classes={"qqq-rapi-doc"} diff --git a/src/qqq/pages/records/edit/RecordEdit.tsx b/src/qqq/pages/records/edit/RecordEdit.tsx index 55de7db..66dd34d 100644 --- a/src/qqq/pages/records/edit/RecordEdit.tsx +++ b/src/qqq/pages/records/edit/RecordEdit.tsx @@ -29,9 +29,15 @@ import BaseLayout from "qqq/layouts/BaseLayout"; interface Props { table?: QTableMetaData; + isDuplicate?: boolean } -function EntityEdit({table}: Props): JSX.Element +EntityEdit.defaultProps = { + table: null, + isDuplicate: false +}; + +function EntityEdit({table, isDuplicate}: Props): JSX.Element { const {id} = useParams(); @@ -43,7 +49,7 @@ function EntityEdit({table}: Props): JSX.Element - + @@ -54,8 +60,4 @@ function EntityEdit({table}: Props): JSX.Element ); } -EntityEdit.defaultProps = { - table: null, -}; - export default EntityEdit; diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 3269b4a..cb47647 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -59,6 +59,7 @@ import {QActionsMenuButton, QCancelButton, QCreateNewButton, QSaveButton} from " import MenuButton from "qqq/components/buttons/MenuButton"; import SavedFilters from "qqq/components/misc/SavedFilters"; import {CustomColumnsPanel} from "qqq/components/query/CustomColumnsPanel"; +import {CustomFilterPanel} from "qqq/components/query/CustomFilterPanel"; import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip"; import BaseLayout from "qqq/layouts/BaseLayout"; import ProcessRun from "qqq/pages/processes/ProcessRun"; @@ -173,7 +174,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } const [filterModel, setFilterModel] = useState({items: []} as GridFilterModel); + const [lastFetchedQFilterJSON, setLastFetchedQFilterJSON] = useState(""); const [columnSortModel, setColumnSortModel] = useState(defaultSort); + const [queryFilter, setQueryFilter] = useState(new QQueryFilter()); + const [columnVisibilityModel, setColumnVisibilityModel] = useState(defaultVisibility); const [shouldSetAllNewJoinFieldsToHidden, setShouldSetAllNewJoinFieldsToHidden] = useState(!didDefaultVisibilityComeFromLocalStorage) const [visibleJoinTables, setVisibleJoinTables] = useState(new Set()); @@ -236,7 +240,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [queryErrors, setQueryErrors] = useState({} as any); const [receivedQueryErrorTimestamp, setReceivedQueryErrorTimestamp] = useState(new Date()); - const {setPageHeader} = useContext(QContext); const [, forceUpdate] = useReducer((x) => x + 1, 0); @@ -354,6 +357,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element updateColumnVisibilityModel(); setColumnsModel([]); setFilterModel({items: []}); + setQueryFilter(new QQueryFilter()); setDefaultFilterLoaded(false); setRows([]); } @@ -365,7 +369,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ////////////////////////////////////////////////////////////////////////////////////////////////////// const buildQFilter = (tableMetaData: QTableMetaData, filterModel: GridFilterModel, limit?: number) => { - const filter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, limit); + let filter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, limit); + filter = FilterUtils.convertFilterPossibleValuesToIds(filter); setHasValidFilters(filter.criteria && filter.criteria.length > 0); return (filter); }; @@ -531,6 +536,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element let models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, null, searchParams, filterLocalStorageKey, sortLocalStorageKey); setFilterModel(models.filter); setColumnSortModel(models.sort); + setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, models.filter, models.sort, rowsPerPage)); return; } @@ -564,6 +570,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { columnSortModel.splice(i, 1); setColumnSortModel(columnSortModel); + // todo - need to setQueryFilter? resetColumnSortModel = true; i--; } @@ -580,6 +587,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element sort: "desc", }); setColumnSortModel(columnSortModel); + // todo - need to setQueryFilter? resetColumnSortModel = true; } @@ -636,6 +644,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element }); } + setLastFetchedQFilterJSON(JSON.stringify(qFilter)); qController.query(tableName, qFilter, queryJoins).then((results) => { console.log(`Received results for query ${thisQueryId}`); @@ -878,6 +887,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const newVisibleJoinTables = getVisibleJoinTables(); if (JSON.stringify([...newVisibleJoinTables.keys()]) != JSON.stringify([...visibleJoinTables.keys()])) { + console.log("calling update table for visible join table change"); updateTable(); setVisibleJoinTables(newVisibleJoinTables); } @@ -889,9 +899,30 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element console.log(columnOrderChangeParams); }; - const handleFilterChange = (filterModel: GridFilterModel) => + const handleFilterChange = (filterModel: GridFilterModel, doSetQueryFilter = true, isChangeFromDataGrid = false) => { 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) { localStorage.setItem(filterLocalStorageKey, JSON.stringify(filterModel)); @@ -903,6 +934,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element if (gridSort && gridSort.length > 0) { setColumnSortModel(gridSort); + setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, gridSort, rowsPerPage)); localStorage.setItem(sortLocalStorageKey, JSON.stringify(gridSort)); } }; @@ -967,8 +999,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ////////////////////////////////////// // construct the url for the export // ////////////////////////////////////// - const d = new Date(); - const dateString = `${d.getFullYear()}-${zp(d.getMonth() + 1)}-${zp(d.getDate())} ${zp(d.getHours())}${zp(d.getMinutes())}`; + const dateString = ValueUtils.formatDateTimeForFileName(new Date()); const filename = `${tableMetaData.label} Export ${dateString}.${format}`; const url = `/data/${tableMetaData.name}/export/${filename}`; @@ -1115,6 +1146,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element newPath.pop(); navigate(newPath.join("/")); + console.log("calling update table for close modal"); updateTable(); }; @@ -1224,6 +1256,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return ( + { + if(fieldName.indexOf(".") > -1) + { + const nameParts = fieldName.split(".", 2); + for (let i = 0; i < tableMetaData?.exposedJoins?.length; i++) + { + const join = tableMetaData?.exposedJoins[i]; + if(join?.joinTable.name == nameParts[0]) + { + return ([join.joinTable.fields.get(nameParts[1]), join.joinTable]); + } + } + } + else + { + return ([tableMetaData.fields.get(fieldName), tableMetaData]); + } + + return (null); + } + const copyColumnValues = async (column: GridColDef) => { let data = ""; let counter = 0; if (latestQueryResults && latestQueryResults.length) { - let qFieldMetaData = tableMetaData.fields.get(column.field); + let [qFieldMetaData, fieldTable] = getFieldAndTable(column.field); for (let i = 0; i < latestQueryResults.length; i++) { let record = latestQueryResults[i] as QRecord; - const value = ValueUtils.getUnadornedValueForDisplay(qFieldMetaData, record.values.get(qFieldMetaData.name), record.displayValues.get(qFieldMetaData.name)); + const value = ValueUtils.getUnadornedValueForDisplay(qFieldMetaData, record.values.get(column.field), record.displayValues.get(column.field)); if (value !== null && value !== undefined && String(value) !== "") { data += value + "\n"; @@ -1321,24 +1376,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setFilterForColumnStats(buildQFilter(tableMetaData, filterModel)); setColumnStatsFieldName(column.field); - if(column.field.indexOf(".") > -1) - { - const nameParts = column.field.split(".", 2); - for (let i = 0; i < tableMetaData?.exposedJoins?.length; i++) - { - const join = tableMetaData?.exposedJoins[i]; - if(join?.joinTable.name == nameParts[0]) - { - setColumnStatsField(join.joinTable.fields.get(nameParts[1])); - setColumnStatsFieldTableName(nameParts[0]); - } - } - } - else - { - setColumnStatsField(tableMetaData.fields.get(column.field)); - setColumnStatsFieldTableName(tableMetaData.name); - } + const [field, fieldTable] = getFieldAndTable(column.field); + setColumnStatsField(field); + setColumnStatsFieldTableName(fieldTable.name); }; const CustomColumnMenu = forwardRef( @@ -1502,6 +1542,15 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }; + const doClearFilter = (event: React.KeyboardEvent, isYesButton: boolean = false) => + { + if (isYesButton|| event.key == "Enter") + { + setShowClearFiltersWarning(false); + handleFilterChange({items: []} as GridFilterModel); + } + } + return (
    @@ -1516,30 +1565,18 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { hasValidFilters && ( - -
    - +
    + setShowClearFiltersWarning(true)}>clear - setShowClearFiltersWarning(false)} onKeyPress={(e) => - { - if (e.key == "Enter") - { - setShowClearFiltersWarning(false) - handleFilterChange({items: []} as GridFilterModel); - } - }}> + setShowClearFiltersWarning(false)} onKeyPress={(e) => doClearFilter(e)}> Confirm - Are you sure you want to clear all filters? + Are you sure you want to remove all conditions from the current filter? setShowClearFiltersWarning(false)} /> - - { - setShowClearFiltersWarning(false); - handleFilterChange({items: []} as GridFilterModel); - }}/> + doClearFilter(null, true)}/>
    @@ -1716,7 +1753,22 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setTotalRecords(null); setDistinctRecords(null); 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(() => { @@ -1724,6 +1776,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element document.scrollingElement.scrollTop = 0; }, [pageNumber, rowsPerPage]); + const updateFilterFromFilterPanel = (newFilter: QQueryFilter): void => + { + setQueryFilter(newFilter); + const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); + handleFilterChange(gridFilterModel, false); + }; + if (tableMetaData && !tableMetaData.readPermission) { return ( @@ -1783,7 +1842,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } - + { + metaData && metaData.processes.has("querySavedFilter") && + + } @@ -1794,7 +1856,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission && } - @@ -1804,18 +1865,34 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element Pagination: CustomPagination, LoadingOverlay: Loading, ColumnMenu: CustomColumnMenu, - ColumnsPanel: CustomColumnsPanel + 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} onPinnedColumnsChange={handlePinnedColumnsChange} pagination @@ -1837,7 +1914,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element density={density} loading={loading} filterModel={filterModel} - onFilterModelChange={handleFilterChange} + onFilterModelChange={(model) => handleFilterChange(model, true, true)} columnVisibilityModel={columnVisibilityModel} onColumnVisibilityModelChange={handleColumnVisibilityChange} onColumnOrderChange={handleColumnOrderChange} diff --git a/src/qqq/pages/records/view/RecordView.tsx b/src/qqq/pages/records/view/RecordView.tsx index 33e1c3e..780aecd 100644 --- a/src/qqq/pages/records/view/RecordView.tsx +++ b/src/qqq/pages/records/view/RecordView.tsx @@ -571,6 +571,13 @@ function RecordView({table, launchProcess}: Props): JSX.Element Create New } + { + table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission && + navigate("duplicate")}> + copy + Create Duplicate + + } { table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission && navigate("edit")}> diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index 665b5ef..b645a8f 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -413,3 +413,113 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } 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; +} + diff --git a/src/qqq/utils/DataGridUtils.tsx b/src/qqq/utils/DataGridUtils.tsx index 2ff1c4d..6a04389 100644 --- a/src/qqq/utils/DataGridUtils.tsx +++ b/src/qqq/utils/DataGridUtils.tsx @@ -25,13 +25,42 @@ import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QField import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; -import {getGridDateOperators, GridColDef, GridRowsProp} from "@mui/x-data-grid-pro"; +import {GridColDef, GridFilterItem, GridRowsProp} from "@mui/x-data-grid-pro"; import {GridFilterOperator} from "@mui/x-data-grid/models/gridFilterOperator"; import React from "react"; import {Link} from "react-router-dom"; import {buildQGridPvsOperators, QGridBlobOperators, QGridBooleanOperators, QGridNumericOperators, QGridStringOperators} from "qqq/pages/records/query/GridFilterOperators"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; + +const emptyApplyFilterFn = (filterItem: GridFilterItem, column: GridColDef): null => null; + +function NullInputComponent() +{ + return (); +} + +const makeGridFilterOperator = (value: string, label: string, takesValues: boolean = false): GridFilterOperator => +{ + const rs: GridFilterOperator = {value: value, label: label, getApplyFilterFn: emptyApplyFilterFn}; + if (takesValues) + { + rs.InputComponent = NullInputComponent; + } + return (rs); +}; + +const QGridDateOperators = [ + makeGridFilterOperator("equals", "equals", true), + makeGridFilterOperator("isNot", "not equals", true), + makeGridFilterOperator("after", "is after", true), + makeGridFilterOperator("onOrAfter", "is on or after", true), + makeGridFilterOperator("before", "is before", true), + makeGridFilterOperator("onOrBefore", "is on or before", true), + makeGridFilterOperator("isEmpty", "is empty"), + makeGridFilterOperator("isNotEmpty", "is not empty"), +]; + export default class DataGridUtils { @@ -40,7 +69,7 @@ export default class DataGridUtils *******************************************************************************/ public static makeRows = (results: QRecord[], tableMetaData: QTableMetaData): GridRowsProp[] => { - const fields = [ ...tableMetaData.fields.values() ]; + const fields = [...tableMetaData.fields.values()]; const rows = [] as any[]; let rowIndex = 0; results.forEach((record: QRecord) => @@ -205,6 +234,7 @@ export default class DataGridUtils }); } + /******************************************************************************* ** *******************************************************************************/ @@ -237,12 +267,12 @@ export default class DataGridUtils case QFieldType.DATE: columnType = "date"; columnWidth = 100; - filterOperators = getGridDateOperators(); + filterOperators = QGridDateOperators; break; case QFieldType.DATE_TIME: columnType = "dateTime"; columnWidth = 200; - filterOperators = getGridDateOperators(true); + filterOperators = QGridDateOperators; break; case QFieldType.BOOLEAN: columnType = "string"; // using boolean gives an odd 'no' for nulls. diff --git a/src/qqq/utils/qqq/FilterUtils.ts b/src/qqq/utils/qqq/FilterUtils.ts index 8c7f1d5..e599433 100644 --- a/src/qqq/utils/qqq/FilterUtils.ts +++ b/src/qqq/utils/qqq/FilterUtils.ts @@ -27,7 +27,7 @@ import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QC import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; -import {GridFilterModel, GridLinkOperator, GridSortItem} from "@mui/x-data-grid-pro"; +import {GridFilterItem, GridFilterModel, GridLinkOperator, GridSortItem} from "@mui/x-data-grid-pro"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; const CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedFilterId"; @@ -256,7 +256,7 @@ class FilterUtils } else if (operator === QCriteriaOperator.IN || operator === QCriteriaOperator.NOT_IN || operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN) { - if (value == null && (operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN)) + if ((value == null || value.length < 2) && (operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN)) { ///////////////////////////////////////////////////////////////////////////////////////////////// // if we send back null, we get a 500 - bad look every time you try to set up a BETWEEN filter // @@ -264,10 +264,10 @@ class FilterUtils ///////////////////////////////////////////////////////////////////////////////////////////////// return ([null, null]); } - return (FilterUtils.prepFilterValuesForBackend(value, fieldMetaData)); + return (FilterUtils.cleanseCriteriaValueForQQQ(value, fieldMetaData)); } - return (FilterUtils.prepFilterValuesForBackend([value], fieldMetaData)); + return (FilterUtils.cleanseCriteriaValueForQQQ([value], fieldMetaData)); }; @@ -278,7 +278,7 @@ class FilterUtils ** ** Or, if the values are date-times, convert them to UTC. *******************************************************************************/ - private static prepFilterValuesForBackend = (param: any[], fieldMetaData: QFieldMetaData): number[] | string[] => + private static cleanseCriteriaValueForQQQ = (param: any[], fieldMetaData: QFieldMetaData): number[] | string[] => { if (param === null || param === undefined) { @@ -291,10 +291,15 @@ class FilterUtils console.log(param[i]); if (param[i] && param[i].id && param[i].label) { - ///////////////////////////////////////////////////////////// - // if the param looks like a possible value, return its id // - ///////////////////////////////////////////////////////////// - rs.push(param[i].id); + ////////////////////////////////////////////////////////////////////////////////////////// + // if the param looks like a possible value, return its id // + // during build of new custom filter panel, this ended up causing us // + // problems (because we wanted the full PV object in the filter model for the frontend) // + // so, we can keep the PV as-is here, and see calls to convertFilterPossibleValuesToIds // + // to do what this used to do. // + ////////////////////////////////////////////////////////////////////////////////////////// + // rs.push(param[i].id); + rs.push(param[i]); } else { @@ -464,7 +469,16 @@ class FilterUtils amount = -amount; } + ///////////////////////////////////////////// + // shift the date/time by the input amount // + ///////////////////////////////////////////// value.setTime(value.getTime() + 1000 * amount); + + ///////////////////////////////////////////////// + // now also shift from local-timezone into UTC // + ///////////////////////////////////////////////// + value.setTime(value.getTime() + 1000 * 60 * value.getTimezoneOffset()); + values = [ValueUtils.formatDateTimeISO8601(value)]; } } @@ -585,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 *******************************************************************************/ - public static buildQFilterFromGridFilter(tableMetaData: QTableMetaData, filterModel: GridFilterModel, columnSortModel: GridSortItem[], limit?: number): QQueryFilter + public static buildQFilterFromGridFilter(tableMetaData: QTableMetaData, filterModel: GridFilterModel, columnSortModel: GridSortItem[], limit?: number, allowIncompleteCriteria = false): QQueryFilter { console.log("Building q filter with model:"); console.log(filterModel); @@ -628,13 +699,15 @@ class FilterUtils //////////////////////////////////////////////////////////////////////////////// // if no value set and not 'empty' or 'not empty' operators, skip this filter // //////////////////////////////////////////////////////////////////////////////// - if ((!item.value || item.value.length == 0) && item.operatorValue !== "isEmpty" && item.operatorValue !== "isNotEmpty") + if ((!item.value || item.value.length == 0 || (item.value.length == 1 && item.value[0] == "")) && item.operatorValue !== "isEmpty" && item.operatorValue !== "isNotEmpty") { - return; + if (!allowIncompleteCriteria) + { + return; + } } - var fieldMetadata = tableMetaData?.fields.get(item.columnField); - + const fieldMetadata = tableMetaData?.fields.get(item.columnField); const operator = FilterUtils.gridCriteriaOperatorToQQQ(item.operatorValue); const values = FilterUtils.gridCriteriaValueToQQQ(operator, item.value, item.operatorValue, fieldMetadata); qFilter.addCriteria(new QFilterCriteria(item.columnField, operator, values)); @@ -654,6 +727,37 @@ class FilterUtils 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; diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index 4667fae..81a8657 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -38,6 +38,8 @@ import {Link} from "react-router-dom"; import HtmlUtils from "qqq/utils/HtmlUtils"; import Client from "qqq/utils/qqq/Client"; +import "ace-builds/src-noconflict/mode-sql"; + /******************************************************************************* ** Utility class for working with QQQ Values ** @@ -379,7 +381,7 @@ class ValueUtils ////////////////////////////////////////////////////////////////// return (value + "T00:00"); } - else if (value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?Z$/)) + else if (value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z$/)) { /////////////////////////////////////////////////////////////////////////////////////////////////////// // If the passed in string has a Z on the end (e.g., in UTC) - make a Date object - the browser will // @@ -462,7 +464,12 @@ function CodeViewer({name, mode, code}: {name: string; mode: string; code: strin const [errorMessage, setErrorMessage] = useState(null as string); const [resetErrorTimeout, setResetErrorTimeout] = useState(null as any); - const formatJson = () => + const isFormattable = (mode: string): boolean => + { + return (mode === "json" || mode === "sql"); + }; + + const formatCode = () => { if (isFormatted) { @@ -473,19 +480,44 @@ function CodeViewer({name, mode, code}: {name: string; mode: string; code: strin { try { - let formatted = JSON.stringify(JSON.parse(activeCode), null, 3); + let formatted = activeCode; + + if (mode === "json") + { + formatted = JSON.stringify(JSON.parse(activeCode), null, 3); + } + else if (mode === "sql") + { + formatted = code; + if (formatted.match(/(^|\s)SELECT\s.*\sFROM\s/i)) + { + const beforeAndAfterFrom = formatted.split(/\sFROM\s/, 2); + let beforeFrom = beforeAndAfterFrom[0]; + beforeFrom = beforeFrom.replaceAll(/,\s*/gi, ",\n "); + const afterFrom = beforeAndAfterFrom[1]; + formatted = beforeFrom + " FROM " + afterFrom; + } + formatted = formatted.replaceAll(/(\s*\b(SELECT|SELECT DISTINCT|FROM|WHERE|ORDER BY|GROUP BY|HAVING|INNER JOIN|LEFT JOIN|RIGHT JOIN)\b\s*)/gi, "\n$2\n "); + formatted = formatted.replaceAll(/(\s*\b(AND|OR)\b\s*)/gi, "\n $2 "); + formatted = formatted.replaceAll(/^\s*/g, ""); + } + else + { + console.log(`Unsupported mode for formatting [${mode}]`); + } + setActiveCode(formatted); setIsFormatted(true); } catch (e) { - setErrorMessage("Error formatting json: " + e); + setErrorMessage("Error formatting code: " + e); clearTimeout(resetErrorTimeout); setResetErrorTimeout(setTimeout(() => { setErrorMessage(null); }, 5000)); - console.log("Error formatting json: " + e); + console.log("Error formatting code: " + e); } } }; @@ -497,7 +529,7 @@ function CodeViewer({name, mode, code}: {name: string; mode: string; code: strin return ( - {mode == "json" && code && } + {isFormattable(mode) && code && } {code && } {errorMessage}
    diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QQQMaterialDashboardSelectors.java b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QQQMaterialDashboardSelectors.java index 3fbea4a..7b2b5c1 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QQQMaterialDashboardSelectors.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QQQMaterialDashboardSelectors.java @@ -10,5 +10,5 @@ public interface QQQMaterialDashboardSelectors String BREADCRUMB_HEADER = ".MuiToolbar-root h5"; 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"; } diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java index d53692e..9bbc344 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java @@ -116,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. // + /////////////////// + } + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java index 2d1d9b4..997c522 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java @@ -29,8 +29,8 @@ import com.kingsrook.qqq.materialdashboard.lib.javalin.CapturedContext; import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; import org.openqa.selenium.By; +import org.openqa.selenium.Keys; import org.openqa.selenium.WebElement; -import org.openqa.selenium.support.ui.Select; import static org.assertj.core.api.Assertions.assertThat; @@ -49,7 +49,8 @@ public class QueryScreenTest extends QBaseSeleniumTest super.addJavalinRoutes(qSeleniumJavalin); qSeleniumJavalin .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.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 // ///////////////////////////////////////////////////////////////////// WebElement filterInput = qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_FILTER_INPUT); qSeleniumLib.waitForElementToHaveFocus(filterInput); + filterInput.sendKeys("id"); + filterInput.sendKeys("\t"); + driver.switchTo().activeElement().sendKeys("\t"); qSeleniumJavalin.beginCapture(); - filterInput.sendKeys("1"); + driver.switchTo().activeElement().sendKeys("1"); /////////////////////////////////////////////////////////////////// // 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.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(); + addQueryFilterInput(qSeleniumLib, 0, "First Name", "contains", "Dar", "Or"); addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or"); String expectedFilterContents0 = """ @@ -145,27 +149,43 @@ public class QueryScreenTest extends QBaseSeleniumTest { 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) { - Select linkOperatorSelect = new Select(subFormForField.findElement(By.cssSelector(".MuiDataGrid-filterFormLinkOperatorInput SELECT"))); - linkOperatorSelect.selectByVisibleText(booleanOperator); + WebElement booleanOperatorInput = subFormForField.findElement(By.cssSelector(".booleanOperatorColumn .MuiInput-input")); + booleanOperatorInput.click(); + qSeleniumLib.waitForMillis(100); + + subFormForField.findElement(By.cssSelector(".booleanOperatorColumn .MuiInput-input")); + qSeleniumLib.waitForSelectorContaining("li", booleanOperator).click(); + qSeleniumLib.waitForMillis(100); } - Select fieldSelect = new Select(subFormForField.findElement(By.cssSelector(".MuiDataGrid-filterFormColumnInput SELECT"))); - fieldSelect.selectByVisibleText(fieldLabel); + WebElement fieldInput = subFormForField.findElement(By.cssSelector(".fieldColumn INPUT")); + fieldInput.click(); + qSeleniumLib.waitForMillis(100); + fieldInput.clear(); + fieldInput.sendKeys(fieldLabel); + qSeleniumLib.waitForMillis(100); + fieldInput.sendKeys("\n"); + qSeleniumLib.waitForMillis(100); - Select operatorSelect = new Select(subFormForField.findElement(By.cssSelector(".MuiDataGrid-filterFormOperatorInput SELECT"))); - operatorSelect.selectByVisibleText(operator); + WebElement operatorInput = subFormForField.findElement(By.cssSelector(".operatorColumn INPUT")); + operatorInput.click(); + qSeleniumLib.waitForMillis(100); + operatorInput.sendKeys(Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, operator); + qSeleniumLib.waitForMillis(100); + operatorInput.sendKeys("\n"); + qSeleniumLib.waitForMillis(100); - WebElement valueInput = subFormForField.findElement(By.cssSelector(".MuiDataGrid-filterFormValueInput INPUT")); + WebElement valueInput = subFormForField.findElement(By.cssSelector(".filterValuesColumn INPUT")); valueInput.click(); valueInput.sendKeys(value); - qSeleniumLib.waitForSeconds(1); + qSeleniumLib.waitForMillis(100); } } diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java index 1252d6b..9bbe204 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java @@ -108,7 +108,7 @@ public class SavedFiltersTest extends QBaseSeleniumTest ////////////////////// // modify the query // ////////////////////// - qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filters").click(); + qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click(); addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or"); qSeleniumLib.waitForSelectorContaining("H5", "Person").click(); qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People")