diff --git a/packages/bindx-dataview/src/columnTypes.ts b/packages/bindx-dataview/src/columnTypes.ts index dd432bc..2ce1c0f 100644 --- a/packages/bindx-dataview/src/columnTypes.ts +++ b/packages/bindx-dataview/src/columnTypes.ts @@ -52,12 +52,25 @@ export function defineColumnType // ============================================================================ /** - * Access a field on an EntityAccessor by name. + * Access a field on an EntityAccessor by name or dotted path. * EntityAccessor is a Proxy — bracket notation triggers the proxy get trap. + * Dotted paths (e.g. `"author.name"`) are traversed through has-one relations: + * each intermediate segment resolves to a related EntityAccessor, and the final + * segment to the leaf field ref. */ export function accessField(accessor: EntityAccessor, fieldName: string): unknown { - // EntityAccessor is a Proxy — bracket access goes through the get trap - return (accessor as unknown as Record)[fieldName] + if (!fieldName.includes('.')) { + // EntityAccessor is a Proxy — bracket access goes through the get trap + return (accessor as unknown as Record)[fieldName] + } + + const segments = fieldName.split('.') + let current: unknown = accessor + for (let i = 0; i < segments.length; i++) { + if (current == null || typeof current !== 'object') return null + current = (current as Record)[segments[i]!] + } + return current } function extractScalarValue(accessor: EntityAccessor, fieldName: string): T | null { diff --git a/packages/bindx-dataview/src/columns.tsx b/packages/bindx-dataview/src/columns.tsx index 2ef4c46..ac0d4c5 100644 --- a/packages/bindx-dataview/src/columns.tsx +++ b/packages/bindx-dataview/src/columns.tsx @@ -45,6 +45,7 @@ interface FieldRefMetaCarrier { readonly [FIELD_REF_META]: { readonly entityType: string readonly fieldName: string + readonly fullPath?: readonly string[] readonly isArray: boolean readonly isRelation: boolean readonly enumName?: string @@ -56,9 +57,18 @@ export function hasFieldRefMeta(ref: unknown): ref is FieldRefMetaCarrier { return ref != null && typeof ref === 'object' && FIELD_REF_META in ref } -/** Extract field name from a field ref (works in both collector and runtime proxies). */ +/** + * Extract the dotted field path from a field ref (works in both collector and + * runtime proxies). For fields reached through has-one relations + * (e.g. `it.author.name`) this is the full dotted path (`"author.name"`) so the + * DataGrid can build correct nested where/orderBy clauses; for top-level fields + * it is simply the field name (`"title"`). + */ export function extractFieldName(ref: unknown): string | null { - return hasFieldRefMeta(ref) ? ref[FIELD_REF_META].fieldName : null + if (!hasFieldRefMeta(ref)) return null + const meta = ref[FIELD_REF_META] + const fullPath = meta.fullPath + return fullPath && fullPath.length > 0 ? fullPath.join('.') : meta.fieldName } /** Extract enum name from a field ref (if field is an enum). */ @@ -303,7 +313,7 @@ export const DataGridColumn = Object.assign( header, renderCell: (accessor: EntityAccessor) => { if (!fieldName) return null - const ref = (accessor as unknown as Record)[fieldName] + const ref = accessField(accessor, fieldName) const value = ref && typeof ref === 'object' && 'value' in ref ? (ref as { value: unknown }).value ?? null : null diff --git a/packages/bindx-react/src/jsx/collectorProxy.ts b/packages/bindx-react/src/jsx/collectorProxy.ts index 786febe..153ed11 100644 --- a/packages/bindx-react/src/jsx/collectorProxy.ts +++ b/packages/bindx-react/src/jsx/collectorProxy.ts @@ -33,13 +33,14 @@ export function createCollectorProxy( scope: SelectionScope, entityName: string | null = null, schemaRegistry: SchemaRegistry> | null = null, + parentPath: readonly string[] = [], ): EntityAccessor { const fieldsProxy = new Proxy({} as EntityFields, { get(_, fieldName: string): FieldAccessor | HasManyAccessor | HasOneAccessor { // Return a collector ref that works for all field types // The actual type (scalar/hasMany/hasOne) will be determined // by how it's used in components or by schema lookup - return createCollectorFieldRef(scope, fieldName, entityName, schemaRegistry) + return createCollectorFieldRef(scope, fieldName, entityName, schemaRegistry, parentPath) }, }) @@ -97,6 +98,7 @@ function createCollectorFieldRef( fieldName: string, entityName: string | null, schemaRegistry: SchemaRegistry> | null, + parentPath: readonly string[] = [], ): CollectorRef { // Look up field type from schema if available const fieldDef = entityName && schemaRegistry @@ -132,11 +134,17 @@ function createCollectorFieldRef( return childScope } + // Absolute path from the root collector to this field. `fieldName` / `path` + // stay relative (last segment only) so selection/relation metadata is + // unaffected; `fullPath` carries the dotted chain for DataGrid columns. + const fullPath = [...parentPath, fieldName] + const meta = { entityType: targetEntityName ?? '', // Collection phase - entity type from schema entityId: '', // Collection phase - no entity path: [fieldName], fieldName, + fullPath, isArray: isHasManyRelation, isRelation: isHasOneRelation || isHasManyRelation, enumName, @@ -149,8 +157,10 @@ function createCollectorFieldRef( get(_, nestedFieldName: string) { // Get child scope (upgrades to relation) const scope = getChildScope() - // Create nested field ref in the child scope, passing target entity info - return createCollectorFieldRef(scope, nestedFieldName, targetEntityName, schemaRegistry) + // Create nested field ref in the child scope, passing target entity info. + // Thread the current field's fullPath so e.g. `it.author.name` carries + // the dotted absolute path `['author', 'name']`. + return createCollectorFieldRef(scope, nestedFieldName, targetEntityName, schemaRegistry, fullPath) }, }) @@ -158,7 +168,9 @@ function createCollectorFieldRef( // Get child scope and mark as array relation const scope = getChildScope() parentScope.markAsArray(fieldName) - // Call fn once with collector to gather nested selection, passing target entity info + // Call fn once with collector to gather nested selection, passing target entity info. + // Items of a has-many start a fresh path: dotted where/sort paths only + // make sense across has-one chains, not across collection items. fn(createCollectorProxy(scope, targetEntityName, schemaRegistry), 0) return [] } @@ -211,9 +223,11 @@ function createCollectorFieldRef( $isDirty: false, $fields: hasOneFieldsProxy, get $entity(): EntityAccessor { - // Get child scope (upgrades to relation) and return proxy with scope + // Get child scope (upgrades to relation) and return proxy with scope. + // Thread fullPath for has-one so nested fields keep their dotted chain; + // has-many items start fresh (handled via getById/map). const scope = getChildScope() - return createCollectorProxy(scope, targetEntityName, schemaRegistry) + return createCollectorProxy(scope, targetEntityName, schemaRegistry, isHasManyRelation ? [] : fullPath) }, $delete: () => {}, $remove: () => {}, diff --git a/packages/bindx/src/handles/types.ts b/packages/bindx/src/handles/types.ts index 58ff3d2..819d760 100644 --- a/packages/bindx/src/handles/types.ts +++ b/packages/bindx/src/handles/types.ts @@ -98,6 +98,16 @@ export interface FieldRefMeta { readonly enumName?: string readonly columnType?: string readonly targetType?: string + /** + * Absolute dot-path of segments from the root entity to this field. + * For a top-level field this is `[fieldName]`; for a field reached through + * relations (e.g. `it.author.name`) it is the full chain + * (`['author', 'name']`). `fieldName` / `path` remain the *last* segment + * (relative to the field's own scope) so selection/relation metadata is + * unaffected — consumers that need the full dotted path (DataGrid column + * filter/sort/value extraction) read `fullPath`. + */ + readonly fullPath?: readonly string[] } export interface InputProps { diff --git a/tests/react/dataview/dataGridFulltextNestedFields.test.tsx b/tests/react/dataview/dataGridFulltextNestedFields.test.tsx new file mode 100644 index 0000000..5178a35 --- /dev/null +++ b/tests/react/dataview/dataGridFulltextNestedFields.test.tsx @@ -0,0 +1,198 @@ +// Regression guard for fulltext query filters over HasOne nested text fields. +// +// `` only renders the toolbar search input when at +// least one column registers `isTextSearchable: true` AND a `fieldName`. +// `DataGridTextColumn` is the only built-in that sets `isTextSearchable`, and +// it extracts the field path from the FieldRef's `FIELD_REF_META`. +// +// Previously that path was just the last accessed segment (`"name"`), not the +// dotted chain (`"author.name"`): a text column bound to a HasOne field like +// `it.author.name` registered the wrong path (`Article.name`), so the auto-built +// `createFullTextFilterHandler(textFieldPaths)` produced a where clause against +// a non-existent field. Now the collector proxy threads the absolute `fullPath`, +// so the registered path is the full dotted chain and the where clause nests +// correctly through the relation. +// +// `createFullTextFilterHandler` already supports dotted paths internally (it +// calls `buildNestedWhere` for each path); this test guards the composition +// across the bindx-dataview column surface. +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { cleanup, render, waitFor } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + defineSchema, + hasOne, + MockAdapter, + scalar, +} from '@contember/bindx-react' +import { entityDef } from '@contember/bindx' +import { + DataGrid, + DataGridHasOneColumn, + DataGridTextColumn, + QUERY_FILTER_NAME, + useDataViewContext, +} from '@contember/bindx-dataview' + +afterEach(() => { + cleanup() +}) + +// ============================================================================ +// Schema — Article has only HasOne author, no direct text scalars +// ============================================================================ + +interface Author { + id: string + name: string + email: string +} + +interface Article { + id: string + author: Author | null +} + +interface TestSchema { + Article: Article + Author: Author +} + +const localSchema = defineSchema({ + entities: { + Article: { + fields: { + id: scalar(), + author: hasOne('Author', { nullable: true }), + }, + }, + Author: { + fields: { + id: scalar(), + name: scalar(), + email: scalar(), + }, + }, + }, +}) + +const localEntityDefs = { + Article: entityDef
('Article'), + Author: entityDef('Author'), +} as const + +function createMockData(): Record>> { + return { + Article: { + a1: { id: 'a1', author: { id: 'auth-1', name: 'John', email: 'john@example.com' } }, + a2: { id: 'a2', author: { id: 'auth-2', name: 'Jane', email: 'jane@example.com' } }, + }, + Author: { + 'auth-1': { id: 'auth-1', name: 'John', email: 'john@example.com' }, + 'auth-2': { id: 'auth-2', name: 'Jane', email: 'jane@example.com' }, + }, + } +} + +interface FilterState { + registered: readonly string[] + hasQueryFilter: boolean +} + +// ============================================================================ +// Regression guard +// ============================================================================ + +describe('DataGrid fulltext across HasOne nested fields', () => { + test('registers the full nested HasOne text path for the toolbar query filter', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + let captured: FilterState | null = null + + function FilterProbe(): React.ReactElement | null { + const { filtering } = useDataViewContext() + captured = { + registered: Array.from(filtering.filters.keys()), + hasQueryFilter: filtering.filters.has(QUERY_FILTER_NAME), + } + return null + } + + // Render a grid where the only displayable content is the HasOne + // author relation. No direct text scalars on Article. + render( + + + {it => ( + <> + {/* Pass `it.author.name` (a field reached through the HasOne + * relation) to a text column. The collector proxy threads the + * absolute `fullPath`, so the registered text-searchable path is + * the dotted chain `"author.name"` and the query filter handler + * builds a where clause nested under the relation. */} + + + {author => author.name.value} + + + + )} + + , + ) + + await waitFor(() => { + expect(captured).not.toBeNull() + }) + + // The toolbar query filter is registered (a text column was found) and + // targets the correct nested path `"author.name"` (asserted via the + // resulting where clause below). + const filters = Array.from(captured!.registered) + expect(filters).toContain(QUERY_FILTER_NAME) + + // The full-text handler exposes its target paths via `toWhere` — + // resolve them by activating the filter and inspecting the where clause. + // Read the actual handler from the DataView context: + const _adapter = adapter // keep reference alive for re-render + let capturedWhere: Record | undefined + function WhereProbe(): React.ReactElement | null { + const { filtering } = useDataViewContext() + const handler = filtering.filters.get(QUERY_FILTER_NAME)?.handler + if (handler) { + capturedWhere = handler.toWhere({ mode: 'contains', query: 'John' } as never) + } + return null + } + + cleanup() // re-render with the WhereProbe in place + render( + + + {it => ( + <> + + + {author => author.name.value} + + + + )} + + , + ) + + await waitFor(() => { + expect(capturedWhere).toBeDefined() + }) + + // Expect { author: { name: { containsCI: 'John' } } } — full-text search + // nested through `author.name`. The collector proxy preserves the parent + // context on the FieldRef via `fullPath`, so the where clause nests under + // the relation instead of targeting a non-existent `Article.name` field. + expect(capturedWhere).toEqual({ + author: { name: { containsCI: 'John' } }, + }) + }) +})