diff --git a/org.knime.js.pagebuilder/package-lock.json b/org.knime.js.pagebuilder/package-lock.json index 4709e3fd..58b9b5a1 100644 --- a/org.knime.js.pagebuilder/package-lock.json +++ b/org.knime.js.pagebuilder/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "UNLICENSED", "dependencies": { - "@knime/components": "1.8.0", + "@knime/components": "1.9.0", "@knime/styles": "1.1.1", "@knime/ui-extension-renderer": "1.1.40", "@knime/ui-extension-service": "1.0.1", @@ -2088,13 +2088,13 @@ } }, "node_modules/@knime/components": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@knime/components/-/components-1.8.0.tgz", - "integrity": "sha512-UjEFrFLWxFxzDWN2z5ECwiNBEjkYgYlVFp+8yMi6g2k1ExlfBfELCjm7xR4o7ZoE8FhM4xhUdutC8v4PZozhqQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@knime/components/-/components-1.9.0.tgz", + "integrity": "sha512-M7Ficec4ut2BsPJqCOr4uwwqk6nxxFlFQDt8M/X96Sen5NAxNg+j1SyPVU4OL8AngrgMoKBBzyE87/vOuG0SgQ==", "dependencies": { "@floating-ui/vue": "1.0.2", "@knime/styles": "1.1.1", - "@knime/utils": "1.1.1", + "@knime/utils": "1.1.2", "@vueuse/components": "^10.7.2", "@vueuse/core": "10.4.1", "@vueuse/shared": "^10.10.0", @@ -2115,6 +2115,17 @@ "vue": "3.x" } }, + "node_modules/@knime/components/node_modules/@knime/utils": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@knime/utils/-/utils-1.1.2.tgz", + "integrity": "sha512-syQ/JCAR2i1MDx+xsWfGhnV3RaOULo0ODnNP3O4y1aCH4h+yaXD7K4g2z0QFCxD9D/Sui5KXsp/hh7EpWtsY1g==", + "dependencies": { + "@knime/styles": "1.1.1", + "consola": "3.2.3", + "date-fns": "2.30.0", + "date-fns-tz": "2.0.0" + } + }, "node_modules/@knime/components/node_modules/typescript": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", @@ -3867,6 +3878,46 @@ "vue": "3.x" } }, + "node_modules/@knime/ui-extension-renderer/node_modules/@knime/components": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@knime/components/-/components-1.8.0.tgz", + "integrity": "sha512-UjEFrFLWxFxzDWN2z5ECwiNBEjkYgYlVFp+8yMi6g2k1ExlfBfELCjm7xR4o7ZoE8FhM4xhUdutC8v4PZozhqQ==", + "dependencies": { + "@floating-ui/vue": "1.0.2", + "@knime/styles": "1.1.1", + "@knime/utils": "1.1.1", + "@vueuse/components": "^10.7.2", + "@vueuse/core": "10.4.1", + "@vueuse/shared": "^10.10.0", + "consola": "3.2.3", + "date-fns": "2.30.0", + "date-fns-tz": "2.0.0", + "filesize": "10.0.6", + "focus-trap-vue": "4.0.2", + "gsap": "^3.12.5", + "lodash-es": "4.17.21", + "typescript": "^5.4.5", + "v-calendar": "3.0.3" + }, + "engines": { + "node": "20.x" + }, + "peerDependencies": { + "vue": "3.x" + } + }, + "node_modules/@knime/ui-extension-renderer/node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@knime/ui-extension-service": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@knime/ui-extension-service/-/ui-extension-service-1.0.1.tgz", diff --git a/org.knime.js.pagebuilder/package.json b/org.knime.js.pagebuilder/package.json index 33dba7e9..cf9aab06 100644 --- a/org.knime.js.pagebuilder/package.json +++ b/org.knime.js.pagebuilder/package.json @@ -29,7 +29,7 @@ "license": "UNLICENSED", "author": "KNIME AG, Zurich, Switzerland", "dependencies": { - "@knime/components": "1.8.0", + "@knime/components": "1.9.0", "@knime/styles": "1.1.1", "@knime/ui-extension-renderer": "1.1.40", "@knime/ui-extension-service": "1.0.1", diff --git a/org.knime.js.pagebuilder/src/components/widgets/input/DateTimeWidget.vue b/org.knime.js.pagebuilder/src/components/widgets/input/DateTimeWidget.vue index 2d266525..8c9eebeb 100644 --- a/org.knime.js.pagebuilder/src/components/widgets/input/DateTimeWidget.vue +++ b/org.knime.js.pagebuilder/src/components/widgets/input/DateTimeWidget.vue @@ -4,7 +4,8 @@ import { DateTimeInput } from "@knime/components/date-time-input"; import ErrorMessage from "../baseElements/text/ErrorMessage.vue"; import { getLocalTimeZone, updateTime } from "@knime/utils"; -import { format, zonedTimeToUtc, utcToZonedTime } from "date-fns-tz"; +import { format } from "date-fns-tz"; +import { fromZonedTime, toZonedTime } from "@/util/widgetUtil/dateTime"; /** * DateTimeWidget. @@ -109,7 +110,7 @@ export default { return this.parseKnimeDateString(this.value.datestring); }, dateObject() { - return zonedTimeToUtc(this.dateValue.datestring, this.timezone); + return fromZonedTime(this.dateValue.datestring, this.timezone); }, timezone() { return this.dateValue.zonestring; @@ -126,9 +127,9 @@ export default { this.viewRep.min, ); if (this.viewRep.useminexectime) { - return zonedTimeToUtc(this.execTime, this.localTimeZone); + return this.execTime; } - return zonedTimeToUtc(datestring, zonestring); + return fromZonedTime(datestring, zonestring); } return null; }, @@ -138,9 +139,9 @@ export default { this.viewRep.max, ); if (this.viewRep.usemaxexectime) { - return zonedTimeToUtc(this.execTime, this.localTimeZone); + return this.execTime; } - return zonedTimeToUtc(datestring, zonestring); + return fromZonedTime(datestring, zonestring); } return null; }, @@ -161,7 +162,9 @@ export default { * @returns {{zonestring: String, datestring: String}} */ parseKnimeDateString(dateAndZoneString) { - let match = dateAndZoneString.match(/(.+)\[(.+)]/) || [null, "", ""]; + let match = dateAndZoneString.match( + /(.+?)(?:Z|[+-]\d\d:?(?:\d\d)?)\[(.+)]/, + ) || [null, "", ""]; return { datestring: match[1], zonestring: match[2], @@ -171,8 +174,11 @@ export default { return format(date, "yyyy-MM-dd'T'HH:mm:ss.SSS"); }, onChange(date, timezone) { - let zonedDate = utcToZonedTime(date, timezone); - let value = this.formatDate(zonedDate); + let zonedDate = toZonedTime(date, timezone); + // this.formatDate takes the local timezone into account, so we do not want to use it here + let value = zonedDate.toISOString().replace("Z", ""); + this.dateValue.datestring = value; + this.dateValue.zonestring = timezone; this.publishUpdate(value, timezone); }, publishUpdate(datestring, zonestring) { @@ -190,7 +196,17 @@ export default { this.onChange(date, this.timezone); }, onTimezoneChange(timezone) { - this.onChange(this.dateObject, timezone); + const existingTimeAsZonedTime = toZonedTime( + this.dateObject, + this.timezone, + ); + const shiftedTime = fromZonedTime(existingTimeAsZonedTime, timezone); + /** + * Calling + * this.onChange(this.dateObject, timezone); + * would instead update the date object with the new timezone, which is not what we want. + */ + this.onChange(shiftedTime, timezone); }, nowButtonClicked() { let now = new Date(Date.now()); diff --git a/org.knime.js.pagebuilder/src/components/widgets/input/__tests__/DateTimeWidget.test.js b/org.knime.js.pagebuilder/src/components/widgets/input/__tests__/DateTimeWidget.test.js index a78506f9..60e54a2e 100644 --- a/org.knime.js.pagebuilder/src/components/widgets/input/__tests__/DateTimeWidget.test.js +++ b/org.knime.js.pagebuilder/src/components/widgets/input/__tests__/DateTimeWidget.test.js @@ -480,7 +480,7 @@ describe("DateTimeWidget.vue", () => { }); describe("events and actions", () => { - it("emits @updateWidget if timezone changes", () => { + it("emits @updateWidget if timezone changes while keeping the local time", () => { let wrapper = mount(DateTimeWidget, { props: propsAll, stubs: { @@ -495,8 +495,11 @@ describe("DateTimeWidget.vue", () => { expect( wrapper.emitted("updateWidget")[1][0].update[ "viewRepresentation.currentValue" - ].zonestring, - ).toBe("Asia/Bangkok"); + ], + ).toStrictEqual({ + zonestring: "Asia/Bangkok", + datestring: "2020-05-03T09:54:55.000", + }); }); it("now button sets date, time and timezone to current values and location", () => { @@ -556,47 +559,71 @@ describe("DateTimeWidget.vue", () => { ).toBe(0); }); - it("emits @updateWidget if DateTimeInput emits @input", () => { - let wrapper = mount(DateTimeWidget, { - props: propsAll, - stubs: { - "client-only": "
", - }, - ...context, - }); - - const testValue = "2020-10-14T13:32:45.153"; - const input = wrapper.findComponent(DateTimeInput); - input.vm.$emit("update:modelValue", new Date(testValue)); - - expect(wrapper.emitted("updateWidget")).toBeTruthy(); - expect(wrapper.emitted("updateWidget")[1][0]).toStrictEqual({ - nodeId: propsAll.nodeId, - update: { - "viewRepresentation.currentValue": { - datestring: testValue, - zonestring: "Europe/Rome", + it.each([ + { + timezone: "UTC", + offset: 0, + }, + { + timezone: "Europe/Rome", + offset: 2, + }, + ])( + "emits @updateWidget if DateTimeInput emits @input", + ({ timezone, offset }) => { + let wrapper = mount(DateTimeWidget, { + props: { + ...propsAll, + valuePair: { + datestring: "2020-01-01T00:00:00.000", + zonestring: timezone, + }, }, - }, - }); - }); + stubs: { + "client-only": "
", + }, + ...context, + }); + + const input = wrapper.findComponent(DateTimeInput); + const testHours = 13; + input.vm.$emit( + "update:modelValue", + Date.UTC(2020, 9, 14, testHours, 32, 45, 153), + ); + + expect(wrapper.emitted("updateWidget")).toBeTruthy(); + expect(wrapper.emitted("updateWidget")[0][0]).toStrictEqual({ + nodeId: propsAll.nodeId, + update: { + "viewRepresentation.currentValue": { + datestring: `2020-10-14T${testHours + offset}:32:45.153`, + zonestring: timezone, + }, + }, + }); + }, + ); }); describe("methods", () => { - it("parses knime date and timezone strings", () => { - let wrapper = mount(DateTimeWidget, { - props: propsAll, - stubs: { - "client-only": "
", - }, - ...context, - }); - const res = wrapper.vm.parseKnimeDateString( - "2020-10-10T13:32:45.153[Europe/Berlin]", - ); - expect(res.datestring).toBe("2020-10-10T13:32:45.153"); - expect(res.zonestring).toBe("Europe/Berlin"); - }); + it.each(["+02:00", "+02", "+0200", "Z"])( + "parses knime date and timezone strings", + (offset) => { + let wrapper = mount(DateTimeWidget, { + props: propsAll, + stubs: { + "client-only": "
", + }, + ...context, + }); + const res = wrapper.vm.parseKnimeDateString( + `2020-10-10T13:32:45.153${offset}[Europe/Rome]`, + ); + expect(res.datestring).toBe("2020-10-10T13:32:45.153"); + expect(res.zonestring).toBe("Europe/Rome"); + }, + ); it("parses broken knime date and timezone strings", () => { let wrapper = mount(DateTimeWidget, { @@ -606,7 +633,9 @@ describe("DateTimeWidget.vue", () => { }, ...context, }); - const res = wrapper.vm.parseKnimeDateString("2020-10-10T13:32:45.153["); + const res = wrapper.vm.parseKnimeDateString( + "2020-10-10T13:32:45.153[UTC]", + ); expect(res.datestring).toBe(""); expect(res.zonestring).toBe(""); }); @@ -645,7 +674,7 @@ describe("DateTimeWidget.vue", () => { it("invalidates if min bound is not kept", () => { propsAll.nodeConfig.viewRepresentation.usemin = true; propsAll.nodeConfig.viewRepresentation.min = - "2020-10-10T13:32:45.153[Europe/Berlin]"; + "2020-10-10T13:32:45.153+02:00[Europe/Berlin]"; propsAll.nodeConfig.viewRepresentation.usemax = false; let wrapper = mount(DateTimeWidget, { props: propsAll, @@ -665,7 +694,7 @@ describe("DateTimeWidget.vue", () => { it("invalidates if max bound is not kept", () => { propsAll.nodeConfig.viewRepresentation.usemax = true; propsAll.nodeConfig.viewRepresentation.max = - "2020-04-10T13:32:45.153[Europe/Berlin]"; + "2020-04-10T13:32:45.153+02:00[Europe/Berlin]"; propsAll.nodeConfig.viewRepresentation.usemin = false; let wrapper = mount(DateTimeWidget, { props: propsAll, diff --git a/org.knime.js.pagebuilder/src/util/widgetUtil/dateTime/__tests__/dateTime.test.ts b/org.knime.js.pagebuilder/src/util/widgetUtil/dateTime/__tests__/dateTime.test.ts new file mode 100644 index 00000000..cf421bac --- /dev/null +++ b/org.knime.js.pagebuilder/src/util/widgetUtil/dateTime/__tests__/dateTime.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { fromZonedTime, toZonedTime } from ".."; + +describe("fromZonedTime", () => { + const createUTCDate = ({ + year = 2021, + monthIndex = 0, + day = 1, + hours = 0, + minutes = 0, + seconds = 0, + milliseconds = 0, + }: { + year?: number; + monthIndex?: number; + day?: number; + hours?: number; + minutes?: number; + seconds?: number; + milliseconds?: number; + } = {}) => { + return new Date( + Date.UTC(year, monthIndex, day, hours, minutes, seconds, milliseconds), + ); + }; + + it.each([fromZonedTime, toZonedTime])( + "is idempotent for a UTC date to itself", + (fromOrToZonedTime) => { + const utcTime = createUTCDate(); + expect(fromOrToZonedTime(utcTime, "UTC")).toStrictEqual(utcTime); + }, + ); + + const cetUtcPairs = [ + [ + "winter", + { + cetTime: createUTCDate({ hours: 23 }), + utcTime: createUTCDate({ hours: 22 }), + }, + ] as const, + [ + "summer", + { + cetTime: createUTCDate({ monthIndex: 6, day: 1, hours: 0 }), + utcTime: createUTCDate({ monthIndex: 5, day: 30, hours: 22 }), + }, + ] as const, + ]; + + it.each(cetUtcPairs)( + "should convert CET to UTC in the %s", + (_, { cetTime, utcTime }) => { + expect(fromZonedTime(cetTime, "CET")).toStrictEqual(utcTime); + }, + ); + + it.each(cetUtcPairs)( + "should convert UTC to CET in the %s", + (_, { cetTime, utcTime }) => { + expect(toZonedTime(utcTime, "CET")).toStrictEqual(cetTime); + }, + ); + + it("can convert a string to a zoned date", () => { + const { utcTime, cetTime } = cetUtcPairs[0][1]; + const cetTimeString = cetTime.toISOString(); + expect(fromZonedTime(cetTimeString, "CET")).toStrictEqual(utcTime); + expect(fromZonedTime(cetTimeString.replace("Z", ""), "CET")).toStrictEqual( + utcTime, + ); + }); + + it("takes offsets in strings into account", () => { + expect(fromZonedTime("2021-01-01T00:00:00+01:00", "UTC")).toStrictEqual( + createUTCDate({ hours: -1 }), + ); + }); +}); diff --git a/org.knime.js.pagebuilder/src/util/widgetUtil/dateTime/index.ts b/org.knime.js.pagebuilder/src/util/widgetUtil/dateTime/index.ts new file mode 100644 index 00000000..2c4e2d21 --- /dev/null +++ b/org.knime.js.pagebuilder/src/util/widgetUtil/dateTime/index.ts @@ -0,0 +1,69 @@ +import { toDate, type OptionsWithTZ } from "date-fns-tz"; +// @ts-expect-error +import tzParseTimezone from "@@/node_modules/date-fns-tz/_lib/tzParseTimezone"; +// @ts-expect-error +import tzPattern from "@@/node_modules/date-fns-tz/_lib/tzPattern"; +/** + * This method is used to circumvent the following open issue in date-fns-tz + * https://github.com/marnusw/date-fns-tz/issues/302 + * + * The problem is that the zonedTimeToUtc returns a Date object so that + * when e.g. getHours is called, the respective UTC time hours are returned. + * But since getHours depends on the systems timezone, + * the actual underlying UTC time is shifted accordingly. + * + * + * The code is an adapted version of date-fns-tz 3.2.0 + * https://www.npmjs.com/package/date-fns-tz?activeTab=code + * /date-fns-tz/dist/cjs/fromZonedTime/index.js + */ +export const fromZonedTime = ( + date: string | Date, + timeZone: string, + options?: OptionsWithTZ, +) => { + // Same code + if (typeof date === "string" && !date.match(tzPattern)) { + return toDate( + date, + Object.assign(Object.assign({}, options), { timeZone }), + ); + } + date = toDate(date, options); + /** + * Here we differ. Original code: + const utc = newDateUTC(date.getFullYear(), date.getMonth(), date.getDate(), + date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()) + .getTime(); + const offsetMilliseconds = tzParseTimezone(timeZone, new Date(utc)); + return new Date(utc + offsetMilliseconds); + */ + const offsetMilliseconds = tzParseTimezone(timeZone, date); + return new Date(date.getTime() + offsetMilliseconds); +}; + +/** + * Similarly to fromTimeZone, we need this method to replace the utcToZonedTime method, + * since this method is the inverse of the (incorrect) zonedTimeToUtc method. + * + * The code is an adapted version of date-fns-tz 3.2.0 + * https://www.npmjs.com/package/date-fns-tz?activeTab=code + * /date-fns-tz/dist/cjs/toZonedTime/index.js + */ +export const toZonedTime = ( + date: string | Date, + timeZone: string, + options?: OptionsWithTZ, +) => { + date = toDate(date, options); + const offsetMilliseconds = tzParseTimezone(timeZone, date, true); + return new Date(date.getTime() - offsetMilliseconds); + /** + * The original code does not return here but instead assigns what is returned here + * to a variable d and transforms it further like this: + const resultDate = new Date(0); + resultDate.setFullYear(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); + resultDate.setHours(d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()); + return resultDate; + */ +};