feat(grid): support datetime formatter patterns

This commit is contained in:
ptma authored and GitHub committed 2026-06-30 20:37:23 +08:00
1 parent c38bc05417
commit 4e012a9dd9
15 files changed
+289 -78

No files matched your search

+40 -17
View File
@@ -115,7 +115,7 @@ import { buildBinaryHexViewRows } from "@/lib/binaryHexViewer";
import { canFormatCellDetailJson, cellDetailEditorText, defaultCellDetailTab, formatJsonText, isGeometryColumnType, linkedCellDetailTarget, valueEditorActions, visibleCellDetailTabs, type CellDetailTab } from "@/lib/cellDetailPresentation";
import { renderWktOnCanvas, isHexGeometry } from "@/lib/geometryPreview";
import { buildDataGridCellDetail, buildDataGridColumnDetail, buildDataGridRowDetail, dataGridColumnDetailJson, dataGridColumnDetailTsv, dataGridRowDetailJson, dataGridRowDetailTsv, filterDataGridDetailFields, type DataGridCellDetail } from "@/lib/dataGridDetail";
import { applyColumnFormatter, buildColumnFormatterKey, normalizeColumnFormatter, resolveColumnFormatter, type ColumnFormatterConfig, type DateTimeFormatterUnit } from "@/lib/columnFormatter";
import { applyColumnFormatter, buildColumnFormatterKey, normalizeColumnFormatter, resolveColumnFormatter, type ColumnFormatterConfig, type DateTimeFormatterUnit, DateTimePatterns } from "@/lib/columnFormatter";
import { temporalCellEditorKind, type TemporalCellEditorKind } from "@/lib/dataGridTemporalEditor";
import { isEnumColumn, enumValuesForColumn } from "@/lib/dataGridEnumEditor";
import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/keyboardShortcuts";
@@ -155,6 +155,7 @@ import { caretPositionInsideInsertedSqlSingleQuotes, insertedSqlSingleQuoteAtCar
import { effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { isMacOS } from "@/lib/platform";
import { formatShortcut } from "@/lib/shortcutRegistry";
import { SearchableSelect } from "@/components/ui/searchable-select";
const SqlPreviewPanel = defineAsyncComponent(() => import("@/components/editor/SqlPreviewPanel.vue"));
const FORMATTED_JSON_EDIT_WARNING_STORAGE_KEY = "dbx-cell-detail-formatted-json-edit-warning-shown";
@@ -600,6 +601,7 @@ type FormatterDraftKind = Exclude<ColumnFormatterConfig["kind"], "custom-ref">;
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 formatterJsonPath = ref("$.user.name");
const formatterMaskPrefix = ref(4);
const formatterMaskSuffix = ref(4);
@@ -960,14 +962,15 @@ function currentFormatterDraft(): ColumnFormatterConfig {
if (formatterKind.value === "custom-template") {
return { kind: "custom-template", template: formatterCustomTemplate.value.trim() || "${value}" };
}
return { kind: "datetime", unit: formatterDateUnit.value };
return { kind: "datetime", unit: formatterDateUnit.value, pattern: formatterDatetimePattern.value };
}
function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) {
const draft = formatter ?? { kind: "datetime", unit: "auto" as const };
const draft = formatter ?? { kind: "datetime", unit: "auto" as const, pattern: "YYYY-MM-DD HH:mm:ss" as const };
formatterKind.value = draft.kind === "custom-ref" ? "custom-template" : draft.kind;
if (draft.kind === "datetime") {
formatterDateUnit.value = draft.unit;
formatterDatetimePattern.value = draft.pattern;
} else if (draft.kind === "json-path") {
formatterJsonPath.value = draft.path;
} else if (draft.kind === "mask") {
@@ -8116,7 +8119,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<Code2 class="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="start" side="bottom" class="w-[380px] 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-[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>
<div class="border-b bg-muted/40 px-3 py-2">
<div class="text-sm font-semibold">
{{ t("grid.columnFormatterFor", { column: col.name }) }}
@@ -8143,20 +8146,40 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</Select>
</div>
<div v-if="formatterKind === 'datetime'" class="space-y-1.5">
<div class="text-xs font-medium text-muted-foreground">
{{ t("grid.formatterTimestampUnit") }}
<div v-if="formatterKind === 'datetime'" class="flex gap-2">
<div class="space-y-1.5">
<div class="text-xs font-medium text-muted-foreground">
{{ t("grid.formatterTimestampUnit") }}
</div>
<Select :model-value="formatterDateUnit" @update:model-value="(value: any) => (formatterDateUnit = value)">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{{ t("grid.formatterUnitAuto") }}</SelectItem>
<SelectItem value="seconds">{{ t("grid.formatterUnitSeconds") }}</SelectItem>
<SelectItem value="milliseconds">{{ t("grid.formatterUnitMilliseconds") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5 flex-1">
<div class="text-xs font-medium text-muted-foreground">
{{ t("grid.formatterDatetimePattern") }}
</div>
<SearchableSelect
:model-value="formatterDatetimePattern"
:options="DateTimePatterns"
:placeholder="t('grid.formatterDatetimePatternPlaceholder')"
:search-placeholder="t('grid.formatterDatetimePatternPlaceholder')"
:empty-text="t('grid.formatterDatetimePatternEmpty')"
:loading-text="t('common.loading')"
:allow-custom="true"
:trigger-class="['border border-input h-8 w-72 pl-2.5 text-xs']"
content-class="w-72"
item-class="h-auto min-h-8 px-2 py-1.5 text-xs"
@update:model-value="(value: any) => (formatterDatetimePattern = value)"
/>
</div>
<Select :model-value="formatterDateUnit" @update:model-value="(value: any) => (formatterDateUnit = value)">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{{ t("grid.formatterUnitAuto") }}</SelectItem>
<SelectItem value="seconds">{{ t("grid.formatterUnitSeconds") }}</SelectItem>
<SelectItem value="milliseconds">{{ t("grid.formatterUnitMilliseconds") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-else-if="formatterKind === 'json-path'" class="space-y-1.5">
+3
View File
@@ -649,6 +649,9 @@ export default {
formatterUnitAuto: "Auto",
formatterUnitSeconds: "Seconds",
formatterUnitMilliseconds: "Milliseconds",
formatterDatetimePattern: "Datetime pattern",
formatterDatetimePatternPlaceholder: "Select or enter a datetime pattern",
formatterDatetimePatternEmpty: "Default ISO8601 format",
formatterJsonPathInput: "JSON path",
formatterMaskPrefix: "Visible prefix",
formatterMaskSuffix: "Visible suffix",
+3
View File
@@ -641,6 +641,9 @@ export default withEnglishFallback({
formatterUnitAuto: "Automático",
formatterUnitSeconds: "Segundos",
formatterUnitMilliseconds: "Milisegundos",
formatterDatetimePattern: "Patrón de fecha y hora",
formatterDatetimePatternPlaceholder: "Seleccionar o ingresar el formato de fecha y hora",
formatterDatetimePatternEmpty: "Formato ISO8601 por defecto",
formatterJsonPathInput: "Ruta JSON",
formatterMaskPrefix: "Prefijo visible",
formatterMaskSuffix: "Sufijo visible",
+3
View File
@@ -648,6 +648,9 @@ export default withEnglishFallback({
formatterUnitAuto: "Auto",
formatterUnitSeconds: "Secondi",
formatterUnitMilliseconds: "Millisecondi",
formatterDatetimePattern: "Formato data e orario",
formatterDatetimePatternPlaceholder: "Seleziona o inserisci un formato data e orario",
formatterDatetimePatternEmpty: "Formato ISO8601 predefinito",
formatterJsonPathInput: "Percorso JSON",
formatterMaskPrefix: "Prefisso visibile",
formatterMaskSuffix: "Suffisso visibile",
+3
View File
@@ -637,6 +637,9 @@ export default withEnglishFallback({
formatterUnitAuto: "自動",
formatterUnitSeconds: "秒",
formatterUnitMilliseconds: "ミリ秒",
formatterDatetimePattern: "日時フォーマット",
formatterDatetimePatternPlaceholder: "日時フォーマットを選択または入力",
formatterDatetimePatternEmpty: "ISO8601フォーマット",
formatterJsonPathInput: "JSONパス",
formatterMaskPrefix: "表示する接頭辞",
formatterMaskSuffix: "表示する接尾辞",
+3
View File
@@ -653,6 +653,9 @@ export default withEnglishFallback({
formatterUnitAuto: "Automático",
formatterUnitSeconds: "Segundos",
formatterUnitMilliseconds: "Milissegundos",
formatterDatetimePattern: "Formato de data e horário",
formatterDatetimePatternPlaceholder: "Selecione ou insira um formato de data e horário",
formatterDatetimePatternEmpty: "Padrão ISO8601",
formatterJsonPathInput: "Caminho JSON",
formatterMaskPrefix: "Prefixo visível",
formatterMaskSuffix: "Sufixo visível",
+3
View File
@@ -652,6 +652,9 @@ export default withEnglishFallback({
formatterUnitAuto: "自动",
formatterUnitSeconds: "秒",
formatterUnitMilliseconds: "毫秒",
formatterDatetimePattern: "日期时间格式",
formatterDatetimePatternPlaceholder: "选择或输入日期时间格式",
formatterDatetimePatternEmpty: "默认 ISO8601 格式",
formatterJsonPathInput: "JSON 路径",
formatterMaskPrefix: "前缀保留",
formatterMaskSuffix: "后缀保留",
+3
View File
@@ -642,6 +642,9 @@ export default withEnglishFallback({
formatterUnitAuto: "自動",
formatterUnitSeconds: "秒",
formatterUnitMilliseconds: "毫秒",
formatterDatetimePattern: "日期時間格式",
formatterDatetimePatternPlaceholder: "選擇或輸入日期時間格式",
formatterDatetimePatternEmpty: "預設 ISO8601 格式",
formatterJsonPathInput: "JSON 路徑",
formatterMaskPrefix: "字首保留",
formatterMaskSuffix: "字尾保留",
+143 -17
View File
@@ -1,6 +1,18 @@
import { displayCellValue, type CellValue } from "@/lib/cellValue";
import dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
dayjs.extend(customParseFormat);
dayjs.extend(utc);
dayjs.extend(timezone);
export type DateTimeFormatterUnit = "seconds" | "milliseconds" | "auto";
const DEFAULT_DATETIME_PATTERN = "YYYY-MM-DD HH:mm:ss";
export const DateTimePatterns = ["HH:mm:ss", "HH:mm:ss.SSS", "YYYY-MM-DD HH:mm:ss", "YYYY-MM-DD HH:mm:ss.SSS", "YYYY/MM/DD HH:mm:ss", "YYYY/MM/DD HH:mm:ss.SSS", "YYYY-MM-DDTHH:mm:ssZ", "YYYY-MM-DDTHH:mm:ss.SSSZ", "YYYY/MM/DDTHH:mm:ssZ", "YYYY/MM/DDTHH:mm:ss.SSSZ"];
const STRICT_LOCAL_DATETIME_INPUT_PATTERNS = ["YYYY-MM-DD", "YYYY/MM/DD", "YYYY-MM-DD HH:mm:ss", "YYYY-MM-DD HH:mm:ss.SSS", "YYYY/MM/DD HH:mm:ss", "YYYY/MM/DD HH:mm:ss.SSS", "YYYY-MM-DDTHH:mm:ss", "YYYY-MM-DDTHH:mm:ss.SSS", "YYYY/MM/DDTHH:mm:ss", "YYYY/MM/DDTHH:mm:ss.SSS"];
const ISO_OFFSET_DATETIME_PATTERN = /^(\d{4})([-/])(\d{2})\2(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,3})?(Z|[+-]\d{2}:\d{2})$/;
export interface CustomColumnFormatterConfig {
id: string;
@@ -8,7 +20,7 @@ export interface CustomColumnFormatterConfig {
template: string;
}
export type ColumnFormatterConfig = { kind: "datetime"; unit: DateTimeFormatterUnit } | { 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 } | { 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;
@@ -27,7 +39,13 @@ export function normalizeColumnFormatter(value: unknown): ColumnFormatterConfig
const config = value as Record<string, unknown>;
if (config.kind === "datetime") {
return config.unit === "seconds" || config.unit === "milliseconds" || config.unit === "auto" ? { kind: "datetime", unit: config.unit } : undefined;
return config.unit === "seconds" || config.unit === "milliseconds" || config.unit === "auto"
? {
kind: "datetime",
unit: config.unit,
pattern: normalizeDateTimePattern(config.pattern),
}
: undefined;
}
if (config.kind === "json-path") {
@@ -75,7 +93,7 @@ export function applyColumnFormatter(value: CellValue, formatter: ColumnFormatte
if (!formatter) return displayCellValue(value);
try {
if (formatter.kind === "datetime") return formatDateTime(value, formatter.unit);
if (formatter.kind === "datetime") return formatDateTime(value, formatter.unit, formatter.pattern);
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);
@@ -85,23 +103,131 @@ export function applyColumnFormatter(value: CellValue, formatter: ColumnFormatte
}
}
function formatDateTime(value: CellValue, unit: DateTimeFormatterUnit): string {
function formatDateTime(value: CellValue, unit: DateTimeFormatterUnit, pattern: string): string {
if (value === null) return displayCellValue(value);
if (typeof value === "string" && !isTimestampString(value, unit)) return displayCellValue(value);
const numeric = typeof value === "number" ? value : Number(String(value).trim());
if (!Number.isFinite(numeric)) return displayCellValue(value);
const timestamp = unit === "seconds" || (unit === "auto" && Math.abs(numeric) < 100_000_000_000) ? numeric * 1000 : numeric;
const date = new Date(timestamp);
return Number.isNaN(date.getTime()) ? displayCellValue(value) : date.toLocaleString();
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);
}
function isTimestampString(value: string, unit: DateTimeFormatterUnit): boolean {
const text = value.trim();
if (!/^-?\d+$/.test(text)) return false;
const digits = text.startsWith("-") ? text.length - 1 : text.length;
if (unit === "seconds") return digits === 10;
if (unit === "milliseconds") return digits === 13;
return digits === 10 || digits === 13;
function resolveDateTimeValue(value: string | number, unit: DateTimeFormatterUnit) {
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const timestamp = parseTimestampMilliseconds(trimmed, unit);
if (timestamp !== undefined) {
const parsedTimestamp = dayjs(timestamp);
return parsedTimestamp.isValid() ? parsedTimestamp : undefined;
}
if (isIntegerString(trimmed)) return undefined;
return parseStrictDateTimeString(trimmed);
}
const timestamp = parseTimestampMilliseconds(value, unit);
if (timestamp === undefined) return undefined;
const parsedTimestamp = dayjs(timestamp);
return parsedTimestamp.isValid() ? parsedTimestamp : undefined;
}
function normalizeDateTimePattern(pattern: unknown): string {
return typeof pattern === "string" && pattern.trim() ? pattern : DEFAULT_DATETIME_PATTERN;
}
function parseStrictDateTimeString(value: string): Dayjs | undefined {
const parsedIsoOffset = parseIsoOffsetDateTimeString(value);
if (parsedIsoOffset) return parsedIsoOffset;
for (const pattern of STRICT_LOCAL_DATETIME_INPUT_PATTERNS) {
// Day.js non-strict parsing normalizes overflow dates such as 2022-01-33.
// Keep cell text strict so invalid values fall back unchanged.
const parsed = dayjs(value, pattern, true);
if (parsed.isValid()) return parsed;
}
return undefined;
}
function parseIsoOffsetDateTimeString(value: string): Dayjs | undefined {
const match = value.match(ISO_OFFSET_DATETIME_PATTERN);
if (!match) return undefined;
const [, yearText, , monthText, dayText, hourText, minuteText, secondText, fractionText = "", zoneText] = match;
if (!isValidDateTimeParts(yearText, monthText, dayText, hourText, minuteText, secondText)) return undefined;
if (!isValidOffset(zoneText)) return undefined;
const normalized = `${yearText}-${monthText}-${dayText}T${hourText}:${minuteText}:${secondText}${fractionText}${zoneText}`;
const parsed = dayjs(normalized);
return parsed.isValid() ? parsed : undefined;
}
function isValidDateTimeParts(yearText: string, monthText: string, dayText: string, hourText: string, minuteText: string, secondText: string): boolean {
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
const hour = Number(hourText);
const minute = Number(minuteText);
const second = Number(secondText);
if (!Number.isInteger(year) || year < 1) return false;
if (!Number.isInteger(month) || month < 1 || month > 12) return false;
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
if (!Number.isInteger(day) || day < 1 || day > daysInMonth) return false;
return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 && second >= 0 && second <= 59;
}
function isValidOffset(offset: string): boolean {
if (offset === "Z") return true;
const match = offset.match(/^([+-])(\d{2}):(\d{2})$/);
if (!match) return false;
const hour = Number(match[2]);
const minute = Number(match[3]);
return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59;
}
function parseTimestampMilliseconds(value: string | number, unit: DateTimeFormatterUnit): number | undefined {
const numeric = typeof value === "string" ? Number(value) : value;
if (!Number.isInteger(numeric)) {
return undefined;
}
if (unit === "seconds" || unit === "milliseconds") {
return convertToMilliseconds(numeric, unit);
}
return isAutoTimestampValue(value, numeric) ? convertToMilliseconds(numeric, unit) : undefined;
}
function convertToMilliseconds(value: number, unit: DateTimeFormatterUnit): number {
return unit === "seconds" || (unit === "auto" && Math.abs(value) < 100_000_000_000) ? value * 1000 : value;
}
function isAutoTimestampValue(originalValue: string | number, numericValue: number): boolean {
if (!Number.isInteger(numericValue)) {
return false;
}
const digits = getTimestampDigits(originalValue);
if (digits !== 10 && digits !== 13) {
return false;
}
const yearFromTimestamp = dayjs(digits === 10 ? numericValue * 1000 : numericValue).year();
return yearFromTimestamp >= 1970 && yearFromTimestamp <= 2100;
}
function getTimestampDigits(value: string | number): number | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
if (!isIntegerString(trimmed)) return undefined;
return trimmed.startsWith("-") ? trimmed.length - 1 : trimmed.length;
} else if (!Number.isInteger(value)) {
return undefined;
} else {
return Math.abs(value).toString().length;
}
}
function isIntegerString(value: string): boolean {
return /^-?\d+$/.test(value);
}
function formatJsonPath(value: CellValue, path: string): string {