diff --git a/packages/bbui/src/Form/Core/DatePicker.svelte b/packages/bbui/src/Form/Core/DatePicker.svelte index 73ba7bb642..39a7d9d626 100644 --- a/packages/bbui/src/Form/Core/DatePicker.svelte +++ b/packages/bbui/src/Form/Core/DatePicker.svelte @@ -15,6 +15,7 @@ export let placeholder = null export let appendTo = undefined export let timeOnly = false + export let ignoreTimezones = false const dispatch = createEventDispatcher() const flatpickrId = `${uuid()}-wrapper` @@ -50,19 +51,35 @@ const handleChange = event => { const [dates] = event.detail + const noTimezone = enableTime && !timeOnly && ignoreTimezones let newValue = dates[0] if (newValue) { newValue = newValue.toISOString() } - // if time only set date component to 2000-01-01 + + // If time only set date component to 2000-01-01 if (timeOnly) { newValue = `2000-01-01T${newValue.split("T")[1]}` } - // date only, offset for timezone so always right date + + // For date-only fields, construct a manual timestamp string without a time + // or time zone else if (!enableTime) { - const offset = dates[0].getTimezoneOffset() * 60000 - newValue = new Date(dates[0].getTime() - offset).toISOString() + const year = dates[0].getFullYear() + const month = `${dates[0].getMonth() + 1}`.padStart(2, "0") + const day = `${dates[0].getDate()}`.padStart(2, "0") + newValue = `${year}-${month}-${day}T00:00:00.000` } + + // For non-timezone-aware fields, create an ISO 8601 timestamp of the exact + // time picked, without timezone + else if (noTimezone) { + const offset = dates[0].getTimezoneOffset() * 60000 + newValue = new Date(dates[0].getTime() - offset) + .toISOString() + .slice(0, -1) + } + dispatch("change", newValue) } @@ -112,10 +129,12 @@ // Treat as numerical timestamp date = new Date(parseInt(val)) } + time = date.getTime() if (isNaN(time)) { return null } + // By rounding to the nearest second we avoid locking up in an endless // loop in the builder, caused by potentially enriching {{ now }} to every // millisecond. diff --git a/packages/bbui/src/Form/DatePicker.svelte b/packages/bbui/src/Form/DatePicker.svelte index 9298c49177..a4b2379782 100644 --- a/packages/bbui/src/Form/DatePicker.svelte +++ b/packages/bbui/src/Form/DatePicker.svelte @@ -12,6 +12,7 @@ export let timeOnly = false export let placeholder = null export let appendTo = undefined + export let ignoreTimezones = false const dispatch = createEventDispatcher() @@ -30,6 +31,7 @@ {enableTime} {timeOnly} {appendTo} + {ignoreTimezones} on:change={onChange} /> diff --git a/packages/bbui/src/Tooltip/TooltipWrapper.svelte b/packages/bbui/src/Tooltip/TooltipWrapper.svelte index 78c69942e5..92f5c6f474 100644 --- a/packages/bbui/src/Tooltip/TooltipWrapper.svelte +++ b/packages/bbui/src/Tooltip/TooltipWrapper.svelte @@ -39,7 +39,6 @@ position: relative; display: flex; justify-content: center; - margin-top: 1px; margin-left: 5px; margin-right: 5px; } diff --git a/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js b/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js index e748161529..9176d535ab 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js +++ b/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js @@ -170,28 +170,29 @@ export function makeDatasourceFormComponents(datasource) { optionsType: "select", optionsSource: "schema", }) - } - if (fieldType === "longform") { + } else if (fieldType === "longform") { component.customProps({ format: "auto", }) - } - if (fieldType === "array") { + } else if (fieldType === "array") { component.customProps({ placeholder: "Choose an option", optionsSource: "schema", }) - } - - if (fieldType === "link") { + } else if (fieldType === "link") { let placeholder = fieldSchema.relationshipType === "one-to-many" ? "Choose an option" : "Choose some options" component.customProps({ placeholder }) - } - if (fieldType === "boolean") { + } else if (fieldType === "boolean") { component.customProps({ text: field, label: "" }) + } else if (fieldType === "datetime") { + component.customProps({ + enableTime: !fieldSchema?.dateOnly, + timeOnly: fieldSchema?.timeOnly, + ignoreTimezones: fieldSchema.ignoreTimezones, + }) } components.push(component) } diff --git a/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte b/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte index 2286ae82aa..ab18f744fc 100644 --- a/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte +++ b/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte @@ -53,6 +53,7 @@ {label} timeOnly={isTimeStamp} enableTime={!meta?.dateOnly} + ignoreTimezones={meta?.ignoreTimezones} bind:value /> {:else if type === "attachment"} diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index 62a367ea7d..77ab75827f 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -14,7 +14,7 @@ } from "@budibase/bbui" import { createEventDispatcher, onMount } from "svelte" import { cloneDeep } from "lodash/fp" - import { tables } from "stores/backend" + import { tables, datasources } from "stores/backend" import { TableNames, UNEDITABLE_USER_FIELDS } from "constants" import { FIELDS, @@ -63,6 +63,7 @@ let primaryDisplay = $tables.selected.primaryDisplay == null || $tables.selected.primaryDisplay === field.name + let isCreating = originalName == null let table = $tables.selected let indexes = [...($tables.selected.indexes || [])] @@ -81,6 +82,9 @@ (field.type === LINK_TYPE && !field.tableId) || Object.keys(errors).length !== 0 $: errors = checkErrors(field) + $: datasource = $datasources.list.find( + source => source._id === table?.sourceId + ) // used to select what different options can be displayed for column type $: canBeSearched = @@ -430,6 +434,18 @@ bind:value={field.constraints.datetime.earliest} /> + {#if datasource?.source !== "ORACLE" && datasource?.source !== "SQL_SERVER"} +
+ + +
+ {/if} {:else if field.type === "number"} {/if} diff --git a/packages/server/src/api/controllers/row/ExternalRequest.ts b/packages/server/src/api/controllers/row/ExternalRequest.ts index c8c8ae8e58..7983044f66 100644 --- a/packages/server/src/api/controllers/row/ExternalRequest.ts +++ b/packages/server/src/api/controllers/row/ExternalRequest.ts @@ -29,7 +29,10 @@ import { breakExternalTableId, isSQL } from "../../../integrations/utils" import { processObjectSync } from "@budibase/string-templates" // @ts-ignore import { cloneDeep } from "lodash/fp" -import { processFormulas } from "../../../utilities/rowProcessor/utils" +import { + processFormulas, + processDates, +} from "../../../utilities/rowProcessor/utils" // @ts-ignore import { getAppDB } from "@budibase/backend-core/context" @@ -434,7 +437,13 @@ module External { relationships ) } - return processFormulas(table, Object.values(finalRows)).map((row: Row) => + + // Process some additional data types + let finalRowArray = Object.values(finalRows) + finalRowArray = processDates(table, finalRowArray) + finalRowArray = processFormulas(table, finalRowArray) + + return finalRowArray.map((row: Row) => this.squashRelationshipColumns(table, row, relationships) ) } diff --git a/packages/server/src/definitions/common.ts b/packages/server/src/definitions/common.ts index 3ee6a71c8f..4aec0d103d 100644 --- a/packages/server/src/definitions/common.ts +++ b/packages/server/src/definitions/common.ts @@ -25,6 +25,7 @@ export interface FieldSchema { formula?: string formulaType?: string main?: boolean + ignoreTimezones?: boolean meta?: { toTable: string toKey: string diff --git a/packages/server/src/integrations/base/sqlTable.ts b/packages/server/src/integrations/base/sqlTable.ts index 0c63b707ae..71f9c4aa64 100644 --- a/packages/server/src/integrations/base/sqlTable.ts +++ b/packages/server/src/integrations/base/sqlTable.ts @@ -61,7 +61,9 @@ function generateSchema( schema.boolean(key) break case FieldTypes.DATETIME: - schema.datetime(key) + schema.datetime(key, { + useTz: !column.ignoreTimezones, + }) break case FieldTypes.ARRAY: schema.json(key) diff --git a/packages/server/src/integrations/mysql.ts b/packages/server/src/integrations/mysql.ts index 4fe996a019..7a06592ef7 100644 --- a/packages/server/src/integrations/mysql.ts +++ b/packages/server/src/integrations/mysql.ts @@ -15,7 +15,6 @@ import { } from "./utils" import { DatasourcePlus } from "./base/datasourcePlus" import dayjs from "dayjs" -import { FieldTypes } from "../constants" const { NUMBER_REGEX } = require("../utilities") module MySQLModule { @@ -30,6 +29,7 @@ module MySQLModule { database: string ssl?: { [key: string]: any } rejectUnauthorized: boolean + typeCast: Function } const SCHEMA: Integration = { @@ -89,6 +89,8 @@ module MySQLModule { }, } + const TimezoneAwareDateTypes = ["timestamp"] + function bindingTypeCoerce(bindings: any[]) { for (let i = 0; i < bindings.length; i++) { const binding = bindings[i] @@ -131,7 +133,19 @@ module MySQLModule { } // @ts-ignore delete config.rejectUnauthorized - this.config = config + this.config = { + ...config, + typeCast: function (field: any, next: any) { + if ( + field.type == "DATETIME" || + field.type === "DATE" || + field.type === "TIMESTAMP" + ) { + return field.string() + } + return next() + }, + } } getBindingIdentifier(): string { diff --git a/packages/server/src/integrations/postgres.ts b/packages/server/src/integrations/postgres.ts index 220f35dae5..c7f02733a2 100644 --- a/packages/server/src/integrations/postgres.ts +++ b/packages/server/src/integrations/postgres.ts @@ -16,10 +16,16 @@ import { import { DatasourcePlus } from "./base/datasourcePlus" module PostgresModule { - const { Client } = require("pg") + const { Client, types } = require("pg") const Sql = require("./base/sql") const { escapeDangerousCharacters } = require("../utilities") + // Return "date" and "timestamp" types as plain strings. + // This lets us reference the original stored timezone. + types.setTypeParser(1114, (val: any) => val) // timestamp + types.setTypeParser(1082, (val: any) => val) // date + types.setTypeParser(1184, (val: any) => val) // timestampz + const JSON_REGEX = /'{.*}'::json/s interface PostgresConfig { diff --git a/packages/server/src/utilities/rowProcessor/utils.js b/packages/server/src/utilities/rowProcessor/utils.js index 262ef40a3a..c80dae497c 100644 --- a/packages/server/src/utilities/rowProcessor/utils.js +++ b/packages/server/src/utilities/rowProcessor/utils.js @@ -65,3 +65,28 @@ exports.processFormulas = ( } return single ? rows[0] : rows } + +/** + * Processes any date columns and ensures that those without the ignoreTimezones + * flag set are parsed as UTC rather than local time. + */ +exports.processDates = (table, rows) => { + let datesWithTZ = [] + for (let [column, schema] of Object.entries(table.schema)) { + if (schema.type !== FieldTypes.DATETIME) { + continue + } + if (!schema.ignoreTimezones) { + datesWithTZ.push(column) + } + } + + for (let row of rows) { + for (let col of datesWithTZ) { + if (row[col] && typeof row[col] === "string" && !row[col].endsWith("Z")) { + row[col] = new Date(row[col]).toISOString() + } + } + } + return rows +}