diff --git a/packages/bindx/src/dataview/filterHandlers.ts b/packages/bindx/src/dataview/filterHandlers.ts index e0ab103..1448b0e 100644 --- a/packages/bindx/src/dataview/filterHandlers.ts +++ b/packages/bindx/src/dataview/filterHandlers.ts @@ -393,16 +393,16 @@ export function createRelationFilterHandler(fieldPath: string): FilterHandler { return { defaultArtifact(): IsDefinedFilterArtifact { - return { defined: null } + return {} }, isActive(artifact: IsDefinedFilterArtifact): boolean { - return artifact.defined !== null + return artifact.nullCondition !== undefined }, toWhere(artifact: IsDefinedFilterArtifact): Record | undefined { - if (artifact.defined === null) return undefined - return buildNestedWhere(fieldPath, { isNull: !artifact.defined }) + if (artifact.nullCondition === undefined) return undefined + return buildNestedWhere(fieldPath, { isNull: artifact.nullCondition }) }, } } diff --git a/packages/bindx/src/dataview/types.ts b/packages/bindx/src/dataview/types.ts index 81face1..7eacc42 100644 --- a/packages/bindx/src/dataview/types.ts +++ b/packages/bindx/src/dataview/types.ts @@ -88,7 +88,12 @@ export interface EnumListFilterArtifact { * IsDefined filter artifact */ export interface IsDefinedFilterArtifact { - readonly defined: boolean | null + /** + * Follows the shared `nullCondition` convention used by every other filter handler. + * `false` = exclude nulls (is-defined); `true` = include-nulls-only (not-defined); + * `undefined` = filter inactive. + */ + readonly nullCondition?: boolean } /** diff --git a/tests/unit/dataview/filterHandlers.test.ts b/tests/unit/dataview/filterHandlers.test.ts index 99b146a..37c70ef 100644 --- a/tests/unit/dataview/filterHandlers.test.ts +++ b/tests/unit/dataview/filterHandlers.test.ts @@ -274,19 +274,19 @@ describe('filter handlers', () => { describe('isDefined filter', () => { const handler = createIsDefinedFilterHandler('publishedAt') - test('defined = true → isNull: false', () => { - const where = handler.toWhere({ defined: true }) + test('nullCondition = false (is-defined) → isNull: false', () => { + const where = handler.toWhere({ nullCondition: false }) expect(where).toEqual({ publishedAt: { isNull: false } }) }) - test('defined = false → isNull: true', () => { - const where = handler.toWhere({ defined: false }) + test('nullCondition = true (not-defined) → isNull: true', () => { + const where = handler.toWhere({ nullCondition: true }) expect(where).toEqual({ publishedAt: { isNull: true } }) }) - test('defined = null is inactive', () => { - expect(handler.isActive({ defined: null })).toBe(false) - expect(handler.toWhere({ defined: null })).toBeUndefined() + test('nullCondition undefined is inactive', () => { + expect(handler.isActive({})).toBe(false) + expect(handler.toWhere({})).toBeUndefined() }) }) @@ -299,7 +299,7 @@ describe('filter handlers', () => { test('isDefined filter on nested path', () => { const handler = createIsDefinedFilterHandler('author.email') - const where = handler.toWhere({ defined: true }) + const where = handler.toWhere({ nullCondition: false }) expect(where).toEqual({ author: { email: { isNull: false } } }) }) }) diff --git a/tests/unit/dataview/isDefinedHandlerNullConditionCompat.test.ts b/tests/unit/dataview/isDefinedHandlerNullConditionCompat.test.ts new file mode 100644 index 0000000..c4a075f --- /dev/null +++ b/tests/unit/dataview/isDefinedHandlerNullConditionCompat.test.ts @@ -0,0 +1,54 @@ +// Regression test for +// +// `DataGridIsDefinedFilterControls` (bindx-ui) drives `DataViewNullFilterTrigger`, +// which writes a `nullCondition: boolean` field onto the filter artifact via +// `useDataViewNullFilter`. That field is the convention used by every other +// filter handler (text/number/date/enum/relation/boolean) — see e.g. +// `filterHandlers.test.ts > text filter > with null condition`. +// +// `createIsDefinedFilterHandler` is the outlier: its `IsDefinedFilterArtifact` +// shape is `{ defined: boolean | null }`. The handler's `isActive` and `toWhere` +// only inspect `defined` — `nullCondition` is completely ignored. So when the +// bindx-ui filter UI writes `nullCondition`, the handler never wakes up and +// the filter has no effect. +// +// `DataGridIsDefinedColumn` (in bindx-ui) wires these two together, but the +// combination is non-functional today and there are no existing usages to +// have caught it. +import '../../setup' +import { describe, expect, test } from 'bun:test' +import { createIsDefinedFilterHandler } from '@contember/bindx' + +describe('createIsDefinedFilterHandler integration with DataViewNullFilterTrigger', () => { + const handler = createIsDefinedFilterHandler('email') + + test('should treat artifact as active when DataGridIsDefinedFilterControls writes nullCondition: false (✓ button)', () => { + // `DataGridIsDefinedFilterControls`'s ✓ button uses + // `DataViewNullFilterTrigger action="toggleExclude"`, which calls + // `useDataViewNullFilter`'s `toggleExclude` branch: + // + // setFilter(it => ({ ...it, nullCondition: it?.nullCondition === false ? undefined : false })) + // + // Starting from the default artifact, this writes `nullCondition: false`. + const afterExcludeClick = { + ...handler.defaultArtifact(), + nullCondition: false, + } as never + + expect(handler.isActive(afterExcludeClick)).toBe(true) + expect(handler.toWhere(afterExcludeClick)).toEqual({ email: { isNull: false } }) + }) + + test('should treat artifact as active when DataGridIsDefinedFilterControls writes nullCondition: true (✗ button)', () => { + // `DataGridIsDefinedFilterControls`'s ✗ button uses + // `DataViewNullFilterTrigger action="toggleInclude"`, which sets + // `nullCondition: true`. + const afterIncludeClick = { + ...handler.defaultArtifact(), + nullCondition: true, + } as never + + expect(handler.isActive(afterIncludeClick)).toBe(true) + expect(handler.toWhere(afterIncludeClick)).toEqual({ email: { isNull: true } }) + }) +})