@contember/bindx-react 0.1.5 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +1,44 @@
1
1
  import { memo, useMemo, type ReactNode } from 'react'
2
2
  import { GraphQlClient } from '@contember/graphql-client'
3
- import { ContentClient, ContentQueryBuilder, type SchemaNames } from '@contember/client-content'
3
+ import { ContentClient } from '@contember/bindx-client'
4
4
  import { ContemberAdapter, SnapshotStore, ActionDispatcher, BatchPersister, MutationCollector, ContemberSchemaMutationAdapter, UndoManager, SchemaRegistry, type SchemaDefinition, type FieldDef, type UndoManagerConfig, type UpdateMode } from '@contember/bindx'
5
5
  import { BindxContext, type BindxContextValue } from './BackendAdapterContext.js'
6
6
  import { QueryBatcher } from '../batching/QueryBatcher.js'
7
7
 
8
+ /**
9
+ * Runtime schema names format (from Contember API / generated code).
10
+ * Used by ContemberBindxProvider for backwards compatibility.
11
+ */
12
+ export interface SchemaNames {
13
+ readonly entities: {
14
+ readonly [entityName: string]: {
15
+ readonly name: string
16
+ readonly scalars: readonly string[]
17
+ readonly fields: {
18
+ readonly [fieldName: string]:
19
+ | { readonly type: 'column'; readonly enumName?: string; readonly columnType?: string }
20
+ | { readonly type: 'many' | 'one'; readonly entity: string }
21
+ }
22
+ }
23
+ }
24
+ readonly enums?: {
25
+ readonly [enumName: string]: readonly string[]
26
+ }
27
+ }
28
+
8
29
  /**
9
30
  * Converts SchemaNames (Contember format) to SchemaDefinition (bindx format)
10
31
  * so that a SchemaRegistry can be created for standalone hooks.
11
32
  */
12
33
  export function schemaNamesToDef(schemaNames: SchemaNames): SchemaDefinition<Record<string, object>> {
13
- const enumsMap = (schemaNames as { enums?: Record<string, readonly string[]> }).enums ?? {}
34
+ const enumsMap = schemaNames.enums ?? {}
14
35
  const entities: Record<string, { fields: Record<string, FieldDef> }> = {}
15
36
  for (const [entityName, entity] of Object.entries(schemaNames.entities)) {
16
37
  const fields: Record<string, FieldDef> = {}
17
38
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
18
39
  if (fieldDef.type === 'column') {
19
- const enumName = (fieldDef as { enumName?: string }).enumName
20
- const columnType = (fieldDef as { columnType?: string }).columnType
40
+ const enumName = fieldDef.enumName
41
+ const columnType = fieldDef.columnType
21
42
  const enumValues = enumName ? enumsMap[enumName] : undefined
22
43
  if (enumName && enumValues) {
23
44
  fields[fieldName] = { type: 'enum', enumName, values: enumValues }
@@ -64,35 +85,6 @@ export interface ContemberBindxProviderProps {
64
85
 
65
86
  /**
66
87
  * Provider component that combines Contember authentication with bindx data binding.
67
- *
68
- * @example
69
- * ```tsx
70
- * import { ContemberBindxProvider, useUndo } from '@contember/bindx-react'
71
- * import { schema } from './generated/schema'
72
- *
73
- * function App() {
74
- * return (
75
- * <ContemberBindxProvider
76
- * client={graphQlClient}
77
- * schema={schema}
78
- * undoManager={true}
79
- * >
80
- * <ArticleEditor id="123" />
81
- * </ContemberBindxProvider>
82
- * )
83
- * }
84
- *
85
- * // Access undo/redo in child components
86
- * function UndoControls() {
87
- * const { canUndo, canRedo, undo, redo } = useUndo()
88
- * return (
89
- * <div>
90
- * <button onClick={undo} disabled={!canUndo}>Undo</button>
91
- * <button onClick={redo} disabled={!canRedo}>Redo</button>
92
- * </div>
93
- * )
94
- * }
95
- * ```
96
88
  */
97
89
  export const ContemberBindxProvider = memo(function ContemberBindxProvider({
98
90
  schema,
@@ -108,7 +100,12 @@ export const ContemberBindxProvider = memo(function ContemberBindxProvider({
108
100
  // Create GraphQL client and adapter
109
101
  const bindxValue = useMemo((): BindxContextValue => {
110
102
 
111
- const adapter = new ContemberAdapter(new ContentClient(graphQlClient), new ContentQueryBuilder(schema))
103
+ // Create schema registry from SchemaNames for standalone hooks
104
+ const schemaRegistry = new SchemaRegistry(schemaNamesToDef(schema))
105
+
106
+ // Create adapter using new bindx-client ContentClient
107
+ const contentClient = new ContentClient(graphQlClient)
108
+ const adapter = new ContemberAdapter(contentClient, schemaRegistry)
112
109
  const batcher = new QueryBatcher(adapter)
113
110
 
114
111
  const store = customStore ?? new SnapshotStore()
@@ -124,9 +121,6 @@ export const ContemberBindxProvider = memo(function ContemberBindxProvider({
124
121
  dispatcher.addMiddleware(undoManager.createMiddleware())
125
122
  }
126
123
 
127
- // Create schema registry from SchemaNames for standalone hooks
128
- const schemaRegistry = new SchemaRegistry(schemaNamesToDef(schema))
129
-
130
124
  // Create mutation collector for proper nested operations
131
125
  // Use ContemberSchemaMutationAdapter to wrap SchemaNames
132
126
  const schemaAdapter = new ContemberSchemaMutationAdapter(schema)
@@ -146,6 +140,7 @@ export const ContemberBindxProvider = memo(function ContemberBindxProvider({
146
140
  batchPersister,
147
141
  schema: schemaRegistry,
148
142
  undoManager,
143
+ graphQlClient,
149
144
  debug,
150
145
  }
151
146
  }, [schema, customStore, undoManagerProp, undoConfig, defaultUpdateMode, debug])
@@ -8,6 +8,7 @@ export {
8
8
  useSchemaRegistry,
9
9
  type BindxProviderProps,
10
10
  type BindxContextValue,
11
+ type BindxGraphQlClient,
11
12
  } from './BackendAdapterContext.js'
12
13
 
13
14
  export {
@@ -21,20 +22,20 @@ export {
21
22
  export {
22
23
  useEntity,
23
24
  type UseEntityOptions,
24
- type LoadingEntityAccessor,
25
- type ErrorEntityAccessor,
26
- type NotFoundEntityAccessor,
27
- type ReadyEntityAccessor,
28
- type EntityAccessorResult,
25
+ type LoadingEntityResult,
26
+ type ErrorEntityResult,
27
+ type NotFoundEntityResult,
28
+ type ReadyEntityResult,
29
+ type UseEntityResult,
29
30
  } from './useEntity.js'
30
31
 
31
32
  export {
32
33
  useEntityList,
33
34
  type UseEntityListOptions,
34
- type LoadingEntityListAccessor,
35
- type ErrorEntityListAccessor,
36
- type ReadyEntityListAccessor,
37
- type EntityListAccessorResult,
35
+ type LoadingEntityListResult,
36
+ type ErrorEntityListResult,
37
+ type ReadyEntityListResult,
38
+ type UseEntityListResult,
38
39
  } from './useEntityList.js'
39
40
 
40
41
  export {
@@ -12,6 +12,7 @@ import type {
12
12
  CommonEntity,
13
13
  EntityForRoles,
14
14
  RoleNames,
15
+ EntityAccessor,
15
16
  } from '@contember/bindx'
16
17
  import {
17
18
  EntityHandle,
@@ -51,165 +52,62 @@ export interface UseEntityOptions {
51
52
  // ============================================================================
52
53
 
53
54
  /**
54
- * Loading state for entity accessor
55
+ * Common properties on every UseEntityResult state.
56
+ * All prefixed with $ to avoid collision with entity field names.
55
57
  */
56
- export interface LoadingEntityAccessor {
57
- readonly status: 'loading'
58
- readonly isLoading: true
59
- readonly isError: false
60
- readonly isNotFound: false
61
- readonly isPersisting: false
62
- readonly isDirty: false
63
- readonly id: string
64
- readonly fields: never
65
- readonly data: never
66
- persist(): Promise<void>
67
- reset(): void
58
+ interface UseEntityResultBase {
59
+ $persist(): Promise<void>
60
+ $reset(): void
68
61
  }
69
62
 
70
- /**
71
- * Error state for entity accessor
72
- */
73
- export interface ErrorEntityAccessor {
74
- readonly status: 'error'
75
- readonly isLoading: false
76
- readonly isError: true
77
- readonly isNotFound: false
78
- readonly error: FieldError
79
- readonly isPersisting: false
80
- readonly isDirty: false
63
+ export type LoadingEntityResult = UseEntityResultBase & {
64
+ readonly $status: 'loading'
65
+ readonly $isLoading: true
66
+ readonly $isError: false
67
+ readonly $isNotFound: false
68
+ readonly $error: null
81
69
  readonly id: string
82
- readonly fields: never
83
- readonly data: never
84
- persist(): Promise<void>
85
- reset(): void
86
70
  }
87
71
 
88
- /**
89
- * Not found state for entity accessor
90
- */
91
- export interface NotFoundEntityAccessor {
92
- readonly status: 'not_found'
93
- readonly isLoading: false
94
- readonly isError: false
95
- readonly isNotFound: true
96
- readonly isPersisting: false
97
- readonly isDirty: false
72
+ export type ErrorEntityResult = UseEntityResultBase & {
73
+ readonly $status: 'error'
74
+ readonly $isLoading: false
75
+ readonly $isError: true
76
+ readonly $isNotFound: false
77
+ readonly $error: FieldError
98
78
  readonly id: string
99
- readonly fields: never
100
- readonly data: never
101
- persist(): Promise<void>
102
- reset(): void
103
79
  }
104
80
 
105
- /**
106
- * Ready state base interface for entity accessor
107
- */
108
- export interface ReadyEntityAccessorBase<TEntity extends object, TSelected extends object = TEntity> {
109
- readonly status: 'ready'
110
- readonly isLoading: false
111
- readonly isError: false
112
- readonly isNotFound: false
113
- readonly isPersisting: boolean
114
- readonly isDirty: boolean
81
+ export type NotFoundEntityResult = UseEntityResultBase & {
82
+ readonly $status: 'not_found'
83
+ readonly $isLoading: false
84
+ readonly $isError: false
85
+ readonly $isNotFound: true
86
+ readonly $error: null
115
87
  readonly id: string
116
- readonly fields: SelectedEntityFields<TEntity, TSelected>
117
- readonly data: TSelected
118
- persist(): Promise<void>
119
- reset(): void
120
88
  }
121
89
 
122
90
  /**
123
- * Ready state for entity accessor with direct field access via Proxy.
91
+ * Ready state — full EntityAccessor with status metadata.
124
92
  */
125
- export type ReadyEntityAccessor<TEntity extends object, TSelected extends object = TEntity> =
126
- ReadyEntityAccessorBase<TEntity, TSelected> & SelectedEntityFields<TEntity, TSelected>
93
+ export type ReadyEntityResult<TEntity extends object, TSelected extends object = TEntity> =
94
+ UseEntityResultBase & EntityAccessor<TEntity, TSelected> & {
95
+ readonly $status: 'ready'
96
+ readonly $isLoading: false
97
+ readonly $isError: false
98
+ readonly $isNotFound: false
99
+ readonly $error: null
100
+ }
127
101
 
128
102
  /**
129
- * Union of all entity accessor states
103
+ * Union of all useEntity return states.
104
+ * Discriminated on $status.
130
105
  */
131
- export type EntityAccessorResult<TEntity extends object, TSelected extends object = TEntity> =
132
- | LoadingEntityAccessor
133
- | ErrorEntityAccessor
134
- | NotFoundEntityAccessor
135
- | ReadyEntityAccessor<TEntity, TSelected>
136
-
137
- // ============================================================================
138
- // Internal helpers
139
- // ============================================================================
140
-
141
- function createLoadingAccessor(id: string): LoadingEntityAccessor {
142
- return {
143
- status: 'loading',
144
- isLoading: true,
145
- isError: false,
146
- isNotFound: false,
147
- isPersisting: false,
148
- isDirty: false,
149
- id,
150
- get fields(): never {
151
- throw new Error('Cannot access fields while loading')
152
- },
153
- get data(): never {
154
- throw new Error('Cannot access data while loading')
155
- },
156
- async persist() {
157
- // No-op while loading
158
- },
159
- reset() {
160
- // No-op while loading
161
- },
162
- }
163
- }
164
-
165
- function createErrorAccessor(id: string, error: FieldError): ErrorEntityAccessor {
166
- return {
167
- status: 'error',
168
- isLoading: false,
169
- isError: true,
170
- isNotFound: false,
171
- error,
172
- isPersisting: false,
173
- isDirty: false,
174
- id,
175
- get fields(): never {
176
- throw new Error('Cannot access fields after error')
177
- },
178
- get data(): never {
179
- throw new Error('Cannot access data after error')
180
- },
181
- async persist() {
182
- // No-op after error
183
- },
184
- reset() {
185
- // No-op after error
186
- },
187
- }
188
- }
189
-
190
- function createNotFoundAccessor(id: string): NotFoundEntityAccessor {
191
- return {
192
- status: 'not_found',
193
- isLoading: false,
194
- isError: false,
195
- isNotFound: true,
196
- isPersisting: false,
197
- isDirty: false,
198
- id,
199
- get fields(): never {
200
- throw new Error('Cannot access fields — entity not found')
201
- },
202
- get data(): never {
203
- throw new Error('Cannot access data — entity not found')
204
- },
205
- async persist() {
206
- // No-op for not found
207
- },
208
- reset() {
209
- // No-op for not found
210
- },
211
- }
212
- }
106
+ export type UseEntityResult<TEntity extends object, TSelected extends object = TEntity> =
107
+ | LoadingEntityResult
108
+ | ErrorEntityResult
109
+ | NotFoundEntityResult
110
+ | ReadyEntityResult<TEntity, TSelected>
213
111
 
214
112
  // ============================================================================
215
113
  // Hook overloads
@@ -217,11 +115,6 @@ function createNotFoundAccessor(id: string): NotFoundEntityAccessor {
217
115
 
218
116
  /**
219
117
  * Hook to fetch and manage a single entity with role-expanded type inference.
220
- *
221
- * @example
222
- * ```tsx
223
- * const article = useEntity(schema.Article, { by: { id }, roles: ['admin'] }, e => e.title().internalNotes())
224
- * ```
225
118
  */
226
119
  export function useEntity<
227
120
  TRoleMap extends Record<string, object>,
@@ -231,16 +124,15 @@ export function useEntity<
231
124
  entity: EntityDef<TRoleMap>,
232
125
  options: UseEntityOptions & { roles: readonly TRoles[] },
233
126
  definer: SelectionInput<EntityForRoles<TRoleMap, TRoles>, TResult>,
234
- ): EntityAccessorResult<EntityForRoles<TRoleMap, TRoles>, TResult>
127
+ ): UseEntityResult<EntityForRoles<TRoleMap, TRoles>, TResult>
235
128
 
236
129
  /**
237
130
  * Hook to fetch and manage a single entity with full type inference.
238
- * Uses the common (narrowest) entity type when no roles are specified.
239
131
  *
240
132
  * @example
241
133
  * ```tsx
242
134
  * const article = useEntity(schema.Article, { by: { id } }, e => e.title().content())
243
- * if (article.status !== 'ready') return <Loading />
135
+ * if (article.$status !== 'ready') return <Loading />
244
136
  * return <input value={article.title.value} onChange={...} />
245
137
  * ```
246
138
  */
@@ -248,31 +140,26 @@ export function useEntity<TRoleMap extends Record<string, object>, TResult exten
248
140
  entity: EntityDef<TRoleMap>,
249
141
  options: UseEntityOptions,
250
142
  definer: SelectionInput<CommonEntity<TRoleMap>, TResult>,
251
- ): EntityAccessorResult<CommonEntity<TRoleMap>, TResult>
143
+ ): UseEntityResult<CommonEntity<TRoleMap>, TResult>
252
144
 
253
145
  /**
254
146
  * Hook to fetch and manage a single entity with pre-resolved selection.
255
- *
256
147
  * Used internally by Entity component that collects selection from JSX.
257
148
  */
258
149
  export function useEntity(
259
150
  entity: EntityDef,
260
151
  options: UseEntityOptions & { selection: SelectionMeta },
261
- ): EntityAccessorResult<object, object>
152
+ ): UseEntityResult<object, object>
262
153
 
263
154
  // ============================================================================
264
155
  // Implementation
265
156
  // ============================================================================
266
157
 
267
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
268
158
  export function useEntity(
269
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
270
159
  entity: EntityDef<any>,
271
160
  options: UseEntityOptions,
272
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
273
161
  definer?: SelectionInput<any, any>,
274
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
275
- ): EntityAccessorResult<any, any> {
162
+ ): UseEntityResult<any, any> {
276
163
  const schemaRegistry = useSchemaRegistry()
277
164
  const entityType = entity.$name
278
165
  const { store, dispatcher, batcher, batchPersister } = useBindxContext()
@@ -409,81 +296,85 @@ export function useEntity(
409
296
  }, [entityType, id, byKey, effectiveQueryKey, options.cache, batcher, store, dispatcher, selectionMeta])
410
297
 
411
298
  // --- EntityHandle ---
412
- // Include snapshot in deps so handle reference changes when entity data changes.
413
- // This ensures memo-wrapped children that receive individual field handles re-render.
414
- const handle = useMemo(
415
- () => EntityHandle.create(id, entityType, store, dispatcher, schemaRegistry as SchemaRegistry<Record<string, object>>),
299
+ const rawHandle = useMemo(
300
+ () => EntityHandle.createRaw(id, entityType, store, dispatcher, schemaRegistry as SchemaRegistry<Record<string, object>>),
416
301
  [id, entityType, store, dispatcher, schemaRegistry, snapshot],
417
302
  )
418
303
 
304
+ const handle = useMemo(
305
+ () => EntityHandle.wrapProxy(rawHandle),
306
+ [rawHandle],
307
+ )
308
+
419
309
  useEffect(() => {
420
310
  return () => {
421
- handle.dispose()
311
+ rawHandle.dispose()
422
312
  }
423
- }, [handle])
313
+ }, [rawHandle])
314
+
315
+ // --- Persist & reset callbacks ---
316
+ const persist = useCallback(async () => {
317
+ await batchPersister.persist(entityType, id)
318
+ }, [batchPersister, entityType, id])
424
319
 
425
- // --- Build accessor ---
426
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
427
- const accessor = useMemo((): EntityAccessorResult<any, any> => {
428
- if (!loadState || loadState.status === 'loading') {
429
- return createLoadingAccessor(id)
320
+ const reset = useCallback(() => {
321
+ rawHandle.reset()
322
+ }, [rawHandle])
323
+
324
+ // --- Build result ---
325
+ const result = useMemo((): UseEntityResult<object, object> => {
326
+ if (!loadState || loadState.status === 'loading' || (!snapshot && loadState.status === 'success')) {
327
+ return { $status: 'loading', $isLoading: true, $isError: false, $isNotFound: false, $error: null, id, $persist: persist, $reset: reset }
430
328
  }
431
329
 
432
330
  if (loadState.status === 'error') {
433
- return createErrorAccessor(id, loadState.error!)
331
+ return { $status: 'error', $isLoading: false, $isError: true, $isNotFound: false, $error: loadState.error!, id, $persist: persist, $reset: reset }
434
332
  }
435
333
 
436
334
  if (loadState.status === 'not_found') {
437
- return createNotFoundAccessor(id)
335
+ return { $status: 'not_found', $isLoading: false, $isError: false, $isNotFound: true, $error: null, id, $persist: persist, $reset: reset }
438
336
  }
439
337
 
440
- if (!snapshot) {
441
- return createLoadingAccessor(id)
442
- }
338
+ // Ready — layer status metadata on top of EntityHandle proxy
339
+ return createReadyResult(handle, persist, reset)
340
+ }, [snapshot, loadState, isPersisting, id, handle, persist, reset])
443
341
 
444
- // Ready state
445
- const realId = (snapshot.data as Record<string, unknown>)?.['id'] as string | undefined ?? id
446
-
447
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
448
- const baseAccessor: ReadyEntityAccessorBase<any, any> = {
449
- status: 'ready',
450
- isLoading: false,
451
- isError: false,
452
- isNotFound: false,
453
- isPersisting,
454
- get isDirty() {
455
- return handle.$isDirty
456
- },
457
- id: realId,
458
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
459
- fields: handle.$fields as any,
460
- data: snapshot.data,
461
- async persist() {
462
- await batchPersister.persist(entityType, realId)
463
- },
464
- reset() {
465
- handle.reset()
466
- },
467
- }
342
+ // Proxy-based result satisfies the full UseEntityResult<T> at runtime via field access delegation
343
+ return result as UseEntityResult<any, any>
344
+ }
468
345
 
469
- // Wrap in Proxy for direct field access
470
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
471
- return new Proxy(baseAccessor as any, {
472
- get(target: Record<string, unknown>, prop: string | symbol, receiver: unknown) {
473
- if (prop in target) {
474
- return Reflect.get(target, prop, receiver)
475
- }
476
- if (typeof prop === 'string') {
477
- const fields = target['fields'] as Record<string, unknown>
478
- const fieldValue = fields[prop]
479
- if (fieldValue !== undefined) {
480
- return fieldValue
481
- }
482
- }
483
- return undefined
484
- },
485
- })
486
- }, [snapshot, loadState, isPersisting, id, handle, batchPersister, entityType])
346
+ // ============================================================================
347
+ // Result factories
348
+ // ============================================================================
349
+
350
+ function createReadyResult(
351
+ handle: EntityAccessor<object, object>,
352
+ persist: () => Promise<void>,
353
+ reset: () => void,
354
+ ): UseEntityResult<object, object> {
355
+ const meta: Record<string, unknown> = {
356
+ $status: 'ready' as const,
357
+ $isLoading: false as const,
358
+ $isError: false as const,
359
+ $isNotFound: false as const,
360
+ $error: null,
361
+ $persist: persist,
362
+ $reset: reset,
363
+ }
487
364
 
488
- return accessor
365
+ // Proxy merges status metadata onto EntityAccessor — satisfies ReadyEntityResult at runtime
366
+ return new Proxy(handle as object, {
367
+ get(target, prop, receiver) {
368
+ if (typeof prop === 'string' && prop in meta) {
369
+ return meta[prop]
370
+ }
371
+ return Reflect.get(target, prop, receiver)
372
+ },
373
+ has(target, prop) {
374
+ if (typeof prop === 'string' && prop in meta) {
375
+ return true
376
+ }
377
+ return Reflect.has(target, prop)
378
+ },
379
+ }) as unknown as UseEntityResult<object, object>
489
380
  }