Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions packages/bindx-dataview/src/columnTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,25 @@ export function defineColumnType<TValue, TFilterArtifact extends FilterArtifact>
// ============================================================================

/**
* 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<object>, fieldName: string): unknown {
// EntityAccessor is a Proxy — bracket access goes through the get trap
return (accessor as unknown as Record<string, unknown>)[fieldName]
if (!fieldName.includes('.')) {
// EntityAccessor is a Proxy — bracket access goes through the get trap
return (accessor as unknown as Record<string, unknown>)[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<string, unknown>)[segments[i]!]
}
return current
}

function extractScalarValue<T>(accessor: EntityAccessor<object>, fieldName: string): T | null {
Expand Down
16 changes: 13 additions & 3 deletions packages/bindx-dataview/src/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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). */
Expand Down Expand Up @@ -303,7 +313,7 @@ export const DataGridColumn = Object.assign(
header,
renderCell: (accessor: EntityAccessor<object>) => {
if (!fieldName) return null
const ref = (accessor as unknown as Record<string, unknown>)[fieldName]
const ref = accessField(accessor, fieldName)
const value = ref && typeof ref === 'object' && 'value' in ref
? (ref as { value: unknown }).value ?? null
: null
Expand Down
26 changes: 20 additions & 6 deletions packages/bindx-react/src/jsx/collectorProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@ export function createCollectorProxy<T>(
scope: SelectionScope,
entityName: string | null = null,
schemaRegistry: SchemaRegistry<Record<string, object>> | null = null,
parentPath: readonly string[] = [],
): EntityAccessor<T> {
const fieldsProxy = new Proxy({} as EntityFields<T>, {
get(_, fieldName: string): FieldAccessor<unknown> | HasManyAccessor<unknown> | HasOneAccessor<unknown> {
// 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)
},
})

Expand Down Expand Up @@ -97,6 +98,7 @@ function createCollectorFieldRef(
fieldName: string,
entityName: string | null,
schemaRegistry: SchemaRegistry<Record<string, object>> | null,
parentPath: readonly string[] = [],
): CollectorRef {
// Look up field type from schema if available
const fieldDef = entityName && schemaRegistry
Expand Down Expand Up @@ -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,
Expand All @@ -149,16 +157,20 @@ 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)
},
})

const mapFn = <R>(fn: (item: EntityAccessor<unknown>, index: number) => R): R[] => {
// 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<unknown>(scope, targetEntityName, schemaRegistry), 0)
return []
}
Expand Down Expand Up @@ -211,9 +223,11 @@ function createCollectorFieldRef(
$isDirty: false,
$fields: hasOneFieldsProxy,
get $entity(): EntityAccessor<unknown> {
// 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<unknown>(scope, targetEntityName, schemaRegistry)
return createCollectorProxy<unknown>(scope, targetEntityName, schemaRegistry, isHasManyRelation ? [] : fullPath)
},
$delete: () => {},
$remove: () => {},
Expand Down
10 changes: 10 additions & 0 deletions packages/bindx/src/handles/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,16 @@ export interface FieldRefMeta<TEntityName extends string = string> {
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<T> {
Expand Down
198 changes: 198 additions & 0 deletions tests/react/dataview/dataGridFulltextNestedFields.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// Regression guard for fulltext query filters over HasOne nested text fields.
//
// `<DataGridQueryFilter />` 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<TestSchema>({
entities: {
Article: {
fields: {
id: scalar(),
author: hasOne('Author', { nullable: true }),
},
},
Author: {
fields: {
id: scalar(),
name: scalar(),
email: scalar(),
},
},
},
})

const localEntityDefs = {
Article: entityDef<Article>('Article'),
Author: entityDef<Author>('Author'),
} as const

function createMockData(): Record<string, Record<string, Record<string, unknown>>> {
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(
<BindxProvider adapter={adapter} schema={localSchema}>
<DataGrid entity={localEntityDefs.Article}>
{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. */}
<DataGridTextColumn field={it.author.name} header="Author name" />
<DataGridHasOneColumn field={it.author} header="Author">
{author => author.name.value}
</DataGridHasOneColumn>
<FilterProbe />
</>
)}
</DataGrid>
</BindxProvider>,
)

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<string, unknown> | 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(
<BindxProvider adapter={_adapter} schema={localSchema}>
<DataGrid entity={localEntityDefs.Article}>
{it => (
<>
<DataGridTextColumn field={it.author.name} header="Author name" />
<DataGridHasOneColumn field={it.author} header="Author">
{author => author.name.value}
</DataGridHasOneColumn>
<WhereProbe />
</>
)}
</DataGrid>
</BindxProvider>,
)

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' } },
})
})
})