Skip to content
Closed
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
8 changes: 6 additions & 2 deletions packages/bindx/src/handles/EntityHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,8 +490,12 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
}

if (fieldDef.type === 'hasOne') {
// Has-one relation - return HasOneHandle
return this.hasOne(fieldName, nestedSelection)
// Has-one relation - return HasOneHandle or null for nullable disconnected
const handle = this.hasOne(fieldName, nestedSelection)
if (fieldDef.nullable && handle.$state === 'disconnected') {
return null
}
return handle
}

if (fieldDef.type === 'hasMany') {
Expand Down
13 changes: 13 additions & 0 deletions packages/bindx/src/handles/HasOneHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,19 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
return this.entity.$fields
}

/**
* Gets a has-one handle for a nested relation on the related entity.
* Delegates to the underlying EntityHandle.
* Enables entity.author.$hasOne('avatar') pattern.
*/
hasOne<TRelated extends object>(fieldName: string, nestedSelection?: SelectionMeta): HasOneAccessor<TRelated> {
const raw = this.entityRaw
if (raw instanceof EntityHandle) {
return raw.hasOne<TRelated>(fieldName, nestedSelection)
}
throw new Error(`Cannot access has-one relation "${fieldName}" on a disconnected placeholder entity`)
}

/**
* Gets the raw (unproxied) related entity handle.
* Returns raw EntityHandle or raw PlaceholderHandle.
Expand Down
28 changes: 22 additions & 6 deletions packages/bindx/src/handles/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export type HasOneKeys<T> = {
: never
}[keyof T]

type NullableHasOne<T, TAccessor> = null extends T ? TAccessor | null : TAccessor

// ============================================================================
// Symbols
// ============================================================================
Expand Down Expand Up @@ -341,7 +343,21 @@ export type EntityAccessor<
> = EntityRef<TEntity, TSelected, TBrand, TEntityName, TSchema, TRoleMap> & {
readonly $fields: EntityFieldsAccessor<TEntity, TSelected, TSchema>
readonly $data: TSelected | null
} & EntityFieldsAccessor<TEntity, TSelected, TSchema>
} & EntityFieldsAccessor<TEntity, TSelected, TSchema> & EntityHasOneMethods<TEntity, TSchema>

/**
* Provides $hasOne(fieldName) method that always returns HasOneAccessor (never null).
* Used for mutation access (connect/disconnect) on nullable has-one relations.
*/
type EntityHasOneMethods<TEntity, TSchema extends Record<string, object>> = {
$hasOne<K extends HasOneKeys<TEntity> & keyof TEntity & string>(fieldName: K): HasOneAccessor<
NonNullable<TEntity[K]>,
NonNullable<TEntity[K]>,
AnyBrand,
EntityNameFromType<TSchema, NonNullable<TEntity[K]>>,
TSchema
>
}

// ============================================================================
// ENTITY FIELDS MAPPING TYPES
Expand All @@ -359,7 +375,7 @@ export type EntityFields<T> = {
: never
: never
} & {
[K in HasOneKeys<T>]: HasOneAccessor<NonNullable<T[K]>>
[K in HasOneKeys<T>]: NullableHasOne<T[K], HasOneAccessor<NonNullable<T[K]>>>
}

/**
Expand All @@ -372,13 +388,13 @@ type FieldRefType<TEntity, TSelected, TSchema extends Record<string, object>, K
? HasManyRef<U, ExtractNestedSelection<TSelected, K> extends (infer S)[] ? S : U, AnyBrand, EntityNameFromType<TSchema, U>, TSchema>
: never
: never) :
K extends HasOneKeys<TEntity> ? HasOneRef<
K extends HasOneKeys<TEntity> ? NullableHasOne<TEntity[K], HasOneRef<
NonNullable<TEntity[K]>,
ExtractNestedSelection<TSelected, K> extends object ? ExtractNestedSelection<TSelected, K> : NonNullable<TEntity[K]>,
AnyBrand,
EntityNameFromType<TSchema, NonNullable<TEntity[K]>>,
TSchema
> :
>> :
never

/**
Expand All @@ -391,13 +407,13 @@ type FieldAccessorType<TEntity, TSelected, TSchema extends Record<string, object
? HasManyAccessor<U, ExtractNestedSelection<TSelected, K> extends (infer S)[] ? S : U, AnyBrand, EntityNameFromType<TSchema, U>, TSchema>
: never
: never) :
K extends HasOneKeys<TEntity> ? HasOneAccessor<
K extends HasOneKeys<TEntity> ? NullableHasOne<TEntity[K], HasOneAccessor<
NonNullable<TEntity[K]>,
ExtractNestedSelection<TSelected, K> extends object ? ExtractNestedSelection<TSelected, K> : NonNullable<TEntity[K]>,
AnyBrand,
EntityNameFromType<TSchema, NonNullable<TEntity[K]>>,
TSchema
> :
>> :
never

/**
Expand Down
16 changes: 8 additions & 8 deletions tests/react/dataview/createRelationColumn.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ describe('createRelationColumn — hasOne', () => {
test('produces leaf with correct metadata', () => {
const proxy = createProjectProxy()
const jsx = (
<HeadlessHasOneColumn field={proxy.organization} header="Org">
<HeadlessHasOneColumn field={proxy.organization!} header="Org">
{(org: any) => org.name.value}
</HeadlessHasOneColumn>
)
Expand All @@ -120,7 +120,7 @@ describe('createRelationColumn — hasOne', () => {
test('filter is enabled by default', () => {
const proxy = createProjectProxy()
const jsx = (
<HeadlessHasOneColumn field={proxy.organization}>
<HeadlessHasOneColumn field={proxy.organization!}>
{(org: any) => org.name.value}
</HeadlessHasOneColumn>
)
Expand All @@ -132,7 +132,7 @@ describe('createRelationColumn — hasOne', () => {
test('filter can be disabled', () => {
const proxy = createProjectProxy()
const jsx = (
<HeadlessHasOneColumn field={proxy.organization} filter={false}>
<HeadlessHasOneColumn field={proxy.organization!} filter={false}>
{(org: any) => org.name.value}
</HeadlessHasOneColumn>
)
Expand All @@ -144,7 +144,7 @@ describe('createRelationColumn — hasOne', () => {
test('headless column has no renderFilter', () => {
const proxy = createProjectProxy()
const jsx = (
<HeadlessHasOneColumn field={proxy.organization}>
<HeadlessHasOneColumn field={proxy.organization!}>
{(org: any) => org.name.value}
</HeadlessHasOneColumn>
)
Expand All @@ -159,7 +159,7 @@ describe('createRelationColumn — hasOne', () => {
})
const proxy = createProjectProxy()
const jsx = (
<StyledHasOneColumn field={proxy.organization}>
<StyledHasOneColumn field={proxy.organization!}>
{(org: any) => org.name.value}
</StyledHasOneColumn>
)
Expand All @@ -174,7 +174,7 @@ describe('createRelationColumn — hasOne', () => {
})
const proxy = createProjectProxy()
const jsx = (
<StyledHasOneColumn field={proxy.organization}>
<StyledHasOneColumn field={proxy.organization!}>
{(org: any) => org.name.value}
</StyledHasOneColumn>
)
Expand All @@ -190,7 +190,7 @@ describe('createRelationColumn — hasOne', () => {
})
const proxy = createProjectProxy()
const jsx = (
<StyledHasOneColumn field={proxy.organization} renderCellWrapper={userWrapper}>
<StyledHasOneColumn field={proxy.organization!} renderCellWrapper={userWrapper}>
{(org: any) => org.name.value}
</StyledHasOneColumn>
)
Expand All @@ -201,7 +201,7 @@ describe('createRelationColumn — hasOne', () => {
test('relatedSelection captures fields from children', () => {
const proxy = createProjectProxy()
const jsx = (
<HeadlessHasOneColumn field={proxy.organization}>
<HeadlessHasOneColumn field={proxy.organization!}>
{(org: any) => org.name.value}
</HeadlessHasOneColumn>
)
Expand Down
2 changes: 1 addition & 1 deletion tests/react/dataview/dataGrid.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ describe('DataGrid', () => {
{it => (
<>
<DataGridTextColumn field={it.title} header="Title" />
<DataGridHasOneColumn field={it.author} header="Author">
<DataGridHasOneColumn field={it.author!} header="Author">
{(author: any) => author.name}
</DataGridHasOneColumn>
<TestTable />
Expand Down
2 changes: 1 addition & 1 deletion tests/react/dataview/dataGridColumns.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ describe('DataGrid layout via DataGridLayout marker', () => {
<div>
<span data-testid="tile-title"><Field field={item.title} /></span>
<span data-testid="tile-author">
<HasOne field={item.author}>
<HasOne field={item.author!}>
{(author: any) => <Field field={author.name} />}
</HasOne>
</span>
Expand Down
16 changes: 8 additions & 8 deletions tests/react/dataview/select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ describe('Select', () => {
}

return (
<Select relation={article.category} options={entities.Category}>
<Select relation={article.category!} options={entities.Category}>
<div>
<SelectPlaceholder>
<span data-testid="placeholder">Select category</span>
Expand Down Expand Up @@ -180,7 +180,7 @@ describe('Select', () => {
}

return (
<Select relation={article.category} options={entities.Category}>
<Select relation={article.category!} options={entities.Category}>
<div>
<SelectPlaceholder>
<span data-testid="placeholder">Select category</span>
Expand Down Expand Up @@ -221,19 +221,19 @@ describe('Select', () => {
}

return (
<Select relation={article.category} options={entities.Category}>
<Select relation={article.category!} options={entities.Category}>
<div>
<span data-testid="state">{article.category.$state}</span>
<span data-testid="cat-id">{article.category.$state === 'connected' ? article.category.$id : 'none'}</span>
<span data-testid="state">{article.category!.$state}</span>
<span data-testid="cat-id">{article.category!.$state === 'connected' ? article.category!.$id : 'none'}</span>
<button
data-testid="connect-cat-2"
onClick={() => article.category.$connect('cat-2')}
onClick={() => article.category!.$connect('cat-2')}
>
Connect
</button>
<button
data-testid="disconnect"
onClick={() => article.category.$disconnect()}
onClick={() => article.category!.$disconnect()}
>
Disconnect
</button>
Expand Down Expand Up @@ -285,7 +285,7 @@ describe('Select', () => {
}

return (
<Select relation={article.category} options={entities.Category}>
<Select relation={article.category!} options={entities.Category}>
<SelectDataView selection={it => <span>{(it as unknown as { name: { value: string } }).name.value}</span>}>
<DataViewLoaderState loaded>
<DataViewEachRow>
Expand Down
4 changes: 2 additions & 2 deletions tests/react/hooks/useEntity/relations.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ describe('useEntity hook - relation field handles', () => {
return (
<div>
<h1 data-testid="title">{article.title.value}</h1>
<p data-testid="author-name-field">{article.author.name.value}</p>
<p data-testid="author-email-field">{article.author.email.value}</p>
<p data-testid="author-name-field">{article.author!.name.value}</p>
<p data-testid="author-email-field">{article.author!.email.value}</p>
</div>
)
}
Expand Down
2 changes: 1 addition & 1 deletion tests/react/hooks/useEntity/rendering.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ describe('useEntity hook - data rendering', () => {
return (
<div data-testid="editor">
<span data-testid="editor-title">{article.title.value}</span>
<span data-testid="editor-author">{article.author.name.value}</span>
<span data-testid="editor-author">{article.author!.name.value}</span>
<span data-testid="editor-location">{article.$data!.location?.label ?? 'N/A'}</span>
<span data-testid="editor-tags">{article.$data!.tags?.length ?? 0}</span>
</div>
Expand Down
20 changes: 10 additions & 10 deletions tests/react/jsx/HasOne.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ describe('HasOne component', () => {

return (
<div>
<HasOne field={article.author}>
<HasOne field={article.author!}>
{author => <AuthorNameEmail author={author} />}
</HasOne>
</div>
Expand Down Expand Up @@ -160,7 +160,7 @@ describe('HasOne component', () => {
// Use $id and $fields.$name.value for proper access
return (
<div>
<span data-testid="author-id">{article.author.$id ?? 'placeholder'}</span>
<span data-testid="author-id">{article.author!.$id ?? 'placeholder'}</span>
<span data-testid="title">{article.title.value}</span>
</div>
)
Expand Down Expand Up @@ -202,7 +202,7 @@ describe('HasOne component', () => {

return (
<div>
<HasOne field={article.author}>
<HasOne field={article.author!}>
{author => <AuthorInfo author={author} />}
</HasOne>
</div>
Expand Down Expand Up @@ -237,7 +237,7 @@ describe('HasOne component', () => {

return (
<div>
<HasOne field={article.author}>
<HasOne field={article.author!}>
{author => <AuthorNameWithUpdate author={author} testIdName="author-name" testIdBtn="update-btn" newName="Jane Doe" />}
</HasOne>
</div>
Expand Down Expand Up @@ -279,7 +279,7 @@ describe('HasOne component', () => {

return (
<div>
<HasOne field={article.author}>
<HasOne field={article.author!}>
{author => (
<div data-testid="author-id">{author.id}</div>
)}
Expand Down Expand Up @@ -322,7 +322,7 @@ describe('HasOne component', () => {

return (
<div>
<HasOne field={article.location}>
<HasOne field={article.location!}>
{location => <LocationInfo location={location} />}
</HasOne>
</div>
Expand Down Expand Up @@ -363,7 +363,7 @@ describe('HasOne component', () => {

return (
<div>
<HasOne field={article.author}>
<HasOne field={article.author!}>
{author => <AuthorCard author={author} />}
</HasOne>
</div>
Expand Down Expand Up @@ -407,7 +407,7 @@ describe('HasOne component', () => {

return (
<div>
<HasOne field={article.author}>
<HasOne field={article.author!}>
{author => <AuthorNameWithUpdate author={author} testIdName="author-name" testIdBtn="update-btn" newName="Updated Name" />}
</HasOne>
</div>
Expand Down Expand Up @@ -453,8 +453,8 @@ describe('HasOne component', () => {

return (
<div>
<span data-testid="author-id">{article.author.$id ?? 'null'}</span>
<HasOne field={article.author}>
<span data-testid="author-id">{article.author!.$id ?? 'null'}</span>
<HasOne field={article.author!}>
{author => <AuthorName author={author} testId="author-name" />}
</HasOne>
</div>
Expand Down
Loading