feat(grid): support datetime formatter patterns
This commit is contained in:
parent
c38bc05417
commit
4e012a9dd9
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -637,6 +637,9 @@ export default withEnglishFallback({
|
|||
formatterUnitAuto: "自動",
|
||||
formatterUnitSeconds: "秒",
|
||||
formatterUnitMilliseconds: "ミリ秒",
|
||||
formatterDatetimePattern: "日時フォーマット",
|
||||
formatterDatetimePatternPlaceholder: "日時フォーマットを選択または入力",
|
||||
formatterDatetimePatternEmpty: "ISO8601フォーマット",
|
||||
formatterJsonPathInput: "JSONパス",
|
||||
formatterMaskPrefix: "表示する接頭辞",
|
||||
formatterMaskSuffix: "表示する接尾辞",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -652,6 +652,9 @@ export default withEnglishFallback({
|
|||
formatterUnitAuto: "自动",
|
||||
formatterUnitSeconds: "秒",
|
||||
formatterUnitMilliseconds: "毫秒",
|
||||
formatterDatetimePattern: "日期时间格式",
|
||||
formatterDatetimePatternPlaceholder: "选择或输入日期时间格式",
|
||||
formatterDatetimePatternEmpty: "默认 ISO8601 格式",
|
||||
formatterJsonPathInput: "JSON 路径",
|
||||
formatterMaskPrefix: "前缀保留",
|
||||
formatterMaskSuffix: "后缀保留",
|
||||
|
|
|
|||
|
|
@ -642,6 +642,9 @@ export default withEnglishFallback({
|
|||
formatterUnitAuto: "自動",
|
||||
formatterUnitSeconds: "秒",
|
||||
formatterUnitMilliseconds: "毫秒",
|
||||
formatterDatetimePattern: "日期時間格式",
|
||||
formatterDatetimePatternPlaceholder: "選擇或輸入日期時間格式",
|
||||
formatterDatetimePatternEmpty: "預設 ISO8601 格式",
|
||||
formatterJsonPathInput: "JSON 路徑",
|
||||
formatterMaskPrefix: "字首保留",
|
||||
formatterMaskSuffix: "字尾保留",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
export const setup = () => {
|
||||
process.env.TZ = "Asia/Shanghai";
|
||||
};
|
||||
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue