diff --git a/packages/bindx/src/handles/EntityHandle.ts b/packages/bindx/src/handles/EntityHandle.ts index d5db13a..27f653e 100644 --- a/packages/bindx/src/handles/EntityHandle.ts +++ b/packages/bindx/src/handles/EntityHandle.ts @@ -490,8 +490,12 @@ export class EntityHandle 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') { diff --git a/packages/bindx/src/handles/HasOneHandle.ts b/packages/bindx/src/handles/HasOneHandle.ts index 2afbce6..b678590 100644 --- a/packages/bindx/src/handles/HasOneHandle.ts +++ b/packages/bindx/src/handles/HasOneHandle.ts @@ -202,6 +202,19 @@ export class HasOneHandle 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(fieldName: string, nestedSelection?: SelectionMeta): HasOneAccessor { + const raw = this.entityRaw + if (raw instanceof EntityHandle) { + return raw.hasOne(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. diff --git a/packages/bindx/src/handles/types.ts b/packages/bindx/src/handles/types.ts index 5627632..c1e4687 100644 --- a/packages/bindx/src/handles/types.ts +++ b/packages/bindx/src/handles/types.ts @@ -78,6 +78,8 @@ export type HasOneKeys = { : never }[keyof T] +type NullableHasOne = null extends T ? TAccessor | null : TAccessor + // ============================================================================ // Symbols // ============================================================================ @@ -341,7 +343,21 @@ export type EntityAccessor< > = EntityRef & { readonly $fields: EntityFieldsAccessor readonly $data: TSelected | null -} & EntityFieldsAccessor +} & EntityFieldsAccessor & EntityHasOneMethods + +/** + * Provides $hasOne(fieldName) method that always returns HasOneAccessor (never null). + * Used for mutation access (connect/disconnect) on nullable has-one relations. + */ +type EntityHasOneMethods> = { + $hasOne & keyof TEntity & string>(fieldName: K): HasOneAccessor< + NonNullable, + NonNullable, + AnyBrand, + EntityNameFromType>, + TSchema + > +} // ============================================================================ // ENTITY FIELDS MAPPING TYPES @@ -359,7 +375,7 @@ export type EntityFields = { : never : never } & { - [K in HasOneKeys]: HasOneAccessor> + [K in HasOneKeys]: NullableHasOne>> } /** @@ -372,13 +388,13 @@ type FieldRefType, K ? HasManyRef extends (infer S)[] ? S : U, AnyBrand, EntityNameFromType, TSchema> : never : never) : - K extends HasOneKeys ? HasOneRef< + K extends HasOneKeys ? NullableHasOne, ExtractNestedSelection extends object ? ExtractNestedSelection : NonNullable, AnyBrand, EntityNameFromType>, TSchema - > : + >> : never /** @@ -391,13 +407,13 @@ type FieldAccessorType extends (infer S)[] ? S : U, AnyBrand, EntityNameFromType, TSchema> : never : never) : - K extends HasOneKeys ? HasOneAccessor< + K extends HasOneKeys ? NullableHasOne, ExtractNestedSelection extends object ? ExtractNestedSelection : NonNullable, AnyBrand, EntityNameFromType>, TSchema - > : + >> : never /** diff --git a/tests/react/dataview/createRelationColumn.test.tsx b/tests/react/dataview/createRelationColumn.test.tsx index 501f452..16ac9af 100644 --- a/tests/react/dataview/createRelationColumn.test.tsx +++ b/tests/react/dataview/createRelationColumn.test.tsx @@ -98,7 +98,7 @@ describe('createRelationColumn — hasOne', () => { test('produces leaf with correct metadata', () => { const proxy = createProjectProxy() const jsx = ( - + {(org: any) => org.name.value} ) @@ -120,7 +120,7 @@ describe('createRelationColumn — hasOne', () => { test('filter is enabled by default', () => { const proxy = createProjectProxy() const jsx = ( - + {(org: any) => org.name.value} ) @@ -132,7 +132,7 @@ describe('createRelationColumn — hasOne', () => { test('filter can be disabled', () => { const proxy = createProjectProxy() const jsx = ( - + {(org: any) => org.name.value} ) @@ -144,7 +144,7 @@ describe('createRelationColumn — hasOne', () => { test('headless column has no renderFilter', () => { const proxy = createProjectProxy() const jsx = ( - + {(org: any) => org.name.value} ) @@ -159,7 +159,7 @@ describe('createRelationColumn — hasOne', () => { }) const proxy = createProjectProxy() const jsx = ( - + {(org: any) => org.name.value} ) @@ -174,7 +174,7 @@ describe('createRelationColumn — hasOne', () => { }) const proxy = createProjectProxy() const jsx = ( - + {(org: any) => org.name.value} ) @@ -190,7 +190,7 @@ describe('createRelationColumn — hasOne', () => { }) const proxy = createProjectProxy() const jsx = ( - + {(org: any) => org.name.value} ) @@ -201,7 +201,7 @@ describe('createRelationColumn — hasOne', () => { test('relatedSelection captures fields from children', () => { const proxy = createProjectProxy() const jsx = ( - + {(org: any) => org.name.value} ) diff --git a/tests/react/dataview/dataGrid.test.tsx b/tests/react/dataview/dataGrid.test.tsx index 432ae93..3daa1a1 100644 --- a/tests/react/dataview/dataGrid.test.tsx +++ b/tests/react/dataview/dataGrid.test.tsx @@ -177,7 +177,7 @@ describe('DataGrid', () => { {it => ( <> - + {(author: any) => author.name} diff --git a/tests/react/dataview/dataGridColumns.test.tsx b/tests/react/dataview/dataGridColumns.test.tsx index 50abec7..04d1aa1 100644 --- a/tests/react/dataview/dataGridColumns.test.tsx +++ b/tests/react/dataview/dataGridColumns.test.tsx @@ -501,7 +501,7 @@ describe('DataGrid layout via DataGridLayout marker', () => {
- + {(author: any) => } diff --git a/tests/react/dataview/select.test.tsx b/tests/react/dataview/select.test.tsx index d06819b..9b6f63e 100644 --- a/tests/react/dataview/select.test.tsx +++ b/tests/react/dataview/select.test.tsx @@ -137,7 +137,7 @@ describe('Select', () => { } return ( -
Select category @@ -180,7 +180,7 @@ describe('Select', () => { } return ( -
Select category @@ -221,19 +221,19 @@ describe('Select', () => { } return ( -
- {article.category.$state} - {article.category.$state === 'connected' ? article.category.$id : 'none'} + {article.category!.$state} + {article.category!.$state === 'connected' ? article.category!.$id : 'none'} @@ -285,7 +285,7 @@ describe('Select', () => { } return ( - {(it as unknown as { name: { value: string } }).name.value}}> diff --git a/tests/react/hooks/useEntity/relations.test.tsx b/tests/react/hooks/useEntity/relations.test.tsx index 40b2bd1..09227a2 100644 --- a/tests/react/hooks/useEntity/relations.test.tsx +++ b/tests/react/hooks/useEntity/relations.test.tsx @@ -36,8 +36,8 @@ describe('useEntity hook - relation field handles', () => { return (

{article.title.value}

-

{article.author.name.value}

-

{article.author.email.value}

+

{article.author!.name.value}

+

{article.author!.email.value}

) } diff --git a/tests/react/hooks/useEntity/rendering.test.tsx b/tests/react/hooks/useEntity/rendering.test.tsx index cee1466..eec999b 100644 --- a/tests/react/hooks/useEntity/rendering.test.tsx +++ b/tests/react/hooks/useEntity/rendering.test.tsx @@ -253,7 +253,7 @@ describe('useEntity hook - data rendering', () => { return (
{article.title.value} - {article.author.name.value} + {article.author!.name.value} {article.$data!.location?.label ?? 'N/A'} {article.$data!.tags?.length ?? 0}
diff --git a/tests/react/jsx/HasOne.test.tsx b/tests/react/jsx/HasOne.test.tsx index fef11cb..724b087 100644 --- a/tests/react/jsx/HasOne.test.tsx +++ b/tests/react/jsx/HasOne.test.tsx @@ -120,7 +120,7 @@ describe('HasOne component', () => { return (
- + {author => }
@@ -160,7 +160,7 @@ describe('HasOne component', () => { // Use $id and $fields.$name.value for proper access return (
- {article.author.$id ?? 'placeholder'} + {article.author!.$id ?? 'placeholder'} {article.title.value}
) @@ -202,7 +202,7 @@ describe('HasOne component', () => { return (
- + {author => }
@@ -237,7 +237,7 @@ describe('HasOne component', () => { return (
- + {author => }
@@ -279,7 +279,7 @@ describe('HasOne component', () => { return (
- + {author => (
{author.id}
)} @@ -322,7 +322,7 @@ describe('HasOne component', () => { return (
- + {location => }
@@ -363,7 +363,7 @@ describe('HasOne component', () => { return (
- + {author => }
@@ -407,7 +407,7 @@ describe('HasOne component', () => { return (
- + {author => }
@@ -453,8 +453,8 @@ describe('HasOne component', () => { return (
- {article.author.$id ?? 'null'} - + {article.author!.$id ?? 'null'} + {author => }
diff --git a/tests/react/relations/hasOne/connect.test.tsx b/tests/react/relations/hasOne/connect.test.tsx index 3aa96fa..4754703 100644 --- a/tests/react/relations/hasOne/connect.test.tsx +++ b/tests/react/relations/hasOne/connect.test.tsx @@ -5,7 +5,6 @@ import React from 'react' import { BindxProvider, MockAdapter, - isPlaceholderId, isPersistedId, useEntity, useEntityList, @@ -32,14 +31,16 @@ describe('HasOne Relations - Connect Operations', () => { return
Loading...
} + const authorHandle = article.$hasOne('author') + return (
- {article.author.$id ?? 'null'} - {article.author.$entity.$fields.name.value ?? 'N/A'} - {article.author.$isDirty ? 'dirty' : 'clean'} + {article.author?.$id ?? 'null'} + {article.author?.$entity.$fields.name.value ?? 'N/A'} + {authorHandle.$isDirty ? 'dirty' : 'clean'} @@ -87,18 +88,20 @@ describe('HasOne Relations - Connect Operations', () => { return
Loading...
} + const authorHandle = article.$hasOne('author') + return (
- {article.author.$id ?? 'null'} + {article.author?.$id ?? 'null'} @@ -128,7 +131,8 @@ describe('HasOne Relations - Connect Operations', () => { ;(getByTestId(container, 'disconnect') as HTMLButtonElement).click() }) - expect(isPlaceholderId(getByTestId(container, 'author-id').textContent!)).toBe(true) + // Nullable has-one returns null when disconnected + expect(getByTestId(container, 'author-id').textContent).toBe('null') }) test('4. Disconnect + Connect different - new entity should be connected', async () => { @@ -146,18 +150,20 @@ describe('HasOne Relations - Connect Operations', () => { return
Loading...
} + const authorHandle = article.$hasOne('author') + return (
- {article.author.$id ?? 'null'} + {article.author?.$id ?? 'null'} @@ -183,7 +189,8 @@ describe('HasOne Relations - Connect Operations', () => { ;(getByTestId(container, 'disconnect') as HTMLButtonElement).click() }) - expect(isPlaceholderId(getByTestId(container, 'author-id').textContent!)).toBe(true) + // Nullable has-one returns null when disconnected + expect(getByTestId(container, 'author-id').textContent).toBe('null') // Connect to author-2 act(() => { @@ -208,18 +215,20 @@ describe('HasOne Relations - Connect Operations', () => { return
Loading...
} + const authorHandle = article.$hasOne('author') + return (
- {article.author.$id ?? 'null'} + {article.author?.$id ?? 'null'} @@ -271,16 +280,18 @@ describe('HasOne Relations - Connect Operations', () => { return
Error
} + const authorHandle = article.$hasOne('author') + return (
- {article.author.$id ?? 'null'} - {article.author.$entity.$fields.name.value ?? 'N/A'} + {article.author?.$id ?? 'null'} + {article.author?.$entity.$fields.name.value ?? 'N/A'} - {currentAuthorId} + {currentAuthorId ?? 'null'} - {isConnected ? authorEntity.$fields.name.value : 'N/A'} + {isConnected ? author?.$entity.$fields.name.value : 'N/A'}
) @@ -400,7 +411,8 @@ describe('HasOne Relations - Connect Operations', () => { select.value = '' select.dispatchEvent(new Event('change', { bubbles: true })) }) - expect(isPlaceholderId(getByTestId(container, 'author-id').textContent!)).toBe(true) + // Nullable has-one returns null when disconnected + expect(getByTestId(container, 'author-id').textContent).toBe('null') expect(getByTestId(container, 'author-name').textContent).toBe('N/A') // Reconnect to author-1 diff --git a/tests/react/relations/hasOne/dirtyState.test.tsx b/tests/react/relations/hasOne/dirtyState.test.tsx index 28ba6a4..2c9a0e7 100644 --- a/tests/react/relations/hasOne/dirtyState.test.tsx +++ b/tests/react/relations/hasOne/dirtyState.test.tsx @@ -5,7 +5,6 @@ import React from 'react' import { BindxProvider, MockAdapter, - isPlaceholderId, useEntity, useEntityList, } from '@contember/bindx-react' @@ -32,14 +31,16 @@ describe('HasOne Relations - Dirty State Tracking', () => { return
Error
} + const authorHandle = article.$hasOne('author') + return (
- {article.author.$id ?? 'null'} - {article.author.$isDirty ? 'dirty' : 'clean'} + {article.author?.$id ?? 'null'} + {authorHandle.$isDirty ? 'dirty' : 'clean'} {article.$isDirty ? 'dirty' : 'clean'} @@ -88,14 +89,16 @@ describe('HasOne Relations - Dirty State Tracking', () => { return
Loading...
} + const authorHandle = article.$hasOne('author') + return (
- {article.author.$id ?? 'null'} - {article.author.$isDirty ? 'dirty' : 'clean'} + {article.author?.$id ?? 'null'} + {authorHandle.$isDirty ? 'dirty' : 'clean'} {article.$isDirty ? 'dirty' : 'clean'} @@ -123,8 +126,8 @@ describe('HasOne Relations - Dirty State Tracking', () => { ;(getByTestId(container, 'disconnect') as HTMLButtonElement).click() }) - // Should be dirty - expect(isPlaceholderId(getByTestId(container, 'author-id').textContent!)).toBe(true) + // Nullable has-one returns null when disconnected + expect(getByTestId(container, 'author-id').textContent).toBe('null') expect(getByTestId(container, 'relation-dirty').textContent).toBe('dirty') expect(getByTestId(container, 'entity-dirty').textContent).toBe('dirty') }) @@ -145,19 +148,21 @@ describe('HasOne Relations - Dirty State Tracking', () => { return
Error
} + const authorHandle = article.$hasOne('author') + return (
- {article.author.$id ?? 'null'} - {article.author.$isDirty ? 'dirty' : 'clean'} + {article.author?.$id ?? 'null'} + {authorHandle.$isDirty ? 'dirty' : 'clean'} @@ -207,14 +212,16 @@ describe('HasOne Relations - Dirty State Tracking', () => { return
Error
} + const authorHandle = article.$hasOne('author') + return (
- {article.author.$id ?? 'null'} - {article.author.$isDirty ? 'dirty' : 'clean'} + {article.author?.$id ?? 'null'} + {authorHandle.$isDirty ? 'dirty' : 'clean'} {article.$isDirty ? 'dirty' : 'clean'} @@ -274,19 +281,21 @@ describe('HasOne Relations - Dirty State Tracking', () => { return
Error
} + const authorHandle = article.$hasOne('author') + return (
- {article.author.$id ?? 'null'} - {article.author.$isDirty ? 'dirty' : 'clean'} + {article.author?.$id ?? 'null'} + {authorHandle.$isDirty ? 'dirty' : 'clean'} @@ -312,7 +321,8 @@ describe('HasOne Relations - Dirty State Tracking', () => { act(() => { ;(getByTestId(container, 'disconnect') as HTMLButtonElement).click() }) - expect(isPlaceholderId(getByTestId(container, 'author-id').textContent!)).toBe(true) + // Nullable has-one returns null when disconnected + expect(getByTestId(container, 'author-id').textContent).toBe('null') expect(getByTestId(container, 'relation-dirty').textContent).toBe('dirty') // Connect back to original - should be clean @@ -338,15 +348,17 @@ describe('HasOne Relations - Dirty State Tracking', () => { return
Error
} + const authorHandle = article.$hasOne('author') + return (
- {article.author.$fields.name.value} - {article.author.$fields.name.isDirty ? 'dirty' : 'clean'} - {article.author.$isDirty ? 'dirty' : 'clean'} + {article.author?.$fields.name.value} + {article.author?.$fields.name.isDirty ? 'dirty' : 'clean'} + {authorHandle.$isDirty ? 'dirty' : 'clean'} {article.$isDirty ? 'dirty' : 'clean'} @@ -399,18 +411,20 @@ describe('HasOne Relations - Dirty State Tracking', () => { return
Error
} + const authorHandle = article.$hasOne('author') + return (
{article.$isDirty ? 'dirty' : 'clean'} diff --git a/tests/react/relations/hasOne/disconnect.test.tsx b/tests/react/relations/hasOne/disconnect.test.tsx index 22ae4ed..38bba19 100644 --- a/tests/react/relations/hasOne/disconnect.test.tsx +++ b/tests/react/relations/hasOne/disconnect.test.tsx @@ -5,7 +5,6 @@ import React from 'react' import { BindxProvider, MockAdapter, - isPlaceholderId, useEntity, } from '@contember/bindx-react' import { getByTestId, queryByTestId, createMockData, entityDefs, schema } from './setup' @@ -15,7 +14,7 @@ afterEach(() => { }) describe('HasOne Relations - Disconnect Operations', () => { - test('2. Disconnect - entity should become null/placeholder, isDirty=true', async () => { + test('2. Disconnect - entity should become null, isDirty=true', async () => { const adapter = new MockAdapter(createMockData(), { delay: 0 }) function TestComponent(): React.ReactElement { @@ -30,13 +29,15 @@ describe('HasOne Relations - Disconnect Operations', () => { return
Loading...
} + const authorHandle = article.$hasOne('author') + return (
- {article.author.$id ?? 'null'} - {article.author.$isDirty ? 'dirty' : 'clean'} + {article.author?.$id ?? 'null'} + {authorHandle.$isDirty ? 'dirty' : 'clean'} @@ -63,12 +64,12 @@ describe('HasOne Relations - Disconnect Operations', () => { ;(getByTestId(container, 'disconnect') as HTMLButtonElement).click() }) - // Should now be null - expect(isPlaceholderId(getByTestId(container, 'author-id').textContent!)).toBe(true) + // Nullable has-one returns null when disconnected + expect(getByTestId(container, 'author-id').textContent).toBe('null') expect(getByTestId(container, 'is-dirty').textContent).toBe('dirty') }) - test('8. Placeholder fields - can write to placeholder fields when disconnected', async () => { + test('8. Placeholder fields - can write to placeholder fields via $hasOne when disconnected', async () => { const adapter = new MockAdapter(createMockData(), { delay: 0 }) function TestComponent(): React.ReactElement { @@ -83,15 +84,19 @@ describe('HasOne Relations - Disconnect Operations', () => { return
Loading...
} + // Nullable has-one returns null when disconnected + // Use $hasOne to access placeholder entity for writes + const authorHandle = article.$hasOne('author') + return (
- {article.author.$id ?? 'null'} + {article.author === null ? 'yes' : 'no'} - {article.author.$entity.$fields.name.value ?? 'empty'} + {authorHandle.$entity.$fields.name.value ?? 'empty'} @@ -106,14 +111,14 @@ describe('HasOne Relations - Disconnect Operations', () => { ) await waitFor(() => { - expect(queryByTestId(container, 'author-id')).not.toBeNull() + expect(queryByTestId(container, 'author-null')).not.toBeNull() }) - // No author initially - expect(isPlaceholderId(getByTestId(container, 'author-id').textContent!)).toBe(true) + // No author initially — nullable has-one returns null + expect(getByTestId(container, 'author-null').textContent).toBe('yes') expect(getByTestId(container, 'placeholder-name').textContent).toBe('empty') - // Set placeholder name + // Set placeholder name via $hasOne handle act(() => { ;(getByTestId(container, 'set-name') as HTMLButtonElement).click() }) @@ -139,11 +144,11 @@ describe('HasOne Relations - Disconnect Operations', () => { return (
- {article.author.$entity.$fields.name.value ?? 'N/A'} - {article.author.$entity.$fields.email.value ?? 'N/A'} + {article.author?.$entity.$fields.name.value ?? 'N/A'} + {article.author?.$entity.$fields.email.value ?? 'N/A'} @@ -188,15 +193,17 @@ describe('HasOne Relations - Disconnect Operations', () => { return
Loading...
} + const authorHandle = article.$hasOne('author') + return (
- {article.author.$entity.$fields.name.value ?? 'N/A'} - {article.author.$entity.$fields.email.value ?? 'N/A'} + {article.author?.$entity.$fields.name.value ?? 'N/A'} + {article.author?.$entity.$fields.email.value ?? 'N/A'} @@ -101,11 +102,13 @@ describe('HasOne Relations - Persistence', () => { return
Loading...
} + const authorHandle = article.$hasOne('author') + return (
- {article.author.$id ?? 'null'} + {article.author?.$id ?? 'null'} {article.$isDirty ? 'dirty' : 'clean'} - -
@@ -90,17 +91,19 @@ describe('HasOne Relations - Reset Operations', () => { return
Loading...
} + const authorHandle = article.$hasOne('author') + return (
- {article.author.$id ?? 'null'} - {article.author.$isDirty ? 'dirty' : 'clean'} + {article.author?.$id ?? 'null'} + {authorHandle.$isDirty ? 'dirty' : 'clean'} -
@@ -122,7 +125,8 @@ describe('HasOne Relations - Reset Operations', () => { ;(getByTestId(container, 'disconnect') as HTMLButtonElement).click() }) - expect(isPlaceholderId(getByTestId(container, 'author-id').textContent!)).toBe(true) + // Nullable has-one returns null when disconnected + expect(getByTestId(container, 'author-id').textContent).toBe('null') expect(getByTestId(container, 'is-dirty').textContent).toBe('dirty') // Reset diff --git a/tests/react/relations/hasOne/setup.ts b/tests/react/relations/hasOne/setup.ts index 9d2c280..25f2829 100644 --- a/tests/react/relations/hasOne/setup.ts +++ b/tests/react/relations/hasOne/setup.ts @@ -35,7 +35,7 @@ export const schema = defineSchema({ fields: { id: scalar(), title: scalar(), - author: hasOne('Author'), + author: hasOne('Author', { nullable: true }), }, }, Author: {