feat(grid): add timezone-aware column formatting
This commit is contained in:
12 files changed
+143
-33
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -824,6 +824,8 @@ export default withEnglishFallback({
|
||||
formatterDatetimePattern: "日時フォーマット",
|
||||
formatterDatetimePatternPlaceholder: "日時フォーマットを選択または入力",
|
||||
formatterDatetimePatternEmpty: "ISO8601フォーマット",
|
||||
formatterDatetimeTimezone: "タイムゾーン",
|
||||
formatterDatetimeTimezonePlaceholder: "タイムゾーンを選択または入力",
|
||||
formatterJsonPathInput: "JSONパス",
|
||||
formatterMaskPrefix: "表示する接頭辞",
|
||||
formatterMaskSuffix: "表示する接尾辞",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -884,6 +884,8 @@ export default withEnglishFallback({
|
||||
formatterDatetimePattern: "日期时间格式",
|
||||
formatterDatetimePatternPlaceholder: "选择或输入日期时间格式",
|
||||
formatterDatetimePatternEmpty: "默认 ISO8601 格式",
|
||||
formatterDatetimeTimezone: "时区",
|
||||
formatterDatetimeTimezonePlaceholder: "选择时区",
|
||||
formatterJsonPathInput: "JSON 路径",
|
||||
formatterMaskPrefix: "前缀保留",
|
||||
formatterMaskSuffix: "后缀保留",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user