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;
+ */
+};