diff --git a/apps/studio/components/grid/components/header/filter/FilterPopoverNew.utils.ts b/apps/studio/components/grid/components/header/filter/FilterPopoverNew.utils.ts index 60bba8b0179..5cfefa6273b 100644 --- a/apps/studio/components/grid/components/header/filter/FilterPopoverNew.utils.ts +++ b/apps/studio/components/grid/components/header/filter/FilterPopoverNew.utils.ts @@ -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 { diff --git a/apps/studio/pages/api/ai/sql/filter-v1.ts b/apps/studio/pages/api/ai/sql/filter-v1.ts index 9dd6cd2b2ad..1f296714c40 100644 --- a/apps/studio/pages/api/ai/sql/filter-v1.ts +++ b/apps/studio/pages/api/ai/sql/filter-v1.ts @@ -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}" `, diff --git a/e2e/studio/features/filter-bar.spec.ts b/e2e/studio/features/filter-bar.spec.ts index 86597c9e758..5c186bf9f2a 100644 --- a/e2e/studio/features/filter-bar.spec.ts +++ b/e2e/studio/features/filter-bar.spec.ts @@ -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, diff --git a/packages/ui-patterns/src/FilterBar/menuItems.test.ts b/packages/ui-patterns/src/FilterBar/menuItems.test.ts new file mode 100644 index 00000000000..14dfa074782 --- /dev/null +++ b/packages/ui-patterns/src/FilterBar/menuItems.test.ts @@ -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' }, + ]) + }) +}) diff --git a/packages/ui-patterns/src/FilterBar/menuItems.ts b/packages/ui-patterns/src/FilterBar/menuItems.ts index f46a0828087..a618e50ceb1 100644 --- a/packages/ui-patterns/src/FilterBar/menuItems.ts +++ b/packages/ui-patterns/src/FilterBar/menuItems.ts @@ -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) + ) +}