diff --git a/packages/bindx-react/src/jsx/collectorProxy.ts b/packages/bindx-react/src/jsx/collectorProxy.ts index 3fcac14..c801e6c 100644 --- a/packages/bindx-react/src/jsx/collectorProxy.ts +++ b/packages/bindx-react/src/jsx/collectorProxy.ts @@ -223,6 +223,7 @@ function createCollectorFieldRef( $clearErrors: () => {}, $connect: () => {}, $disconnect: () => {}, + $isConnected: false, $reset: () => {}, $onConnect: noop, $onDisconnect: noop, diff --git a/packages/bindx/src/handles/HasOneHandle.ts b/packages/bindx/src/handles/HasOneHandle.ts index 2afbce6..f8a24d5 100644 --- a/packages/bindx/src/handles/HasOneHandle.ts +++ b/packages/bindx/src/handles/HasOneHandle.ts @@ -328,6 +328,13 @@ export class HasOneHandle return this.store.isPersisting(this.targetType, id) } + /** + * Checks if the relation is connected to a persisted entity. + */ + get isConnected(): boolean { + return this.state === 'connected' + } + /** * Checks if the relation is dirty. */ diff --git a/packages/bindx/src/handles/types.ts b/packages/bindx/src/handles/types.ts index 5627632..5a02d3c 100644 --- a/packages/bindx/src/handles/types.ts +++ b/packages/bindx/src/handles/types.ts @@ -219,6 +219,7 @@ export interface HasOneRefInterface< readonly id: string readonly $id: string readonly $isDirty: boolean + readonly $isConnected: boolean readonly $isNew: boolean readonly $isPersisting: boolean readonly $persistedId: string | null diff --git a/tests/react/dataview/dataGrid.test.tsx b/tests/react/dataview/dataGrid.test.tsx index 432ae93..a079bc4 100644 --- a/tests/react/dataview/dataGrid.test.tsx +++ b/tests/react/dataview/dataGrid.test.tsx @@ -59,7 +59,7 @@ const localSchema = defineSchema({ content: scalar(), status: scalar(), publishedAt: scalar(), - author: hasOne('Author'), + author: hasOne('Author', { nullable: true }), tags: hasMany('Tag'), }, }, diff --git a/tests/react/dataview/dataGridFiltering.test.tsx b/tests/react/dataview/dataGridFiltering.test.tsx index dd3a67b..1063b59 100644 --- a/tests/react/dataview/dataGridFiltering.test.tsx +++ b/tests/react/dataview/dataGridFiltering.test.tsx @@ -61,7 +61,7 @@ const localSchema = defineSchema({ published: scalar(), views: scalar(), publishedAt: scalar(), - author: hasOne('Author'), + author: hasOne('Author', { nullable: true }), }, }, Author: { diff --git a/tests/react/hooks/useEntityList/selection.test.tsx b/tests/react/hooks/useEntityList/selection.test.tsx index 9732a1b..53df115 100644 --- a/tests/react/hooks/useEntityList/selection.test.tsx +++ b/tests/react/hooks/useEntityList/selection.test.tsx @@ -51,7 +51,7 @@ const schema = defineSchema({ id: scalar(), title: scalar(), content: scalar(), - author: hasOne('Author'), + author: hasOne('Author', { nullable: true }), }, }, }, diff --git a/tests/react/relations/hasOne/placeholder.test.tsx b/tests/react/relations/hasOne/placeholder.test.tsx new file mode 100644 index 0000000..193c734 --- /dev/null +++ b/tests/react/relations/hasOne/placeholder.test.tsx @@ -0,0 +1,279 @@ +import '../../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, act, cleanup } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + MockAdapter, + isPlaceholderId, + useEntity, +} from '@contember/bindx-react' +import { getByTestId, queryByTestId, createMockData, entityDefs, schema } from './setup' + +afterEach(() => { + cleanup() +}) + +describe('HasOne Relations - Placeholder Entity Behavior', () => { + test('nullable has-one always returns accessor, never null, even when disconnected', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + function TestComponent(): React.ReactElement { + const article = useEntity(entityDefs.Article, { by: { id: 'article-no-author' } }, e => + e.id().title().author(a => a.id().name().email()), + ) + + if (article.$isLoading) { + return
Loading...
+ } + if (article.$isError || article.$isNotFound) { + return
Error
+ } + + return ( +
+ {article.author !== null && article.author !== undefined ? 'yes' : 'no'} + {article.author.$state} + {article.author.$isConnected ? 'yes' : 'no'} + {article.author.$id} + {article.author.name.value ?? 'empty'} + {article.author.email.value ?? 'empty'} +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'author-defined')).not.toBeNull() + }) + + // Accessor is always returned, never null + expect(getByTestId(container, 'author-defined').textContent).toBe('yes') + // State correctly reports disconnected + expect(getByTestId(container, 'author-state').textContent).toBe('disconnected') + // $isConnected is false + expect(getByTestId(container, 'author-is-connected').textContent).toBe('no') + // Placeholder ID is assigned + expect(isPlaceholderId(getByTestId(container, 'author-id').textContent!)).toBe(true) + // Field values are null on placeholder + expect(getByTestId(container, 'author-name').textContent).toBe('empty') + expect(getByTestId(container, 'author-email').textContent).toBe('empty') + }) + + test('connected has-one returns accessor with $isConnected true', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + function TestComponent(): React.ReactElement { + const article = useEntity(entityDefs.Article, { by: { id: 'article-1' } }, e => + e.id().title().author(a => a.id().name()), + ) + + if (article.$isLoading) { + return
Loading...
+ } + if (article.$isError || article.$isNotFound) { + return
Error
+ } + + return ( +
+ {article.author.$state} + {article.author.$isConnected ? 'yes' : 'no'} + {article.author.name.value} +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'author-state')).not.toBeNull() + }) + + expect(getByTestId(container, 'author-state').textContent).toBe('connected') + expect(getByTestId(container, 'author-is-connected').textContent).toBe('yes') + expect(getByTestId(container, 'author-name').textContent).toBe('John Doe') + }) + + test('disconnect transitions to placeholder, connect restores — accessor always available', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + function TestComponent(): React.ReactElement { + const article = useEntity(entityDefs.Article, { by: { id: 'article-1' } }, e => + e.id().title().author(a => a.id().name()), + ) + + if (article.$isLoading) { + return
Loading...
+ } + if (article.$isError || article.$isNotFound) { + return
Error
+ } + + return ( +
+ {article.author.$state} + {article.author.$isConnected ? 'yes' : 'no'} + {article.author.name.value ?? 'empty'} + {article.author.$id} + + +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'author-state')).not.toBeNull() + }) + + // Initially connected + expect(getByTestId(container, 'author-state').textContent).toBe('connected') + expect(getByTestId(container, 'author-is-connected').textContent).toBe('yes') + expect(getByTestId(container, 'author-name').textContent).toBe('John Doe') + + // Disconnect + act(() => { + ;(getByTestId(container, 'disconnect') as HTMLButtonElement).click() + }) + + // Accessor still available, but now placeholder + expect(getByTestId(container, 'author-state').textContent).toBe('disconnected') + expect(getByTestId(container, 'author-is-connected').textContent).toBe('no') + expect(isPlaceholderId(getByTestId(container, 'author-id').textContent!)).toBe(true) + expect(getByTestId(container, 'author-name').textContent).toBe('empty') + + // Connect to different author + act(() => { + ;(getByTestId(container, 'connect-author-2') as HTMLButtonElement).click() + }) + + // Back to connected + expect(getByTestId(container, 'author-state').textContent).toBe('connected') + expect(getByTestId(container, 'author-is-connected').textContent).toBe('yes') + expect(getByTestId(container, 'author-id').textContent).toBe('author-2') + }) + + test('placeholder entity fields can be written to', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + function TestComponent(): React.ReactElement { + const article = useEntity(entityDefs.Article, { by: { id: 'article-no-author' } }, e => + e.id().title().author(a => a.id().name()), + ) + + if (article.$isLoading) { + return
Loading...
+ } + if (article.$isError || article.$isNotFound) { + return
Error
+ } + + return ( +
+ {article.author.$state} + {article.author.name.value ?? 'empty'} + +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'author-state')).not.toBeNull() + }) + + // Initially disconnected with empty fields + expect(getByTestId(container, 'author-state').textContent).toBe('disconnected') + expect(getByTestId(container, 'author-name').textContent).toBe('empty') + + // Write to placeholder + act(() => { + ;(getByTestId(container, 'set-name') as HTMLButtonElement).click() + }) + + expect(getByTestId(container, 'author-name').textContent).toBe('New Author') + }) + + test('$remove() on nullable relation calls disconnect, not delete', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + function TestComponent(): React.ReactElement { + const article = useEntity(entityDefs.Article, { by: { id: 'article-1' } }, e => + e.id().title().author(a => a.id().name()), + ) + + if (article.$isLoading) { + return
Loading...
+ } + if (article.$isError || article.$isNotFound) { + return
Error
+ } + + return ( +
+ {article.author.$state} + +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'author-state')).not.toBeNull() + }) + + expect(getByTestId(container, 'author-state').textContent).toBe('connected') + + act(() => { + ;(getByTestId(container, 'remove') as HTMLButtonElement).click() + }) + + // $remove() on nullable FK should disconnect, not delete + expect(getByTestId(container, 'author-state').textContent).toBe('disconnected') + }) +}) 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: { diff --git a/tests/shared/schema.ts b/tests/shared/schema.ts index 48037b5..d136722 100644 --- a/tests/shared/schema.ts +++ b/tests/shared/schema.ts @@ -78,8 +78,8 @@ export const testSchema = defineSchema({ rating: scalar(), publishedAt: scalar(), createdAt: scalar(), - author: hasOne('Author'), - location: hasOne('Location'), + author: hasOne('Author', { nullable: true }), + location: hasOne('Location', { nullable: true }), tags: hasMany('Tag'), }, }, @@ -146,7 +146,7 @@ export const minimalSchema = defineSchema({ fields: { id: scalar(), title: scalar(), - author: hasOne('Author'), + author: hasOne('Author', { nullable: true }), }, }, Author: {