Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 2192750

Browse files
authored
add global error handler (#1000)
1 parent cc866b7 commit 2192750

File tree

6 files changed

+330
-3
lines changed

6 files changed

+330
-3
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// ============================================================================
2+
// RTK Query Middleware for Global Error Handling
3+
// ============================================================================
4+
5+
import {isRejectedWithValue, Middleware, Action} from "@reduxjs/toolkit"
6+
import {notifications} from "@mantine/notifications"
7+
import {t} from "i18next"
8+
import {getErrorMessage, getErrorTitle} from "@/utils/errorHandling"
9+
10+
interface RTKQueryAction extends Action {
11+
meta?: {
12+
arg?: {
13+
endpointName?: string
14+
[key: string]: any
15+
}
16+
[key: string]: any
17+
}
18+
}
19+
20+
/**
21+
* Global error handling middleware for RTK Query
22+
* Automatically shows notifications for all rejected mutations
23+
*/
24+
export const rtkQueryErrorLogger: Middleware = () => next => action => {
25+
// Check if this is a rejected action from RTK Query
26+
if (isRejectedWithValue(action)) {
27+
// Extract endpoint name and operation type from the action
28+
const endpointName = (action as RTKQueryAction).meta?.arg?.endpointName
29+
const operationType = extractOperationType(endpointName)
30+
31+
// Create context for better error messages
32+
const context = operationType || "generic"
33+
34+
const errorMessage = getErrorMessage(action.payload, t, context)
35+
const errorTitle = getErrorTitle(action.payload, t, context)
36+
37+
// Show error notification
38+
notifications.show({
39+
autoClose: false,
40+
withBorder: true,
41+
color: "red",
42+
title: errorTitle,
43+
message: errorMessage
44+
})
45+
}
46+
47+
return next(action)
48+
}
49+
50+
/**
51+
* Extract operation type from endpoint name
52+
* e.g., "addNewUser" -> "user.create"
53+
*/
54+
function extractOperationType(endpointName?: string): string | undefined {
55+
if (!endpointName) return undefined
56+
57+
// Map of common prefixes to operations
58+
const operationMap: Record<string, string> = {
59+
addNew: "create",
60+
add: "create",
61+
edit: "update",
62+
update: "update",
63+
delete: "delete",
64+
remove: "delete"
65+
}
66+
67+
// Extract the operation prefix
68+
for (const [prefix, operation] of Object.entries(operationMap)) {
69+
if (endpointName.startsWith(prefix)) {
70+
// Extract entity name (e.g., "User" from "addNewUser")
71+
const entityName = endpointName.replace(prefix, "").toLowerCase()
72+
return `${entityName}.${operation}`
73+
}
74+
}
75+
76+
return undefined
77+
}

‎frontend/apps/ui/src/app/listenerMiddleware.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {customFieldCRUDListeners} from "@/features/custom-fields/storage/custom_
22
import {documentTypeCRUDListeners} from "@/features/document-types/storage/documentType"
33
import {moveNodesListeners} from "@/features/nodes/nodesSlice"
44
import {roleCRUDListeners} from "@/features/roles/storage/role"
5+
import {userCRUDListeners} from "@/features/users/storage/user"
56

67
import {addListener, createListenerMiddleware} from "@reduxjs/toolkit"
78
import type {AppDispatch, RootState} from "./types"
@@ -21,3 +22,4 @@ moveNodesListeners(startAppListening)
2122
roleCRUDListeners(startAppListening)
2223
documentTypeCRUDListeners(startAppListening)
2324
customFieldCRUDListeners(startAppListening)
25+
userCRUDListeners(startAppListening)

‎frontend/apps/ui/src/app/store.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import usersReducer from "@/features/users/storage/user"
2525
import currentUserReducer from "@/slices/currentUser"
2626
import {configureStore} from "@reduxjs/toolkit"
2727
import {listenerMiddleware} from "./listenerMiddleware"
28+
import {rtkQueryErrorLogger} from "./globalErrorMiddleware"
2829

2930
export const store = configureStore({
3031
reducer: {
@@ -58,4 +59,5 @@ export const store = configureStore({
5859
.prepend(listenerMiddleware.middleware)
5960
.prepend(docsListenerMiddleware.middleware)
6061
.concat(apiSlice.middleware)
62+
.concat(rtkQueryErrorLogger)
6163
})

‎frontend/apps/ui/src/features/users/components/NewUserModal.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,9 @@ export default function NewUserModal({
9090
}
9191
try {
9292
await addNewUser(newUserData).unwrap()
93+
onSubmit()
94+
reset()
9395
} catch (err) {}
94-
95-
onSubmit()
96-
reset()
9796
}
9897

9998
const reset = () => {

‎frontend/apps/ui/src/features/users/storage/user.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import {createSelector, createSlice} from "@reduxjs/toolkit"
2+
import {AppStartListening} from "@/app/listenerMiddleware"
3+
import {notifications} from "@mantine/notifications"
4+
import {t} from "i18next"
25
import {apiSliceWithUsers} from "./api"
36

47
import {RootState} from "@/app/types"
@@ -32,3 +35,51 @@ export const selectUserById = createSelector(
3235
return usersData.data?.find(u => userId == u.id)
3336
}
3437
)
38+
39+
// ============================================================================
40+
// CRUD LISTENERS - Only success cases (errors handled globally)
41+
// ============================================================================
42+
43+
export const userCRUDListeners = (startAppListening: AppStartListening) => {
44+
// Create success
45+
startAppListening({
46+
matcher: apiSliceWithUsers.endpoints.addNewUser.matchFulfilled,
47+
effect: async () => {
48+
notifications.show({
49+
withBorder: true,
50+
message: t("notifications.user.created.success", {
51+
defaultValue: "User created successfully"
52+
})
53+
})
54+
}
55+
})
56+
57+
// Update success
58+
startAppListening({
59+
matcher: apiSliceWithUsers.endpoints.editUser.matchFulfilled,
60+
effect: async () => {
61+
notifications.show({
62+
withBorder: true,
63+
message: t("notifications.user.updated.success", {
64+
defaultValue: "User updated successfully"
65+
})
66+
})
67+
}
68+
})
69+
70+
// Delete success
71+
startAppListening({
72+
matcher: apiSliceWithUsers.endpoints.deleteUser.matchFulfilled,
73+
effect: async () => {
74+
notifications.show({
75+
withBorder: true,
76+
message: t("notifications.user.deleted.success", {
77+
defaultValue: "User deleted successfully"
78+
})
79+
})
80+
}
81+
})
82+
83+
// Note: Error cases are now handled by the global error middleware
84+
// No need for matchRejected listeners anymore!
85+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// ============================================================================
2+
// Global Error Handling Utilities
3+
// ============================================================================
4+
5+
import {SerializedError} from "@reduxjs/toolkit"
6+
import {FetchBaseQueryError} from "@reduxjs/toolkit/query"
7+
import {TFunction} from "i18next"
8+
9+
/**
10+
* Standard error structure from the backend
11+
*/
12+
export interface ServerErrorType {
13+
data: {
14+
detail: string
15+
[key: string]: any
16+
}
17+
status: number
18+
}
19+
20+
/**
21+
* Type guard to check if error is FetchBaseQueryError
22+
*/
23+
export function isFetchBaseQueryError(
24+
error: unknown
25+
): error is FetchBaseQueryError {
26+
return typeof error === "object" && error != null && "status" in error
27+
}
28+
29+
/**
30+
* Type guard to check if error is SerializedError
31+
*/
32+
export function isSerializedError(error: unknown): error is SerializedError {
33+
return (
34+
typeof error === "object" &&
35+
error != null &&
36+
("message" in error || "code" in error)
37+
)
38+
}
39+
40+
/**
41+
* Extract user-friendly error message from various error types
42+
*/
43+
export function getErrorMessage(
44+
error: unknown,
45+
t: TFunction,
46+
context?: string
47+
): string {
48+
// Handle RTK Query FetchBaseQueryError
49+
if (isFetchBaseQueryError(error)) {
50+
// Network errors
51+
if (error.status === "FETCH_ERROR") {
52+
return t("errors.network_error", {
53+
defaultValue: "Network error. Please check your connection."
54+
})
55+
}
56+
57+
// Parsing errors
58+
if (error.status === "PARSING_ERROR") {
59+
return t("errors.parsing_error", {
60+
defaultValue: "Server response error."
61+
})
62+
}
63+
64+
// Timeout errors
65+
if (error.status === "TIMEOUT_ERROR") {
66+
return t("errors.timeout_error", {
67+
defaultValue: "Request timed out. Please try again."
68+
})
69+
}
70+
71+
// Handle HTTP status codes
72+
if (typeof error.status === "number") {
73+
const serverError = error as ServerErrorType
74+
75+
// If backend provides specific error detail, use it
76+
if (serverError.data?.detail) {
77+
return serverError.data.detail
78+
}
79+
80+
// Generic status code messages with context
81+
const statusMessages: Record<number, string> = {
82+
400: t(`errors.${context}.bad_request`, {
83+
defaultValue: t("errors.bad_request", {
84+
defaultValue: "Invalid request. Please check your input."
85+
})
86+
}),
87+
401: t(`errors.${context}.unauthorized`, {
88+
defaultValue: t("errors.unauthorized", {
89+
defaultValue: "You are not authorized to perform this action."
90+
})
91+
}),
92+
403: t(`errors.${context}.forbidden`, {
93+
defaultValue: t("errors.forbidden", {
94+
defaultValue: "Access denied."
95+
})
96+
}),
97+
404: t(`errors.${context}.not_found`, {
98+
defaultValue: t("errors.not_found", {
99+
defaultValue: "Resource not found."
100+
})
101+
}),
102+
409: t(`errors.${context}.conflict`, {
103+
defaultValue: t("errors.conflict", {
104+
defaultValue: "Conflict: Resource already exists or is in use."
105+
})
106+
}),
107+
422: t(`errors.${context}.validation_error`, {
108+
defaultValue: t("errors.validation_error", {
109+
defaultValue: "Validation error. Please check your input."
110+
})
111+
}),
112+
500: t(`errors.${context}.server_error`, {
113+
defaultValue: t("errors.server_error", {
114+
defaultValue: "Server error. Please try again later."
115+
})
116+
})
117+
}
118+
119+
if (error.status in statusMessages) {
120+
return statusMessages[error.status]
121+
}
122+
123+
// Generic 5xx error
124+
if (error.status >= 500) {
125+
return t("errors.server_error", {
126+
defaultValue: "Server error. Please try again later."
127+
})
128+
}
129+
}
130+
}
131+
132+
// Handle SerializedError from RTK
133+
if (isSerializedError(error)) {
134+
if (error.message) {
135+
return error.message
136+
}
137+
}
138+
139+
// Fallback error message with context
140+
if (context) {
141+
return t(`errors.${context}.generic`, {
142+
defaultValue: t("errors.generic", {
143+
defaultValue: "An unexpected error occurred. Please try again."
144+
})
145+
})
146+
}
147+
148+
return t("errors.generic", {
149+
defaultValue: "An unexpected error occurred. Please try again."
150+
})
151+
}
152+
153+
/**
154+
* Get error title for notifications
155+
*/
156+
export function getErrorTitle(
157+
error: unknown,
158+
t: TFunction,
159+
context?: string
160+
): string {
161+
if (isFetchBaseQueryError(error) && typeof error.status === "number") {
162+
if (error.status >= 500) {
163+
return t("errors.title.server_error", {
164+
defaultValue: "Server Error"
165+
})
166+
}
167+
if (error.status === 401 || error.status === 403) {
168+
return t("errors.title.access_denied", {
169+
defaultValue: "Access Denied"
170+
})
171+
}
172+
if (error.status === 404) {
173+
return t("errors.title.not_found", {
174+
defaultValue: "Not Found"
175+
})
176+
}
177+
if (error.status === 409) {
178+
return t("errors.title.conflict", {
179+
defaultValue: "Conflict"
180+
})
181+
}
182+
if (error.status === 422 || error.status === 400) {
183+
return t("errors.title.validation_error", {
184+
defaultValue: "Validation Error"
185+
})
186+
}
187+
}
188+
189+
if (context) {
190+
return t(`errors.${context}.title`, {
191+
defaultValue: t("errors.title.error", {defaultValue: "Error"})
192+
})
193+
}
194+
195+
return t("errors.title.error", {defaultValue: "Error"})
196+
}

0 commit comments

Comments
(0)

AltStyle によって変換されたページ (->オリジナル) /