Skip to content

Commit b34663d

Browse files
matej21claude
andcommitted
feat: keep placeholder entities for nullable has-one, add $isConnected
Alternative approach to #15. Instead of returning null for disconnected nullable has-one relations, keep the placeholder entity pattern from contember-oss binding. This provides a consistent API where has-one always returns an accessor, and users check connection state via $isConnected or $state. Changes: - Add $isConnected boolean getter to HasOneHandle and HasOneRefInterface - Fix schema definitions to include nullable: true where entity types have | null (enables correct $remove() behavior: disconnect vs delete) - Add placeholder behavior tests for nullable has-one relations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ff9ba7c commit b34663d

9 files changed

Lines changed: 295 additions & 7 deletions

File tree

packages/bindx-react/src/jsx/collectorProxy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ function createCollectorFieldRef(
223223
$clearErrors: () => {},
224224
$connect: () => {},
225225
$disconnect: () => {},
226+
$isConnected: false,
226227
$reset: () => {},
227228
$onConnect: noop,
228229
$onDisconnect: noop,

packages/bindx/src/handles/HasOneHandle.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,13 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
328328
return this.store.isPersisting(this.targetType, id)
329329
}
330330

331+
/**
332+
* Checks if the relation is connected to a persisted entity.
333+
*/
334+
get isConnected(): boolean {
335+
return this.state === 'connected'
336+
}
337+
331338
/**
332339
* Checks if the relation is dirty.
333340
*/

packages/bindx/src/handles/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ export interface HasOneRefInterface<
219219
readonly id: string
220220
readonly $id: string
221221
readonly $isDirty: boolean
222+
readonly $isConnected: boolean
222223
readonly $isNew: boolean
223224
readonly $isPersisting: boolean
224225
readonly $persistedId: string | null

tests/react/dataview/dataGrid.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ const localSchema = defineSchema<TestSchema>({
5959
content: scalar(),
6060
status: scalar(),
6161
publishedAt: scalar(),
62-
author: hasOne('Author'),
62+
author: hasOne('Author', { nullable: true }),
6363
tags: hasMany('Tag'),
6464
},
6565
},

tests/react/dataview/dataGridFiltering.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ const localSchema = defineSchema<TestSchema>({
6161
published: scalar(),
6262
views: scalar(),
6363
publishedAt: scalar(),
64-
author: hasOne('Author'),
64+
author: hasOne('Author', { nullable: true }),
6565
},
6666
},
6767
Author: {

tests/react/hooks/useEntityList/selection.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const schema = defineSchema<TestSchema>({
5151
id: scalar(),
5252
title: scalar(),
5353
content: scalar(),
54-
author: hasOne('Author'),
54+
author: hasOne('Author', { nullable: true }),
5555
},
5656
},
5757
},
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import '../../../setup'
2+
import { describe, test, expect, afterEach } from 'bun:test'
3+
import { render, waitFor, act, cleanup } from '@testing-library/react'
4+
import React from 'react'
5+
import {
6+
BindxProvider,
7+
MockAdapter,
8+
isPlaceholderId,
9+
useEntity,
10+
} from '@contember/bindx-react'
11+
import { getByTestId, queryByTestId, createMockData, entityDefs, schema } from './setup'
12+
13+
afterEach(() => {
14+
cleanup()
15+
})
16+
17+
describe('HasOne Relations - Placeholder Entity Behavior', () => {
18+
test('nullable has-one always returns accessor, never null, even when disconnected', async () => {
19+
const adapter = new MockAdapter(createMockData(), { delay: 0 })
20+
21+
function TestComponent(): React.ReactElement {
22+
const article = useEntity(entityDefs.Article, { by: { id: 'article-no-author' } }, e =>
23+
e.id().title().author(a => a.id().name().email()),
24+
)
25+
26+
if (article.$isLoading) {
27+
return <div>Loading...</div>
28+
}
29+
if (article.$isError || article.$isNotFound) {
30+
return <div>Error</div>
31+
}
32+
33+
return (
34+
<div>
35+
<span data-testid="author-defined">{article.author !== null && article.author !== undefined ? 'yes' : 'no'}</span>
36+
<span data-testid="author-state">{article.author.$state}</span>
37+
<span data-testid="author-is-connected">{article.author.$isConnected ? 'yes' : 'no'}</span>
38+
<span data-testid="author-id">{article.author.$id}</span>
39+
<span data-testid="author-name">{article.author.name.value ?? 'empty'}</span>
40+
<span data-testid="author-email">{article.author.email.value ?? 'empty'}</span>
41+
</div>
42+
)
43+
}
44+
45+
const { container } = render(
46+
<BindxProvider adapter={adapter} schema={schema}>
47+
<TestComponent />
48+
</BindxProvider>,
49+
)
50+
51+
await waitFor(() => {
52+
expect(queryByTestId(container, 'author-defined')).not.toBeNull()
53+
})
54+
55+
// Accessor is always returned, never null
56+
expect(getByTestId(container, 'author-defined').textContent).toBe('yes')
57+
// State correctly reports disconnected
58+
expect(getByTestId(container, 'author-state').textContent).toBe('disconnected')
59+
// $isConnected is false
60+
expect(getByTestId(container, 'author-is-connected').textContent).toBe('no')
61+
// Placeholder ID is assigned
62+
expect(isPlaceholderId(getByTestId(container, 'author-id').textContent!)).toBe(true)
63+
// Field values are null on placeholder
64+
expect(getByTestId(container, 'author-name').textContent).toBe('empty')
65+
expect(getByTestId(container, 'author-email').textContent).toBe('empty')
66+
})
67+
68+
test('connected has-one returns accessor with $isConnected true', async () => {
69+
const adapter = new MockAdapter(createMockData(), { delay: 0 })
70+
71+
function TestComponent(): React.ReactElement {
72+
const article = useEntity(entityDefs.Article, { by: { id: 'article-1' } }, e =>
73+
e.id().title().author(a => a.id().name()),
74+
)
75+
76+
if (article.$isLoading) {
77+
return <div>Loading...</div>
78+
}
79+
if (article.$isError || article.$isNotFound) {
80+
return <div>Error</div>
81+
}
82+
83+
return (
84+
<div>
85+
<span data-testid="author-state">{article.author.$state}</span>
86+
<span data-testid="author-is-connected">{article.author.$isConnected ? 'yes' : 'no'}</span>
87+
<span data-testid="author-name">{article.author.name.value}</span>
88+
</div>
89+
)
90+
}
91+
92+
const { container } = render(
93+
<BindxProvider adapter={adapter} schema={schema}>
94+
<TestComponent />
95+
</BindxProvider>,
96+
)
97+
98+
await waitFor(() => {
99+
expect(queryByTestId(container, 'author-state')).not.toBeNull()
100+
})
101+
102+
expect(getByTestId(container, 'author-state').textContent).toBe('connected')
103+
expect(getByTestId(container, 'author-is-connected').textContent).toBe('yes')
104+
expect(getByTestId(container, 'author-name').textContent).toBe('John Doe')
105+
})
106+
107+
test('disconnect transitions to placeholder, connect restores — accessor always available', async () => {
108+
const adapter = new MockAdapter(createMockData(), { delay: 0 })
109+
110+
function TestComponent(): React.ReactElement {
111+
const article = useEntity(entityDefs.Article, { by: { id: 'article-1' } }, e =>
112+
e.id().title().author(a => a.id().name()),
113+
)
114+
115+
if (article.$isLoading) {
116+
return <div>Loading...</div>
117+
}
118+
if (article.$isError || article.$isNotFound) {
119+
return <div>Error</div>
120+
}
121+
122+
return (
123+
<div>
124+
<span data-testid="author-state">{article.author.$state}</span>
125+
<span data-testid="author-is-connected">{article.author.$isConnected ? 'yes' : 'no'}</span>
126+
<span data-testid="author-name">{article.author.name.value ?? 'empty'}</span>
127+
<span data-testid="author-id">{article.author.$id}</span>
128+
<button
129+
data-testid="disconnect"
130+
onClick={() => article.author.$disconnect()}
131+
>
132+
Disconnect
133+
</button>
134+
<button
135+
data-testid="connect-author-2"
136+
onClick={() => article.author.$connect('author-2')}
137+
>
138+
Connect
139+
</button>
140+
</div>
141+
)
142+
}
143+
144+
const { container } = render(
145+
<BindxProvider adapter={adapter} schema={schema}>
146+
<TestComponent />
147+
</BindxProvider>,
148+
)
149+
150+
await waitFor(() => {
151+
expect(queryByTestId(container, 'author-state')).not.toBeNull()
152+
})
153+
154+
// Initially connected
155+
expect(getByTestId(container, 'author-state').textContent).toBe('connected')
156+
expect(getByTestId(container, 'author-is-connected').textContent).toBe('yes')
157+
expect(getByTestId(container, 'author-name').textContent).toBe('John Doe')
158+
159+
// Disconnect
160+
act(() => {
161+
;(getByTestId(container, 'disconnect') as HTMLButtonElement).click()
162+
})
163+
164+
// Accessor still available, but now placeholder
165+
expect(getByTestId(container, 'author-state').textContent).toBe('disconnected')
166+
expect(getByTestId(container, 'author-is-connected').textContent).toBe('no')
167+
expect(isPlaceholderId(getByTestId(container, 'author-id').textContent!)).toBe(true)
168+
expect(getByTestId(container, 'author-name').textContent).toBe('empty')
169+
170+
// Connect to different author
171+
act(() => {
172+
;(getByTestId(container, 'connect-author-2') as HTMLButtonElement).click()
173+
})
174+
175+
// Back to connected
176+
expect(getByTestId(container, 'author-state').textContent).toBe('connected')
177+
expect(getByTestId(container, 'author-is-connected').textContent).toBe('yes')
178+
expect(getByTestId(container, 'author-id').textContent).toBe('author-2')
179+
})
180+
181+
test('placeholder entity fields can be written to', async () => {
182+
const adapter = new MockAdapter(createMockData(), { delay: 0 })
183+
184+
function TestComponent(): React.ReactElement {
185+
const article = useEntity(entityDefs.Article, { by: { id: 'article-no-author' } }, e =>
186+
e.id().title().author(a => a.id().name()),
187+
)
188+
189+
if (article.$isLoading) {
190+
return <div>Loading...</div>
191+
}
192+
if (article.$isError || article.$isNotFound) {
193+
return <div>Error</div>
194+
}
195+
196+
return (
197+
<div>
198+
<span data-testid="author-state">{article.author.$state}</span>
199+
<span data-testid="author-name">{article.author.name.value ?? 'empty'}</span>
200+
<button
201+
data-testid="set-name"
202+
onClick={() => article.author.$entity.$fields.name.setValue('New Author')}
203+
>
204+
Set Name
205+
</button>
206+
</div>
207+
)
208+
}
209+
210+
const { container } = render(
211+
<BindxProvider adapter={adapter} schema={schema}>
212+
<TestComponent />
213+
</BindxProvider>,
214+
)
215+
216+
await waitFor(() => {
217+
expect(queryByTestId(container, 'author-state')).not.toBeNull()
218+
})
219+
220+
// Initially disconnected with empty fields
221+
expect(getByTestId(container, 'author-state').textContent).toBe('disconnected')
222+
expect(getByTestId(container, 'author-name').textContent).toBe('empty')
223+
224+
// Write to placeholder
225+
act(() => {
226+
;(getByTestId(container, 'set-name') as HTMLButtonElement).click()
227+
})
228+
229+
expect(getByTestId(container, 'author-name').textContent).toBe('New Author')
230+
})
231+
232+
test('$remove() on nullable relation calls disconnect, not delete', async () => {
233+
const adapter = new MockAdapter(createMockData(), { delay: 0 })
234+
235+
function TestComponent(): React.ReactElement {
236+
const article = useEntity(entityDefs.Article, { by: { id: 'article-1' } }, e =>
237+
e.id().title().author(a => a.id().name()),
238+
)
239+
240+
if (article.$isLoading) {
241+
return <div>Loading...</div>
242+
}
243+
if (article.$isError || article.$isNotFound) {
244+
return <div>Error</div>
245+
}
246+
247+
return (
248+
<div>
249+
<span data-testid="author-state">{article.author.$state}</span>
250+
<button
251+
data-testid="remove"
252+
onClick={() => article.author.$remove()}
253+
>
254+
Remove
255+
</button>
256+
</div>
257+
)
258+
}
259+
260+
const { container } = render(
261+
<BindxProvider adapter={adapter} schema={schema}>
262+
<TestComponent />
263+
</BindxProvider>,
264+
)
265+
266+
await waitFor(() => {
267+
expect(queryByTestId(container, 'author-state')).not.toBeNull()
268+
})
269+
270+
expect(getByTestId(container, 'author-state').textContent).toBe('connected')
271+
272+
act(() => {
273+
;(getByTestId(container, 'remove') as HTMLButtonElement).click()
274+
})
275+
276+
// $remove() on nullable FK should disconnect, not delete
277+
expect(getByTestId(container, 'author-state').textContent).toBe('disconnected')
278+
})
279+
})

tests/react/relations/hasOne/setup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const schema = defineSchema<TestSchema>({
3535
fields: {
3636
id: scalar(),
3737
title: scalar(),
38-
author: hasOne('Author'),
38+
author: hasOne('Author', { nullable: true }),
3939
},
4040
},
4141
Author: {

tests/shared/schema.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ export const testSchema = defineSchema<TestSchema>({
7878
rating: scalar(),
7979
publishedAt: scalar(),
8080
createdAt: scalar(),
81-
author: hasOne('Author'),
82-
location: hasOne('Location'),
81+
author: hasOne('Author', { nullable: true }),
82+
location: hasOne('Location', { nullable: true }),
8383
tags: hasMany('Tag'),
8484
},
8585
},
@@ -146,7 +146,7 @@ export const minimalSchema = defineSchema<MinimalSchema>({
146146
fields: {
147147
id: scalar(),
148148
title: scalar(),
149-
author: hasOne('Author'),
149+
author: hasOne('Author', { nullable: true }),
150150
},
151151
},
152152
Author: {

0 commit comments

Comments
 (0)