feat(grid): add timezone-aware column formatting

This commit is contained in:
ptma authored and GitHub committed 2026-07-16 18:42:33 +08:00
1 parent 41e0989f96
commit 9efdf79ad9
12 files changed
+143 -33

No files matched your search

+29 -4
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">
@@ -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>
+2
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",
+2
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",
+2
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",
+2
View File
@@ -824,6 +824,8 @@ export default withEnglishFallback({
formatterDatetimePattern: "日時フォーマット",
formatterDatetimePatternPlaceholder: "日時フォーマットを選択または入力",
formatterDatetimePatternEmpty: "ISO8601フォーマット",
formatterDatetimeTimezone: "タイムゾーン",
formatterDatetimeTimezonePlaceholder: "タイムゾーンを選択または入力",
formatterJsonPathInput: "JSONパス",
formatterMaskPrefix: "表示する接頭辞",
formatterMaskSuffix: "表示する接尾辞",
+2
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",
+2
View File
@@ -884,6 +884,8 @@ export default withEnglishFallback({
formatterDatetimePattern: "日期时间格式",
formatterDatetimePatternPlaceholder: "选择或输入日期时间格式",
formatterDatetimePatternEmpty: "默认 ISO8601 格式",
formatterDatetimeTimezone: "时区",
formatterDatetimeTimezonePlaceholder: "选择时区",
formatterJsonPathInput: "JSON 路径",
formatterMaskPrefix: "前缀保留",
formatterMaskSuffix: "后缀保留",
+2
View File
@@ -829,6 +829,8 @@ export default withEnglishFallback({
formatterDatetimePattern: "日期時間格式",
formatterDatetimePatternPlaceholder: "選擇或輸入日期時間格式",
formatterDatetimePatternEmpty: "預設 ISO8601 格式",
formatterDatetimeTimezone: "時區",
formatterDatetimeTimezonePlaceholder: "選擇時區",
formatterJsonPathInput: "JSON 路徑",
formatterMaskPrefix: "字首保留",
formatterMaskSuffix: "字尾保留",
@@ -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) {