feat(grid): distinguish blank and null filters

Closes #5006
This commit is contained in:
t8y2 committed 2026-08-24 22:33:21 +00:00
1 parent f621e40655
commit e2fa810f2e
16 files changed
+146 -10

No files matched your search

@@ -1036,6 +1036,8 @@ const allFilterModeOptions: Array<{ value: FilterMode; labelKey: string }> = [
{ value: "not-between", labelKey: "grid.filterBuilderNotBetween" }, { value: "not-between", labelKey: "grid.filterBuilderNotBetween" },
{ value: "is-null", labelKey: "grid.filterBuilderIsNull" }, { value: "is-null", labelKey: "grid.filterBuilderIsNull" },
{ value: "is-not-null", labelKey: "grid.filterBuilderIsNotNull" }, { value: "is-not-null", labelKey: "grid.filterBuilderIsNotNull" },
{ value: "is-blank", labelKey: "grid.filterBuilderIsBlank" },
{ value: "is-not-blank", labelKey: "grid.filterBuilderIsNotBlank" },
]; ];
const filterModeOptions = computed(() => allFilterModeOptions.filter((option) => filterModeIsSupportedForDatabase(option.value, resolvedDatabaseType.value))); const filterModeOptions = computed(() => allFilterModeOptions.filter((option) => filterModeIsSupportedForDatabase(option.value, resolvedDatabaseType.value)));
const filterBuilderColumnOptions = computed(() => filterBuilderColumns.value.map((column) => column.name)); const filterBuilderColumnOptions = computed(() => filterBuilderColumns.value.map((column) => column.name));
@@ -6372,7 +6374,7 @@ async function contextFilterCondition(target: ContextFilterTarget, mode: FilterM
const { columnName, sourceResult, sourceIndex, sourceValue, requiresHydration } = target; const { columnName, sourceResult, sourceIndex, sourceValue, requiresHydration } = target;
// CustomContextMenu closes before invoking its action. Keep the target // CustomContextMenu closes before invoking its action. Keep the target
// stable across hydration after the close lifecycle clears contextCell. // stable across hydration after the close lifecycle clears contextCell.
if (mode !== "is-null" && mode !== "is-not-null") { if (filterModeNeedsValue(mode)) {
if (!(await hydrateLargeValueCell(target.rowId, target.col))) return null; if (!(await hydrateLargeValueCell(target.rowId, target.col))) return null;
} }
if (props.result !== sourceResult) return null; if (props.result !== sourceResult) return null;
+2
View File
@@ -1711,6 +1711,8 @@ export default {
filterBuilderNotBetween: "Outside range", filterBuilderNotBetween: "Outside range",
filterBuilderIsNull: "Is NULL", filterBuilderIsNull: "Is NULL",
filterBuilderIsNotNull: "Is not NULL", filterBuilderIsNotNull: "Is not NULL",
filterBuilderIsBlank: "Is blank (NULL or empty)",
filterBuilderIsNotBlank: "Is not blank (not NULL and not empty)",
columnActions: "Column actions", columnActions: "Column actions",
localFilter: "Local value filter", localFilter: "Local value filter",
localFilterFor: "Local Values For '{column}'", localFilterFor: "Local Values For '{column}'",
+2
View File
@@ -1594,6 +1594,8 @@ export default withEnglishFallback({
filterBuilderNotBetween: "Fuera de rango", filterBuilderNotBetween: "Fuera de rango",
filterBuilderIsNull: "Es NULL", filterBuilderIsNull: "Es NULL",
filterBuilderIsNotNull: "No es NULL", filterBuilderIsNotNull: "No es NULL",
filterBuilderIsBlank: "Está vacío (NULL o cadena vacía)",
filterBuilderIsNotBlank: "No está vacío (ni NULL ni cadena vacía)",
columnActions: "Acciones de columna", columnActions: "Acciones de columna",
localFilter: "Filtro local de valores", localFilter: "Filtro local de valores",
localFilterFor: "Valores locales para '{column}'", localFilterFor: "Valores locales para '{column}'",
+2
View File
@@ -1592,6 +1592,8 @@ export default withEnglishFallback({
filterBuilderNotBetween: "Fuori intervallo", filterBuilderNotBetween: "Fuori intervallo",
filterBuilderIsNull: "È NULL", filterBuilderIsNull: "È NULL",
filterBuilderIsNotNull: "Non è NULL", filterBuilderIsNotNull: "Non è NULL",
filterBuilderIsBlank: "È vuoto (NULL o stringa vuota)",
filterBuilderIsNotBlank: "Non è vuoto (né NULL né stringa vuota)",
columnActions: "Azioni colonna", columnActions: "Azioni colonna",
localFilter: "Filtro valori locale", localFilter: "Filtro valori locale",
localFilterFor: "Valori locali per '{column}'", localFilterFor: "Valori locali per '{column}'",
+2
View File
@@ -1610,6 +1610,8 @@ export default withEnglishFallback({
filterBuilderNotBetween: "範囲外", filterBuilderNotBetween: "範囲外",
filterBuilderIsNull: "NULLである", filterBuilderIsNull: "NULLである",
filterBuilderIsNotNull: "NULLでない", filterBuilderIsNotNull: "NULLでない",
filterBuilderIsBlank: "空(NULLまたは空文字)",
filterBuilderIsNotBlank: "空でない(NULLでも空文字でもない)",
columnActions: "列の操作", columnActions: "列の操作",
localFilter: "ローカル値フィルター", localFilter: "ローカル値フィルター",
localFilterFor: "'{column}'のローカル値", localFilterFor: "'{column}'のローカル値",
+2
View File
@@ -1587,6 +1587,8 @@ export default withEnglishFallback({
filterBuilderNotBetween: "범위 외", filterBuilderNotBetween: "범위 외",
filterBuilderIsNull: "NULL임", filterBuilderIsNull: "NULL임",
filterBuilderIsNotNull: "NULL이 아님", filterBuilderIsNotNull: "NULL이 아님",
filterBuilderIsBlank: "비어 있음(NULL 또는 빈 문자열)",
filterBuilderIsNotBlank: "비어 있지 않음(NULL도 빈 문자열도 아님)",
columnActions: "컬럼 작업", columnActions: "컬럼 작업",
localFilter: "로컬 값 필터", localFilter: "로컬 값 필터",
localFilterFor: "'{column}' 로컬 값", localFilterFor: "'{column}' 로컬 값",
+2
View File
@@ -1594,6 +1594,8 @@ export default withEnglishFallback({
filterBuilderNotBetween: "Fora do intervalo", filterBuilderNotBetween: "Fora do intervalo",
filterBuilderIsNull: "É NULL", filterBuilderIsNull: "É NULL",
filterBuilderIsNotNull: "Não é NULL", filterBuilderIsNotNull: "Não é NULL",
filterBuilderIsBlank: "Está em branco (NULL ou string vazia)",
filterBuilderIsNotBlank: "Não está em branco (nem NULL nem string vazia)",
columnActions: "Ações da coluna", columnActions: "Ações da coluna",
localFilter: "Filtro local de valores", localFilter: "Filtro local de valores",
localFilterFor: "Valores locais para '{column}'", localFilterFor: "Valores locais para '{column}'",
+4 -2
View File
@@ -1634,8 +1634,10 @@ export default withEnglishFallback({
filterBuilderNotIn: "列表外", filterBuilderNotIn: "列表外",
filterBuilderBetween: "介于", filterBuilderBetween: "介于",
filterBuilderNotBetween: "不介于", filterBuilderNotBetween: "不介于",
filterBuilderIsNull: "为空", filterBuilderIsNull: "为 NULL",
filterBuilderIsNotNull: "不为空", filterBuilderIsNotNull: "不为 NULL",
filterBuilderIsBlank: "为空(NULL 或空字符串)",
filterBuilderIsNotBlank: "不为空(非 NULL 且非空字符串)",
columnActions: "字段操作", columnActions: "字段操作",
localFilter: "本地值筛选", localFilter: "本地值筛选",
localFilterFor: "本地值筛选“{column}”", localFilterFor: "本地值筛选“{column}”",
+4 -2
View File
@@ -1591,8 +1591,10 @@ export default withEnglishFallback({
filterBuilderNotIn: "清單外", filterBuilderNotIn: "清單外",
filterBuilderBetween: "介於", filterBuilderBetween: "介於",
filterBuilderNotBetween: "不介於", filterBuilderNotBetween: "不介於",
filterBuilderIsNull: "為空", filterBuilderIsNull: "為 NULL",
filterBuilderIsNotNull: "不為空", filterBuilderIsNotNull: "不為 NULL",
filterBuilderIsBlank: "為空(NULL 或空字串)",
filterBuilderIsNotBlank: "不為空(非 NULL 且非空字串)",
columnActions: "欄位操作", columnActions: "欄位操作",
localFilter: "本機值篩選", localFilter: "本機值篩選",
localFilterFor: "本機值篩選「{column}」", localFilterFor: "本機值篩選「{column}」",
@@ -36,6 +36,19 @@ describe("normalizeColumnFormatter", () => {
expect(normalizeColumnFormatter({ ...base, filter: { column: "dict_type", mode: "between", value: "A" } })).toBeUndefined(); expect(normalizeColumnFormatter({ ...base, filter: { column: "dict_type", mode: "between", value: "A" } })).toBeUndefined();
expect(normalizeColumnFormatter({ ...base, filter: { column: "dict_type", mode: "raw-sql", value: "1=1" } })).toBeUndefined(); expect(normalizeColumnFormatter({ ...base, filter: { column: "dict_type", mode: "raw-sql", value: "1=1" } })).toBeUndefined();
}); });
it.each(["is-blank", "is-not-blank"] as const)("preserves the value-less %s manual reference filter", (mode) => {
expect(
normalizeColumnFormatter({
kind: "foreign-key-display",
referenceMode: "manual",
refTable: "dictionary",
refColumn: "dict_key",
displayColumn: "dict_value",
filter: { column: "dict_type", mode },
}),
).toMatchObject({ filter: { column: "dict_type", mode, value: undefined, endValue: undefined } });
});
}); });
describe("normalizeSupportedDateTimePattern", () => { describe("normalizeSupportedDateTimePattern", () => {
@@ -4,7 +4,7 @@ import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
describe("VictoriaMetrics data grid filters", () => { describe("VictoriaMetrics data grid filters", () => {
it("does not expose SQL filter modes for MetricsQL results", () => { it("does not expose SQL filter modes for MetricsQL results", () => {
const modes: DataGridContextFilterMode[] = ["equals", "not-equals", "is-null", "is-not-null", "like", "not-like", "less-than", "greater-than", "in", "not-in", "between", "not-between"]; const modes: DataGridContextFilterMode[] = ["equals", "not-equals", "is-null", "is-not-null", "is-blank", "is-not-blank", "like", "not-like", "less-than", "greater-than", "in", "not-in", "between", "not-between"];
expect(modes.every((mode) => !filterModeIsSupportedForDatabase(mode, "victoriametrics"))).toBe(true); expect(modes.every((mode) => !filterModeIsSupportedForDatabase(mode, "victoriametrics"))).toBe(true);
expect(filterModeIsSupportedForDatabase("equals", "postgres")).toBe(true); expect(filterModeIsSupportedForDatabase("equals", "postgres")).toBe(true);
@@ -190,7 +190,7 @@ export function normalizeColumnFormatter(value: unknown): ColumnFormatterConfig
return undefined; return undefined;
} }
const FOREIGN_KEY_DISPLAY_FILTER_MODES = new Set<DataGridContextFilterMode>(["equals", "not-equals", "is-null", "is-not-null", "like", "not-like", "less-than", "less-than-or-equal", "greater-than", "greater-than-or-equal", "in", "not-in", "between", "not-between"]); const FOREIGN_KEY_DISPLAY_FILTER_MODES = new Set<DataGridContextFilterMode>(["equals", "not-equals", "is-null", "is-not-null", "is-blank", "is-not-blank", "like", "not-like", "less-than", "less-than-or-equal", "greater-than", "greater-than-or-equal", "in", "not-in", "between", "not-between"]);
function normalizeForeignKeyDisplayFilter(value: unknown): ForeignKeyDisplayFilterConfig | undefined { function normalizeForeignKeyDisplayFilter(value: unknown): ForeignKeyDisplayFilterConfig | undefined {
if (!value || typeof value !== "object") return undefined; if (!value || typeof value !== "object") return undefined;
@@ -200,7 +200,7 @@ function normalizeForeignKeyDisplayFilter(value: unknown): ForeignKeyDisplayFilt
const mode = filter.mode as DataGridContextFilterMode; const mode = filter.mode as DataGridContextFilterMode;
const rawValue = typeof filter.value === "string" ? filter.value.trim() : ""; const rawValue = typeof filter.value === "string" ? filter.value.trim() : "";
const rawEndValue = typeof filter.endValue === "string" ? filter.endValue.trim() : ""; const rawEndValue = typeof filter.endValue === "string" ? filter.endValue.trim() : "";
if (mode !== "is-null" && mode !== "is-not-null" && !rawValue) return undefined; if (mode !== "is-null" && mode !== "is-not-null" && mode !== "is-blank" && mode !== "is-not-blank" && !rawValue) return undefined;
if ((mode === "between" || mode === "not-between") && !rawEndValue) return undefined; if ((mode === "between" || mode === "not-between") && !rawEndValue) return undefined;
return { return {
column: filter.column.trim(), column: filter.column.trim(),
@@ -172,7 +172,7 @@ export function combineWhereInputs(manualWhereInput?: string, structuredWhereInp
} }
export function filterModeNeedsValue(mode: DataGridContextFilterMode): boolean { export function filterModeNeedsValue(mode: DataGridContextFilterMode): boolean {
return mode !== "is-null" && mode !== "is-not-null"; return mode !== "is-null" && mode !== "is-not-null" && mode !== "is-blank" && mode !== "is-not-blank";
} }
export function filterModeUsesList(mode: DataGridContextFilterMode): boolean { export function filterModeUsesList(mode: DataGridContextFilterMode): boolean {
+1 -1
View File
@@ -58,7 +58,7 @@ export interface DataGridCopyInsertStatementOptions {
insertMode?: DataGridCopyInsertMode; insertMode?: DataGridCopyInsertMode;
} }
export type DataGridContextFilterMode = "equals" | "not-equals" | "is-null" | "is-not-null" | "like" | "not-like" | "less-than" | "less-than-or-equal" | "greater-than" | "greater-than-or-equal" | "in" | "not-in" | "between" | "not-between"; export type DataGridContextFilterMode = "equals" | "not-equals" | "is-null" | "is-not-null" | "is-blank" | "is-not-blank" | "like" | "not-like" | "less-than" | "less-than-or-equal" | "greater-than" | "greater-than-or-equal" | "in" | "not-in" | "between" | "not-between";
export interface DataGridContextFilterConditionOptions { export interface DataGridContextFilterConditionOptions {
databaseType?: DatabaseType; databaseType?: DatabaseType;
+83
View File
@@ -149,6 +149,8 @@ pub enum DataGridContextFilterMode {
NotEquals, NotEquals,
IsNull, IsNull,
IsNotNull, IsNotNull,
IsBlank,
IsNotBlank,
Like, Like,
NotLike, NotLike,
LessThan, LessThan,
@@ -546,6 +548,18 @@ pub fn build_data_grid_context_filter_condition(options: DataGridContextFilterCo
match options.mode { match options.mode {
DataGridContextFilterMode::IsNull => Some(format!("{column} IS NULL")), DataGridContextFilterMode::IsNull => Some(format!("{column} IS NULL")),
DataGridContextFilterMode::IsNotNull => Some(format!("{column} IS NOT NULL")), DataGridContextFilterMode::IsNotNull => Some(format!("{column} IS NOT NULL")),
DataGridContextFilterMode::IsBlank
if matches!(options.database_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle)) =>
{
Some(format!("{column} IS NULL"))
}
DataGridContextFilterMode::IsNotBlank
if matches!(options.database_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle)) =>
{
Some(format!("{column} IS NOT NULL"))
}
DataGridContextFilterMode::IsBlank => Some(format!("({column} IS NULL OR {column} = '')")),
DataGridContextFilterMode::IsNotBlank => Some(format!("({column} IS NOT NULL AND {column} <> '')")),
DataGridContextFilterMode::Equals if value.is_null() => Some(format!("{column} IS NULL")), DataGridContextFilterMode::Equals if value.is_null() => Some(format!("{column} IS NULL")),
DataGridContextFilterMode::NotEquals if value.is_null() => Some(format!("{column} IS NOT NULL")), DataGridContextFilterMode::NotEquals if value.is_null() => Some(format!("{column} IS NOT NULL")),
DataGridContextFilterMode::Like => Some(format!( DataGridContextFilterMode::Like => Some(format!(
@@ -4152,6 +4166,73 @@ mod tests {
); );
} }
#[test]
fn builds_blank_and_nonblank_context_filter_conditions() {
let build = |database_type: DatabaseType,
mode: DataGridContextFilterMode,
identifier_quote: Option<&str>,
column_name: &str| {
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(database_type),
identifier_quote: identifier_quote.map(str::to_string),
column_name: column_name.to_string(),
mode,
value: Value::Null,
values: Vec::new(),
end_value: None,
column_info: Some(column(column_name, "varchar", true, None)),
})
};
assert_eq!(
build(DatabaseType::Mysql, DataGridContextFilterMode::IsBlank, None, "status"),
Some("(`status` IS NULL OR `status` = '')".to_string())
);
assert_eq!(
build(DatabaseType::Mysql, DataGridContextFilterMode::IsNotBlank, None, "status"),
Some("(`status` IS NOT NULL AND `status` <> '')".to_string())
);
assert_eq!(
build(DatabaseType::Mysql, DataGridContextFilterMode::IsNull, None, "status"),
Some("`status` IS NULL".to_string())
);
assert_eq!(
build(DatabaseType::Mysql, DataGridContextFilterMode::IsNotNull, None, "status"),
Some("`status` IS NOT NULL".to_string())
);
assert_eq!(
build(DatabaseType::Kingbase, DataGridContextFilterMode::IsBlank, Some("`"), "order detail"),
Some("(`order detail` IS NULL OR `order detail` = '')".to_string())
);
for database_type in [DatabaseType::Oracle, DatabaseType::OceanbaseOracle] {
assert_eq!(
build(database_type, DataGridContextFilterMode::IsBlank, None, "STATUS"),
Some("\"STATUS\" IS NULL".to_string())
);
assert_eq!(
build(database_type, DataGridContextFilterMode::IsNotBlank, None, "STATUS"),
Some("\"STATUS\" IS NOT NULL".to_string())
);
}
}
#[test]
fn keeps_context_filter_mode_serialization_stable() {
assert_eq!(serde_json::to_string(&DataGridContextFilterMode::IsNull).unwrap(), "\"is-null\"");
assert_eq!(serde_json::to_string(&DataGridContextFilterMode::IsNotNull).unwrap(), "\"is-not-null\"");
assert!(matches!(
serde_json::from_str::<DataGridContextFilterMode>("\"is-null\"").unwrap(),
DataGridContextFilterMode::IsNull
));
assert!(matches!(
serde_json::from_str::<DataGridContextFilterMode>("\"is-not-null\"").unwrap(),
DataGridContextFilterMode::IsNotNull
));
assert_eq!(serde_json::to_string(&DataGridContextFilterMode::IsBlank).unwrap(), "\"is-blank\"");
assert_eq!(serde_json::to_string(&DataGridContextFilterMode::IsNotBlank).unwrap(), "\"is-not-blank\"");
}
#[test] #[test]
fn builds_oracle_synthetic_rowid_context_filter_conditions() { fn builds_oracle_synthetic_rowid_context_filter_conditions() {
let equals = |database_type, column_name: &str, value| { let equals = |database_type, column_name: &str, value| {
@@ -4405,6 +4486,8 @@ mod tests {
DataGridContextFilterMode::Like, DataGridContextFilterMode::Like,
DataGridContextFilterMode::GreaterThan, DataGridContextFilterMode::GreaterThan,
DataGridContextFilterMode::IsNull, DataGridContextFilterMode::IsNull,
DataGridContextFilterMode::IsBlank,
DataGridContextFilterMode::IsNotBlank,
DataGridContextFilterMode::In, DataGridContextFilterMode::In,
DataGridContextFilterMode::Between, DataGridContextFilterMode::Between,
] { ] {
+22
View File
@@ -136,6 +136,7 @@ test("hides list and range modes for unsupported dialects", () => {
} }
assert.equal(filterModeIsSupportedForDatabase("between", "postgres"), true); assert.equal(filterModeIsSupportedForDatabase("between", "postgres"), true);
assert.equal(filterModeIsSupportedForDatabase("is-null", "influxdb"), true); assert.equal(filterModeIsSupportedForDatabase("is-null", "influxdb"), true);
assert.equal(filterModeIsSupportedForDatabase("is-blank", "influxdb"), true);
}); });
test("requires usable values for structured list and range filters", () => { test("requires usable values for structured list and range filters", () => {
@@ -144,6 +145,27 @@ test("requires usable values for structured list and range filters", () => {
assert.equal(filterModeHasCompleteValue("between", "10", ""), false); assert.equal(filterModeHasCompleteValue("between", "10", ""), false);
assert.equal(filterModeHasCompleteValue("not-between", "10", "20"), true); assert.equal(filterModeHasCompleteValue("not-between", "10", "20"), true);
assert.equal(filterModeHasCompleteValue("is-null", "", ""), true); assert.equal(filterModeHasCompleteValue("is-null", "", ""), true);
assert.equal(filterModeHasCompleteValue("is-not-null", "", ""), true);
assert.equal(filterModeHasCompleteValue("is-blank", "", ""), true);
assert.equal(filterModeHasCompleteValue("is-not-blank", "", ""), true);
});
test("passes value-less blank modes through the shared context filter API", async () => {
installFilterFetchMock();
for (const mode of ["is-blank", "is-not-blank"] as const) {
await buildDataGridContextFilterCondition({
databaseType: "mysql",
columnName: "status",
mode,
value: null,
});
assert.deepEqual(lastContextFilterOptions, {
databaseType: "mysql",
columnName: "status",
mode,
value: null,
});
}
}); });
test("passes list and range values through the shared context filter API", async () => { test("passes list and range values through the shared context filter API", async () => {