fix(iotdb): correct timestamp display and table editing
This commit is contained in:
16 files changed
+1031
-73
No files matched your search
@@ -147,7 +147,21 @@ import {
|
||||
import { buildBinaryHexViewRows } from "@/lib/dataGrid/binaryHexViewer";
|
||||
import { canFormatCellDetailJson, cellDetailEditorText, compactJsonText, defaultCellDetailTab, formatJsonText, isGeometryColumnType, linkedCellDetailTarget, looksLikeJsonContainerText, valueEditorActions, visibleCellDetailTabs, type CellDetailTab } from "@/lib/dataGrid/cellDetailPresentation";
|
||||
import { buildDataGridCellDetail, buildDataGridColumnDetail, buildDataGridRowDetail, CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH, dataGridColumnDetailJson, dataGridColumnDetailTsv, dataGridRowDetailJson, dataGridRowDetailTsv, type DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
|
||||
import { applyColumnFormatter, buildColumnFormatterKey, getSupportedTimeZoneOptions, normalizeColumnFormatter, resolveColumnFormatter, type ColumnFormatterConfig, type DateTimeFormatterUnit, DateTimePatterns } from "@/lib/dataGrid/columnFormatter";
|
||||
import {
|
||||
applyColumnFormatter,
|
||||
buildColumnFormatterKey,
|
||||
defaultIoTDBTimestampFormatter,
|
||||
formatIoTDBTimestampEditorValue,
|
||||
getSupportedTimeZoneOptions,
|
||||
iotdbTimestampFractionDigits,
|
||||
iotdbTimestampPrecision,
|
||||
normalizeColumnFormatter,
|
||||
parseIoTDBTimestampEditorValue,
|
||||
resolveColumnFormatter,
|
||||
type ColumnFormatterConfig,
|
||||
type DateTimeFormatterUnit,
|
||||
DateTimePatterns,
|
||||
} from "@/lib/dataGrid/columnFormatter";
|
||||
import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor";
|
||||
import { BOOLEAN_CELL_EDITOR_VALUES, booleanCellEditorValue, isBooleanCellValue, isBooleanColumnType, isPointInBooleanCheckbox, nextBooleanCellValue, normalizeBooleanCellValue, parseBooleanCellEditorValue } from "@/lib/dataGrid/dataGridBooleanColumn";
|
||||
import { resolveDataGridColumnNullability, resolveDataGridColumnsByResultIndex } from "@/lib/dataGrid/dataGridColumnMetadata";
|
||||
@@ -910,7 +924,7 @@ const filterEditorView = computed(() => settingsStore.editorSettings.dataGridFil
|
||||
const structuredFilterCount = computed(() => structuredFilterRules.value.filter((rule) => !rule.disabled && !!rule.columnName && filterModeHasCompleteValue(rule.mode, rule.rawValue, rule.rawEndValue)).length);
|
||||
const hasStructuredFilters = computed(() => !!combineWhereInputs(undefined, appliedStructuredWhereInput.value));
|
||||
const formatterOpenColumn = ref<number | null>(null);
|
||||
type FormatterDraftKind = Exclude<ColumnFormatterConfig["kind"], "custom-ref">;
|
||||
type FormatterDraftKind = Exclude<ColumnFormatterConfig["kind"], "custom-ref" | "iotdb-timestamp">;
|
||||
const CUSTOM_FORMATTER_NEW = "__new";
|
||||
const formatterKind = ref<FormatterDraftKind>("datetime");
|
||||
const formatterDateUnit = ref<DateTimeFormatterUnit>("auto");
|
||||
@@ -1274,10 +1288,14 @@ function columnFormatter(columnIndex: number): ColumnFormatterConfig | undefined
|
||||
const column = props.result.columns[columnIndex];
|
||||
if (!column) return undefined;
|
||||
const key = formatterKeyForColumn(column);
|
||||
return resolveColumnFormatter(key ? settingsStore.editorSettings.columnFormatters[key] : undefined, settingsStore.editorSettings.customColumnFormatters, {
|
||||
const columnType = props.result.column_types?.[columnIndex] ?? tableColumnForGridColumn(columnIndex)?.data_type;
|
||||
const savedFormatter = key ? settingsStore.editorSettings.columnFormatters[key] : undefined;
|
||||
const configured = resolveColumnFormatter(savedFormatter, settingsStore.editorSettings.customColumnFormatters, {
|
||||
pattern: settingsStore.editorSettings.globalDateTimeDisplayFormat,
|
||||
columnType: props.result.column_types?.[columnIndex] ?? tableColumnForGridColumn(columnIndex)?.data_type,
|
||||
columnType,
|
||||
});
|
||||
if (savedFormatter && configured) return configured;
|
||||
return defaultIoTDBTimestampFormatter(resolvedDatabaseType.value, columnType, resolvedConnectionConfig.value?.url_params) ?? configured;
|
||||
}
|
||||
|
||||
function savedColumnFormatter(columnIndex: number): ColumnFormatterConfig | undefined {
|
||||
@@ -1332,11 +1350,15 @@ function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) {
|
||||
pattern: "YYYY-MM-DD HH:mm:ss" as const,
|
||||
timezone: dayjs.tz.guess(),
|
||||
};
|
||||
formatterKind.value = draft.kind === "custom-ref" ? "custom-template" : draft.kind;
|
||||
formatterKind.value = draft.kind === "custom-ref" ? "custom-template" : draft.kind === "iotdb-timestamp" ? "datetime" : draft.kind;
|
||||
if (draft.kind === "datetime") {
|
||||
formatterDateUnit.value = draft.unit;
|
||||
formatterDatetimePattern.value = draft.pattern;
|
||||
formatterDateTimezone.value = draft.timezone || dayjs.tz.guess() || "UTC";
|
||||
} else if (draft.kind === "iotdb-timestamp") {
|
||||
formatterDateUnit.value = "milliseconds";
|
||||
formatterDatetimePattern.value = "YYYY-MM-DDTHH:mm:ss.SSSZ";
|
||||
formatterDateTimezone.value = draft.timezone;
|
||||
} else if (draft.kind === "json-path") {
|
||||
formatterJsonPath.value = draft.path;
|
||||
} else if (draft.kind === "mask") {
|
||||
@@ -2316,6 +2338,11 @@ function measureColumnHeaderText(text: string): number | undefined {
|
||||
return Math.ceil(columnHeaderMeasureContext.measureText(text).width);
|
||||
}
|
||||
|
||||
function columnWidthDisplayValue(value: CellValue, columnIndex: number): CellValue {
|
||||
const formatter = columnFormatter(columnIndex);
|
||||
return formatter ? applyColumnFormatter(value, formatter) : value;
|
||||
}
|
||||
|
||||
const { initColumnWidths, onResizeStart, autoFitColumn, renderedColumnWidths, totalWidth, columnVars, getIsResizing } = useDataGridColumnResize({
|
||||
columns: visibleColumns,
|
||||
sourceRows: computed(() => props.result.rows),
|
||||
@@ -2328,6 +2355,7 @@ const { initColumnWidths, onResizeStart, autoFitColumn, renderedColumnWidths, to
|
||||
measureHeaderText: measureColumnHeaderText,
|
||||
headerMeasurementKey: columnHeaderMeasurementKey,
|
||||
rowNumberWidth,
|
||||
displayValue: columnWidthDisplayValue,
|
||||
});
|
||||
const gridStyle = computed(() => ({
|
||||
...columnVars.value,
|
||||
@@ -3596,7 +3624,9 @@ async function startCellEdit(rowId: number, columnIndex: number, expanded: boole
|
||||
if (!(await hydrateLargeValueCell(rowId, columnIndex))) return;
|
||||
closeReadonlyCellTextSelection();
|
||||
expandedCellEditor.value = expanded ? { rowId, col: columnIndex } : null;
|
||||
const item = getRowItem(rowId);
|
||||
startEdit(rowId, columnIndex);
|
||||
if (item) editValue.value = inlineCellEditorText(item.data[columnIndex] ?? null, columnIndex);
|
||||
}
|
||||
|
||||
async function startDomCellEdit(rowId: number, columnIndex: number, displayText: string, event: MouseEvent) {
|
||||
@@ -3716,9 +3746,44 @@ function resultColumnInfoForGridColumn(columnIndex: number): Pick<ColumnInfo, "d
|
||||
}
|
||||
|
||||
function temporalEditorConfigForColumn(columnIndex: number): TemporalCellEditorConfig | undefined {
|
||||
if (resolvedDatabaseType.value === "iotdb") {
|
||||
const resultType = props.result.column_types?.[columnIndex];
|
||||
const tableType = tableColumnForGridColumn(columnIndex)?.data_type;
|
||||
if ((resultType ?? tableType)?.trim().toUpperCase().startsWith("TIMESTAMP")) {
|
||||
const precision = iotdbTimestampPrecision(resolvedDatabaseType.value, resultType);
|
||||
return precision ? { kind: "datetime", fractionPrecision: iotdbTimestampFractionDigits(precision) } : undefined;
|
||||
}
|
||||
}
|
||||
return temporalCellEditorConfig(tableColumnForGridColumn(columnIndex), props.databaseType);
|
||||
}
|
||||
|
||||
function isIoTDBTimestampColumn(columnIndex: number): boolean {
|
||||
const columnType = props.result.column_types?.[columnIndex] ?? tableColumnForGridColumn(columnIndex)?.data_type;
|
||||
return !!iotdbTimestampPrecision(resolvedDatabaseType.value, columnType);
|
||||
}
|
||||
|
||||
function inlineCellEditorText(value: CellValue, columnIndex: number): string {
|
||||
const columnInfo = tableColumnForGridColumn(columnIndex);
|
||||
const columnType = props.result.column_types?.[columnIndex] ?? columnInfo?.data_type;
|
||||
return (
|
||||
formatIoTDBTimestampEditorValue(value, resolvedDatabaseType.value, columnType, resolvedConnectionConfig.value?.url_params) ??
|
||||
dataGridCellEditorText({
|
||||
value,
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
columnInfo,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTemporalCellEditorValue(value: string, columnIndex: number): string {
|
||||
if (!isIoTDBTimestampColumn(columnIndex)) return value;
|
||||
const columnType = props.result.column_types?.[columnIndex] ?? tableColumnForGridColumn(columnIndex)?.data_type;
|
||||
const timestamp = parseIoTDBTimestampEditorValue(value, resolvedDatabaseType.value, columnType, resolvedConnectionConfig.value?.url_params);
|
||||
if (timestamp === null) return "NULL";
|
||||
if (timestamp === undefined) return value;
|
||||
return formatIoTDBTimestampEditorValue(timestamp, resolvedDatabaseType.value, columnType, resolvedConnectionConfig.value?.url_params) ?? value;
|
||||
}
|
||||
|
||||
function enumValuesForGridColumn(columnIndex: number): string[] {
|
||||
return tableColumnForGridColumn(columnIndex)?.enum_values ?? [];
|
||||
}
|
||||
@@ -5162,11 +5227,7 @@ const hasPendingInlineEditorDraft = computed(() => {
|
||||
if (!cell) return false;
|
||||
const item = getRowItem(cell.rowId);
|
||||
if (!item || item.isDeleted) return false;
|
||||
const originalValue = dataGridCellEditorText({
|
||||
value: item.data[cell.col] ?? null,
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
columnInfo: tableColumnForGridColumn(cell.col),
|
||||
});
|
||||
const originalValue = inlineCellEditorText(item.data[cell.col] ?? null, cell.col);
|
||||
return editValue.value !== originalValue;
|
||||
});
|
||||
const hasPendingDataEditorDraft = computed(() => hasPendingDetailEditorDraft.value || hasPendingInlineEditorDraft.value);
|
||||
@@ -5285,7 +5346,7 @@ watch(activeCellDetail, (detail) => {
|
||||
|
||||
const detailTemporalEditorConfig = computed(() => {
|
||||
const detail = activeCellDetail.value;
|
||||
return detail ? temporalEditorConfigForColumn(detail.colIndex) : undefined;
|
||||
return detail && !isIoTDBTimestampColumn(detail.colIndex) ? temporalEditorConfigForColumn(detail.colIndex) : undefined;
|
||||
});
|
||||
const sideDetailValueFillsHeight = computed(() => cellDetailPanelIsBottom.value || isEditingDetail.value || (!cellDetailPanelIsBottom.value && !activeCellDetail.value?.imagePreviewUrl));
|
||||
const canCompactDetailJson = computed(() => {
|
||||
@@ -7871,7 +7932,15 @@ function deleteCurrentRow(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function currentIoTDBTimestampEditValue(): CellValue | undefined {
|
||||
const cell = editingCell.value;
|
||||
if (!cell || !isIoTDBTimestampColumn(cell.col)) return undefined;
|
||||
const columnType = props.result.column_types?.[cell.col] ?? tableColumnForGridColumn(cell.col)?.data_type;
|
||||
return parseIoTDBTimestampEditorValue(editValue.value, resolvedDatabaseType.value, columnType, resolvedConnectionConfig.value?.url_params);
|
||||
}
|
||||
|
||||
function commitGridEdit(value?: CellValue) {
|
||||
if (value === undefined) value = currentIoTDBTimestampEditValue();
|
||||
void commitEditAndMaybeAutoSave(value === undefined ? undefined : { explicitValue: value }).finally(() => nextTick(() => gridRef.value?.focus({ preventScroll: true })));
|
||||
}
|
||||
|
||||
@@ -7891,15 +7960,17 @@ function commitBooleanGridEdit(value?: string | null) {
|
||||
async function commitEditFromCellBlur() {
|
||||
const target = pendingQuickEntryDraftCellFocus.value;
|
||||
pendingQuickEntryDraftCellFocus.value = null;
|
||||
const timestamp = currentIoTDBTimestampEditValue();
|
||||
const timestampOptions = timestamp === undefined ? {} : { explicitValue: timestamp };
|
||||
if (target && editingCell.value?.rowId === quickEntryDraftRowId && target.rowId === quickEntryDraftRowId) {
|
||||
await commitEditFromBlur({ promoteDraft: false });
|
||||
await commitEditFromBlur({ ...timestampOptions, promoteDraft: false });
|
||||
nextTick(() => {
|
||||
const item = getRowItem(target.rowId);
|
||||
if (item && canEditCellItem(item, target.col)) void startCellEdit(target.rowId, target.col, false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
await commitEditFromBlur();
|
||||
await commitEditFromBlur(timestampOptions);
|
||||
}
|
||||
|
||||
function onRowNumberMouseDown(item: RowItem, event: MouseEvent) {
|
||||
@@ -10905,6 +10976,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
|
||||
v-model="editValue"
|
||||
:kind="temporalEditorConfigForColumn(cell.valueIndex)!.kind"
|
||||
:fraction-precision="temporalEditorConfigForColumn(cell.valueIndex)!.fractionPrecision"
|
||||
:normalize-value="(value) => normalizeTemporalCellEditorValue(value, cell.valueIndex)"
|
||||
cell-layout="transpose"
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitGridEdit"
|
||||
@@ -11495,6 +11567,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
|
||||
v-model="editValue"
|
||||
:kind="temporalEditorConfigForColumn(canvasEditingCell.actualColIdx)!.kind"
|
||||
:fraction-precision="temporalEditorConfigForColumn(canvasEditingCell.actualColIdx)!.fractionPrecision"
|
||||
:normalize-value="(value) => normalizeTemporalCellEditorValue(value, canvasEditingCell!.actualColIdx)"
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitGridEdit"
|
||||
/>
|
||||
@@ -11700,6 +11773,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
|
||||
v-model="editValue"
|
||||
:kind="temporalEditorConfigForColumn(col.actualColIdx)!.kind"
|
||||
:fraction-precision="temporalEditorConfigForColumn(col.actualColIdx)!.fractionPrecision"
|
||||
:normalize-value="(value) => normalizeTemporalCellEditorValue(value, col.actualColIdx)"
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitGridEdit"
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,7 @@ const props = withDefaults(
|
||||
cellLayout?: "grid" | "transpose";
|
||||
commitOnClose?: boolean;
|
||||
fractionPrecision?: number;
|
||||
normalizeValue?: (value: string) => string;
|
||||
}>(),
|
||||
{
|
||||
variant: "cell",
|
||||
@@ -99,9 +100,10 @@ function setOpen(value: boolean) {
|
||||
if (!value && props.commitOnClose && !closeHandled && !skipCommitOnClose) finishCommit();
|
||||
}
|
||||
|
||||
function setModelValue(value: string) {
|
||||
localValue.value = value;
|
||||
emit("update:modelValue", value);
|
||||
function setModelValue(value: string, normalize = false) {
|
||||
const nextValue = normalize ? (props.normalizeValue?.(value) ?? value) : value;
|
||||
localValue.value = nextValue;
|
||||
emit("update:modelValue", nextValue);
|
||||
}
|
||||
|
||||
function updateTextInput(event: Event) {
|
||||
@@ -125,14 +127,14 @@ function updateDateFromInput(part: "day" | "month" | "year", event: Event) {
|
||||
}
|
||||
|
||||
function stepDate(part: "day" | "month" | "year", delta: number) {
|
||||
setModelValue(stepTemporalInputValue(localValue.value, props.kind, part, delta));
|
||||
setModelValue(stepTemporalInputValue(localValue.value, props.kind, part, delta), true);
|
||||
}
|
||||
|
||||
function updateTime(part: "hour" | "minute" | "second", rawValue: string | number) {
|
||||
const parts = { ...timeParts.value, [part]: normalizeTimePart(rawValue, part === "hour" ? 23 : 59) };
|
||||
const nextTime = `${parts.hour}:${parts.minute}:${parts.second}${fractionSuffix.value}`;
|
||||
if (props.kind === "time") {
|
||||
setModelValue(nextTime);
|
||||
setModelValue(nextTime, true);
|
||||
return;
|
||||
}
|
||||
setDateTimeValue(dateParts.value.year, dateParts.value.month, dateParts.value.day, nextTime);
|
||||
@@ -143,7 +145,7 @@ function updateTimeFromInput(part: "hour" | "minute" | "second", event: Event) {
|
||||
}
|
||||
|
||||
function stepTime(part: "hour" | "minute" | "second", delta: number) {
|
||||
setModelValue(stepTemporalInputValue(localValue.value, props.kind, part, delta));
|
||||
setModelValue(stepTemporalInputValue(localValue.value, props.kind, part, delta), true);
|
||||
}
|
||||
|
||||
const fractionSuffix = computed(() => {
|
||||
@@ -160,7 +162,7 @@ function updateFractionValue(rawValue: string) {
|
||||
const digits = rawValue.replace(/\D/g, "").slice(0, maxLength);
|
||||
const nextTime = `${timeParts.value.hour}:${timeParts.value.minute}:${timeParts.value.second}${digits ? `.${digits}` : ""}`;
|
||||
if (props.kind === "time") {
|
||||
setModelValue(nextTime);
|
||||
setModelValue(nextTime, true);
|
||||
return;
|
||||
}
|
||||
setDateTimeValue(dateParts.value.year, dateParts.value.month, dateParts.value.day, nextTime);
|
||||
@@ -179,20 +181,20 @@ function flushInputValue(target: EventTarget | null) {
|
||||
}
|
||||
|
||||
function normalizeTextInputValue() {
|
||||
setModelValue(parseTemporalInputValue(localValue.value, props.kind) ?? "");
|
||||
setModelValue(parseTemporalInputValue(localValue.value, props.kind) ?? "", true);
|
||||
}
|
||||
|
||||
function setNull() {
|
||||
setModelValue("NULL");
|
||||
setModelValue("NULL", true);
|
||||
}
|
||||
|
||||
function setNow() {
|
||||
const now = new Date();
|
||||
const dateText = [String(now.getFullYear()).padStart(4, "0"), String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0")].join("-");
|
||||
const nextTime = [String(now.getHours()).padStart(2, "0"), String(now.getMinutes()).padStart(2, "0"), String(now.getSeconds()).padStart(2, "0")].join(":") + nowFractionSuffix(now);
|
||||
if (props.kind === "date") setModelValue(dateText);
|
||||
else if (props.kind === "time") setModelValue(nextTime);
|
||||
else setModelValue(`${dateText} ${nextTime}`);
|
||||
if (props.kind === "date") setModelValue(dateText, true);
|
||||
else if (props.kind === "time") setModelValue(nextTime, true);
|
||||
else setModelValue(`${dateText} ${nextTime}`, true);
|
||||
}
|
||||
|
||||
function finishCommit() {
|
||||
@@ -264,8 +266,8 @@ function normalizeTimePart(value: string | number, max: number): string {
|
||||
|
||||
function setDateTimeValue(year: number, month: number, day: number, time: string) {
|
||||
const dateText = [String(year).padStart(4, "0"), String(month).padStart(2, "0"), String(day).padStart(2, "0")].join("-");
|
||||
if (props.kind === "date") setModelValue(dateText);
|
||||
else setModelValue(`${dateText} ${time}`);
|
||||
if (props.kind === "date") setModelValue(dateText, true);
|
||||
else setModelValue(`${dateText} ${time}`, true);
|
||||
}
|
||||
|
||||
function parseFractionDigits(value: string): string {
|
||||
|
||||
@@ -16,6 +16,7 @@ function createResizeState(options: {
|
||||
compactColumnHeaderActions?: boolean;
|
||||
indexIndicatorColumnIndexes?: number[] | ReturnType<typeof ref<number[]>>;
|
||||
headerTextWidth?: number;
|
||||
displayValue?: (value: string | number | boolean | null, columnIndex: number) => string | number | boolean | null;
|
||||
}) {
|
||||
const compact = ref(options.compactColumnHeaderActions ?? true);
|
||||
const headerTextWidth = ref(options.headerTextWidth);
|
||||
@@ -34,6 +35,7 @@ function createResizeState(options: {
|
||||
columnStructureSignature: computed(() => createDataGridColumnStructureSignature(options.columns, options.columnTypes)),
|
||||
measureHeaderText: () => headerTextWidth.value,
|
||||
headerMeasurementKey,
|
||||
displayValue: options.displayValue,
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
@@ -107,6 +109,23 @@ describe("useDataGridColumnResize", () => {
|
||||
expect(state.columnWidths.value[0]).toBeGreaterThan(60);
|
||||
});
|
||||
|
||||
it("measures formatted display values instead of raw source values", () => {
|
||||
const raw = createResizeState({
|
||||
columns: ["event_time"],
|
||||
rows: [[1786958306456]],
|
||||
});
|
||||
const formatted = createResizeState({
|
||||
columns: ["event_time"],
|
||||
rows: [[1786958306456]],
|
||||
displayValue: () => "2026-08-17T17:18:26.456+08:00",
|
||||
});
|
||||
|
||||
raw.initColumnWidths();
|
||||
formatted.initColumnWidths();
|
||||
|
||||
expect(formatted.columnWidths.value[0]).toBeGreaterThan(raw.columnWidths.value[0]);
|
||||
});
|
||||
|
||||
it("clamps manual column resizing to the minimum width", () => {
|
||||
expect(resizeDataGridColumnWidth(120, -200)).toBe(DATA_GRID_COL_MIN_WIDTH);
|
||||
expect(resizeDataGridColumnWidth(120, 30)).toBe(150);
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface UseDataGridColumnResizeOptions {
|
||||
measureHeaderText?: (text: string) => number | undefined;
|
||||
headerMeasurementKey?: Ref<unknown>;
|
||||
rowNumberWidth?: Ref<number> | ComputedRef<number>;
|
||||
displayValue?: (value: CellValue, columnIndex: number) => CellValue;
|
||||
}
|
||||
|
||||
export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) {
|
||||
@@ -68,7 +69,8 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
|
||||
const actualColIdx = columnIndexes.value[visibleColIdx];
|
||||
if (actualColIdx === undefined) return [];
|
||||
const preset = COLUMN_WIDTH_DENSITY_PRESETS[density.value];
|
||||
return sampleDataGridColumnValues(sourceRows.value, actualColIdx, preset.sampleRows);
|
||||
const values = sampleDataGridColumnValues(sourceRows.value, actualColIdx, preset.sampleRows);
|
||||
return options.displayValue ? values.map((value) => options.displayValue!(value, actualColIdx)) : values;
|
||||
}
|
||||
|
||||
function neededColumnWidth(colIdx: number): number {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeSupportedDateTimePattern } from "@/lib/dataGrid/columnFormatter";
|
||||
import { applyColumnFormatter, defaultIoTDBTimestampFormatter, formatIoTDBTimestampEditorValue, normalizeSupportedDateTimePattern, parseIoTDBTimestampEditorValue } from "@/lib/dataGrid/columnFormatter";
|
||||
|
||||
describe("normalizeSupportedDateTimePattern", () => {
|
||||
it("accepts the format grammar shared by the frontend and backend", () => {
|
||||
@@ -12,3 +12,54 @@ describe("normalizeSupportedDateTimePattern", () => {
|
||||
expect(normalizeSupportedDateTimePattern("%Y-%m-%d")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultIoTDBTimestampFormatter", () => {
|
||||
it.each([
|
||||
["ms", "1786954706123", "2026-08-17T16:18:26.123+08:00"],
|
||||
["us", "1786954706123456", "2026-08-17T16:18:26.123456+08:00"],
|
||||
["ns", "1786954706123456789", "2026-08-17T16:18:26.123456789+08:00"],
|
||||
])("formats %s precision in the connection time zone without replacing the raw value", (precision, rawValue, expected) => {
|
||||
const formatter = defaultIoTDBTimestampFormatter("iotdb", `TIMESTAMP(${precision})`, "time_zone=Asia%2FShanghai");
|
||||
const row = [rawValue];
|
||||
|
||||
expect(applyColumnFormatter(row[0], formatter)).toBe(expected);
|
||||
expect(row).toEqual([rawValue]);
|
||||
});
|
||||
|
||||
it("does not guess when precision metadata is absent or invalid", () => {
|
||||
expect(defaultIoTDBTimestampFormatter("iotdb", "TIMESTAMP", "time_zone=Asia%2FShanghai")).toBeUndefined();
|
||||
expect(defaultIoTDBTimestampFormatter("iotdb", "TIMESTAMP(seconds)", "time_zone=Asia%2FShanghai")).toBeUndefined();
|
||||
expect(defaultIoTDBTimestampFormatter("mysql", "TIMESTAMP(ms)", "time_zone=Asia%2FShanghai")).toBeUndefined();
|
||||
expect(defaultIoTDBTimestampFormatter("iotdb", "INT64", "time_zone=Asia%2FShanghai")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses UTC when the connection does not specify a time zone", () => {
|
||||
const formatter = defaultIoTDBTimestampFormatter("iotdb", "TIMESTAMP(ms)", "");
|
||||
expect(applyColumnFormatter(1, formatter)).toBe("1970-01-01T00:00:00.001+00:00");
|
||||
});
|
||||
|
||||
it("round-trips a negative nanosecond timestamp without truncating toward zero", () => {
|
||||
const formatter = defaultIoTDBTimestampFormatter("iotdb", "TIMESTAMP(ns)", "time_zone=UTC");
|
||||
const display = "1969-12-31T23:59:59.999999999+00:00";
|
||||
expect(applyColumnFormatter("-1", formatter)).toBe(display);
|
||||
expect(parseIoTDBTimestampEditorValue(display, "iotdb", "TIMESTAMP(ns)", "time_zone=UTC")).toBe("-1");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ms", "1786954706123", "2026-08-17T16:18:26.123+08:00"],
|
||||
["us", "1786954706123456", "2026-08-17T16:18:26.123456+08:00"],
|
||||
["ns", "1786954706123456789", "2026-08-17T16:18:26.123456789+08:00"],
|
||||
])("round-trips %s precision through the temporal editor", (precision, rawValue, editorValue) => {
|
||||
const columnType = `TIMESTAMP(${precision})`;
|
||||
expect(formatIoTDBTimestampEditorValue(rawValue, "iotdb", columnType, "time_zone=Asia%2FShanghai")).toBe(editorValue);
|
||||
expect(parseIoTDBTimestampEditorValue(editorValue, "iotdb", columnType, "time_zone=UTC")).toBe(rawValue);
|
||||
expect(parseIoTDBTimestampEditorValue(editorValue.replace("T", " ").replace("+08:00", ""), "iotdb", columnType, "time_zone=Asia%2FShanghai")).toBe(rawValue);
|
||||
});
|
||||
|
||||
it("keeps invalid and unrelated editor values on the generic path", () => {
|
||||
expect(parseIoTDBTimestampEditorValue("2026-02-30 10:00:00", "iotdb", "TIMESTAMP(ms)", "time_zone=Asia%2FShanghai")).toBeUndefined();
|
||||
expect(parseIoTDBTimestampEditorValue("2026-08-17 10:00:00.1234", "iotdb", "TIMESTAMP(ms)", "time_zone=Asia%2FShanghai")).toBeUndefined();
|
||||
expect(parseIoTDBTimestampEditorValue("2026-08-17 10:00:00", "mysql", "TIMESTAMP(ms)", "time_zone=Asia%2FShanghai")).toBeUndefined();
|
||||
expect(parseIoTDBTimestampEditorValue("2026-08-17 10:00:00", "iotdb", "TIMESTAMP", "time_zone=Asia%2FShanghai")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export type DateTimeFormatterUnit = "seconds" | "milliseconds" | "auto";
|
||||
export type IoTDBTimestampPrecision = "ms" | "us" | "ns";
|
||||
const DEFAULT_DATETIME_PATTERN = "YYYY-MM-DD HH:mm:ss";
|
||||
export const DateTimePatterns = [
|
||||
"YYYY-MM-DD",
|
||||
@@ -101,6 +102,7 @@ export function normalizeSupportedDateTimePattern(value: string): string {
|
||||
|
||||
export type ColumnFormatterConfig =
|
||||
| { kind: "datetime"; unit: DateTimeFormatterUnit; pattern: string; timezone: string | undefined }
|
||||
| { kind: "iotdb-timestamp"; precision: IoTDBTimestampPrecision; timezone: string }
|
||||
| { kind: "json-path"; path: string }
|
||||
| { kind: "mask"; prefix: number; suffix: number }
|
||||
| { kind: "foreign-key-display"; refSchema?: string; refTable: string; refColumn: string; displayColumn: string }
|
||||
@@ -212,6 +214,77 @@ export function resolveColumnFormatter(formatter: ColumnFormatterConfig | undefi
|
||||
return customFormatter ? { kind: "custom-template", template: customFormatter.template } : undefined;
|
||||
}
|
||||
|
||||
export function defaultIoTDBTimestampFormatter(databaseType: string | undefined, columnType: string | null | undefined, urlParams: string | undefined): ColumnFormatterConfig | undefined {
|
||||
const precision = iotdbTimestampPrecision(databaseType, columnType);
|
||||
if (!precision) return undefined;
|
||||
const timezone = iotdbTimestampTimeZone(urlParams);
|
||||
return { kind: "iotdb-timestamp", precision, timezone };
|
||||
}
|
||||
|
||||
export function formatIoTDBTimestampEditorValue(value: CellValue, databaseType: string | undefined, columnType: string | null | undefined, urlParams: string | undefined): string | undefined {
|
||||
const formatter = defaultIoTDBTimestampFormatter(databaseType, columnType, urlParams);
|
||||
if (!formatter || formatter.kind !== "iotdb-timestamp") return undefined;
|
||||
return formatIoTDBTimestamp(value, formatter.precision, formatter.timezone);
|
||||
}
|
||||
|
||||
export function parseIoTDBTimestampEditorValue(value: string, databaseType: string | undefined, columnType: string | null | undefined, urlParams: string | undefined): string | null | undefined {
|
||||
const precision = iotdbTimestampPrecision(databaseType, columnType);
|
||||
if (!precision) return undefined;
|
||||
const text = value.trim();
|
||||
if (!text || text.toUpperCase() === "NULL") return null;
|
||||
if (isIntegerString(text)) {
|
||||
try {
|
||||
return BigInt(text).toString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const offsetMatch = text.match(ISO_OFFSET_DATETIME_PATTERN);
|
||||
if (offsetMatch) {
|
||||
const [, year, separator, month, day, hour, minute, second, fraction = "", zone] = offsetMatch;
|
||||
if (!isValidDateTimeParts(year, month, day, hour, minute, second) || !isValidOffset(zone)) return undefined;
|
||||
const baseMilliseconds = Date.parse(`${year}${separator}${month}${separator}${day}T${hour}:${minute}:${second}${zone}`);
|
||||
if (!Number.isFinite(baseMilliseconds)) return undefined;
|
||||
return iotdbRawTimestamp(baseMilliseconds, fraction.slice(1), precision);
|
||||
}
|
||||
|
||||
const match = text.match(FRACTIONAL_LOCAL_DATETIME_PATTERN) ?? text.match(/^(\d{4})([-/])(\d{1,2})\2(\d{1,2})([ T])(\d{1,2}):(\d{1,2}):(\d{1,2})$/);
|
||||
if (!match) return undefined;
|
||||
const [, year, , month, day, , hour, minute, second] = match;
|
||||
if (!isValidDateTimeParts(year, month, day, hour, minute, second)) return undefined;
|
||||
const fraction = match.length > 9 ? String(match[9] ?? "") : "";
|
||||
const normalized = `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}T${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:${second.padStart(2, "0")}`;
|
||||
const pattern = "YYYY-MM-DDTHH:mm:ss";
|
||||
try {
|
||||
const parsed = dayjs.tz(normalized, pattern, iotdbTimestampTimeZone(urlParams));
|
||||
return parsed.isValid() && parsed.format(pattern) === normalized ? iotdbRawTimestamp(parsed.valueOf(), fraction, precision) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function iotdbTimestampPrecision(databaseType: string | undefined, columnType: string | null | undefined): IoTDBTimestampPrecision | undefined {
|
||||
if (databaseType !== "iotdb") return undefined;
|
||||
const match = columnType?.trim().match(/^TIMESTAMP\((ms|us|ns)\)$/i);
|
||||
return match?.[1]?.toLowerCase() as IoTDBTimestampPrecision | undefined;
|
||||
}
|
||||
|
||||
export function iotdbTimestampFractionDigits(precision: IoTDBTimestampPrecision): number {
|
||||
return precision === "ms" ? 3 : precision === "us" ? 6 : 9;
|
||||
}
|
||||
|
||||
function iotdbTimestampTimeZone(urlParams: string | undefined): string {
|
||||
const params = new URLSearchParams(urlParams ?? "");
|
||||
const candidate = params.get("time_zone") || params.get("timezone") || params.get("zone_id") || "UTC";
|
||||
try {
|
||||
dayjs(0).tz(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
return "UTC";
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
@@ -248,6 +321,7 @@ export function applyColumnFormatter(value: CellValue, formatter: ColumnFormatte
|
||||
if (!formatter) return displayCellValue(value);
|
||||
|
||||
try {
|
||||
if (formatter.kind === "iotdb-timestamp") return formatIoTDBTimestamp(value, formatter.precision, formatter.timezone) ?? displayCellValue(value);
|
||||
if (formatter.kind === "datetime") return formatDateTime(value, formatter.unit, formatter.pattern, formatter.timezone);
|
||||
if (formatter.kind === "json-path") return formatJsonPath(value, formatter.path);
|
||||
if (formatter.kind === "mask") return formatMask(value, formatter);
|
||||
@@ -378,6 +452,43 @@ function parseTimestampMilliseconds(value: string | number, unit: DateTimeFormat
|
||||
return isAutoTimestampValue(value, numeric) ? convertToMilliseconds(numeric, unit) : undefined;
|
||||
}
|
||||
|
||||
function formatIoTDBTimestamp(value: CellValue, precision: IoTDBTimestampPrecision, timezone: string): string | undefined {
|
||||
if (typeof value !== "number" && (typeof value !== "string" || !isIntegerString(value))) return undefined;
|
||||
let raw: bigint;
|
||||
try {
|
||||
raw = BigInt(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const fractionDigits = iotdbTimestampFractionDigits(precision);
|
||||
const factor = 10n ** BigInt(fractionDigits);
|
||||
let seconds = raw / factor;
|
||||
let fraction = raw % factor;
|
||||
if (fraction < 0) {
|
||||
seconds -= 1n;
|
||||
fraction += factor;
|
||||
}
|
||||
const milliseconds = seconds * 1000n + (fraction * 1000n) / factor;
|
||||
const numericMilliseconds = Number(milliseconds);
|
||||
if (!Number.isSafeInteger(numericMilliseconds)) return undefined;
|
||||
const parsed = dayjs(numericMilliseconds);
|
||||
if (!parsed.isValid()) return undefined;
|
||||
try {
|
||||
const zoned = parsed.tz(timezone);
|
||||
return `${zoned.format("YYYY-MM-DDTHH:mm:ss")}.${fraction.toString().padStart(fractionDigits, "0")}${zoned.format("Z")}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function iotdbRawTimestamp(baseMilliseconds: number, fraction: string, precision: IoTDBTimestampPrecision): string | undefined {
|
||||
if (!Number.isSafeInteger(baseMilliseconds) || baseMilliseconds % 1000 !== 0) return undefined;
|
||||
const digits = iotdbTimestampFractionDigits(precision);
|
||||
if (fraction.length > digits && /[1-9]/.test(fraction.slice(digits))) return undefined;
|
||||
const normalizedFraction = fraction.slice(0, digits).padEnd(digits, "0");
|
||||
return (BigInt(baseMilliseconds / 1000) * 10n ** BigInt(digits) + BigInt(normalizedFraction || "0")).toString();
|
||||
}
|
||||
|
||||
function convertToMilliseconds(value: number, unit: DateTimeFormatterUnit): number {
|
||||
return unit === "seconds" || (unit === "auto" && Math.abs(value) < 100_000_000_000) ? value * 1000 : value;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user