feat(settings): add global date and time formats
This commit is contained in:
31 files changed
+1109
-88
No files matched your search
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeSupportedDateTimePattern } from "@/lib/dataGrid/columnFormatter";
|
||||
|
||||
describe("normalizeSupportedDateTimePattern", () => {
|
||||
it("accepts the format grammar shared by the frontend and backend", () => {
|
||||
expect(normalizeSupportedDateTimePattern(" YYYY/M/D [at] HH:mm:ss.SSSZ ")).toBe("YYYY/M/D [at] HH:mm:ss.SSSZ");
|
||||
});
|
||||
|
||||
it("rejects unsupported or malformed Day.js tokens", () => {
|
||||
expect(normalizeSupportedDateTimePattern("MM/DD/YYYY hh:mm A")).toBe("");
|
||||
expect(normalizeSupportedDateTimePattern("YYYY-MM-DD [at HH:mm:ss")).toBe("");
|
||||
expect(normalizeSupportedDateTimePattern("%Y-%m-%d")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -2201,6 +2201,7 @@ export interface TableImportRequest {
|
||||
mode: TableImportMode;
|
||||
createTable?: boolean;
|
||||
batchSize: number;
|
||||
dateTimeFormat?: string;
|
||||
}
|
||||
|
||||
export interface TableImportSummary {
|
||||
@@ -2291,6 +2292,7 @@ export interface TableExportRequest {
|
||||
skipCount?: boolean;
|
||||
batchSize?: number;
|
||||
rowLimit?: number | null;
|
||||
dateTimeFormat?: string;
|
||||
}
|
||||
|
||||
export interface TableCsvExportOptions {
|
||||
@@ -2332,6 +2334,7 @@ export interface QueryResultExportRequest {
|
||||
keysetOptimizationEnabled: boolean;
|
||||
clientSessionId?: string;
|
||||
executionId?: string;
|
||||
dateTimeFormat?: string;
|
||||
}
|
||||
|
||||
export async function startTableExport(request: TableExportRequest, onProgress: (progress: TableExportProgress) => void): Promise<TableExportProgress> {
|
||||
|
||||
@@ -10,9 +10,47 @@ 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 const DateTimePatterns = [
|
||||
"YYYY-MM-DD",
|
||||
"YYYY/MM/DD",
|
||||
"YYYY/M/D",
|
||||
"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/M/D HH:mm:ss",
|
||||
"YYYY-MM-DDTHH:mm:ssZ",
|
||||
"YYYY-MM-DDTHH:mm:ss.SSSZ",
|
||||
"YYYY/MM/DDTHH:mm:ssZ",
|
||||
"YYYY/MM/DDTHH:mm:ss.SSSZ",
|
||||
];
|
||||
const SUPPORTED_DATE_TIME_PATTERN_TOKENS = ["YYYY", "SSS", "ZZ", "MM", "DD", "HH", "mm", "ss", "M", "D", "H", "m", "s", "Z"];
|
||||
const STRICT_LOCAL_DATETIME_INPUT_PATTERNS = [
|
||||
"YYYY-MM-DD",
|
||||
"YYYY-M-D",
|
||||
"YYYY/MM/DD",
|
||||
"YYYY/M/D",
|
||||
"YYYY-MM-DD HH:mm:ss",
|
||||
"YYYY-M-D H:m:s",
|
||||
"YYYY-MM-DD HH:mm:ss.SSS",
|
||||
"YYYY-M-D H:m:s.SSS",
|
||||
"YYYY/MM/DD HH:mm:ss",
|
||||
"YYYY/M/D H:m:s",
|
||||
"YYYY/MM/DD HH:mm:ss.SSS",
|
||||
"YYYY/M/D H:m:s.SSS",
|
||||
"YYYY-MM-DDTHH:mm:ss",
|
||||
"YYYY-M-DTH:m:s",
|
||||
"YYYY-MM-DDTHH:mm:ss.SSS",
|
||||
"YYYY-M-DTH:m:s.SSS",
|
||||
"YYYY/MM/DDTHH:mm:ss",
|
||||
"YYYY/M/DTH:m:s",
|
||||
"YYYY/MM/DDTHH:mm:ss.SSS",
|
||||
"YYYY/M/DTH:m:s.SSS",
|
||||
];
|
||||
const ISO_OFFSET_DATETIME_PATTERN = /^(\d{4})([-/])(\d{2})\2(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
|
||||
const FRACTIONAL_LOCAL_DATETIME_PATTERN = /^(\d{4})([-/])(\d{1,2})\2(\d{1,2})([ T])(\d{1,2}):(\d{1,2}):(\d{1,2})\.(\d{1,9})$/;
|
||||
|
||||
export interface CustomColumnFormatterConfig {
|
||||
id: string;
|
||||
@@ -20,6 +58,33 @@ export interface CustomColumnFormatterConfig {
|
||||
template: string;
|
||||
}
|
||||
|
||||
export function normalizeSupportedDateTimePattern(value: string): string {
|
||||
const pattern = value.trim();
|
||||
if (!pattern || pattern.length > 100 || pattern.includes("%")) return "";
|
||||
|
||||
let index = 0;
|
||||
while (index < pattern.length) {
|
||||
const remaining = pattern.slice(index);
|
||||
if (remaining.startsWith("[")) {
|
||||
const closeIndex = remaining.indexOf("]");
|
||||
if (closeIndex < 0) return "";
|
||||
index += closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const token = SUPPORTED_DATE_TIME_PATTERN_TOKENS.find((candidate) => remaining.startsWith(candidate));
|
||||
if (token) {
|
||||
index += token.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/[A-Za-z]/.test(pattern[index])) return "";
|
||||
index += 1;
|
||||
}
|
||||
|
||||
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 interface ColumnFormatterKeyParts {
|
||||
@@ -82,13 +147,69 @@ export function normalizeCustomColumnFormatter(value: unknown): CustomColumnForm
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveColumnFormatter(formatter: ColumnFormatterConfig | undefined, customFormatters: Record<string, CustomColumnFormatterConfig>): ColumnFormatterConfig | undefined {
|
||||
if (!formatter) return undefined;
|
||||
export function normalizeGlobalDateTimePattern(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().slice(0, 100) : "";
|
||||
}
|
||||
|
||||
export function isTemporalColumnType(dataType: string | null | undefined): boolean {
|
||||
const normalized = String(dataType || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ");
|
||||
if (!normalized) return false;
|
||||
const base = normalized.split(/[(:]/)[0]?.trim() ?? "";
|
||||
return (
|
||||
["date", "date32", "daten", "time", "time64", "timen", "timetz", "datetime", "datetime2", "datetime4", "datetime64", "datetimen", "datetimeoffset", "datetimeoffsetn", "smalldatetime", "timestamp", "timestampdty", "timestamptz"].includes(base) ||
|
||||
base.startsWith("timestamp_") ||
|
||||
normalized.startsWith("timestamp with ") ||
|
||||
normalized.startsWith("timestamp without ") ||
|
||||
normalized.startsWith("time with ") ||
|
||||
normalized.startsWith("time without ")
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (formatter.kind !== "custom-ref") return formatter;
|
||||
const customFormatter = customFormatters[formatter.formatterId];
|
||||
return customFormatter ? { kind: "custom-template", template: customFormatter.template } : undefined;
|
||||
}
|
||||
|
||||
export function formatTemporalRowsForExport<T extends CellValue>(rows: readonly (readonly T[])[], columnTypes: readonly (string | null | undefined)[], pattern: string): T[][] {
|
||||
const normalizedPattern = normalizeGlobalDateTimePattern(pattern);
|
||||
if (!normalizedPattern) return rows.map((row) => [...row]);
|
||||
return rows.map((row) =>
|
||||
row.map((value, index) => {
|
||||
if (!isTemporalColumnType(columnTypes[index])) return value;
|
||||
return formatTemporalValueForExport(value, normalizedPattern) as T;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function formatTemporalValueForExport(value: CellValue, pattern: string): string {
|
||||
if (typeof value === "string") {
|
||||
const match = value.match(ISO_OFFSET_DATETIME_PATTERN);
|
||||
if (match) {
|
||||
const [, year, separator, month, day, hour, minute, second, fraction = "", zone] = match;
|
||||
if (!isValidDateTimeParts(year, month, day, hour, minute, second) || !isValidOffset(zone)) {
|
||||
return displayCellValue(value);
|
||||
}
|
||||
const normalizedFraction = fraction ? `.${fraction.slice(1, 4).padEnd(3, "0")}` : "";
|
||||
const localValue = `${year}${separator}${month}${separator}${day}T${hour}:${minute}:${second}${normalizedFraction}`;
|
||||
const inputPattern = `${separator === "-" ? "YYYY-MM-DD" : "YYYY/MM/DD"}THH:mm:ss${fraction ? ".SSS" : ""}`;
|
||||
const parsed = dayjs(localValue, inputPattern, true);
|
||||
if (parsed.isValid()) {
|
||||
const offsetMinutes = zone === "Z" ? 0 : (zone.startsWith("-") ? -1 : 1) * (Number(zone.slice(1, 3)) * 60 + Number(zone.slice(4, 6)));
|
||||
return parsed.utcOffset(offsetMinutes, true).format(pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
return applyColumnFormatter(value, { kind: "datetime", unit: "auto", pattern });
|
||||
}
|
||||
|
||||
export function applyColumnFormatter(value: CellValue, formatter: ColumnFormatterConfig | undefined): string {
|
||||
if (!formatter) return displayCellValue(value);
|
||||
|
||||
@@ -142,6 +263,16 @@ function parseStrictDateTimeString(value: string): Dayjs | undefined {
|
||||
const parsedIsoOffset = parseIsoOffsetDateTimeString(value);
|
||||
if (parsedIsoOffset) return parsedIsoOffset;
|
||||
|
||||
const fractionalLocalMatch = value.match(FRACTIONAL_LOCAL_DATETIME_PATTERN);
|
||||
if (fractionalLocalMatch) {
|
||||
const [, yearText, , monthText, dayText, , hourText, minuteText, secondText, fractionText] = fractionalLocalMatch;
|
||||
if (isValidDateTimeParts(yearText, monthText, dayText, hourText, minuteText, secondText)) {
|
||||
const normalized = `${yearText}-${monthText.padStart(2, "0")}-${dayText.padStart(2, "0")}T${hourText.padStart(2, "0")}:${minuteText.padStart(2, "0")}:${secondText.padStart(2, "0")}.${fractionText.slice(0, 3).padEnd(3, "0")}`;
|
||||
const parsed = dayjs(normalized, "YYYY-MM-DDTHH:mm:ss.SSS", true);
|
||||
if (parsed.isValid()) return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -159,7 +290,8 @@ function parseIsoOffsetDateTimeString(value: string): Dayjs | undefined {
|
||||
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 normalizedFraction = fractionText ? `.${fractionText.slice(1, 4).padEnd(3, "0")}` : "";
|
||||
const normalized = `${yearText}-${monthText}-${dayText}T${hourText}:${minuteText}:${secondText}${normalizedFraction}${zoneText}`;
|
||||
const parsed = dayjs(normalized);
|
||||
return parsed.isValid() ? parsed : undefined;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
|
||||
"exportRowLimitEnabled",
|
||||
"exportRowLimit",
|
||||
"queryExportKeysetOptimizationEnabled",
|
||||
"globalDateTimeDisplayFormat",
|
||||
"globalDateTimeExportFormat",
|
||||
"globalDateTimeImportFormat",
|
||||
"updateDownloadSource",
|
||||
"toolbarItems",
|
||||
"snippets",
|
||||
|
||||
Reference in new issue
Block a user