Skip to content
Merged
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
16 changes: 16 additions & 0 deletions packages/bindx-react/src/hooks/BackendAdapterContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { BatchPersister } from '@contember/bindx'
import { MutationCollector } from '@contember/bindx'
import { SchemaRegistry } from '@contember/bindx'
import { UndoManager } from '@contember/bindx'
import { NotificationStore } from '@contember/bindx'
import { QueryBatcher } from '../batching/QueryBatcher.js'

/**
Expand Down Expand Up @@ -36,6 +37,8 @@ export interface BindxContextValue {
undoManager: UndoManager | null
/** GraphQL client (available when using ContemberBindxProvider) */
graphQlClient: BindxGraphQlClient | null
/** Notification store for user-facing feedback (toasts) */
notificationStore: NotificationStore
/** Whether debug logging is enabled */
debug: boolean
}
Expand Down Expand Up @@ -133,6 +136,7 @@ export function BindxProvider({
schema: schemaRegistry,
undoManager,
graphQlClient: null,
notificationStore: new NotificationStore(),
debug,
}
}, [adapter, customStore, schemaDefinition, customMutationCollector, enableUndo, undoConfig, defaultUpdateMode, debug])
Expand Down Expand Up @@ -223,3 +227,15 @@ export function useQueryBatcher(): QueryBatcher {
}
return context.batcher
}

/**
* Hook to access the notification store.
* Must be used within a BindxProvider.
*/
export function useNotificationStore(): NotificationStore {
const context = useContext(BindxContext)
if (!context) {
throw new Error('useNotificationStore must be used within a BindxProvider')
}
return context.notificationStore
}
3 changes: 2 additions & 1 deletion packages/bindx-react/src/hooks/ContemberBindxProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { memo, useMemo, type ReactNode } from 'react'
import { GraphQlClient } from '@contember/graphql-client'
import { ContentClient } from '@contember/bindx-client'
import { ContemberAdapter, SnapshotStore, ActionDispatcher, BatchPersister, MutationCollector, ContemberSchemaMutationAdapter, UndoManager, SchemaRegistry, type SchemaDefinition, type SchemaNames, type FieldDef, type UndoManagerConfig, type UpdateMode } from '@contember/bindx'
import { ContemberAdapter, SnapshotStore, ActionDispatcher, BatchPersister, MutationCollector, ContemberSchemaMutationAdapter, UndoManager, SchemaRegistry, NotificationStore, type SchemaDefinition, type SchemaNames, type FieldDef, type UndoManagerConfig, type UpdateMode } from '@contember/bindx'
import { BindxContext, type BindxContextValue } from './BackendAdapterContext.js'
import { QueryBatcher } from '../batching/QueryBatcher.js'

Expand Down Expand Up @@ -121,6 +121,7 @@ export const ContemberBindxProvider = memo(function ContemberBindxProvider({
schema: schemaRegistry,
undoManager,
graphQlClient,
notificationStore: new NotificationStore(),
debug,
}
}, [schema, customStore, undoManagerProp, undoConfig, defaultUpdateMode, debug])
Expand Down
12 changes: 12 additions & 0 deletions packages/bindx-react/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
useBatchPersister,
useBindxContext,
useSchemaRegistry,
useNotificationStore,
type BindxProviderProps,
type BindxContextValue,
type BindxGraphQlClient,
Expand Down Expand Up @@ -65,3 +66,14 @@ export {
useEntityErrors,
type EntityErrorsState,
} from './useErrors.js'

export {
useNotifications,
useShowNotification,
useDismissNotification,
} from './useNotifications.js'

export {
usePersistWithFeedback,
type PersistWithFeedbackApi,
} from './usePersistWithFeedback.js'
40 changes: 40 additions & 0 deletions packages/bindx-react/src/hooks/useNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useCallback, useSyncExternalStore } from 'react'
import type { Notification, NotificationInput } from '@contember/bindx'
import { useNotificationStore } from './BackendAdapterContext.js'

const EMPTY: readonly Notification[] = []

/**
* Returns all active notifications, re-rendering on changes.
* Uses useSyncExternalStore for efficient subscriptions.
*/
export function useNotifications(): readonly Notification[] {
const store = useNotificationStore()
return useSyncExternalStore(
store.subscribe.bind(store),
() => store.getAll(),
() => EMPTY,
)
}

/**
* Returns a stable callback for adding a notification.
*/
export function useShowNotification(): (input: NotificationInput) => string {
const store = useNotificationStore()
return useCallback(
(input: NotificationInput) => store.add(input),
[store],
)
}

/**
* Returns a stable callback for dismissing a notification.
*/
export function useDismissNotification(): (id: string) => void {
const store = useNotificationStore()
return useCallback(
(id: string) => store.dismiss(id),
[store],
)
}
106 changes: 106 additions & 0 deletions packages/bindx-react/src/hooks/usePersistWithFeedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useCallback } from 'react'
import type { BatchPersistOptions, PersistenceResult, NotificationDetail } from '@contember/bindx'
import { usePersist, type PersistApi } from './usePersist.js'
import { useNotificationStore } from './BackendAdapterContext.js'

/**
* Extended persist API that automatically shows notifications on success/failure.
*/
export interface PersistWithFeedbackApi extends PersistApi {
/** Persist all dirty entities with automatic toast feedback */
persistAllWithFeedback(options?: BatchPersistOptions): Promise<PersistenceResult>
}

/**
* Wraps `usePersist()` with automatic notification feedback.
*
* - On success: shows a success notification
* - On failure with server errors: shows an error notification with details
* - On failure with client validation errors: shows a warning notification
*
* The raw `usePersist()` methods are still available for manual control.
*
* @example
* ```tsx
* function SaveButton() {
* const { persistAllWithFeedback, isPersisting, isDirty } = usePersistWithFeedback()
* return (
* <button onClick={() => persistAllWithFeedback()} disabled={isPersisting || !isDirty}>
* Save
* </button>
* )
* }
* ```
*/
export function usePersistWithFeedback(): PersistWithFeedbackApi {
const persistApi = usePersist()
const notificationStore = useNotificationStore()

const persistAllWithFeedback = useCallback(
async (options?: BatchPersistOptions): Promise<PersistenceResult> => {
const result = await persistApi.persistAll(options)

if (result.success) {
notificationStore.add({
type: 'success',
message: 'Changes saved successfully',
source: 'persist',
dismissAfter: 6_000,
})
} else {
const details: NotificationDetail[] = []
let hasClientErrors = false

for (const entityResult of result.results) {
if (entityResult.success) continue

if (entityResult.error) {
// Server error
details.push({
entityType: entityResult.entityType,
message: entityResult.error.message,
})

// Add field-level details from mutation result
if (entityResult.error.mutationResult) {
for (const err of entityResult.error.mutationResult.errors) {
details.push({ message: err.message })
}
for (const err of entityResult.error.mutationResult.validation.errors) {
details.push({ message: err.message.text })
}
}
} else {
// No server error means blocked by client validation
hasClientErrors = true
}
}

if (hasClientErrors && details.length === 0) {
notificationStore.add({
type: 'warning',
message: 'Please fix validation errors before saving',
source: 'persist',
dismissAfter: 15_000,
})
} else {
notificationStore.add({
type: 'error',
message: 'Failed to save changes',
details: details.length > 0 ? details : undefined,
source: 'persist',
dismissAfter: 60_000,
})
}
}

return result
},
[persistApi, notificationStore],
)

return {
...persistApi,
persistAllWithFeedback,
}
}
8 changes: 8 additions & 0 deletions packages/bindx-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ export type {
PersistApi,
EntityPersistApi,
AnyRefWithMeta,
// Notification hook types
PersistWithFeedbackApi,
} from './hooks/index.js'

// Persistence types from @contember/bindx
Expand Down Expand Up @@ -301,6 +303,12 @@ export {
useField,
useHasMany,
useHasOne,
// Notifications
useNotificationStore,
useNotifications,
useShowNotification,
useDismissNotification,
usePersistWithFeedback,
// Contember
ContemberBindxProvider,
schemaNamesToDef,
Expand Down
10 changes: 10 additions & 0 deletions packages/bindx-ui/src/dict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ export const dict = {
persist: {
save: 'Save',
},
toast: {
persistSuccess: 'Changes saved successfully',
persistError: 'Failed to save changes',
persistValidationError: 'Please fix validation errors before saving',
loadError: 'Failed to load data',
networkError: 'A network error occurred',
dismiss: 'Dismiss',
showDetails: 'Show details',
hideDetails: 'Hide details',
},
select: {
placeholder: 'Select…',
search: 'Search…',
Expand Down
66 changes: 66 additions & 0 deletions packages/bindx-ui/src/errors/error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Overlay } from '../ui/overlay.js'
import { Button } from '../ui/button.js'

export interface BindxErrorBoundaryProps {
readonly children: ReactNode
readonly fallback?: ReactNode
}

interface BindxErrorBoundaryState {
readonly error: Error | null
}

/**
* React Error Boundary that catches render errors and displays
* a full-screen fallback. Wrap your bindx-powered UI with this
* component to prevent white screens on unexpected errors.
*
* @example
* ```tsx
* <BindxErrorBoundary>
* <App />
* <ToastContainer />
* </BindxErrorBoundary>
* ```
*/
export class BindxErrorBoundary extends Component<BindxErrorBoundaryProps, BindxErrorBoundaryState> {
override state: BindxErrorBoundaryState = { error: null }

static getDerivedStateFromError(error: Error): BindxErrorBoundaryState {
return { error }
}

override componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error('[Bindx ErrorBoundary]', error, errorInfo)
}

private handleRetry = (): void => {
this.setState({ error: null })
}

override render(): ReactNode {
if (this.state.error) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<Overlay showImmediately>
<div className="max-w-md w-full mx-4 bg-background rounded-lg border shadow-lg p-6 text-center">
<div className="text-4xl mb-4" aria-hidden>⚠</div>
<h2 className="text-lg font-semibold text-foreground mb-2">
Something went wrong
</h2>
<p className="text-sm text-muted-foreground mb-4">
{this.state.error.message}
</p>
<Button variant="default" onClick={this.handleRetry}>
Try again
</Button>
</div>
</Overlay>
)
}
return this.props.children
}
}
56 changes: 56 additions & 0 deletions packages/bindx-ui/src/errors/error-overlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { ReactNode } from 'react'
import type { FieldError } from '@contember/bindx'
import { Overlay } from '../ui/overlay.js'
import { Button } from '../ui/button.js'
import { dict } from '../dict.js'

export interface ErrorOverlayProps {
readonly error: FieldError
readonly onRetry?: () => void
readonly onDismiss?: () => void
}

/**
* Full-screen error overlay for critical load errors.
* Use this when `useEntity()` or `useEntityList()` returns an error result.
*
* @example
* ```tsx
* const result = useEntity('Article', { id })
* if (result.$status === 'error') {
* return <ErrorOverlay error={result.$error} onRetry={() => window.location.reload()} />
* }
* ```
*/
export function ErrorOverlay({ error, onRetry, onDismiss }: ErrorOverlayProps): ReactNode {
return (
<Overlay>
<div className="max-w-md w-full mx-4 bg-background rounded-lg border shadow-lg p-6 text-center">
<div className="text-4xl mb-4" aria-hidden>⚠</div>
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.toast.loadError}
</h2>
<p className="text-sm text-muted-foreground mb-4">
{error.message}
</p>
{error.code && (
<p className="text-xs text-muted-foreground font-mono mb-4">
{error.code}
</p>
)}
<div className="flex gap-2 justify-center">
{onRetry && (
<Button variant="default" onClick={onRetry}>
Retry
</Button>
)}
{onDismiss && (
<Button variant="outline" onClick={onDismiss}>
{dict.toast.dismiss}
</Button>
)}
</div>
</div>
</Overlay>
)
}
2 changes: 2 additions & 0 deletions packages/bindx-ui/src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { useErrorFormatter } from './useErrorFormatter.js'
export { ErrorOverlay, type ErrorOverlayProps } from './error-overlay.js'
export { BindxErrorBoundary, type BindxErrorBoundaryProps } from './error-boundary.js'
Loading