fix: added support for is and is not null (#43404)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

- For the new filters experience, we are missing null changes for dates
and also no support for isNull
- Updated E2E tests to account for new null cases
This commit is contained in:
Ali Waseem authored and GitHub committed 2026-03-05 07:07:54 -07:00
1 parent 03bd82de81
commit 6cd3857d70
5 files changed
+214

No files matched your search

@@ -50,11 +50,13 @@ const DATE_OPERATORS: OperatorDefinition[] = [
{ value: '<=', label: 'Less or equal', group: 'comparison' },
{ value: '=', label: 'Equals', group: 'comparison' },
{ value: '<>', label: 'Not equal', group: 'comparison' },
{ value: 'is', label: 'Is', group: 'setNull' },
]
const BOOLEAN_OPERATORS: OperatorDefinition[] = [
{ value: '=', label: 'Equals', group: 'comparison' },
{ value: '<>', label: 'Not equal', group: 'comparison' },
{ value: 'is', label: 'Is', group: 'setNull' },
]
export function columnToFilterProperty(column: SupaColumn): FilterProperty {
@@ -78,6 +78,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
- When unsure, default to simple equality comparisons with reasonable values.
- Values should respect property types: booleans must be true/false, dates should be ISO date strings (YYYY-MM-DD), and numbers must be numbers.
- If options are provided for a property, choose from those values when appropriate.
- The "is" operator is used for NULL checks. Valid values are: null, not null. For boolean columns, true and false are also valid.
User request: "${prompt}"
`,
+79
View File
@@ -813,6 +813,85 @@ test.describe('Filter Bar', () => {
})
})
test.describe('IS NULL Filters', () => {
test('IS NULL on a text column shows only null rows', async ({ page, ref }) => {
const tableName = `${tableNamePrefix}_is_null_txt`
await query(
`CREATE TABLE IF NOT EXISTS ${tableName} (
id bigint generated by default as identity primary key,
name text
)`
)
await query(`INSERT INTO ${tableName} (name) VALUES ('Alice'), (NULL), ('Charlie')`)
try {
await setupFilterBarPage(page, ref, toUrl(`/project/${ref}/editor?schema=public`))
await navigateToTable(page, ref, tableName)
await addFilterWithDropdownValue(page, ref, 'name', 'is', 'null')
const rows = page.locator('[role="row"]')
// Header row + 1 data row with NULL
await expect(rows).toHaveCount(2)
} finally {
await dropTable(tableName)
}
})
test('IS NOT NULL on a text column shows only non-null rows', async ({ page, ref }) => {
const tableName = `${tableNamePrefix}_is_nnull_txt`
await query(
`CREATE TABLE IF NOT EXISTS ${tableName} (
id bigint generated by default as identity primary key,
name text
)`
)
await query(`INSERT INTO ${tableName} (name) VALUES ('Alice'), (NULL), ('Charlie')`)
try {
await setupFilterBarPage(page, ref, toUrl(`/project/${ref}/editor?schema=public`))
await navigateToTable(page, ref, tableName)
await addFilterWithDropdownValue(page, ref, 'name', 'is', 'not null')
const rows = page.locator('[role="row"]')
// Header row + 2 data rows (Alice, Charlie)
await expect(rows).toHaveCount(3)
} finally {
await dropTable(tableName)
}
})
test('IS NULL on a timestamptz column shows only null rows', async ({ page, ref }) => {
const tableName = `${tableNamePrefix}_is_null_ts`
await query(
`CREATE TABLE IF NOT EXISTS ${tableName} (
id bigint generated by default as identity primary key,
created_at timestamptz
)`
)
await query(
`INSERT INTO ${tableName} (created_at) VALUES (NOW()), (NULL), (NOW() - INTERVAL '1 day')`
)
try {
await setupFilterBarPage(page, ref, toUrl(`/project/${ref}/editor?schema=public`))
await navigateToTable(page, ref, tableName)
await addFilterWithDropdownValue(page, ref, 'created_at', 'is', 'null')
const rows = page.locator('[role="row"]')
// Header row + 1 data row with NULL
await expect(rows).toHaveCount(2)
} finally {
await dropTable(tableName)
}
})
})
test.describe('Filter Error Feedback', () => {
test('invalid filter value shows friendly error and remove button clears filters', async ({
page,
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import { buildValueItems } from './menuItems'
import { FilterGroup, FilterProperty } from './types'
const stringProperty: FilterProperty = {
label: 'Name',
name: 'name',
type: 'string',
options: [
{ label: 'Alice', value: 'alice' },
{ label: 'Bob', value: 'bob' },
],
operators: [
{ value: '=', label: 'Equals', group: 'comparison' as const },
{ value: 'is', label: 'Is', group: 'setNull' as const },
],
}
const booleanProperty: FilterProperty = {
label: 'Active',
name: 'active',
type: 'boolean',
options: [
{ label: 'true', value: 'true' },
{ label: 'false', value: 'false' },
],
operators: [
{ value: '=', label: 'Equals', group: 'comparison' as const },
{ value: 'is', label: 'Is', group: 'setNull' as const },
],
}
const filterProperties: FilterProperty[] = [stringProperty, booleanProperty]
describe('buildValueItems', () => {
it('returns NULL and NOT NULL options when IS operator is selected', () => {
const filters: FilterGroup = {
logicalOperator: 'AND',
conditions: [{ propertyName: 'name', operator: 'is', value: '' }],
}
const items = buildValueItems(
{ type: 'value', path: [0] },
filters,
filterProperties,
{},
{},
'',
false
)
expect(items).toEqual([
{ value: 'null', label: 'NULL' },
{ value: 'not null', label: 'NOT NULL' },
])
})
it('includes TRUE and FALSE options for boolean properties with IS operator', () => {
const filters: FilterGroup = {
logicalOperator: 'AND',
conditions: [{ propertyName: 'active', operator: 'is', value: '' }],
}
const items = buildValueItems(
{ type: 'value', path: [0] },
filters,
filterProperties,
{},
{},
'',
false
)
expect(items).toEqual([
{ value: 'null', label: 'NULL' },
{ value: 'not null', label: 'NOT NULL' },
{ value: 'true', label: 'TRUE' },
{ value: 'false', label: 'FALSE' },
])
})
it('returns normal property options for non-IS operators', () => {
const filters: FilterGroup = {
logicalOperator: 'AND',
conditions: [{ propertyName: 'name', operator: '=', value: '' }],
}
const items = buildValueItems(
{ type: 'value', path: [0] },
filters,
filterProperties,
{},
{},
'',
false
)
expect(items).toEqual([
{ value: 'alice', label: 'Alice' },
{ value: 'bob', label: 'Bob' },
])
})
})
@@ -97,6 +97,10 @@ export function buildValueItems(
if (!property) return items
if (activeCondition?.operator === 'is') {
return getIsOperatorValueItems(property, inputValue, hasTypedSinceFocus)
}
if (!Array.isArray(property.options) && isCustomOptionObject(property.options)) {
items.push({
value: 'custom',
@@ -157,3 +161,27 @@ function getCachedOptionItems(options: any[]): MenuItem[] {
return { value: option.value, label: option.label }
})
}
function getIsOperatorValueItems(
property: FilterProperty,
inputValue: string,
hasTypedSinceFocus: boolean
): MenuItem[] {
const options: { value: string; label: string }[] = [
{ value: 'null', label: 'NULL' },
{ value: 'not null', label: 'NOT NULL' },
]
if (property.type === 'boolean') {
options.push({ value: 'true', label: 'TRUE' }, { value: 'false', label: 'FALSE' })
}
const shouldFilter = hasTypedSinceFocus && inputValue.length > 0
if (!shouldFilter) return options
const normalizedInput = inputValue.toLowerCase()
return options.filter(
(opt) =>
opt.label.toLowerCase().includes(normalizedInput) || opt.value.includes(normalizedInput)
)
}