feat(grid): filter typed column values

This commit is contained in:
t8y2
2026-05-18 16:00:06 +08:00
parent 4a8c95f9b9
commit e38e6b468f
6 changed files with 162 additions and 2 deletions
+59 -2
View File
@@ -104,6 +104,7 @@ import {
import { isCancelSearchShortcut, isFocusSearchShortcut } from "@/lib/keyboardShortcuts";
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGridScrollGutter";
import { dataGridSaveActionMode } from "@/lib/dataGridSaveUi";
import { appendColumnValueFilterCondition, buildColumnValueFilterCondition } from "@/lib/dataGridColumnFilter";
import {
filterColumnVisibilityOptions,
nextHiddenColumnIndexes,
@@ -411,6 +412,23 @@ const localFilterAllVisibleSelected = computed(() => {
return localFilterOptions.value.every((option) => draft.values.has(option.key));
});
const localFilterTypedValue = computed(() => localFilterSearch.value.trim());
const localFilterDraftIsAllSelected = computed(() => {
const draft = localFilterDraft.value;
if (!draft) return false;
const allKeys = localFilterAllOptions.value.map((option) => option.key);
return allKeys.length > 0 && allKeys.every((key) => draft.values.has(key));
});
const canApplyTypedLocalFilterValue = computed(() => {
const draft = localFilterDraft.value;
const typed = localFilterTypedValue.value;
if (!draft || !typed || !canUseWhereSearch.value) return false;
const normalized = typed.toLowerCase();
return !localFilterAllOptions.value.some((option) => option.label.toLowerCase() === normalized);
});
function openLocalFilter(colIdx: number) {
localFilterSearch.value = "";
const allKeys = buildLocalFilterOptions(colIdx).map((option) => option.key);
@@ -594,9 +612,17 @@ function toggleAllLocalFilterOptions() {
localFilterDraft.value = { ...draft, values: next };
}
function applyLocalFilter() {
async function applyLocalFilter() {
const draft = localFilterDraft.value;
if (!draft) return;
if (
canApplyTypedLocalFilterValue.value &&
localFilterDraftIsAllSelected.value &&
localFilterOptions.value.length === 0
) {
await applyTypedLocalFilterValue();
return;
}
const allKeys = new Set(localFilterAllOptions.value.map((option) => option.key));
const next = { ...localColumnFilters.value };
let selected = draft.values;
@@ -614,6 +640,26 @@ function applyLocalFilter() {
resetGridVerticalScroll();
}
async function applyTypedLocalFilterValue() {
const draft = localFilterDraft.value;
if (!draft) return;
const columnName = props.result.columns[draft.columnIndex];
if (!columnName) return;
const condition = buildColumnValueFilterCondition({
databaseType: props.databaseType,
columnName,
columnInfo: props.tableMeta?.columns.find((column) => column.name === columnName),
rawValue: localFilterTypedValue.value,
});
if (!condition) return;
const next = { ...localColumnFilters.value };
delete next[draft.columnIndex];
localColumnFilters.value = next;
whereFilterInput.value = appendColumnValueFilterCondition(whereFilterInput.value, condition);
closeLocalFilter();
await applyWhereFilter();
}
function clearLocalFilter(colIdx?: number) {
if (colIdx === undefined) {
localColumnFilters.value = {};
@@ -3243,8 +3289,19 @@ defineExpose({
})
}}
</div>
<button
v-if="canApplyTypedLocalFilterValue"
type="button"
class="grid w-full grid-cols-[2rem_minmax(0,1fr)] items-center px-3 py-2 text-left text-sm text-blue-700 hover:bg-sky-50"
@click="applyTypedLocalFilterValue"
>
<Search class="h-4 w-4" />
<span class="truncate font-mono">
{{ t("grid.filterTypedValue", { value: localFilterTypedValue }) }}
</span>
</button>
<div
v-if="localFilterOptions.length === 0"
v-if="localFilterOptions.length === 0 && !canApplyTypedLocalFilterValue"
class="px-3 py-8 text-center text-sm text-slate-500"
>
{{ t("grid.noSearchResults") }}
+1
View File
@@ -289,6 +289,7 @@ export default {
saveFormatter: "Save",
clearFormatter: "Clear",
searchValues: "Search values...",
filterTypedValue: "Use '{value}'",
value: "Value",
count: "Count",
applyFilter: "Apply Filter",
+1
View File
@@ -265,6 +265,7 @@ export default {
localFilter: "Filtro local de columna",
localFilterFor: "Filtro local para '{column}'",
searchValues: "Buscar valores...",
filterTypedValue: "Usar '{value}'",
value: "Valor",
count: "Conteo",
applyFilter: "Aplicar filtro",
+1
View File
@@ -285,6 +285,7 @@ export default {
saveFormatter: "保存",
clearFormatter: "清除",
searchValues: "搜索值...",
filterTypedValue: "使用“{value}”筛选",
value: "值",
count: "数量",
applyFilter: "应用筛选",
@@ -0,0 +1,61 @@
import type { ColumnInfo, DatabaseType } from "@/types/database";
import { formatGridSqlLiteral } from "@/lib/dataGridSql";
import { quoteTableIdentifier, normalizeWhereInput } from "@/lib/tableSelectSql";
export function buildColumnValueFilterCondition(options: {
databaseType?: DatabaseType;
columnName: string;
columnInfo?: Pick<ColumnInfo, "data_type">;
rawValue: string;
}): string | undefined {
const text = options.rawValue.trim();
if (!text) return undefined;
const column = columnFilterRef(options.databaseType, options.columnName);
if (/^null$/i.test(text)) return `${column} IS NULL`;
return `${column} = ${formatGridSqlLiteral(parseTypedFilterValue(text, options.columnInfo), options.databaseType)}`;
}
export function appendColumnValueFilterCondition(
whereInput: string | undefined,
condition: string | undefined,
): string {
if (!condition) return normalizeWhereInput(whereInput);
const existing = normalizeWhereInput(whereInput);
return existing ? `(${existing}) AND (${condition})` : condition;
}
function columnFilterRef(databaseType: DatabaseType | undefined, columnName: string): string {
const quoted = quoteTableIdentifier(databaseType, columnName);
return databaseType === "neo4j" ? `n.${quoted}` : quoted;
}
function parseTypedFilterValue(text: string, columnInfo: Pick<ColumnInfo, "data_type"> | undefined) {
const unquoted = unwrapMatchingQuotes(text);
const dataType = columnInfo?.data_type.toLowerCase() ?? "";
if (isBooleanType(dataType) && /^(true|false)$/i.test(unquoted)) return /^true$/i.test(unquoted);
if ((isNumericType(dataType) || !dataType) && isNumericLiteral(unquoted)) return Number(unquoted);
return unquoted;
}
function unwrapMatchingQuotes(text: string): string {
if (text.length < 2) return text;
const first = text[0];
const last = text[text.length - 1];
if ((first === "'" && last === "'") || (first === '"' && last === '"')) return text.slice(1, -1);
return text;
}
function isNumericType(dataType: string): boolean {
return /\b(int|integer|bigint|smallint|tinyint|mediumint|serial|number|numeric|decimal|float|double|real|money)\b/i.test(
dataType,
);
}
function isBooleanType(dataType: string): boolean {
return /\b(bool|boolean|bit)\b/i.test(dataType);
}
function isNumericLiteral(text: string): boolean {
return /^[+-]?(?:\d+|\d*\.\d+)(?:e[+-]?\d+)?$/i.test(text) && Number.isFinite(Number(text));
}
@@ -0,0 +1,39 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import {
appendColumnValueFilterCondition,
buildColumnValueFilterCondition,
} from "../../apps/desktop/src/lib/dataGridColumnFilter.ts";
test("builds a numeric server-side column filter from typed text", () => {
const condition = buildColumnValueFilterCondition({
databaseType: "mysql",
columnName: "id",
columnInfo: { name: "id", data_type: "int", is_nullable: false, is_primary_key: true },
rawValue: "49436",
});
assert.equal(condition, "`id` = 49436");
});
test("quotes text server-side column filters and appends them to existing WHERE input", () => {
const condition = buildColumnValueFilterCondition({
databaseType: "postgres",
columnName: "status",
columnInfo: { name: "status", data_type: "varchar", is_nullable: true, is_primary_key: false },
rawValue: "active",
});
assert.equal(condition, `"status" = 'active'`);
assert.equal(appendColumnValueFilterCondition("deleted_at IS NULL", condition), `(deleted_at IS NULL) AND ("status" = 'active')`);
});
test("builds IS NULL for typed NULL filters", () => {
const condition = buildColumnValueFilterCondition({
databaseType: "sqlserver",
columnName: "archived_at",
rawValue: "NULL",
});
assert.equal(condition, "[archived_at] IS NULL");
});