mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:55:32 +00:00
feat(grid): filter typed column values
This commit is contained in:
@@ -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") }}
|
||||
|
||||
@@ -289,6 +289,7 @@ export default {
|
||||
saveFormatter: "Save",
|
||||
clearFormatter: "Clear",
|
||||
searchValues: "Search values...",
|
||||
filterTypedValue: "Use '{value}'",
|
||||
value: "Value",
|
||||
count: "Count",
|
||||
applyFilter: "Apply Filter",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user