From 9efdf79ad966b231ceb3d2c6077a812ede39a20c Mon Sep 17 00:00:00 2001 From: ptma Date: Thu, 16 Jul 2026 18:42:33 +0800 Subject: [PATCH] feat(grid): add timezone-aware column formatting --- apps/desktop/src/components/grid/DataGrid.vue | 33 ++++++- .../ui/searchable-select/SearchableSelect.vue | 2 +- apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../src/lib/dataGrid/columnFormatter.ts | 38 ++++++-- packages/app-tests/columnFormatter.test.ts | 87 ++++++++++++++----- packages/app-tests/settingsStore.test.ts | 2 +- 12 files changed, 143 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index f115b43a0..36e23f738 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -116,7 +116,7 @@ import { import { buildBinaryHexViewRows } from "@/lib/dataGrid/binaryHexViewer"; import { canFormatCellDetailJson, cellDetailEditorText, compactJsonText, defaultCellDetailTab, formatJsonText, isGeometryColumnType, linkedCellDetailTarget, looksLikeJsonContainerText, valueEditorActions, visibleCellDetailTabs, type CellDetailTab } from "@/lib/dataGrid/cellDetailPresentation"; import { buildDataGridCellDetail, buildDataGridColumnDetail, buildDataGridRowDetail, CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH, dataGridColumnDetailJson, dataGridColumnDetailTsv, dataGridRowDetailJson, dataGridRowDetailTsv, type DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail"; -import { applyColumnFormatter, buildColumnFormatterKey, normalizeColumnFormatter, resolveColumnFormatter, type ColumnFormatterConfig, type DateTimeFormatterUnit, DateTimePatterns } from "@/lib/dataGrid/columnFormatter"; +import { applyColumnFormatter, buildColumnFormatterKey, getSupportedTimeZoneOptions, normalizeColumnFormatter, resolveColumnFormatter, type ColumnFormatterConfig, type DateTimeFormatterUnit, DateTimePatterns } from "@/lib/dataGrid/columnFormatter"; import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor"; import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts"; import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter"; @@ -197,6 +197,12 @@ import { isMacOS } from "@/lib/backend/platform"; import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog"; import { formatShortcut } from "@/lib/editor/shortcutRegistry"; import { SearchableSelect } from "@/components/ui/searchable-select"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; +import dayjs from "dayjs"; + +dayjs.extend(utc); +dayjs.extend(timezone); const SqlPreviewPanel = defineAsyncComponent(() => import("@/components/editor/SqlPreviewPanel.vue")); const ImagePreviewDialog = defineAsyncComponent(() => import("@/components/grid/ImagePreviewDialog.vue")); @@ -636,6 +642,8 @@ const CUSTOM_FORMATTER_NEW = "__new"; const formatterKind = ref("datetime"); const formatterDateUnit = ref("auto"); const formatterDatetimePattern = ref("YYYY-MM-DD HH:mm:ss"); +const formatterDateTimezone = ref(dayjs.tz.guess() || "UTC"); +const timezoneOptions = getSupportedTimeZoneOptions(Intl as typeof Intl & { supportedValuesOf?: (key: "timeZone") => string[] }, formatterDateTimezone.value); const formatterJsonPath = ref("$.user.name"); const formatterMaskPrefix = ref(4); const formatterMaskSuffix = ref(4); @@ -995,15 +1003,16 @@ function currentFormatterDraft(): ColumnFormatterConfig { if (formatterKind.value === "custom-template") { return { kind: "custom-template", template: formatterCustomTemplate.value.trim() || "${value}" }; } - return { kind: "datetime", unit: formatterDateUnit.value, pattern: formatterDatetimePattern.value }; + return { kind: "datetime", unit: formatterDateUnit.value, pattern: formatterDatetimePattern.value, timezone: formatterDateTimezone.value }; } function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) { - const draft = formatter ?? { kind: "datetime", unit: "auto" as const, pattern: "YYYY-MM-DD HH:mm:ss" as const }; + const draft = formatter ?? { kind: "datetime", unit: "auto" as const, pattern: "YYYY-MM-DD HH:mm:ss" as const, timezone: dayjs.tz.guess() }; formatterKind.value = draft.kind === "custom-ref" ? "custom-template" : draft.kind; if (draft.kind === "datetime") { formatterDateUnit.value = draft.unit; formatterDatetimePattern.value = draft.pattern; + formatterDateTimezone.value = draft.timezone || dayjs.tz.guess() || "UTC"; } else if (draft.kind === "json-path") { formatterJsonPath.value = draft.path; } else if (draft.kind === "mask") { @@ -7743,7 +7752,7 @@ const gridContextMenuItems = computed(() => { - +
{{ t("grid.columnFormatterFor", { column: col.name }) }} @@ -7804,6 +7813,22 @@ const gridContextMenuItems = computed(() => { @update:model-value="(value: any) => (formatterDatetimePattern = value)" />
+
+
+ {{ t("grid.formatterDatetimeTimezone") }} +
+ +
diff --git a/apps/desktop/src/components/ui/searchable-select/SearchableSelect.vue b/apps/desktop/src/components/ui/searchable-select/SearchableSelect.vue index 8a49738ae..4b682ee07 100644 --- a/apps/desktop/src/components/ui/searchable-select/SearchableSelect.vue +++ b/apps/desktop/src/components/ui/searchable-select/SearchableSelect.vue @@ -243,7 +243,7 @@ function handleKeydown(event: KeyboardEvent) {
-
+
{{ searchPlaceholder }} diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index f91b1df3b..9047f8884 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -882,6 +882,8 @@ export default { formatterDatetimePattern: "Datetime pattern", formatterDatetimePatternPlaceholder: "Select or enter a datetime pattern", formatterDatetimePatternEmpty: "Default ISO8601 format", + formatterDatetimeTimezone: "Timezone", + formatterDatetimeTimezonePlaceholder: "Select a timezone", formatterJsonPathInput: "JSON path", formatterMaskPrefix: "Visible prefix", formatterMaskSuffix: "Visible suffix", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 9c7563c5f..7a2743bbd 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -829,6 +829,8 @@ export default withEnglishFallback({ formatterDatetimePattern: "Patrón de fecha y hora", formatterDatetimePatternPlaceholder: "Seleccionar o ingresar el formato de fecha y hora", formatterDatetimePatternEmpty: "Formato ISO8601 por defecto", + formatterDatetimeTimezone: "Zona horaria", + formatterDatetimeTimezonePlaceholder: "Seleccionar o ingresar la zona horaria", formatterJsonPathInput: "Ruta JSON", formatterMaskPrefix: "Prefijo visible", formatterMaskSuffix: "Sufijo visible", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 6a1afedb0..2bcde98e7 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -827,6 +827,8 @@ export default withEnglishFallback({ formatterDatetimePattern: "Formato data e orario", formatterDatetimePatternPlaceholder: "Seleziona o inserisci un formato data e orario", formatterDatetimePatternEmpty: "Formato ISO8601 predefinito", + formatterDatetimeTimezone: "Fuso orario", + formatterDatetimeTimezonePlaceholder: "Seleziona o inserisci un fuso orario", formatterJsonPathInput: "Percorso JSON", formatterMaskPrefix: "Prefisso visibile", formatterMaskSuffix: "Suffisso visibile", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 60ad5f8ef..b3dce1be1 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -824,6 +824,8 @@ export default withEnglishFallback({ formatterDatetimePattern: "日時フォーマット", formatterDatetimePatternPlaceholder: "日時フォーマットを選択または入力", formatterDatetimePatternEmpty: "ISO8601フォーマット", + formatterDatetimeTimezone: "タイムゾーン", + formatterDatetimeTimezonePlaceholder: "タイムゾーンを選択または入力", formatterJsonPathInput: "JSONパス", formatterMaskPrefix: "表示する接頭辞", formatterMaskSuffix: "表示する接尾辞", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 4298b10b9..1576223a1 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -829,6 +829,8 @@ export default withEnglishFallback({ formatterDatetimePattern: "Formato de data e horário", formatterDatetimePatternPlaceholder: "Selecione ou insira um formato de data e horário", formatterDatetimePatternEmpty: "Padrão ISO8601", + formatterDatetimeTimezone: "Fuso horário", + formatterDatetimeTimezonePlaceholder: "Selecione um fuso horário", formatterJsonPathInput: "Caminho JSON", formatterMaskPrefix: "Prefixo visível", formatterMaskSuffix: "Sufixo visível", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 8b36dd7bc..1e1e20f56 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -884,6 +884,8 @@ export default withEnglishFallback({ formatterDatetimePattern: "日期时间格式", formatterDatetimePatternPlaceholder: "选择或输入日期时间格式", formatterDatetimePatternEmpty: "默认 ISO8601 格式", + formatterDatetimeTimezone: "时区", + formatterDatetimeTimezonePlaceholder: "选择时区", formatterJsonPathInput: "JSON 路径", formatterMaskPrefix: "前缀保留", formatterMaskSuffix: "后缀保留", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index d5a5257b3..248c294fb 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -829,6 +829,8 @@ export default withEnglishFallback({ formatterDatetimePattern: "日期時間格式", formatterDatetimePatternPlaceholder: "選擇或輸入日期時間格式", formatterDatetimePatternEmpty: "預設 ISO8601 格式", + formatterDatetimeTimezone: "時區", + formatterDatetimeTimezonePlaceholder: "選擇時區", formatterJsonPathInput: "JSON 路徑", formatterMaskPrefix: "字首保留", formatterMaskSuffix: "字尾保留", diff --git a/apps/desktop/src/lib/dataGrid/columnFormatter.ts b/apps/desktop/src/lib/dataGrid/columnFormatter.ts index 8b64911d4..8653428b3 100644 --- a/apps/desktop/src/lib/dataGrid/columnFormatter.ts +++ b/apps/desktop/src/lib/dataGrid/columnFormatter.ts @@ -58,6 +58,19 @@ export interface CustomColumnFormatterConfig { template: string; } +interface IntlTimeZoneSupport { + supportedValuesOf?: (key: "timeZone") => string[]; +} + +export function getSupportedTimeZoneOptions(intl: IntlTimeZoneSupport, fallbackTimeZone = "UTC"): string[] { + try { + const timeZones = intl.supportedValuesOf?.("timeZone"); + if (timeZones?.length) return timeZones; + } catch {} + // Older WebViews may omit or throw from this API, so keep the detected timezone selectable. + return [fallbackTimeZone || "UTC"]; +} + export function normalizeSupportedDateTimePattern(value: string): string { const pattern = value.trim(); if (!pattern || pattern.length > 100 || pattern.includes("%")) return ""; @@ -85,7 +98,12 @@ export function normalizeSupportedDateTimePattern(value: string): string { return pattern; } -export type ColumnFormatterConfig = { kind: "datetime"; unit: DateTimeFormatterUnit; pattern: string } | { kind: "json-path"; path: string } | { kind: "mask"; prefix: number; suffix: number } | { kind: "custom-template"; template: string } | { kind: "custom-ref"; formatterId: string }; +export type ColumnFormatterConfig = + | { kind: "datetime"; unit: DateTimeFormatterUnit; pattern: string; timezone: string | undefined } + | { kind: "json-path"; path: string } + | { kind: "mask"; prefix: number; suffix: number } + | { kind: "custom-template"; template: string } + | { kind: "custom-ref"; formatterId: string }; export interface ColumnFormatterKeyParts { connectionId: string; @@ -101,7 +119,7 @@ export function buildColumnFormatterKey(parts: ColumnFormatterKeyParts): string export function normalizeColumnFormatter(value: unknown): ColumnFormatterConfig | undefined { if (!value || typeof value !== "object") return undefined; - const config = value as Record; + const config = value as Record; if (config.kind === "datetime") { return config.unit === "seconds" || config.unit === "milliseconds" || config.unit === "auto" @@ -109,6 +127,7 @@ export function normalizeColumnFormatter(value: unknown): ColumnFormatterConfig kind: "datetime", unit: config.unit, pattern: normalizeDateTimePattern(config.pattern), + timezone: config.timezone, } : undefined; } @@ -171,7 +190,7 @@ export function isTemporalColumnType(dataType: string | null | undefined): boole export function resolveColumnFormatter(formatter: ColumnFormatterConfig | undefined, customFormatters: Record, globalDateTime?: { pattern?: string; columnType?: string | null }): ColumnFormatterConfig | undefined { if (!formatter) { const pattern = normalizeGlobalDateTimePattern(globalDateTime?.pattern); - return pattern && isTemporalColumnType(globalDateTime?.columnType) ? { kind: "datetime", unit: "auto", pattern } : undefined; + return pattern && isTemporalColumnType(globalDateTime?.columnType) ? { kind: "datetime", unit: "auto", pattern, timezone: undefined } : undefined; } if (formatter.kind !== "custom-ref") return formatter; const customFormatter = customFormatters[formatter.formatterId]; @@ -207,14 +226,14 @@ function formatTemporalValueForExport(value: CellValue, pattern: string): string } } } - return applyColumnFormatter(value, { kind: "datetime", unit: "auto", pattern }); + return applyColumnFormatter(value, { kind: "datetime", unit: "auto", pattern, timezone: undefined }); } export function applyColumnFormatter(value: CellValue, formatter: ColumnFormatterConfig | undefined): string { if (!formatter) return displayCellValue(value); try { - if (formatter.kind === "datetime") return formatDateTime(value, formatter.unit, formatter.pattern); + if (formatter.kind === "datetime") return formatDateTime(value, formatter.unit, formatter.pattern, formatter.timezone); if (formatter.kind === "json-path") return formatJsonPath(value, formatter.path); if (formatter.kind === "mask") return formatMask(value, formatter); if (formatter.kind === "custom-template") return formatCustomTemplate(value, formatter.template); @@ -224,12 +243,17 @@ export function applyColumnFormatter(value: CellValue, formatter: ColumnFormatte } } -function formatDateTime(value: CellValue, unit: DateTimeFormatterUnit, pattern: string): string { +function formatDateTime(value: CellValue, unit: DateTimeFormatterUnit, pattern: string, timezone: string | undefined): string { if (value === null) return displayCellValue(value); if (typeof value === "boolean") return value ? "true" : "false"; if (typeof value === "object") return JSON.stringify(value); const parsed: Dayjs | undefined = resolveDateTimeValue(value, unit); - return parsed ? parsed.format(pattern || DEFAULT_DATETIME_PATTERN) : displayCellValue(value); + if (parsed) { + const template = pattern || DEFAULT_DATETIME_PATTERN; + return timezone ? parsed.tz(timezone).format(template) : parsed.format(template); + } else { + return displayCellValue(value); + } } function resolveDateTimeValue(value: string | number, unit: DateTimeFormatterUnit) { diff --git a/packages/app-tests/columnFormatter.test.ts b/packages/app-tests/columnFormatter.test.ts index df5d97932..a99524be3 100644 --- a/packages/app-tests/columnFormatter.test.ts +++ b/packages/app-tests/columnFormatter.test.ts @@ -1,30 +1,77 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { applyColumnFormatter, buildColumnFormatterKey, resolveColumnFormatter, normalizeColumnFormatter, type ColumnFormatterConfig } from "../../apps/desktop/src/lib/dataGrid/columnFormatter.ts"; +import { applyColumnFormatter, buildColumnFormatterKey, getSupportedTimeZoneOptions, resolveColumnFormatter, normalizeColumnFormatter, type ColumnFormatterConfig } from "../../apps/desktop/src/lib/dataGrid/columnFormatter.ts"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; import dayjs from "dayjs"; +test("builds timezone options when Intl.supportedValuesOf is available", () => { + assert.deepEqual( + getSupportedTimeZoneOptions( + { + supportedValuesOf: (key) => { + assert.equal(key, "timeZone"); + return ["Asia/Shanghai", "Europe/London"]; + }, + }, + "UTC", + ), + ["Asia/Shanghai", "Europe/London"], + ); +}); + +test("falls back when Intl.supportedValuesOf is missing", () => { + assert.deepEqual(getSupportedTimeZoneOptions({}, "Asia/Shanghai"), ["Asia/Shanghai"]); +}); + +test("falls back when Intl.supportedValuesOf throws", () => { + assert.deepEqual( + getSupportedTimeZoneOptions( + { + supportedValuesOf: () => { + throw new Error("unsupported"); + }, + }, + "Asia/Shanghai", + ), + ["Asia/Shanghai"], + ); +}); + +test("falls back when Intl.supportedValuesOf returns no timezones", () => { + assert.deepEqual(getSupportedTimeZoneOptions({ supportedValuesOf: () => [] }, "Asia/Shanghai"), ["Asia/Shanghai"]); +}); + +test("uses UTC when no fallback timezone can be detected", () => { + assert.deepEqual(getSupportedTimeZoneOptions({}, ""), ["UTC"]); +}); + test("formats unix timestamps in seconds, milliseconds, and auto mode", () => { dayjs.extend(utc); dayjs.extend(timezone); assert.equal(dayjs.tz.guess(), "Asia/Shanghai"); - assert.equal(applyColumnFormatter(1715758200, { kind: "datetime", unit: "seconds", pattern: "YYYY-MM-DD HH:mm:ssZ" }), "2024-05-15 15:30:00+08:00"); - assert.equal(applyColumnFormatter(1715758200001, { kind: "datetime", unit: "milliseconds", pattern: "YYYY-MM-DD HH:mm:ss.SSSZ" }), "2024-05-15 15:30:00.001+08:00"); - assert.equal(applyColumnFormatter(1715758200001, { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ss.SSSZ" }), "2024-05-15 15:30:00.001+08:00"); - assert.equal(applyColumnFormatter("1715758200001", { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ss.SSSZ" }), "2024-05-15 15:30:00.001+08:00"); - assert.equal(applyColumnFormatter(1715758200, { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ssZ" }), "2024-05-15 15:30:00+08:00"); - assert.equal(applyColumnFormatter("1715758200", { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ssZ" }), "2024-05-15 15:30:00+08:00"); + assert.equal(applyColumnFormatter(1715758200, { kind: "datetime", unit: "seconds", pattern: "YYYY-MM-DD HH:mm:ssZ", timezone: "Asia/Shanghai" }), "2024-05-15 15:30:00+08:00"); + assert.equal(applyColumnFormatter(1763103348, { kind: "datetime", unit: "seconds", pattern: "YYYY-MM-DD HH:mm:ssZ", timezone: "Asia/Shanghai" }), "2025-11-14 14:55:48+08:00"); + // British Summer Time + assert.equal(applyColumnFormatter(1715758200, { kind: "datetime", unit: "seconds", pattern: "YYYY-MM-DD HH:mm:ssZ", timezone: "Europe/London" }), "2024-05-15 08:30:00+01:00"); + // British Standard Time + assert.equal(applyColumnFormatter(1763103348, { kind: "datetime", unit: "seconds", pattern: "YYYY-MM-DD HH:mm:ssZ", timezone: "Europe/London" }), "2025-11-14 06:55:48+00:00"); + + assert.equal(applyColumnFormatter(1715758200001, { kind: "datetime", unit: "milliseconds", pattern: "YYYY-MM-DD HH:mm:ss.SSSZ", timezone: undefined }), "2024-05-15 15:30:00.001+08:00"); + assert.equal(applyColumnFormatter(1715758200001, { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ss.SSSZ", timezone: undefined }), "2024-05-15 15:30:00.001+08:00"); + assert.equal(applyColumnFormatter("1715758200001", { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ss.SSSZ", timezone: undefined }), "2024-05-15 15:30:00.001+08:00"); + assert.equal(applyColumnFormatter(1715758200, { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ssZ", timezone: undefined }), "2024-05-15 15:30:00+08:00"); + assert.equal(applyColumnFormatter("1715758200", { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ssZ", timezone: undefined }), "2024-05-15 15:30:00+08:00"); }); test("uses the global datetime formatter only for temporal columns without a column override", () => { const global = { pattern: "YYYY/MM/DD HH:mm:ss", columnType: "TIMESTAMP(6)" }; - assert.deepEqual(resolveColumnFormatter(undefined, {}, global), { kind: "datetime", unit: "auto", pattern: "YYYY/MM/DD HH:mm:ss" }); + assert.deepEqual(resolveColumnFormatter(undefined, {}, global), { kind: "datetime", unit: "auto", pattern: "YYYY/MM/DD HH:mm:ss", timezone: undefined }); assert.equal(resolveColumnFormatter(undefined, {}, { ...global, columnType: "VARCHAR2(100)" }), undefined); assert.deepEqual(resolveColumnFormatter({ kind: "mask", prefix: 2, suffix: 2 }, {}, global), { kind: "mask", prefix: 2, suffix: 2 }); - assert.deepEqual(resolveColumnFormatter(undefined, {}, { ...global, columnType: "DateTime64(3)" }), { kind: "datetime", unit: "auto", pattern: global.pattern }); - assert.deepEqual(resolveColumnFormatter(undefined, {}, { ...global, columnType: "datetimeoffsetn" }), { kind: "datetime", unit: "auto", pattern: global.pattern }); + assert.deepEqual(resolveColumnFormatter(undefined, {}, { ...global, columnType: "DateTime64(3)" }), { kind: "datetime", unit: "auto", pattern: global.pattern, timezone: undefined }); + assert.deepEqual(resolveColumnFormatter(undefined, {}, { ...global, columnType: "datetimeoffsetn" }), { kind: "datetime", unit: "auto", pattern: global.pattern, timezone: undefined }); }); test("formats only typed temporal cells for export", async () => { @@ -35,18 +82,18 @@ test("formats only typed temporal cells for export", async () => { }); test("formats Oracle timestamp fractional precision in the data grid", () => { - assert.equal(applyColumnFormatter("2024-02-25 13:02:15.123456", { kind: "datetime", unit: "auto", pattern: "YYYY/MM/DD HH:mm:ss.SSS" }), "2024/02/25 13:02:15.123"); + assert.equal(applyColumnFormatter("2024-02-25 13:02:15.123456", { kind: "datetime", unit: "auto", pattern: "YYYY/MM/DD HH:mm:ss.SSS", timezone: undefined }), "2024/02/25 13:02:15.123"); }); test("does not treat compact date strings as unix timestamps", () => { dayjs.extend(utc); dayjs.extend(timezone); assert.equal(dayjs.tz.guess(), "Asia/Shanghai"); - assert.equal(applyColumnFormatter("20260514", { kind: "datetime", unit: "auto", pattern: "YYYYMMDD" }), "20260514"); - assert.equal(applyColumnFormatter("20260514", { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ss" }), "20260514"); - assert.equal(applyColumnFormatter(20260514, { kind: "datetime", unit: "auto", pattern: "YYYYMMDD" }), "20260514"); - assert.equal(applyColumnFormatter("2026-05-14T16:00:00Z", { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DDTHH:mm:ssZ" }), "2026-05-15T00:00:00+08:00"); - assert.equal(applyColumnFormatter("2026-05-14", { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD" }), "2026-05-14"); + assert.equal(applyColumnFormatter("20260514", { kind: "datetime", unit: "auto", pattern: "YYYYMMDD", timezone: undefined }), "20260514"); + assert.equal(applyColumnFormatter("20260514", { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ss", timezone: undefined }), "20260514"); + assert.equal(applyColumnFormatter(20260514, { kind: "datetime", unit: "auto", pattern: "YYYYMMDD", timezone: undefined }), "20260514"); + assert.equal(applyColumnFormatter("2026-05-14T16:00:00Z", { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DDTHH:mm:ssZ", timezone: undefined }), "2026-05-15T00:00:00+08:00"); + assert.equal(applyColumnFormatter("2026-05-14", { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD", timezone: undefined }), "2026-05-14"); }); test("extracts simple JSON paths from object and array strings", () => { @@ -63,12 +110,12 @@ test("masks strings while preserving prefix and suffix", () => { }); test("falls back to normal display for nulls and invalid formatter input", () => { - assert.equal(applyColumnFormatter(null, { kind: "datetime", unit: "auto", pattern: "YYYYMMDD" }), "NULL"); - assert.equal(applyColumnFormatter("abc", { kind: "datetime", unit: "auto", pattern: "YYYYMMDD" }), "abc"); - assert.equal(applyColumnFormatter("2022-01-33", { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD" }), "2022-01-33"); + assert.equal(applyColumnFormatter(null, { kind: "datetime", unit: "auto", pattern: "YYYYMMDD", timezone: undefined }), "NULL"); + assert.equal(applyColumnFormatter("abc", { kind: "datetime", unit: "auto", pattern: "YYYYMMDD", timezone: undefined }), "abc"); + assert.equal(applyColumnFormatter("2022-01-33", { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD", timezone: undefined }), "2022-01-33"); assert.equal(applyColumnFormatter("not json", { kind: "json-path", path: "$.a" }), "not json"); assert.deepEqual(normalizeColumnFormatter({ kind: "datetime", unit: "invalid" }), undefined); - assert.deepEqual(normalizeColumnFormatter({ kind: "datetime", unit: "auto", pattern: 123 }), { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ss" }); + assert.deepEqual(normalizeColumnFormatter({ kind: "datetime", unit: "auto", pattern: 123 }), { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ss", timezone: undefined }); }); test("normalizes only supported formatter configs", () => { diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index ccc4c08c2..f9d6c27d8 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -373,7 +373,7 @@ test("keeps only valid saved column formatter configs", () => { } as any); assert.deepEqual(settings.columnFormatters, { - "conn::db::public::users::created_at": { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ss" }, + "conn::db::public::users::created_at": { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ss", timezone: undefined }, "conn::db::public::users::name": { kind: "mask", prefix: 2, suffix: 2 }, "conn::db::public::users::payload": { kind: "json-path", path: "$.user.name" }, "conn::db::public::users::status": { kind: "custom-ref", formatterId: "fmt_1" },