diff --git a/packages/bindx-ui/src/datagrid/columns/has-many-column.tsx b/packages/bindx-ui/src/datagrid/columns/has-many-column.tsx index 9c3b030..b9047c0 100644 --- a/packages/bindx-ui/src/datagrid/columns/has-many-column.tsx +++ b/packages/bindx-ui/src/datagrid/columns/has-many-column.tsx @@ -31,7 +31,8 @@ function extractScalarFieldNames(selection: unknown): string[] { if (!meta.fields) return [] const names: string[] = [] for (const [name, field] of meta.fields) { - if (!field.nested) names.push(name) + // The relation's `id` is always selected, and the engine offers no text search on it. + if (!field.nested && name !== 'id') names.push(name) } return names } diff --git a/packages/bindx-ui/src/datagrid/columns/has-one-column.tsx b/packages/bindx-ui/src/datagrid/columns/has-one-column.tsx index a6999ee..eaf8a79 100644 --- a/packages/bindx-ui/src/datagrid/columns/has-one-column.tsx +++ b/packages/bindx-ui/src/datagrid/columns/has-one-column.tsx @@ -31,7 +31,8 @@ function extractScalarFieldNames(selection: unknown): string[] { if (!meta.fields) return [] const names: string[] = [] for (const [name, field] of meta.fields) { - if (!field.nested) names.push(name) + // The relation's `id` is always selected, and the engine offers no text search on it. + if (!field.nested && name !== 'id') names.push(name) } return names } diff --git a/tests/react/dataview/relationFilterSearchFields.test.tsx b/tests/react/dataview/relationFilterSearchFields.test.tsx new file mode 100644 index 0000000..2246273 --- /dev/null +++ b/tests/react/dataview/relationFilterSearchFields.test.tsx @@ -0,0 +1,108 @@ +// Regression test for https://github.com/contember/bindx/issues/138 +// +// The search box in a relation column's header filter must query only the +// text fields the cell renders. The column's related selection also holds the +// relation's `id`, and a `containsCI` on an id column is not a valid Contember +// condition, so the engine rejects the options query and the list goes empty. +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import React from 'react' +import { BindxProvider, MockAdapter } from '@contember/bindx-react' +import { DataGrid } from '@contember/bindx-dataview' +import { DataGridAutoTable, DataGridHasManyColumn, DataGridHasOneColumn } from '@contember/bindx-ui' +import { schema, testSchema } from '../../shared/index.js' + +afterEach(async () => { + await act(async () => { + cleanup() + await new Promise(resolve => setTimeout(resolve, 0)) + }) +}) + +function renderGrid(): { optionFilters: (entityType: string) => unknown[] } { + const adapter = new MockAdapter({ + Article: { + 'article-1': { + id: 'article-1', + title: 'First', + content: '', + author: { id: 'author-1', name: 'Alice', email: 'alice@example.com' }, + tags: [{ id: 'tag-1', name: 'News', color: 'red' }], + }, + }, + Author: { + 'author-1': { id: 'author-1', name: 'Alice', email: 'alice@example.com' }, + }, + Tag: { + 'tag-1': { id: 'tag-1', name: 'News', color: 'red' }, + }, + Location: {}, + }, { delay: 0 }) + + const listFilters: Array<{ entityType: string; filter: unknown }> = [] + const query = adapter.query.bind(adapter) + adapter.query = (queries, options) => { + for (const q of queries) { + if (q.type === 'list') listFilters.push({ entityType: q.entityType, filter: q.filter }) + } + return query(queries, options) + } + + render( + + + {it => ( + <> + + {author => author.name.value} + + + {tag => tag.name.value} + + + + )} + + , + ) + + return { + optionFilters: entityType => listFilters.filter(entry => entry.entityType === entityType).map(entry => entry.filter), + } +} + +async function searchInHeaderFilter(header: string, text: string): Promise { + const trigger = await waitFor(() => { + const button = Array.from(document.querySelectorAll('button')).find(candidate => candidate.textContent?.trim() === header) + if (!button) throw new Error(`no header button ${header}`) + return button + }) + fireEvent.click(trigger) + const search = await waitFor(() => { + const input = document.querySelector('input[placeholder="Search…"]') + if (!input) throw new Error('no search input') + return input + }) + fireEvent.change(search, { target: { value: text } }) +} + +describe('relation column header filter search', () => { + test('should search a has-one column by the rendered text field only', async () => { + const { optionFilters } = renderGrid() + await searchInHeaderFilter('Author', 'Al') + + await waitFor(() => { + expect(optionFilters('Author').at(-1)).toEqual({ name: { containsCI: 'Al' } }) + }) + }) + + test('should search a has-many column by the rendered text field only', async () => { + const { optionFilters } = renderGrid() + await searchInHeaderFilter('Tags', 'Ne') + + await waitFor(() => { + expect(optionFilters('Tag').at(-1)).toEqual({ name: { containsCI: 'Ne' } }) + }) + }) +})