feat(grid): add custom column formatters

This commit is contained in:
t8y2 2026-05-17 22:18:08 +08:00
parent 75893eee27
commit d95225d641
7 changed files with 289 additions and 15 deletions

View File

@ -95,6 +95,7 @@ import {
applyColumnFormatter,
buildColumnFormatterKey,
normalizeColumnFormatter,
resolveColumnFormatter,
type ColumnFormatterConfig,
type DateTimeFormatterUnit,
} from "@/lib/columnFormatter";
@ -294,11 +295,22 @@ const localFilterOpenColumn = ref<number | null>(null);
const localFilterSearch = ref("");
const localFilterDraft = ref<LocalColumnFilterDraft | null>(null);
const formatterOpenColumn = ref<number | null>(null);
const formatterKind = ref<ColumnFormatterConfig["kind"]>("datetime");
type FormatterDraftKind = Exclude<ColumnFormatterConfig["kind"], "custom-ref">;
const CUSTOM_FORMATTER_NEW = "__new";
const formatterKind = ref<FormatterDraftKind>("datetime");
const formatterDateUnit = ref<DateTimeFormatterUnit>("auto");
const formatterJsonPath = ref("$.user.name");
const formatterMaskPrefix = ref(4);
const formatterMaskSuffix = ref(4);
const formatterCustomId = ref(CUSTOM_FORMATTER_NEW);
const formatterCustomName = ref("");
const formatterCustomTemplate = ref("${value}");
const savedCustomFormatters = computed(() => {
return Object.values(settingsStore.editorSettings.customColumnFormatters).sort((a, b) =>
a.name.localeCompare(b.name),
);
});
function localFilterKey(value: CellValue): string {
if (value === null) return "__dbx_null__";
@ -414,6 +426,18 @@ function formatterKeyForColumn(column: string): string | null {
}
function columnFormatter(columnIndex: number): ColumnFormatterConfig | undefined {
const column = props.result.columns[columnIndex];
if (!column) return undefined;
const key = formatterKeyForColumn(column);
return key
? resolveColumnFormatter(
settingsStore.editorSettings.columnFormatters[key],
settingsStore.editorSettings.customColumnFormatters,
)
: undefined;
}
function savedColumnFormatter(columnIndex: number): ColumnFormatterConfig | undefined {
const column = props.result.columns[columnIndex];
if (!column) return undefined;
const key = formatterKeyForColumn(column);
@ -435,12 +459,15 @@ function currentFormatterDraft(): ColumnFormatterConfig {
suffix: Math.max(0, Math.floor(Number(formatterMaskSuffix.value) || 0)),
};
}
if (formatterKind.value === "custom-template") {
return { kind: "custom-template", template: formatterCustomTemplate.value.trim() || "${value}" };
}
return { kind: "datetime", unit: formatterDateUnit.value };
}
function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) {
const draft = formatter ?? { kind: "datetime", unit: "auto" as const };
formatterKind.value = draft.kind;
formatterKind.value = draft.kind === "custom-ref" ? "custom-template" : draft.kind;
if (draft.kind === "datetime") {
formatterDateUnit.value = draft.unit;
} else if (draft.kind === "json-path") {
@ -448,11 +475,20 @@ function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) {
} else if (draft.kind === "mask") {
formatterMaskPrefix.value = draft.prefix;
formatterMaskSuffix.value = draft.suffix;
} else if (draft.kind === "custom-ref") {
const saved = settingsStore.editorSettings.customColumnFormatters[draft.formatterId];
formatterCustomId.value = saved ? saved.id : CUSTOM_FORMATTER_NEW;
formatterCustomName.value = saved?.name ?? "";
formatterCustomTemplate.value = saved?.template ?? "${value}";
} else if (draft.kind === "custom-template") {
formatterCustomId.value = CUSTOM_FORMATTER_NEW;
formatterCustomName.value = "";
formatterCustomTemplate.value = draft.template;
}
}
function openColumnFormatter(columnIndex: number) {
loadFormatterDraft(columnFormatter(columnIndex));
loadFormatterDraft(savedColumnFormatter(columnIndex));
formatterOpenColumn.value = columnIndex;
}
@ -464,7 +500,17 @@ function saveColumnFormatter(columnIndex: number) {
const column = props.result.columns[columnIndex];
const key = column ? formatterKeyForColumn(column) : null;
if (!key) return;
settingsStore.updateColumnFormatter(key, currentFormatterDraft());
let formatter = currentFormatterDraft();
if (formatterKind.value === "custom-template" && formatterCustomName.value.trim()) {
const id = formatterCustomId.value === CUSTOM_FORMATTER_NEW ? createCustomFormatterId() : formatterCustomId.value;
const saved = settingsStore.upsertCustomColumnFormatter({
id,
name: formatterCustomName.value,
template: formatterCustomTemplate.value,
});
if (saved) formatter = { kind: "custom-ref", formatterId: saved.id };
}
settingsStore.updateColumnFormatter(key, formatter);
closeColumnFormatter();
}
@ -480,8 +526,29 @@ function formatterDraftIsSavable(): boolean {
return !!normalizeColumnFormatter(currentFormatterDraft());
}
function selectCustomFormatter(value: string) {
formatterCustomId.value = value;
if (value === CUSTOM_FORMATTER_NEW) {
formatterCustomName.value = "";
formatterCustomTemplate.value = "${value}";
return;
}
const saved = settingsStore.editorSettings.customColumnFormatters[value];
if (!saved) return;
formatterCustomName.value = saved.name;
formatterCustomTemplate.value = saved.template;
}
function createCustomFormatterId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return `fmt_${crypto.randomUUID()}`;
return `fmt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
}
function formatterPreviewRows(columnIndex: number) {
const formatter = currentFormatterDraft();
const formatter = resolveColumnFormatter(
currentFormatterDraft(),
settingsStore.editorSettings.customColumnFormatters,
);
return displayItems.value.slice(0, 5).map((item, index) => {
const value = item.data[columnIndex] ?? null;
return {
@ -2810,6 +2877,9 @@ defineExpose({
<SelectItem value="datetime">{{ t("grid.formatterDatetime") }}</SelectItem>
<SelectItem value="json-path">{{ t("grid.formatterJsonPath") }}</SelectItem>
<SelectItem value="mask">{{ t("grid.formatterMask") }}</SelectItem>
<SelectItem value="custom-template">{{
t("grid.formatterCustomTemplate")
}}</SelectItem>
</SelectContent>
</Select>
</div>
@ -2849,7 +2919,7 @@ defineExpose({
/>
</div>
<div v-else class="grid grid-cols-2 gap-2">
<div v-else-if="formatterKind === 'mask'" class="grid grid-cols-2 gap-2">
<label class="space-y-1.5">
<span class="text-xs font-medium text-muted-foreground">
{{ t("grid.formatterMaskPrefix") }}
@ -2874,6 +2944,60 @@ defineExpose({
</label>
</div>
<div v-else class="space-y-2">
<div v-if="savedCustomFormatters.length" class="space-y-1.5">
<div class="text-xs font-medium text-muted-foreground">
{{ t("grid.formatterSavedCustom") }}
</div>
<Select
:model-value="formatterCustomId"
@update:model-value="(value: any) => selectCustomFormatter(String(value))"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem :value="CUSTOM_FORMATTER_NEW">{{
t("grid.formatterNewCustom")
}}</SelectItem>
<SelectItem
v-for="formatter in savedCustomFormatters"
:key="formatter.id"
:value="formatter.id"
>
{{ formatter.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<label class="block space-y-1.5">
<span class="text-xs font-medium text-muted-foreground">
{{ t("grid.formatterCustomName") }}
</span>
<input
v-model="formatterCustomName"
class="h-8 w-full rounded border bg-background px-2 text-xs outline-none focus:border-primary"
:placeholder="t('grid.formatterCustomNamePlaceholder')"
/>
</label>
<label class="block space-y-1.5">
<span class="text-xs font-medium text-muted-foreground">
{{ t("grid.formatterCustomTemplateInput") }}
</span>
<input
v-model="formatterCustomTemplate"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="h-8 w-full rounded border bg-background px-2 font-mono text-xs outline-none focus:border-primary"
placeholder="ID-${value}"
/>
</label>
<div class="text-[11px] leading-4 text-muted-foreground">
{{ t("grid.formatterCustomTemplateHint") }}
</div>
</div>
<div class="space-y-1.5">
<div class="text-xs font-medium text-muted-foreground">
{{ t("grid.formatterPreview") }}

View File

@ -270,6 +270,7 @@ export default {
formatterDatetime: "Unix timestamp",
formatterJsonPath: "JSON path",
formatterMask: "Mask text",
formatterCustomTemplate: "Custom template",
formatterTimestampUnit: "Timestamp unit",
formatterUnitAuto: "Auto",
formatterUnitSeconds: "Seconds",
@ -277,6 +278,13 @@ export default {
formatterJsonPathInput: "JSON path",
formatterMaskPrefix: "Visible prefix",
formatterMaskSuffix: "Visible suffix",
formatterSavedCustom: "Saved templates",
formatterNewCustom: "New template",
formatterCustomName: "Template name",
formatterCustomNamePlaceholder: "Example: User label",
formatterCustomTemplateInput: "Template",
formatterCustomTemplateHint:
"Available variables: ${value}, ${upper}, ${lower}, ${length}. Give it a name to save it to the list.",
formatterPreview: "Preview",
saveFormatter: "Save",
clearFormatter: "Clear",

View File

@ -267,6 +267,7 @@ export default {
formatterDatetime: "Unix 时间戳",
formatterJsonPath: "JSON 路径",
formatterMask: "文本脱敏",
formatterCustomTemplate: "自定义模板",
formatterTimestampUnit: "时间戳单位",
formatterUnitAuto: "自动",
formatterUnitSeconds: "秒",
@ -274,6 +275,12 @@ export default {
formatterJsonPathInput: "JSON 路径",
formatterMaskPrefix: "前缀保留",
formatterMaskSuffix: "后缀保留",
formatterSavedCustom: "已保存模板",
formatterNewCustom: "新建模板",
formatterCustomName: "模板名称",
formatterCustomNamePlaceholder: "例如:用户标签",
formatterCustomTemplateInput: "模板",
formatterCustomTemplateHint: "可用变量:${value}、${upper}、${lower}、${length}。填写名称后会保存到列表。",
formatterPreview: "预览",
saveFormatter: "保存",
clearFormatter: "清除",

View File

@ -2,10 +2,18 @@ import { displayCellValue, type CellValue } from "@/lib/cellValue";
export type DateTimeFormatterUnit = "seconds" | "milliseconds" | "auto";
export interface CustomColumnFormatterConfig {
id: string;
name: string;
template: string;
}
export type ColumnFormatterConfig =
| { kind: "datetime"; unit: DateTimeFormatterUnit }
| { kind: "json-path"; path: string }
| { kind: "mask"; prefix: number; suffix: number };
| { kind: "mask"; prefix: number; suffix: number }
| { kind: "custom-template"; template: string }
| { kind: "custom-ref"; formatterId: string };
export interface ColumnFormatterKeyParts {
connectionId: string;
@ -41,24 +49,60 @@ export function normalizeColumnFormatter(value: unknown): ColumnFormatterConfig
return { kind: "mask", prefix: config.prefix as number, suffix: config.suffix as number };
}
if (config.kind === "custom-template") {
return typeof config.template === "string" && config.template.trim()
? { kind: "custom-template", template: config.template.slice(0, 500) }
: undefined;
}
if (config.kind === "custom-ref") {
return typeof config.formatterId === "string" && config.formatterId.trim()
? { kind: "custom-ref", formatterId: config.formatterId }
: undefined;
}
return undefined;
}
export function normalizeCustomColumnFormatter(value: unknown): CustomColumnFormatterConfig | undefined {
if (!value || typeof value !== "object") return undefined;
const config = value as Record<string, unknown>;
if (typeof config.id !== "string" || !config.id.trim()) return undefined;
if (typeof config.name !== "string" || !config.name.trim()) return undefined;
if (typeof config.template !== "string" || !config.template.trim()) return undefined;
return {
id: config.id.trim(),
name: config.name.trim().slice(0, 80),
template: config.template.slice(0, 500),
};
}
export function resolveColumnFormatter(
formatter: ColumnFormatterConfig | undefined,
customFormatters: Record<string, CustomColumnFormatterConfig>,
): ColumnFormatterConfig | undefined {
if (!formatter) return undefined;
if (formatter.kind !== "custom-ref") return formatter;
const customFormatter = customFormatters[formatter.formatterId];
return customFormatter ? { kind: "custom-template", template: customFormatter.template } : undefined;
}
export function applyColumnFormatter(value: CellValue, formatter: ColumnFormatterConfig | undefined): string {
if (!formatter) return displayCellValue(value);
if (value === null) return displayCellValue(value);
try {
if (formatter.kind === "datetime") return formatDateTime(value, formatter.unit);
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);
return displayCellValue(value);
} catch {
return displayCellValue(value);
}
}
function formatDateTime(value: Exclude<CellValue, null>, unit: DateTimeFormatterUnit): string {
function formatDateTime(value: CellValue, unit: DateTimeFormatterUnit): string {
if (value === null) return displayCellValue(value);
const numeric = typeof value === "number" ? value : Number(String(value).trim());
if (!Number.isFinite(numeric)) return displayCellValue(value);
const timestamp =
@ -67,7 +111,8 @@ function formatDateTime(value: Exclude<CellValue, null>, unit: DateTimeFormatter
return Number.isNaN(date.getTime()) ? displayCellValue(value) : date.toLocaleString();
}
function formatJsonPath(value: Exclude<CellValue, null>, path: string): string {
function formatJsonPath(value: CellValue, path: string): string {
if (value === null) return displayCellValue(value);
if (typeof value !== "string") return displayCellValue(value);
const parsed = JSON.parse(value);
const tokens = parseJsonPath(path);
@ -90,10 +135,8 @@ function formatJsonPath(value: Exclude<CellValue, null>, path: string): string {
return String(current);
}
function formatMask(
value: Exclude<CellValue, null>,
formatter: Extract<ColumnFormatterConfig, { kind: "mask" }>,
): string {
function formatMask(value: CellValue, formatter: Extract<ColumnFormatterConfig, { kind: "mask" }>): string {
if (value === null) return displayCellValue(value);
const text = displayCellValue(value);
const visibleCount = formatter.prefix + formatter.suffix;
if (text.length <= visibleCount) return "*".repeat(text.length);
@ -102,6 +145,15 @@ function formatMask(
)}`;
}
function formatCustomTemplate(value: CellValue, template: string): string {
const text = displayCellValue(value);
return template
.replaceAll("${value}", text)
.replaceAll("${upper}", text.toUpperCase())
.replaceAll("${lower}", text.toLowerCase())
.replaceAll("${length}", String(text.length));
}
function isSupportedJsonPath(path: string): boolean {
if (!path.startsWith("$")) return false;
try {

View File

@ -1,7 +1,12 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import * as api from "@/lib/api";
import { normalizeColumnFormatter, type ColumnFormatterConfig } from "@/lib/columnFormatter";
import {
normalizeColumnFormatter,
normalizeCustomColumnFormatter,
type ColumnFormatterConfig,
type CustomColumnFormatterConfig,
} from "@/lib/columnFormatter";
import { normalizeShortcutSettings, type ShortcutSettings } from "@/lib/shortcutRegistry";
import type { SidebarActivation } from "@/lib/treeNodeClick";
@ -151,6 +156,7 @@ export interface EditorSettings {
shortcuts: ShortcutSettings;
sidebarActivation: SidebarActivation;
columnFormatters: Record<string, ColumnFormatterConfig>;
customColumnFormatters: Record<string, CustomColumnFormatterConfig>;
}
export const EDITOR_THEMES: { value: EditorTheme; label: string; dark: boolean }[] = [
@ -187,6 +193,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
shortcuts: normalizeShortcutSettings(),
sidebarActivation: "single",
columnFormatters: {},
customColumnFormatters: {},
};
export const STORAGE_KEY = "dbx-editor-settings";
@ -202,6 +209,16 @@ function normalizeColumnFormatters(value: unknown): Record<string, ColumnFormatt
return formatters;
}
function normalizeCustomColumnFormatters(value: unknown): Record<string, CustomColumnFormatterConfig> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const formatters: Record<string, CustomColumnFormatterConfig> = {};
for (const formatter of Object.values(value as Record<string, unknown>)) {
const normalized = normalizeCustomColumnFormatter(formatter);
if (normalized) formatters[normalized.id] = normalized;
}
return formatters;
}
export function normalizeEditorSettings(settings: Partial<EditorSettings>): EditorSettings {
return {
fontFamily: settings.fontFamily ?? DEFAULT_EDITOR_SETTINGS.fontFamily,
@ -218,6 +235,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>): Edit
? settings.sidebarActivation
: DEFAULT_EDITOR_SETTINGS.sidebarActivation,
columnFormatters: normalizeColumnFormatters(settings.columnFormatters),
customColumnFormatters: normalizeCustomColumnFormatters(settings.customColumnFormatters),
};
}
@ -309,6 +327,31 @@ export const useSettingsStore = defineStore("settings", () => {
updateEditorSettings({ columnFormatters });
}
function upsertCustomColumnFormatter(
formatter: CustomColumnFormatterConfig,
): CustomColumnFormatterConfig | undefined {
const normalized = normalizeCustomColumnFormatter(formatter);
if (!normalized) return undefined;
updateEditorSettings({
customColumnFormatters: {
...editorSettings.value.customColumnFormatters,
[normalized.id]: normalized,
},
});
return normalized;
}
function deleteCustomColumnFormatter(id: string) {
const customColumnFormatters = { ...editorSettings.value.customColumnFormatters };
delete customColumnFormatters[id];
const columnFormatters = Object.fromEntries(
Object.entries(editorSettings.value.columnFormatters).filter(([, formatter]) => {
return formatter.kind !== "custom-ref" || formatter.formatterId !== id;
}),
);
updateEditorSettings({ customColumnFormatters, columnFormatters });
}
return {
aiConfig,
isAiConfigLoaded,
@ -318,5 +361,7 @@ export const useSettingsStore = defineStore("settings", () => {
editorSettings,
updateEditorSettings,
updateColumnFormatter,
upsertCustomColumnFormatter,
deleteCustomColumnFormatter,
};
});

View File

@ -3,6 +3,7 @@ import test from "node:test";
import {
applyColumnFormatter,
buildColumnFormatterKey,
resolveColumnFormatter,
normalizeColumnFormatter,
type ColumnFormatterConfig,
} from "../src/lib/columnFormatter.ts";
@ -50,6 +51,14 @@ test("normalizes only supported formatter configs", () => {
path: "$.a[0]",
});
assert.equal(normalizeColumnFormatter({ kind: "json-path", path: "a.b" }), undefined);
assert.deepEqual(normalizeColumnFormatter({ kind: "custom-template", template: "ID-${value}" }), {
kind: "custom-template",
template: "ID-${value}",
});
assert.deepEqual(normalizeColumnFormatter({ kind: "custom-ref", formatterId: "fmt_1" }), {
kind: "custom-ref",
formatterId: "fmt_1",
});
});
test("builds stable formatter keys for table columns", () => {
@ -64,3 +73,22 @@ test("builds stable formatter keys for table columns", () => {
"conn::db::public::users::created_at",
);
});
test("applies safe custom formatter templates", () => {
assert.equal(applyColumnFormatter("ada", { kind: "custom-template", template: "user:${value}" }), "user:ada");
assert.equal(applyColumnFormatter("Ada", { kind: "custom-template", template: "${upper}" }), "ADA");
assert.equal(applyColumnFormatter("Ada", { kind: "custom-template", template: "${lower}" }), "ada");
assert.equal(applyColumnFormatter("Ada", { kind: "custom-template", template: "${length}" }), "3");
assert.equal(applyColumnFormatter(null, { kind: "custom-template", template: "value=${value}" }), "value=NULL");
});
test("resolves saved custom formatter references", () => {
assert.deepEqual(
resolveColumnFormatter(
{ kind: "custom-ref", formatterId: "fmt_1" },
{ fmt_1: { id: "fmt_1", name: "User label", template: "user:${value}" } },
),
{ kind: "custom-template", template: "user:${value}" },
);
assert.equal(resolveColumnFormatter({ kind: "custom-ref", formatterId: "missing" }, {}), undefined);
});

View File

@ -54,6 +54,12 @@ test("keeps only valid saved column formatter configs", () => {
"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::invalid_json": { kind: "json-path", path: "user.name" },
"conn::db::public::users::status": { kind: "custom-ref", formatterId: "fmt_1" },
},
customColumnFormatters: {
fmt_1: { id: "fmt_1", name: "Status label", template: "status:${value}" },
fmt_empty_name: { id: "fmt_empty_name", name: "", template: "x:${value}" },
fmt_empty_template: { id: "fmt_empty_template", name: "Broken", template: "" },
},
} as any);
@ -61,6 +67,10 @@ test("keeps only valid saved column formatter configs", () => {
"conn::db::public::users::created_at": { kind: "datetime", unit: "auto" },
"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" },
});
assert.deepEqual(settings.customColumnFormatters, {
fmt_1: { id: "fmt_1", name: "Status label", template: "status:${value}" },
});
});