From 4e012a9dd983291941d3c235717b074b4f52bc27 Mon Sep 17 00:00:00 2001 From: ptma Date: Tue, 30 Jun 2026 20:37:23 +0800 Subject: [PATCH] feat(grid): support datetime formatter patterns --- apps/desktop/src/components/grid/DataGrid.vue | 57 +++++-- apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/it.ts | 3 + apps/desktop/src/i18n/locales/ja.ts | 3 + apps/desktop/src/i18n/locales/pt-BR.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + apps/desktop/src/lib/columnFormatter.ts | 160 ++++++++++++++++-- package.json | 1 + packages/app-tests/columnFormatter.test.ts | 30 +++- packages/app-tests/settingsStore.test.ts | 4 +- packages/test-globals.ts | 3 + pnpm-lock.yaml | 90 ++++++---- vitest.config.ts | 1 + 15 files changed, 289 insertions(+), 78 deletions(-) create mode 100644 packages/test-globals.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 2c73ad6b4..c2da9b777 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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; const CUSTOM_FORMATTER_NEW = "__new"; const formatterKind = ref("datetime"); const formatterDateUnit = ref("auto"); +const formatterDatetimePattern = ref("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(() => { - +
{{ t("grid.columnFormatterFor", { column: col.name }) }} @@ -8143,20 +8146,40 @@ const gridContextMenuItems = computed(() => {
-
-
- {{ t("grid.formatterTimestampUnit") }} +
+
+
+ {{ t("grid.formatterTimestampUnit") }} +
+ +
+
+
+ {{ t("grid.formatterDatetimePattern") }} +
+
-
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 65b59859e..5c6c20176 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index c825891dd..d9d2235a4 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index b97fc7dff..34373e617 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index bd6727bd2..95de3c620 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -637,6 +637,9 @@ export default withEnglishFallback({ formatterUnitAuto: "自動", formatterUnitSeconds: "秒", formatterUnitMilliseconds: "ミリ秒", + formatterDatetimePattern: "日時フォーマット", + formatterDatetimePatternPlaceholder: "日時フォーマットを選択または入力", + formatterDatetimePatternEmpty: "ISO8601フォーマット", 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 54144061b..94fa19f83 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index dbc6aff28..c12ccd9ad 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -652,6 +652,9 @@ export default withEnglishFallback({ formatterUnitAuto: "自动", formatterUnitSeconds: "秒", formatterUnitMilliseconds: "毫秒", + formatterDatetimePattern: "日期时间格式", + formatterDatetimePatternPlaceholder: "选择或输入日期时间格式", + formatterDatetimePatternEmpty: "默认 ISO8601 格式", 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 0a07b94c0..e6373ff5e 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -642,6 +642,9 @@ export default withEnglishFallback({ formatterUnitAuto: "自動", formatterUnitSeconds: "秒", formatterUnitMilliseconds: "毫秒", + formatterDatetimePattern: "日期時間格式", + formatterDatetimePatternPlaceholder: "選擇或輸入日期時間格式", + formatterDatetimePatternEmpty: "預設 ISO8601 格式", formatterJsonPathInput: "JSON 路徑", formatterMaskPrefix: "字首保留", formatterMaskSuffix: "字尾保留", diff --git a/apps/desktop/src/lib/columnFormatter.ts b/apps/desktop/src/lib/columnFormatter.ts index 77abf18a8..241a87a3f 100644 --- a/apps/desktop/src/lib/columnFormatter.ts +++ b/apps/desktop/src/lib/columnFormatter.ts @@ -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; 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 { diff --git a/package.json b/package.json index e22223e00..f244d9dc5 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "codemirror": "^6.0.2", + "dayjs": "^1.11.21", "diff": "^9.0.0", "dom-to-image-more": "^3.7.2", "echarts": "^6.1.0", diff --git a/packages/app-tests/columnFormatter.test.ts b/packages/app-tests/columnFormatter.test.ts index 0875a31de..4d5347760 100644 --- a/packages/app-tests/columnFormatter.test.ts +++ b/packages/app-tests/columnFormatter.test.ts @@ -1,16 +1,31 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; import { applyColumnFormatter, buildColumnFormatterKey, resolveColumnFormatter, normalizeColumnFormatter, type ColumnFormatterConfig } from "../../apps/desktop/src/lib/columnFormatter.ts"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; +import dayjs from "dayjs"; test("formats unix timestamps in seconds, milliseconds, and auto mode", () => { - assert.equal(applyColumnFormatter(1715758200, { kind: "datetime", unit: "seconds" }), new Date(1715758200 * 1000).toLocaleString()); - assert.equal(applyColumnFormatter(1715758200000, { kind: "datetime", unit: "milliseconds" }), new Date(1715758200000).toLocaleString()); - assert.equal(applyColumnFormatter("1715758200", { kind: "datetime", unit: "auto" }), new Date(1715758200 * 1000).toLocaleString()); + 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"); }); test("does not treat compact date strings as unix timestamps", () => { - assert.equal(applyColumnFormatter("20260514", { kind: "datetime", unit: "auto" }), "20260514"); - assert.equal(applyColumnFormatter("2026-05-14", { kind: "datetime", unit: "auto" }), "2026-05-14"); + 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"); }); test("extracts simple JSON paths from object and array strings", () => { @@ -27,9 +42,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" }), "NULL"); + 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("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" }); }); test("normalizes only supported formatter configs", () => { diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index b3a5ebfff..d331ef9fd 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -265,7 +265,7 @@ test("defaults column formatters to an empty record", () => { test("keeps only valid saved column formatter configs", () => { const settings = normalizeEditorSettings({ columnFormatters: { - "conn::db::public::users::created_at": { kind: "datetime", unit: "auto" }, + "conn::db::public::users::created_at": { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ss" }, "conn::db::public::users::bad_date": { kind: "datetime", unit: "bogus" }, "conn::db::public::users::name": { kind: "mask", prefix: 2, suffix: 2 }, "conn::db::public::users::payload": { kind: "json-path", path: "$.user.name" }, @@ -280,7 +280,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" }, + "conn::db::public::users::created_at": { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ss" }, "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" }, diff --git a/packages/test-globals.ts b/packages/test-globals.ts new file mode 100644 index 000000000..5db3b8db9 --- /dev/null +++ b/packages/test-globals.ts @@ -0,0 +1,3 @@ +export const setup = () => { + process.env.TZ = "Asia/Shanghai"; +}; \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 559e5ab4a..1fbdb4e24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: codemirror: specifier: ^6.0.2 version: 6.0.2 + dayjs: + specifier: ^1.11.21 + version: 1.11.21 diff: specifier: ^9.0.0 version: 9.0.0 @@ -915,6 +918,9 @@ packages: resolution: {integrity: sha512-QRUCHqda1U6aR14FR0vvXD4+4gj6+fm0AhAozvSuRCw0fCvrmCugWpgiR4xH2NI6s8am6N9p5OhirplsX8ZS3g==} engines: {node: '>= 22'} + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@isaacs/cliui@9.0.0': resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} @@ -2018,6 +2024,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} @@ -2114,6 +2124,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2623,6 +2636,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -3370,6 +3387,14 @@ packages: resolution: {integrity: sha512-CkfWKhQiYsMQYaWUkHdERXUxT2jJLBoa5y7zFv3dUAE7Ly5oU/0hsqrENyEfrCL03pDsQYbnoz17Cbagx/c2OA==} engines: {node: '>= 4'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -3561,6 +3586,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -4017,23 +4045,6 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} - '@ioredis/commands@1.10.0': - resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} - cluster-key-slot@1.1.1: - resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} - engines: {node: '>=0.10.0'} - ioredis@5.11.1: - resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} - engines: {node: '>=12.22.0'} - redis-errors@1.2.0: - resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} - engines: {node: '>=4'} - redis-parser@3.0.0: - resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} - engines: {node: '>=4'} - standard-as-callback@2.1.0: - resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} - snapshots: '@babel/code-frame@7.29.0': @@ -4644,6 +4655,8 @@ snapshots: '@intlify/shared@11.4.4': {} + '@ioredis/commands@1.10.0': {} + '@isaacs/cliui@9.0.0': {} '@jridgewell/gen-mapping@0.3.13': @@ -5664,6 +5677,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.1: {} + code-block-writer@13.0.3: {} codemirror@6.0.2: @@ -5747,6 +5762,8 @@ snapshots: csstype@3.2.3: {} + dayjs@1.11.21: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -6301,6 +6318,18 @@ snapshots: ini@1.3.8: {} + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ip-address@10.1.0: {} ipaddr.js@1.9.1: {} @@ -7002,6 +7031,12 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -7272,6 +7307,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + statuses@2.0.2: {} std-env@4.1.0: {} @@ -7695,22 +7732,3 @@ snapshots: tslib: 2.3.0 zwitch@2.0.4: {} - - '@ioredis/commands@1.10.0': {} - cluster-key-slot@1.1.1: {} - ioredis@5.11.1: - dependencies: - '@ioredis/commands': 1.10.0 - cluster-key-slot: 1.1.1 - debug: 4.4.3 - denque: 2.1.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color - redis-errors@1.2.0: {} - redis-parser@3.0.0: - dependencies: - redis-errors: 1.2.0 - standard-as-callback@2.1.0: {} diff --git a/vitest.config.ts b/vitest.config.ts index 2c17f5186..d8687fa9f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,5 +9,6 @@ export default defineConfig({ }, test: { include: ["packages/app-tests/*.test.ts", "packages/node-core/tests/*.test.ts", "apps/desktop/src/**/*.spec.ts", "docs/lib/*.test.ts"], + globalSetup: "packages/test-globals.ts", }, });