feat(grid): add timezone-aware column formatting

This commit is contained in:
ptma 2026-07-16 18:42:33 +08:00 committed by GitHub
parent 41e0989f96
commit 9efdf79ad9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 143 additions and 33 deletions

View File

@ -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<FormatterDraftKind>("datetime");
const formatterDateUnit = ref<DateTimeFormatterUnit>("auto");
const formatterDatetimePattern = ref<string>("YYYY-MM-DD HH:mm:ss");
const formatterDateTimezone = ref<string>(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<ContextMenuItem[]>(() => {
<Code2 class="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="start" side="bottom" class="w-[420px] max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
<PopoverContent align="start" side="bottom" class="w-[450px] max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
<div class="border-b bg-muted/40 px-3 py-2">
<div class="text-sm font-semibold">
{{ t("grid.columnFormatterFor", { column: col.name }) }}
@ -7804,6 +7813,22 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
@update:model-value="(value: any) => (formatterDatetimePattern = value)"
/>
</div>
<div class="space-y-1.5 flex-1">
<div class="text-xs font-medium text-muted-foreground">
{{ t("grid.formatterDatetimeTimezone") }}
</div>
<SearchableSelect
:model-value="formatterDateTimezone"
:options="timezoneOptions"
:placeholder="t('grid.formatterDatetimeTimezonePlaceholder')"
:search-placeholder="t('grid.formatterDatetimeTimezonePlaceholder')"
:empty-text="t('grid.formatterDatetimeTimezonePlaceholder')"
:loading-text="t('common.loading')"
:trigger-class="['border border-input h-8 pl-2.5 text-xs']"
item-class="h-auto min-h-8 px-2 py-1.5 text-xs"
@update:model-value="(value: any) => (formatterDateTimezone = value)"
/>
</div>
</div>
<div v-else-if="formatterKind === 'json-path'" class="space-y-1.5">

View File

@ -243,7 +243,7 @@ function handleKeydown(event: KeyboardEvent) {
</PopoverTrigger>
<PopoverContent :align="SEARCHABLE_SELECT_HELP_PANEL_ALIGN" :class="cn('w-auto max-w-[calc(100vw-1rem)] border-0 bg-transparent p-0 shadow-none ring-0', contentClass)">
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start">
<div ref="listCard" :class="cn('w-52 shrink-0 rounded-md border bg-popover p-1.5 shadow-md', listClass)">
<div ref="listCard" :class="cn('shrink-0 rounded-md border bg-popover p-1.5 shadow-md', listClass)">
<div class="relative rounded-sm border bg-background">
<Search class="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<span v-if="!searchText" class="pointer-events-none absolute left-[25px] top-1/2 -translate-y-1/2 text-sm text-muted-foreground">{{ searchPlaceholder }}</span>

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -824,6 +824,8 @@ export default withEnglishFallback({
formatterDatetimePattern: "日時フォーマット",
formatterDatetimePatternPlaceholder: "日時フォーマットを選択または入力",
formatterDatetimePatternEmpty: "ISO8601フォーマット",
formatterDatetimeTimezone: "タイムゾーン",
formatterDatetimeTimezonePlaceholder: "タイムゾーンを選択または入力",
formatterJsonPathInput: "JSONパス",
formatterMaskPrefix: "表示する接頭辞",
formatterMaskSuffix: "表示する接尾辞",

View File

@ -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",

View File

@ -884,6 +884,8 @@ export default withEnglishFallback({
formatterDatetimePattern: "日期时间格式",
formatterDatetimePatternPlaceholder: "选择或输入日期时间格式",
formatterDatetimePatternEmpty: "默认 ISO8601 格式",
formatterDatetimeTimezone: "时区",
formatterDatetimeTimezonePlaceholder: "选择时区",
formatterJsonPathInput: "JSON 路径",
formatterMaskPrefix: "前缀保留",
formatterMaskSuffix: "后缀保留",

View File

@ -829,6 +829,8 @@ export default withEnglishFallback({
formatterDatetimePattern: "日期時間格式",
formatterDatetimePatternPlaceholder: "選擇或輸入日期時間格式",
formatterDatetimePatternEmpty: "預設 ISO8601 格式",
formatterDatetimeTimezone: "時區",
formatterDatetimeTimezonePlaceholder: "選擇時區",
formatterJsonPathInput: "JSON 路徑",
formatterMaskPrefix: "字首保留",
formatterMaskSuffix: "字尾保留",

View File

@ -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<string, unknown>;
const config = value as Record<string, any>;
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<string, CustomColumnFormatterConfig>, 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) {

View File

@ -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", () => {

View File

@ -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" },