Merge branch 'refs/heads/wip/dashboard-custom-timeframe' into dev

This commit is contained in:
2023-03-15 08:25:02 -05:00
9 changed files with 196 additions and 29 deletions

View File

@ -6,7 +6,7 @@
"@auth0/auth0-react": "1.10.2", "@auth0/auth0-react": "1.10.2",
"@emotion/react": "11.7.1", "@emotion/react": "11.7.1",
"@emotion/styled": "11.6.0", "@emotion/styled": "11.6.0",
"@kingsrook/qqq-frontend-core": "1.0.55", "@kingsrook/qqq-frontend-core": "1.0.56",
"@mui/icons-material": "5.4.1", "@mui/icons-material": "5.4.1",
"@mui/material": "5.11.1", "@mui/material": "5.11.1",
"@mui/styles": "5.11.1", "@mui/styles": "5.11.1",

View File

@ -130,6 +130,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
const navigate = useNavigate(); const navigate = useNavigate();
const [dropdownData, setDropdownData] = useState([]); const [dropdownData, setDropdownData] = useState([]);
const [counter, setCounter] = useState(0); const [counter, setCounter] = useState(0);
const [fullScreenWidgetClassName, setFullScreenWidgetClassName] = useState("");
function openEditForm(table: QTableMetaData, id: any = null, defaultValues: any, disabledFields: any) function openEditForm(table: QTableMetaData, id: any = null, defaultValues: any, disabledFields: any)
{ {
@ -175,6 +176,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
return ( return (
<Box my={2} sx={{float: "right"}}> <Box my={2} sx={{float: "right"}}>
<DropdownMenu <DropdownMenu
name={dropdownName}
defaultValue={defaultValue} defaultValue={defaultValue}
sx={{width: 200, marginLeft: "15px"}} sx={{width: 200, marginLeft: "15px"}}
label={`Select ${dropdown.label}`} label={`Select ${dropdown.label}`}
@ -283,6 +285,18 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
} }
}, [counter]); }, [counter]);
const toggleFullScreenWidget = () =>
{
if(fullScreenWidgetClassName)
{
setFullScreenWidgetClassName("");
}
else
{
setFullScreenWidgetClassName("fullScreenWidget");
}
}
const hasPermission = props.widgetData?.hasPermission === undefined || props.widgetData?.hasPermission === true; const hasPermission = props.widgetData?.hasPermission === undefined || props.widgetData?.hasPermission === true;
const widgetContent = const widgetContent =
<Box sx={{width: "100%", height: "100%", minHeight: props.widgetMetaData?.minHeight ? props.widgetMetaData?.minHeight : "initial"}}> <Box sx={{width: "100%", height: "100%", minHeight: props.widgetMetaData?.minHeight ? props.widgetMetaData?.minHeight : "initial"}}>
@ -336,17 +350,22 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
// first look for a label in the widget data, which would override that in the metadata // // first look for a label in the widget data, which would override that in the metadata //
////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////
hasPermission && props.widgetData?.label? ( hasPermission && props.widgetData?.label? (
<Typography sx={{position: "relative", top: -4}} variant="h6" fontWeight="medium" pl={2} display="inline"> <Typography sx={{position: "relative", top: -4}} variant="h6" fontWeight="medium" pl={2} display="inline-block">
{props.widgetData.label} {props.widgetData.label}
</Typography> </Typography>
) : ( ) : (
hasPermission && props.widgetMetaData?.label && ( hasPermission && props.widgetMetaData?.label && (
<Typography sx={{position: "relative", top: -4}} variant="h6" fontWeight="medium" pl={3} display="inline"> <Typography sx={{position: "relative", top: -4}} variant="h6" fontWeight="medium" pl={3} display="inline-block">
{props.widgetMetaData.label} {props.widgetMetaData.label}
</Typography> </Typography>
) )
) )
} }
{/*
<Button onClick={() => toggleFullScreenWidget()}>
{fullScreenWidgetClassName ? "-" : "+"}
</Button>
*/}
{ {
hasPermission && ( hasPermission && (
props.labelAdditionalComponentsLeft.map((component, i) => props.labelAdditionalComponentsLeft.map((component, i) =>
@ -384,7 +403,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
} }
</Box>; </Box>;
return props.widgetMetaData?.isCard ? <Card sx={{marginTop: props.widgetMetaData?.icon ? 2 : 0, width: "100%"}}>{widgetContent}</Card> : widgetContent; return props.widgetMetaData?.isCard ? <Card sx={{marginTop: props.widgetMetaData?.icon ? 2 : 0, width: "100%"}} className={fullScreenWidgetClassName}>{widgetContent}</Card> : widgetContent;
} }
export default Widget; export default Widget;

View File

@ -46,6 +46,8 @@ export const options = {
scales: { scales: {
x: { x: {
stacked: true, stacked: true,
grid: {offset: false},
ticks: {autoSkip: false, maxRotation: 90}
}, },
y: { y: {
stacked: true, stacked: true,

View File

@ -19,10 +19,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Theme} from "@mui/material"; import {Collapse, Theme} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete"; import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import {SxProps} from "@mui/system"; import {SxProps} from "@mui/system";
import {Field, Form, Formik} from "formik";
import React, {useState} from "react";
import MDInput from "qqq/components/legacy/MDInput";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
export interface DropdownOption export interface DropdownOption
@ -36,6 +42,7 @@ export interface DropdownOption
///////////////////////// /////////////////////////
interface Props interface Props
{ {
name: string;
defaultValue?: any; defaultValue?: any;
label?: string; label?: string;
dropdownOptions?: DropdownOption[]; dropdownOptions?: DropdownOption[];
@ -43,23 +50,119 @@ interface Props
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
function DropdownMenu({defaultValue, label, dropdownOptions, onChangeCallback, sx}: Props): JSX.Element interface StartAndEndDate
{ {
startDate?: string,
endDate?: string
}
function parseCustomTimeValuesFromDefaultValue(defaultValue: any): StartAndEndDate
{
const customTimeValues: StartAndEndDate = {};
if(defaultValue && defaultValue.id)
{
var parts = defaultValue.id.split(",");
if(parts.length >= 2)
{
customTimeValues["startDate"] = ValueUtils.formatDateTimeValueForForm(parts[1]);
}
if(parts.length >= 3)
{
customTimeValues["endDate"] = ValueUtils.formatDateTimeValueForForm(parts[2]);
}
}
return (customTimeValues);
}
function makeBackendValuesFromFrontendValues(frontendDefaultValues: StartAndEndDate): StartAndEndDate
{
const backendTimeValues: StartAndEndDate = {};
if(frontendDefaultValues && frontendDefaultValues.startDate)
{
backendTimeValues.startDate = FilterUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(frontendDefaultValues.startDate);
}
if(frontendDefaultValues && frontendDefaultValues.endDate)
{
backendTimeValues.endDate = FilterUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(frontendDefaultValues.endDate);
}
return (backendTimeValues);
}
function DropdownMenu({name, defaultValue, label, dropdownOptions, onChangeCallback, sx}: Props): JSX.Element
{
const [customTimesVisible, setCustomTimesVisible] = useState(defaultValue && defaultValue.id && defaultValue.id.startsWith("custom,"));
const [customTimeValuesFrontend, setCustomTimeValuesFrontend] = useState(parseCustomTimeValuesFromDefaultValue(defaultValue) as StartAndEndDate);
const [customTimeValuesBackend, setCustomTimeValuesBackend] = useState(makeBackendValuesFromFrontendValues(customTimeValuesFrontend) as StartAndEndDate);
const [debounceTimeout, setDebounceTimeout] = useState(null as any);
const handleOnChange = (event: any, newValue: any, reason: string) => const handleOnChange = (event: any, newValue: any, reason: string) =>
{ {
onChangeCallback(label, newValue); const isTimeframeCustom = name == "timeframe" && newValue && newValue.id == "custom"
setCustomTimesVisible(isTimeframeCustom);
if(isTimeframeCustom)
{
callOnChangeCallbackIfCustomTimeframeHasDateValues();
}
else
{
onChangeCallback(label, newValue);
}
};
const callOnChangeCallbackIfCustomTimeframeHasDateValues = () =>
{
if(customTimeValuesBackend["startDate"] && customTimeValuesBackend["endDate"])
{
onChangeCallback(label, {id: `custom,${customTimeValuesBackend["startDate"]},${customTimeValuesBackend["endDate"]}`, label: "Custom"});
}
}
let customTimes = <></>;
if (name == "timeframe")
{
const handleSubmit = async (values: any, actions: any) =>
{
};
const dateChanged = (fieldName: "startDate" | "endDate", event: any) =>
{
customTimeValuesFrontend[fieldName] = event.target.value;
customTimeValuesBackend[fieldName] = FilterUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(event.target.value);
clearTimeout(debounceTimeout);
const newDebounceTimeout = setTimeout(() =>
{
callOnChangeCallbackIfCustomTimeframeHasDateValues();
}, 500);
setDebounceTimeout(newDebounceTimeout);
};
customTimes = <Box sx={{display: "inline-block", position: "relative", top: "-7px"}}>
<Collapse orientation="horizontal" in={customTimesVisible}>
<Formik initialValues={customTimeValuesFrontend} onSubmit={handleSubmit}>
{({}) => (
<Form id="timeframe-form" autoComplete="off">
<Field name="startDate" type="datetime-local" as={MDInput} variant="standard" label="Custom Timeframe Start" InputLabelProps={{shrink: true}} InputProps={{size: "small"}} sx={{ml: 2, width: 198}} onChange={(event: any) => dateChanged("startDate", event)} />
<Field name="endDate" type="datetime-local" as={MDInput} variant="standard" label="Custom Timeframe End" InputLabelProps={{shrink: true}} InputProps={{size: "small"}} sx={{ml: 2, width: 198}} onChange={(event: any) => dateChanged("endDate", event)} />
</Form>
)}
</Formik>
</Collapse>
</Box>;
} }
return ( return (
dropdownOptions ? ( dropdownOptions ? (
<span style={{whiteSpace: "nowrap"}}> <span style={{whiteSpace: "nowrap", display: "flex"}} className="dashboardDropdownMenu">
<Autocomplete <Autocomplete
defaultValue={defaultValue} defaultValue={defaultValue}
size="small" size="small"
disablePortal disablePortal
id={`${label}-combo-box`} id={`${label}-combo-box`}
options={dropdownOptions} options={dropdownOptions}
sx={{...sx, cursor: "pointer"}} sx={{...sx, cursor: "pointer", display: "inline-block"}}
onChange={handleOnChange} onChange={handleOnChange}
isOptionEqualToValue={(option, value) => option.id === value.id} isOptionEqualToValue={(option, value) => option.id === value.id}
renderInput={(params: any) => <TextField {...params} label={label} />} renderInput={(params: any) => <TextField {...params} label={label} />}
@ -67,9 +170,10 @@ function DropdownMenu({defaultValue, label, dropdownOptions, onChangeCallback, s
<li {...props} style={{whiteSpace: "normal"}}>{option.label}</li> <li {...props} style={{whiteSpace: "normal"}}>{option.label}</li>
)} )}
/> />
{customTimes}
</span> </span>
) : null ) : null
) );
} }
export default DropdownMenu; export default DropdownMenu;

View File

@ -289,7 +289,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
const buildQFilter = (filterModel: GridFilterModel) => //////////////////////////////////////////////////////////////////////////////////////////////////////
// note - important to take tableMetaData as a param, even though it's a state var, as the //
// first time we call in here, we may not yet have set it in state (but will have fetched it async) //
// so we'll pass in the local version of it! //
//////////////////////////////////////////////////////////////////////////////////////////////////////
const buildQFilter = (tableMetaData: QTableMetaData, filterModel: GridFilterModel) =>
{ {
const filter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel); const filter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel);
setHasValidFilters(filter.criteria && filter.criteria.length > 0); setHasValidFilters(filter.criteria && filter.criteria.length > 0);
@ -337,6 +342,15 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
setTableMetaData(tableMetaData); setTableMetaData(tableMetaData);
setTableLabel(tableMetaData.label); setTableLabel(tableMetaData.label);
if(columnsModel.length == 0)
{
let linkBase = metaData.getTablePath(table)
linkBase += linkBase.endsWith("/") ? "" : "/";
const columns = DataGridUtils.setupGridColumns(tableMetaData, null, linkBase);
setColumnsModel(columns);
}
if (columnSortModel.length === 0) if (columnSortModel.length === 0)
{ {
columnSortModel.push({ columnSortModel.push({
@ -346,7 +360,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
setColumnSortModel(columnSortModel); setColumnSortModel(columnSortModel);
} }
const qFilter = buildQFilter(localFilterModel); const qFilter = buildQFilter(tableMetaData, localFilterModel);
////////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////////
// assign a new query id to the query being issued here. then run both the count & query async // // assign a new query id to the query being issued here. then run both the count & query async //
@ -440,14 +454,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
const {rows, columnsToRender} = DataGridUtils.makeRows(results, tableMetaData); const {rows, columnsToRender} = DataGridUtils.makeRows(results, tableMetaData);
if(columnsModel.length == 0)
{
let linkBase = metaData.getTablePath(table)
linkBase += linkBase.endsWith("/") ? "" : "/";
const columns = DataGridUtils.setupGridColumns(tableMetaData, columnsToRender, linkBase);
setColumnsModel(columns);
}
setRows(rows); setRows(rows);
setLoading(false); setLoading(false);
@ -616,6 +622,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{ {
(async () => (async () =>
{ {
setTableMetaData(null);
setTableState(tableName); setTableState(tableName);
const metaData = await qController.loadMetaData(); const metaData = await qController.loadMetaData();
setMetaData(metaData); setMetaData(metaData);
@ -675,7 +682,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
const d = new Date(); const d = new Date();
const dateString = `${d.getFullYear()}-${zp(d.getMonth()+1)}-${zp(d.getDate())} ${zp(d.getHours())}${zp(d.getMinutes())}`; const dateString = `${d.getFullYear()}-${zp(d.getMonth()+1)}-${zp(d.getDate())} ${zp(d.getHours())}${zp(d.getMinutes())}`;
const filename = `${tableMetaData.label} Export ${dateString}.${format}`; const filename = `${tableMetaData.label} Export ${dateString}.${format}`;
const url = `/data/${tableMetaData.name}/export/${filename}?filter=${encodeURIComponent(JSON.stringify(buildQFilter(filterModel)))}&fields=${visibleFields.join(",")}`; const url = `/data/${tableMetaData.name}/export/${filename}?filter=${encodeURIComponent(JSON.stringify(buildQFilter(tableMetaData, filterModel)))}&fields=${visibleFields.join(",")}`;
////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////
// open a window (tab) with a little page that says the file is being generated. // // open a window (tab) with a little page that says the file is being generated. //
@ -742,7 +749,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{ {
if (selectFullFilterState === "filter") if (selectFullFilterState === "filter")
{ {
return `?recordsParam=filterJSON&filterJSON=${JSON.stringify(buildQFilter(filterModel))}`; return `?recordsParam=filterJSON&filterJSON=${JSON.stringify(buildQFilter(tableMetaData, filterModel))}`;
} }
if (selectedIds.length > 0) if (selectedIds.length > 0)
@ -757,7 +764,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{ {
if (selectFullFilterState === "filter") if (selectFullFilterState === "filter")
{ {
setRecordIdsForProcess(buildQFilter(filterModel)); setRecordIdsForProcess(buildQFilter(tableMetaData, filterModel));
} }
else if (selectedIds.length > 0) else if (selectedIds.length > 0)
{ {

View File

@ -379,3 +379,8 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
} }
.dashboardDropdownMenu #timeframe-form label
{
font-size: 0.875rem;
}

View File

@ -175,7 +175,10 @@ export default class DataGridUtils
filterOperators: filterOperators, filterOperators: filterOperators,
}; };
if (columnsToRender[field.name]) /////////////////////////////////////////////////////////////////////////////////////////
// looks like, maybe we can just always render all columns, and remove this parameter? //
/////////////////////////////////////////////////////////////////////////////////////////
if (columnsToRender == null || columnsToRender[field.name])
{ {
column.renderCell = (cellValues: any) => ( column.renderCell = (cellValues: any) => (
(cellValues.value) (cellValues.value)

View File

@ -314,11 +314,7 @@ class FilterUtils
{ {
try try
{ {
let localDate = new Date(param[i]); let toPush = this.frontendLocalZoneDateTimeStringToUTCStringForBackend(param[i]);
let month = (1 + localDate.getUTCMonth());
let zp = FilterUtils.zeroPad;
let toPush = localDate.getUTCFullYear() + "-" + zp(month) + "-" + zp(localDate.getUTCDate()) + "T" + zp(localDate.getUTCHours()) + ":" + zp(localDate.getUTCMinutes()) + ":" + zp(localDate.getUTCSeconds()) + "Z";
console.log(`Input date was ${localDate}. Sending to backend as ${toPush}`);
rs.push(toPush); rs.push(toPush);
} }
catch (e) catch (e)
@ -336,6 +332,22 @@ class FilterUtils
return (rs); return (rs);
}; };
/*******************************************************************************
** Take a string date (w/o a timezone) like that our calendar widgets make,
** and convert it to UTC, e.g., for submitting to the backend.
*******************************************************************************/
public static frontendLocalZoneDateTimeStringToUTCStringForBackend(param: string)
{
let localDate = new Date(param);
let month = (1 + localDate.getUTCMonth());
let zp = FilterUtils.zeroPad;
let toPush = localDate.getUTCFullYear() + "-" + zp(month) + "-" + zp(localDate.getUTCDate()) + "T" + zp(localDate.getUTCHours()) + ":" + zp(localDate.getUTCMinutes()) + ":" + zp(localDate.getUTCSeconds()) + "Z";
console.log(`Input date was ${localDate}. Sending to backend as ${toPush}`);
return toPush;
}
/******************************************************************************* /*******************************************************************************
** Convert a filter field's value from the style that qqq uses, to the style that ** Convert a filter field's value from the style that qqq uses, to the style that
** the grid uses. ** the grid uses.

View File

@ -353,6 +353,21 @@ class ValueUtils
////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////
return (value + "T00:00"); return (value + "T00:00");
} }
else if (value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?Z$/))
{
///////////////////////////////////////////////////////////////////////////////////////////////////////
// If the passed in string has a Z on the end (e.g., in UTC) - make a Date object - the browser will //
// shift the value into the user's time zone, so it will display correctly for them //
///////////////////////////////////////////////////////////////////////////////////////////////////////
const date = new Date(value);
// @ts-ignore
const formattedDate = `${date.toString("yyyy-MM-ddTHH:mm")}`
console.log(`Converted UTC date value string [${value}] to local time value for form [${formattedDate}]`)
return (formattedDate);
}
else if (value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}.*/)) else if (value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}.*/))
{ {
/////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////