From 94767bcbb3f92e355fb5113a21c3b8d18aeec85d Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 14 Mar 2023 17:01:43 -0500 Subject: [PATCH] Better timezone support on query and dropdowns/custom-timeframes. --- package.json | 2 +- .../widgets/charts/StackedBarChart.tsx | 2 + .../widgets/components/DropdownMenu.tsx | 69 ++++++++++++------- src/qqq/pages/records/query/RecordQuery.tsx | 33 +++++---- src/qqq/styles/qqq-override-styles.css | 5 ++ src/qqq/utils/DataGridUtils.tsx | 5 +- src/qqq/utils/qqq/FilterUtils.ts | 22 ++++-- src/qqq/utils/qqq/ValueUtils.tsx | 15 ++++ 8 files changed, 110 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index 3cf7568..f001b13 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@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/material": "5.11.1", "@mui/styles": "5.11.1", diff --git a/src/qqq/components/widgets/charts/StackedBarChart.tsx b/src/qqq/components/widgets/charts/StackedBarChart.tsx index 4cd2d7f..f205ce8 100644 --- a/src/qqq/components/widgets/charts/StackedBarChart.tsx +++ b/src/qqq/components/widgets/charts/StackedBarChart.tsx @@ -46,6 +46,8 @@ export const options = { scales: { x: { stacked: true, + grid: {offset: false}, + ticks: {autoSkip: false, maxRotation: 90} }, y: { stacked: true, diff --git a/src/qqq/components/widgets/components/DropdownMenu.tsx b/src/qqq/components/widgets/components/DropdownMenu.tsx index d5ce522..fb6f994 100644 --- a/src/qqq/components/widgets/components/DropdownMenu.tsx +++ b/src/qqq/components/widgets/components/DropdownMenu.tsx @@ -24,9 +24,11 @@ import Autocomplete from "@mui/material/Autocomplete"; import Box from "@mui/material/Box"; import TextField from "@mui/material/TextField"; import {SxProps} from "@mui/system"; -import {Field, Form, Formik, useFormik} from "formik"; +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 @@ -48,29 +50,51 @@ interface Props sx?: SxProps; } -function parseCustomTimeValuesFromDefaultValue(defaultValue: any): any +interface StartAndEndDate { - const customTimeValues: { [key: string]: string } = {}; + 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"] = parts[1]; + customTimeValues["startDate"] = ValueUtils.formatDateTimeValueForForm(parts[1]); } if(parts.length >= 3) { - customTimeValues["endDate"] = parts[2]; + 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 [customTimeValues, setCustomTimeValues] = useState(parseCustomTimeValuesFromDefaultValue(defaultValue) as any); + 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) => { @@ -89,9 +113,9 @@ function DropdownMenu({name, defaultValue, label, dropdownOptions, onChangeCallb const callOnChangeCallbackIfCustomTimeframeHasDateValues = () => { - if(customTimeValues["startDate"] && customTimeValues["endDate"]) + if(customTimeValuesBackend["startDate"] && customTimeValuesBackend["endDate"]) { - onChangeCallback(label, {id: `custom,${customTimeValues["startDate"]},${customTimeValues["endDate"]}`, label: "Custom"}); + onChangeCallback(label, {id: `custom,${customTimeValuesBackend["startDate"]},${customTimeValuesBackend["endDate"]}`, label: "Custom"}); } } @@ -102,27 +126,26 @@ function DropdownMenu({name, defaultValue, label, dropdownOptions, onChangeCallb { }; - const dateChanged = (fieldName: string, event: any) => + const dateChanged = (fieldName: "startDate" | "endDate", event: any) => { - console.log(event.target.value); - customTimeValues[fieldName] = event.target.value; - console.log(customTimeValues); + customTimeValuesFrontend[fieldName] = event.target.value; + customTimeValuesBackend[fieldName] = FilterUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(event.target.value); - callOnChangeCallbackIfCustomTimeframeHasDateValues(); + clearTimeout(debounceTimeout); + const newDebounceTimeout = setTimeout(() => + { + callOnChangeCallbackIfCustomTimeframeHasDateValues(); + }, 500); + setDebounceTimeout(newDebounceTimeout); }; customTimes = - - {({ - values, - errors, - touched, - isSubmitting, - }) => ( + + {({}) => (
- dateChanged("startDate", event)} /> - dateChanged("endDate", event)} /> + dateChanged("startDate", event)} /> + dateChanged("endDate", event)} /> )}
@@ -132,7 +155,7 @@ function DropdownMenu({name, defaultValue, label, dropdownOptions, onChangeCallb return ( dropdownOptions ? ( - + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // 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); setHasValidFilters(filter.criteria && filter.criteria.length > 0); @@ -337,6 +342,15 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setTableMetaData(tableMetaData); 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) { columnSortModel.push({ @@ -346,7 +360,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element 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 // @@ -440,14 +454,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element 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); setLoading(false); @@ -616,6 +622,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { (async () => { + setTableMetaData(null); setTableState(tableName); const metaData = await qController.loadMetaData(); setMetaData(metaData); @@ -675,7 +682,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const d = new Date(); const dateString = `${d.getFullYear()}-${zp(d.getMonth()+1)}-${zp(d.getDate())} ${zp(d.getHours())}${zp(d.getMinutes())}`; const filename = `${tableMetaData.label} Export ${dateString}.${format}`; - const 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. // @@ -742,7 +749,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { if (selectFullFilterState === "filter") { - return `?recordsParam=filterJSON&filterJSON=${JSON.stringify(buildQFilter(filterModel))}`; + return `?recordsParam=filterJSON&filterJSON=${JSON.stringify(buildQFilter(tableMetaData, filterModel))}`; } if (selectedIds.length > 0) @@ -757,7 +764,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { if (selectFullFilterState === "filter") { - setRecordIdsForProcess(buildQFilter(filterModel)); + setRecordIdsForProcess(buildQFilter(tableMetaData, filterModel)); } else if (selectedIds.length > 0) { diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index 0b5585c..c01fa06 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -379,3 +379,8 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } padding-left: 0; padding-right: 0; } + +.dashboardDropdownMenu #timeframe-form label +{ + font-size: 0.875rem; +} \ No newline at end of file diff --git a/src/qqq/utils/DataGridUtils.tsx b/src/qqq/utils/DataGridUtils.tsx index 0438872..6d6bee0 100644 --- a/src/qqq/utils/DataGridUtils.tsx +++ b/src/qqq/utils/DataGridUtils.tsx @@ -175,7 +175,10 @@ export default class DataGridUtils 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) => ( (cellValues.value) diff --git a/src/qqq/utils/qqq/FilterUtils.ts b/src/qqq/utils/qqq/FilterUtils.ts index 257e875..c54758c 100644 --- a/src/qqq/utils/qqq/FilterUtils.ts +++ b/src/qqq/utils/qqq/FilterUtils.ts @@ -314,11 +314,7 @@ class FilterUtils { try { - let localDate = new Date(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}`); + let toPush = this.frontendLocalZoneDateTimeStringToUTCStringForBackend(param[i]); rs.push(toPush); } catch (e) @@ -336,6 +332,22 @@ class FilterUtils 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 ** the grid uses. diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index 65e5b08..c46a404 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -353,6 +353,21 @@ class ValueUtils ////////////////////////////////////////////////////////////////// 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}.*/)) { ///////////////////////////////////////////////////////////////////////////////////