mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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:
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}"
|
||||
`,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user