1+ import { type PropsWithChildren , useCallback , useEffect , forwardRef } from "react" ;
12import {
2- createContext ,
3- forwardRef ,
4- type PropsWithChildren ,
5- useCallback ,
6- useContext ,
7- useEffect ,
8- useMemo ,
9- useState ,
10- } from "react" ;
3+ ApiProvider as CoreApiProvider ,
4+ createApiInstance ,
5+ type ApiContextType ,
6+ type FormattedError ,
7+ } from "@humansignal/core" ;
8+ import type { ApiResponse } from "@humansignal/core/lib/api-proxy/types" ;
119import { ErrorWrapper } from "../components/Error/Error" ;
1210import { modal } from "../components/Modal/Modal" ;
1311import { API_CONFIG } from "../config/ApiConfig" ;
14- import { type ApiParams , APIProxy } from "@humansignal/core/lib/api-proxy" ;
1512import { absoluteURL , isDefined } from "../utils/helpers" ;
1613import { FF_IMPROVE_GLOBAL_ERROR_MESSAGES , isFF } from "../utils/feature-flags" ;
17- import type { ApiResponse , WrappedResponse } from "@humansignal/core/lib/api-proxy/types" ;
1814import { ToastType , useToast } from "@humansignal/ui" ;
1915import { captureException } from "../config/Sentry" ;
2016
2117export const IMPROVE_GLOBAL_ERROR_MESSAGES = isFF ( FF_IMPROVE_GLOBAL_ERROR_MESSAGES ) ;
2218// Duration for toast errors
2319export const API_ERROR_TOAST_DURATION = 10000 ;
2420
25- export const API = new APIProxy ( {
21+ // Initialize API instance with Label Studio configuration
22+ const apiInstance = createApiInstance ( {
2623 ...API_CONFIG ,
2724 onRequestFinished ( res ) {
2825 if ( res . status === 401 ) {
@@ -31,65 +28,20 @@ export const API = new APIProxy({
3128 } ,
3229} ) ;
3330
34- export type ApiEndpoints = keyof typeof API . methods ;
35-
36- let apiLocked = false ;
37-
38- export type ApiCallOptions = {
39- params ?: any ;
40- suppressError ?: boolean ;
41- errorFilter ?: ( response : ApiResponse ) => boolean ;
42- } & ApiParams ;
43-
44- export type ErrorDisplayMessage = (
45- errorDetails : FormattedError ,
46- result : ApiResponse ,
47- showGlobalError ?: boolean ,
48- ) => void ;
49-
50- export type ApiContextType = {
51- api : typeof API ;
52- callApi : < T > ( method : keyof ( typeof API ) [ "methods" ] , options ?: ApiCallOptions ) => Promise < WrappedResponse < T > | null > ;
53- handleError : (
54- response : Response | ApiResponse ,
55- displayErrorMessage ?: ErrorDisplayMessage ,
56- showGlobalError ?: boolean ,
57- ) => Promise < boolean > ;
58- resetError : ( ) => void ;
59- error : ApiResponse | null ;
60- showGlobalError : boolean ;
61- errorFormatter : ( result : ApiResponse ) => FormattedError ;
62- isValidMethod : ( name : string ) => boolean ;
63- } ;
64-
65- export type FormattedError = {
66- title : string ;
67- message : string ;
68- stacktrace : string ;
69- version : string ;
70- validation : [ string , string [ ] ] [ ] ;
71- isShutdown : boolean ;
72- } ;
31+ // Export API instance for backward compatibility
32+ export const API = apiInstance ;
7333
74- export const ApiContext = createContext < ApiContextType | null > ( null ) ;
75- ApiContext . displayName = "ApiContext ";
34+ // Re- export useAPI and ApiContext from core for convenience
35+ export { useAPI , ApiContext } from "@humansignal/core ";
7636
77- export const errorFormatter = ( result : ApiResponse ) : FormattedError => {
78- const response = "response" in result ? result . response : null ;
79- // we should not block app because of some network issue
80- const isShutdown = false ;
37+ export type ApiEndpoints = keyof typeof API . methods ;
8138
82- return {
83- isShutdown,
84- title : result . error ? "Runtime error" : "Server error" ,
85- message : response ?. detail ?? result ?. error ,
86- stacktrace : response ?. exc_info ?? null ,
87- version : response ?. version ,
88- validation : Object . entries < string [ ] > ( response ?. validation_errors ?? { } ) ,
89- } ;
90- } ;
39+ let apiLocked = false ;
9140
92- const displayErrorModal : ErrorDisplayMessage = ( errorDetails ) => {
41+ /**
42+ * Displays an error modal with the error details.
43+ */
44+ const displayErrorModal = ( errorDetails : FormattedError ) => {
9345 const { isShutdown, title, message, stacktrace, ...formattedError } = errorDetails ;
9446
9547 modal ( {
@@ -114,173 +66,82 @@ const displayErrorModal: ErrorDisplayMessage = (errorDetails) => {
11466 } ) ;
11567} ;
11668
117- const handleError = async (
118- response : Response | ApiResponse ,
119- displayErrorMessage ?: ErrorDisplayMessage ,
120- showGlobalError = true ,
121- ) => {
122- let result : ApiResponse = response as ApiResponse ;
123-
124- if ( response instanceof Response ) {
125- result = await API . generateError ( response ) ;
126- }
127-
128- const errorDetails = errorFormatter ( result ) ;
129-
130- // Allow inline error handling
131- console . log ( showGlobalError ) ;
132- if ( ! showGlobalError ) {
133- return errorDetails . isShutdown ;
134- }
135-
136- if ( displayErrorMessage ) {
137- displayErrorMessage ( errorDetails , result ) ;
138- } else {
139- displayErrorModal ( errorDetails , result ) ;
140- }
141-
142- return errorDetails . isShutdown ;
143- } ;
144-
145- const handleGlobalErrorMessage = ( result ?: ApiResponse , errorFilter ?: ( result : ApiResponse ) => boolean ) => {
146- return result ?. error && ( ! isDefined ( errorFilter ) || errorFilter ( result ) === false ) ;
147- } ;
148-
149- export const ApiProvider = forwardRef < ApiContextType , PropsWithChildren < any > > ( ( { children } , ref ) => {
150- const [ error , setError ] = useState < ApiResponse | null > ( null ) ;
69+ /**
70+ * Label Studio application-specific ApiProvider.
71+ * Wraps the core ApiProvider with Label Studio-specific error handling.
72+ */
73+ export const ApiProvider = forwardRef < ApiContextType , PropsWithChildren < Record < string , never > > > ( ( { children } , ref ) => {
15174 const toast = useToast ( ) ;
15275
153- const resetError = ( ) => setError ( null ) ;
154-
155- const callApi = useCallback (
156- async < T , > (
157- method : keyof ( typeof API ) [ "methods" ] ,
158- { params = { } , errorFilter, suppressError, ...rest } : ApiCallOptions = { } ,
159- ) : Promise < WrappedResponse < T > | null > => {
160- if ( apiLocked ) return null ;
161-
162- setError ( null ) ;
163-
164- const result = await API . invoke ( method , params , rest ) ;
165- const shouldHandleGlobalErrorMessage = handleGlobalErrorMessage ( result , errorFilter ) ;
166-
167- // If the error is due to a 404 and we are not handling it inline, we need to redirect to a working page
168- // and show a global error message of the resource not being found
169- if (
170- result &&
171- "status" in result &&
172- ( result . status === 401 ||
173- ( IMPROVE_GLOBAL_ERROR_MESSAGES && result . status === 404 && shouldHandleGlobalErrorMessage ) )
174- ) {
175- apiLocked = true ;
176-
177- let redirectUrl = absoluteURL ( "/" ) ;
178-
179- if ( result . status === 404 ) {
180- // If coming from projects or a labelling page, redirect to projects
181- if ( location . pathname . startsWith ( "/projects" ) ) {
182- redirectUrl = absoluteURL ( "/projects" ) ;
183- }
184-
185- // Store the error message in sessionStorage to show after redirect
186- sessionStorage . setItem ( "redirectMessage" , "The page or resource you were looking for does not exist." ) ;
187- }
188-
189- // Perform immediate redirect
190- location . href = redirectUrl ;
191- return null ;
76+ /**
77+ * Handles errors with Label Studio-specific logic including:
78+ * - Toast notifications for 4xx errors
79+ * - Modal errors for validation errors
80+ * - Sentry logging for server errors
81+ */
82+ const handleError = useCallback (
83+ ( errorDetails : FormattedError , result : ApiResponse ) => {
84+ const status = result . $meta ?. status ;
85+ const is4xx = status ?. toString ( ) . startsWith ( "4" ) ;
86+ const containsValidationErrors =
87+ isDefined ( result . response ?. validation_errors ) && Object . keys ( result . response . validation_errors ) . length > 0 ;
88+
89+ // Log to Sentry for non-4xx or errors with stacktraces
90+ if ( ( ! is4xx || result . response ?. exc_info ) && result . error ) {
91+ captureException ( new Error ( result . error ) , {
92+ extra : {
93+ status,
94+ server_stacktrace : result . response ?. exc_info ,
95+ server_version : result . response ?. version ,
96+ } ,
97+ } ) ;
19298 }
19399
194- if ( result ?. error ) {
195- const status = result . $meta . status ;
196- const requestCancelled = ! status ;
197- const requestAborted = result . error ?. includes ( "aborted" ) ;
198- const requestCompleted = ! ( requestCancelled || requestAborted ) ;
199- const containsValidationErrors =
200- isDefined ( result . response ?. validation_errors ) && Object . keys ( result . response ?. validation_errors ) . length > 0 ;
201-
202- let shouldShowGlobalError = shouldHandleGlobalErrorMessage && requestCompleted ;
203-
204- if ( IMPROVE_GLOBAL_ERROR_MESSAGES && requestCompleted ) {
205- // We only show toast errors for 4xx errors
206- // Any non-4xx errors are logged to Sentry but there is nothing the user can do about them so don't show them to the user
207- // 401 errors are handled above
208- // If we end up with an empty status string from a cancelled request, don't show the error
209- const is4xx = status . toString ( ) . startsWith ( "4" ) ;
210- const stacktrace = result . response ?. exc_info ;
211- const version = result . response ?. version ;
212-
213- shouldShowGlobalError = shouldShowGlobalError && is4xx ;
214-
215- // Log non-4xx errors that are not aborted or cancelled requests, or any errors containing an api stacktrace to Sentry
216- // So we know about them but don't show them to the user
217- if ( ( ! is4xx || stacktrace ) && result . error ) {
218- captureException ( new Error ( result . error ) , {
219- extra : {
220- method,
221- params,
222- status,
223- server_stacktrace : stacktrace ,
224- server_version : version ,
225- } ,
226- } ) ;
227- }
228- }
100+ // Show toast for 4xx without validation errors
101+ if ( IMPROVE_GLOBAL_ERROR_MESSAGES && is4xx && ! containsValidationErrors ) {
102+ toast ?. show ( {
103+ message : `${ errorDetails . title } : ${ errorDetails . message } ` ,
104+ type : ToastType . error ,
105+ duration : API_ERROR_TOAST_DURATION ,
106+ } ) ;
107+ } else {
108+ // Show modal for validation errors or non-4xx
109+ displayErrorModal ( errorDetails ) ;
110+ }
111+ } ,
112+ [ toast ] ,
113+ ) ;
229114
230- // Allow inline error handling
231- if ( suppressError !== true ) {
232- setError ( result ) ;
233- }
115+ /**
116+ * Handles fatal errors like 401 and 404.
117+ */
118+ const handleFatalError = useCallback ( ( errorDetails : FormattedError , result : ApiResponse ) => {
119+ if ( apiLocked ) return ;
234120
235- if ( shouldShowGlobalError && suppressError !== true ) {
236- let displayErrorToast : ErrorDisplayMessage | undefined ;
121+ const status = result . $meta ?. status ;
237122
238- // If there are no validation errors, show a toast error
239- // Otherwise, show a modal error as previously handled
240- if ( IMPROVE_GLOBAL_ERROR_MESSAGES && ! containsValidationErrors ) {
241- displayErrorToast = ( errorDetails ) => {
242- toast ?. show ( {
243- message : `${ errorDetails . title } : ${ errorDetails . message } ` ,
244- type : ToastType . error ,
245- duration : API_ERROR_TOAST_DURATION ,
246- } ) ;
247- } ;
248- }
123+ // Handle 401 redirects
124+ if ( status === 401 ) {
125+ apiLocked = true ;
126+ location . href = absoluteURL ( "/" ) ;
127+ return ;
128+ }
249129
250- // Use global error handling
251- const isShutdown = await handleError ( result , displayErrorToast , contextValue . showGlobalError ) ;
252- apiLocked = apiLocked || isShutdown ;
130+ // Handle 404 redirects with improved error messages
131+ if ( IMPROVE_GLOBAL_ERROR_MESSAGES && status === 404 ) {
132+ apiLocked = true ;
133+ let redirectUrl = absoluteURL ( "/" ) ;
253134
254- return null ;
255- }
135+ if ( location . pathname . startsWith ( "/projects" ) ) {
136+ redirectUrl = absoluteURL ( "/projects" ) ;
256137 }
257138
258- return result as WrappedResponse < T > ;
259- } ,
260- [ ] ,
261- ) ;
262-
263- const contextValue : ApiContextType = useMemo (
264- ( ) => ( {
265- api : API ,
266- callApi,
267- handleError,
268- resetError,
269- error,
270- showGlobalError : true ,
271- errorFormatter,
272- isValidMethod ( name : string ) {
273- return API . isValidMethod ( name ) ;
274- } ,
275- } ) ,
276- [ error , callApi ] ,
277- ) ;
278-
279- useEffect ( ( ) => {
280- if ( ref && ! ( ref instanceof Function ) ) ref . current = contextValue ;
281- } , [ ref ] ) ;
139+ sessionStorage . setItem ( "redirectMessage" , "The page or resource you were looking for does not exist." ) ;
140+ location . href = redirectUrl ;
141+ }
142+ } , [ ] ) ;
282143
283- // Check for redirect message in sessionStorage and display it
144+ // Check for redirect messages on mount
284145 useEffect ( ( ) => {
285146 const redirectMessage = sessionStorage . getItem ( "redirectMessage" ) ;
286147 if ( redirectMessage ) {
@@ -289,14 +150,15 @@ export const ApiProvider = forwardRef<ApiContextType, PropsWithChildren<any>>(({
289150 type : ToastType . error ,
290151 duration : API_ERROR_TOAST_DURATION ,
291152 } ) ;
292- // Remove the message from sessionStorage to prevent showing it again
293153 sessionStorage . removeItem ( "redirectMessage" ) ;
294154 }
295155 } , [ toast ] ) ;
296156
297- return < ApiContext . Provider value = { contextValue } > { children } </ ApiContext . Provider > ;
157+ return (
158+ < CoreApiProvider ref = { ref } onError = { handleError } onFatalError = { handleFatalError } >
159+ { children }
160+ </ CoreApiProvider >
161+ ) ;
298162} ) ;
299163
300- export const useAPI = ( ) => {
301- return useContext ( ApiContext ) ! ;
302- } ;
164+ ApiProvider . displayName = "ApiProvider" ;
0 commit comments