Skip to content

Commit 72d95b1

Browse files
authored
Merge pull request #14 from contember/feat/notification-toast-system
feat: add notification/toast system for error feedback
2 parents 50ccf65 + 613db78 commit 72d95b1

19 files changed

Lines changed: 779 additions & 6 deletions

File tree

packages/bindx-react/src/hooks/BackendAdapterContext.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { BatchPersister } from '@contember/bindx'
66
import { MutationCollector } from '@contember/bindx'
77
import { SchemaRegistry } from '@contember/bindx'
88
import { UndoManager } from '@contember/bindx'
9+
import { NotificationStore } from '@contember/bindx'
910
import { QueryBatcher } from '../batching/QueryBatcher.js'
1011

1112
/**
@@ -36,6 +37,8 @@ export interface BindxContextValue {
3637
undoManager: UndoManager | null
3738
/** GraphQL client (available when using ContemberBindxProvider) */
3839
graphQlClient: BindxGraphQlClient | null
40+
/** Notification store for user-facing feedback (toasts) */
41+
notificationStore: NotificationStore
3942
/** Whether debug logging is enabled */
4043
debug: boolean
4144
}
@@ -133,6 +136,7 @@ export function BindxProvider({
133136
schema: schemaRegistry,
134137
undoManager,
135138
graphQlClient: null,
139+
notificationStore: new NotificationStore(),
136140
debug,
137141
}
138142
}, [adapter, customStore, schemaDefinition, customMutationCollector, enableUndo, undoConfig, defaultUpdateMode, debug])
@@ -223,3 +227,15 @@ export function useQueryBatcher(): QueryBatcher {
223227
}
224228
return context.batcher
225229
}
230+
231+
/**
232+
* Hook to access the notification store.
233+
* Must be used within a BindxProvider.
234+
*/
235+
export function useNotificationStore(): NotificationStore {
236+
const context = useContext(BindxContext)
237+
if (!context) {
238+
throw new Error('useNotificationStore must be used within a BindxProvider')
239+
}
240+
return context.notificationStore
241+
}

packages/bindx-react/src/hooks/ContemberBindxProvider.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { memo, useMemo, type ReactNode } from 'react'
22
import { GraphQlClient } from '@contember/graphql-client'
33
import { ContentClient } from '@contember/bindx-client'
4-
import { ContemberAdapter, SnapshotStore, ActionDispatcher, BatchPersister, MutationCollector, ContemberSchemaMutationAdapter, UndoManager, SchemaRegistry, type SchemaDefinition, type SchemaNames, type FieldDef, type UndoManagerConfig, type UpdateMode } from '@contember/bindx'
4+
import { ContemberAdapter, SnapshotStore, ActionDispatcher, BatchPersister, MutationCollector, ContemberSchemaMutationAdapter, UndoManager, SchemaRegistry, NotificationStore, type SchemaDefinition, type SchemaNames, type FieldDef, type UndoManagerConfig, type UpdateMode } from '@contember/bindx'
55
import { BindxContext, type BindxContextValue } from './BackendAdapterContext.js'
66
import { QueryBatcher } from '../batching/QueryBatcher.js'
77

@@ -121,6 +121,7 @@ export const ContemberBindxProvider = memo(function ContemberBindxProvider({
121121
schema: schemaRegistry,
122122
undoManager,
123123
graphQlClient,
124+
notificationStore: new NotificationStore(),
124125
debug,
125126
}
126127
}, [schema, customStore, undoManagerProp, undoConfig, defaultUpdateMode, debug])

packages/bindx-react/src/hooks/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export {
66
useBatchPersister,
77
useBindxContext,
88
useSchemaRegistry,
9+
useNotificationStore,
910
type BindxProviderProps,
1011
type BindxContextValue,
1112
type BindxGraphQlClient,
@@ -65,3 +66,14 @@ export {
6566
useEntityErrors,
6667
type EntityErrorsState,
6768
} from './useErrors.js'
69+
70+
export {
71+
useNotifications,
72+
useShowNotification,
73+
useDismissNotification,
74+
} from './useNotifications.js'
75+
76+
export {
77+
usePersistWithFeedback,
78+
type PersistWithFeedbackApi,
79+
} from './usePersistWithFeedback.js'
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useCallback, useSyncExternalStore } from 'react'
2+
import type { Notification, NotificationInput } from '@contember/bindx'
3+
import { useNotificationStore } from './BackendAdapterContext.js'
4+
5+
const EMPTY: readonly Notification[] = []
6+
7+
/**
8+
* Returns all active notifications, re-rendering on changes.
9+
* Uses useSyncExternalStore for efficient subscriptions.
10+
*/
11+
export function useNotifications(): readonly Notification[] {
12+
const store = useNotificationStore()
13+
return useSyncExternalStore(
14+
store.subscribe.bind(store),
15+
() => store.getAll(),
16+
() => EMPTY,
17+
)
18+
}
19+
20+
/**
21+
* Returns a stable callback for adding a notification.
22+
*/
23+
export function useShowNotification(): (input: NotificationInput) => string {
24+
const store = useNotificationStore()
25+
return useCallback(
26+
(input: NotificationInput) => store.add(input),
27+
[store],
28+
)
29+
}
30+
31+
/**
32+
* Returns a stable callback for dismissing a notification.
33+
*/
34+
export function useDismissNotification(): (id: string) => void {
35+
const store = useNotificationStore()
36+
return useCallback(
37+
(id: string) => store.dismiss(id),
38+
[store],
39+
)
40+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { useCallback } from 'react'
2+
import type { BatchPersistOptions, PersistenceResult, NotificationDetail } from '@contember/bindx'
3+
import { usePersist, type PersistApi } from './usePersist.js'
4+
import { useNotificationStore } from './BackendAdapterContext.js'
5+
6+
/**
7+
* Extended persist API that automatically shows notifications on success/failure.
8+
*/
9+
export interface PersistWithFeedbackApi extends PersistApi {
10+
/** Persist all dirty entities with automatic toast feedback */
11+
persistAllWithFeedback(options?: BatchPersistOptions): Promise<PersistenceResult>
12+
}
13+
14+
/**
15+
* Wraps `usePersist()` with automatic notification feedback.
16+
*
17+
* - On success: shows a success notification
18+
* - On failure with server errors: shows an error notification with details
19+
* - On failure with client validation errors: shows a warning notification
20+
*
21+
* The raw `usePersist()` methods are still available for manual control.
22+
*
23+
* @example
24+
* ```tsx
25+
* function SaveButton() {
26+
* const { persistAllWithFeedback, isPersisting, isDirty } = usePersistWithFeedback()
27+
* return (
28+
* <button onClick={() => persistAllWithFeedback()} disabled={isPersisting || !isDirty}>
29+
* Save
30+
* </button>
31+
* )
32+
* }
33+
* ```
34+
*/
35+
export function usePersistWithFeedback(): PersistWithFeedbackApi {
36+
const persistApi = usePersist()
37+
const notificationStore = useNotificationStore()
38+
39+
const persistAllWithFeedback = useCallback(
40+
async (options?: BatchPersistOptions): Promise<PersistenceResult> => {
41+
const result = await persistApi.persistAll(options)
42+
43+
if (result.success) {
44+
notificationStore.add({
45+
type: 'success',
46+
message: 'Changes saved successfully',
47+
source: 'persist',
48+
dismissAfter: 6_000,
49+
})
50+
} else {
51+
const details: NotificationDetail[] = []
52+
let hasClientErrors = false
53+
54+
for (const entityResult of result.results) {
55+
if (entityResult.success) continue
56+
57+
if (entityResult.error) {
58+
// Server error
59+
details.push({
60+
entityType: entityResult.entityType,
61+
message: entityResult.error.message,
62+
})
63+
64+
// Add field-level details from mutation result
65+
if (entityResult.error.mutationResult) {
66+
for (const err of entityResult.error.mutationResult.errors) {
67+
details.push({ message: err.message })
68+
}
69+
for (const err of entityResult.error.mutationResult.validation.errors) {
70+
details.push({ message: err.message.text })
71+
}
72+
}
73+
} else {
74+
// No server error means blocked by client validation
75+
hasClientErrors = true
76+
}
77+
}
78+
79+
if (hasClientErrors && details.length === 0) {
80+
notificationStore.add({
81+
type: 'warning',
82+
message: 'Please fix validation errors before saving',
83+
source: 'persist',
84+
dismissAfter: 15_000,
85+
})
86+
} else {
87+
notificationStore.add({
88+
type: 'error',
89+
message: 'Failed to save changes',
90+
details: details.length > 0 ? details : undefined,
91+
source: 'persist',
92+
dismissAfter: 60_000,
93+
})
94+
}
95+
}
96+
97+
return result
98+
},
99+
[persistApi, notificationStore],
100+
)
101+
102+
return {
103+
...persistApi,
104+
persistAllWithFeedback,
105+
}
106+
}

packages/bindx-react/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@ export type {
207207
PersistApi,
208208
EntityPersistApi,
209209
AnyRefWithMeta,
210+
// Notification hook types
211+
PersistWithFeedbackApi,
210212
} from './hooks/index.js'
211213

212214
// Persistence types from @contember/bindx
@@ -301,6 +303,12 @@ export {
301303
useField,
302304
useHasMany,
303305
useHasOne,
306+
// Notifications
307+
useNotificationStore,
308+
useNotifications,
309+
useShowNotification,
310+
useDismissNotification,
311+
usePersistWithFeedback,
304312
// Contember
305313
ContemberBindxProvider,
306314
schemaNamesToDef,

packages/bindx-ui/src/dict.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ export const dict = {
3737
persist: {
3838
save: 'Save',
3939
},
40+
toast: {
41+
persistSuccess: 'Changes saved successfully',
42+
persistError: 'Failed to save changes',
43+
persistValidationError: 'Please fix validation errors before saving',
44+
loadError: 'Failed to load data',
45+
networkError: 'A network error occurred',
46+
dismiss: 'Dismiss',
47+
showDetails: 'Show details',
48+
hideDetails: 'Hide details',
49+
},
4050
select: {
4151
placeholder: 'Select…',
4252
search: 'Search…',
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Component, type ErrorInfo, type ReactNode } from 'react'
2+
import { Overlay } from '../ui/overlay.js'
3+
import { Button } from '../ui/button.js'
4+
5+
export interface BindxErrorBoundaryProps {
6+
readonly children: ReactNode
7+
readonly fallback?: ReactNode
8+
}
9+
10+
interface BindxErrorBoundaryState {
11+
readonly error: Error | null
12+
}
13+
14+
/**
15+
* React Error Boundary that catches render errors and displays
16+
* a full-screen fallback. Wrap your bindx-powered UI with this
17+
* component to prevent white screens on unexpected errors.
18+
*
19+
* @example
20+
* ```tsx
21+
* <BindxErrorBoundary>
22+
* <App />
23+
* <ToastContainer />
24+
* </BindxErrorBoundary>
25+
* ```
26+
*/
27+
export class BindxErrorBoundary extends Component<BindxErrorBoundaryProps, BindxErrorBoundaryState> {
28+
override state: BindxErrorBoundaryState = { error: null }
29+
30+
static getDerivedStateFromError(error: Error): BindxErrorBoundaryState {
31+
return { error }
32+
}
33+
34+
override componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
35+
console.error('[Bindx ErrorBoundary]', error, errorInfo)
36+
}
37+
38+
private handleRetry = (): void => {
39+
this.setState({ error: null })
40+
}
41+
42+
override render(): ReactNode {
43+
if (this.state.error) {
44+
if (this.props.fallback) {
45+
return this.props.fallback
46+
}
47+
return (
48+
<Overlay showImmediately>
49+
<div className="max-w-md w-full mx-4 bg-background rounded-lg border shadow-lg p-6 text-center">
50+
<div className="text-4xl mb-4" aria-hidden></div>
51+
<h2 className="text-lg font-semibold text-foreground mb-2">
52+
Something went wrong
53+
</h2>
54+
<p className="text-sm text-muted-foreground mb-4">
55+
{this.state.error.message}
56+
</p>
57+
<Button variant="default" onClick={this.handleRetry}>
58+
Try again
59+
</Button>
60+
</div>
61+
</Overlay>
62+
)
63+
}
64+
return this.props.children
65+
}
66+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { ReactNode } from 'react'
2+
import type { FieldError } from '@contember/bindx'
3+
import { Overlay } from '../ui/overlay.js'
4+
import { Button } from '../ui/button.js'
5+
import { dict } from '../dict.js'
6+
7+
export interface ErrorOverlayProps {
8+
readonly error: FieldError
9+
readonly onRetry?: () => void
10+
readonly onDismiss?: () => void
11+
}
12+
13+
/**
14+
* Full-screen error overlay for critical load errors.
15+
* Use this when `useEntity()` or `useEntityList()` returns an error result.
16+
*
17+
* @example
18+
* ```tsx
19+
* const result = useEntity('Article', { id })
20+
* if (result.$status === 'error') {
21+
* return <ErrorOverlay error={result.$error} onRetry={() => window.location.reload()} />
22+
* }
23+
* ```
24+
*/
25+
export function ErrorOverlay({ error, onRetry, onDismiss }: ErrorOverlayProps): ReactNode {
26+
return (
27+
<Overlay>
28+
<div className="max-w-md w-full mx-4 bg-background rounded-lg border shadow-lg p-6 text-center">
29+
<div className="text-4xl mb-4" aria-hidden></div>
30+
<h2 className="text-lg font-semibold text-foreground mb-2">
31+
{dict.toast.loadError}
32+
</h2>
33+
<p className="text-sm text-muted-foreground mb-4">
34+
{error.message}
35+
</p>
36+
{error.code && (
37+
<p className="text-xs text-muted-foreground font-mono mb-4">
38+
{error.code}
39+
</p>
40+
)}
41+
<div className="flex gap-2 justify-center">
42+
{onRetry && (
43+
<Button variant="default" onClick={onRetry}>
44+
Retry
45+
</Button>
46+
)}
47+
{onDismiss && (
48+
<Button variant="outline" onClick={onDismiss}>
49+
{dict.toast.dismiss}
50+
</Button>
51+
)}
52+
</div>
53+
</div>
54+
</Overlay>
55+
)
56+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export { useErrorFormatter } from './useErrorFormatter.js'
2+
export { ErrorOverlay, type ErrorOverlayProps } from './error-overlay.js'
3+
export { BindxErrorBoundary, type BindxErrorBoundaryProps } from './error-boundary.js'

0 commit comments

Comments
 (0)