Skip to content

Commit 55aafeb

Browse files
authored
chore: FIT-772: [FE] Refactor API Provider to avoid problematic imports (#8620)
Co-authored-by: bmartel <bmartel@users.noreply.github.com>
1 parent 5469013 commit 55aafeb

File tree

14 files changed

+536
-256
lines changed

14 files changed

+536
-256
lines changed
Lines changed: 89 additions & 227 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,25 @@
1+
import { type PropsWithChildren, useCallback, useEffect, forwardRef } from "react";
12
import {
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";
119
import { ErrorWrapper } from "../components/Error/Error";
1210
import { modal } from "../components/Modal/Modal";
1311
import { API_CONFIG } from "../config/ApiConfig";
14-
import { type ApiParams, APIProxy } from "@humansignal/core/lib/api-proxy";
1512
import { absoluteURL, isDefined } from "../utils/helpers";
1613
import { FF_IMPROVE_GLOBAL_ERROR_MESSAGES, isFF } from "../utils/feature-flags";
17-
import type { ApiResponse, WrappedResponse } from "@humansignal/core/lib/api-proxy/types";
1814
import { ToastType, useToast } from "@humansignal/ui";
1915
import { captureException } from "../config/Sentry";
2016

2117
export const IMPROVE_GLOBAL_ERROR_MESSAGES = isFF(FF_IMPROVE_GLOBAL_ERROR_MESSAGES);
2218
// Duration for toast errors
2319
export 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

Comments
 (0)