From 233ce5602129714628e2c95ec739b77d780f245a Mon Sep 17 00:00:00 2001 From: Junyi Date: Mon, 17 Aug 2026 00:24:22 +0800 Subject: [PATCH] fix(client-v2): show API response notifications (#10376) --- packages/core/client-v2/src/APIClient.ts | 92 +++++++++++++++++++ packages/core/client-v2/src/Application.tsx | 4 +- .../src/__tests__/APIClient.test.tsx | 58 ++++++++++++ 3 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 packages/core/client-v2/src/__tests__/APIClient.test.tsx diff --git a/packages/core/client-v2/src/APIClient.ts b/packages/core/client-v2/src/APIClient.ts index 5f310c06e3e..9f6889544f5 100644 --- a/packages/core/client-v2/src/APIClient.ts +++ b/packages/core/client-v2/src/APIClient.ts @@ -8,6 +8,68 @@ */ import { APIClient as APIClientSDK, hasHeaderValue } from '@nocobase/sdk'; +import type { NotificationInstance } from 'antd/es/notification/interface'; +import type { AxiosRequestConfig } from 'axios'; +import React from 'react'; + +type ResponseMessage = string | { message?: unknown }; + +interface APIClientApplication { + getName?: () => string | undefined; + context?: { + notification?: NotificationInstance; + }; +} + +interface NotificationError { + config?: AxiosRequestConfig & { + skipNotify?: boolean | ((error: unknown) => boolean); + }; +} + +const notificationCache = new Map(); + +function getMessageText(item: ResponseMessage): string { + if (typeof item === 'string') { + return item; + } + return typeof item?.message === 'string' ? item.message : ''; +} + +function deduplicateMessages(messages: ResponseMessage[]): ResponseMessage[] { + if (notificationCache.size > 10) { + notificationCache.clear(); + } + const now = Date.now(); + return messages.filter((item) => { + const message = getMessageText(item); + if (!message) { + return false; + } + const lastTime = notificationCache.get(message); + if (lastTime && now - lastTime < 500) { + return false; + } + notificationCache.set(message, now); + return true; + }); +} + +function notify(type: 'success' | 'error', messages: ResponseMessage[], instance?: NotificationInstance) { + if (!instance || messages.length === 0) { + return; + } + const filteredMessages = deduplicateMessages(messages); + if (filteredMessages.length === 0) { + return; + } + instance[type]({ + message: filteredMessages.map((item, index) => { + const message = getMessageText(item); + return React.createElement('div', { key: `${index}_${message}` }, message); + }), + }); +} function offsetToTimeZone(offset: number) { const hours = Math.floor(Math.abs(offset)); @@ -23,6 +85,12 @@ function getCurrentTimezone() { } export class APIClient extends APIClientSDK { + app?: APIClientApplication; + + get notification() { + return this.app?.context?.notification; + } + getHostname() { if (process.env.API_BASE_URL) { try { @@ -55,5 +123,29 @@ export class APIClient extends APIClientSDK { return config; }); super.interceptors(); + this.useNotificationMiddleware(); + } + + handleNotificationError(error: unknown) { + const notificationError = error as NotificationError; + const skipNotify = notificationError.config?.skipNotify; + if (skipNotify && (skipNotify === true || (typeof skipNotify === 'function' && skipNotify(error)))) { + throw error; + } + const messages = this.toErrMessages(error); + if (Array.isArray(messages)) { + notify('error', messages, this.notification); + } + throw error; + } + + useNotificationMiddleware() { + this.axios.interceptors.response.use((response) => { + const messages = response.data?.messages; + if (Array.isArray(messages)) { + notify('success', messages, this.notification); + } + return response; + }, this.handleNotificationError.bind(this)); } } diff --git a/packages/core/client-v2/src/Application.tsx b/packages/core/client-v2/src/Application.tsx index 87f570ce38d..a6f02339eb4 100644 --- a/packages/core/client-v2/src/Application.tsx +++ b/packages/core/client-v2/src/Application.tsx @@ -45,7 +45,7 @@ export class Application extends BaseApplication< public hasLoadError = false; protected createApiClient(options: ApplicationOptions) { - return new APIClient({ + const apiClient = new APIClient({ // Cross-origin API deployments rely on cookies for auth (e.g. permanent file // URLs); trust is enforced server-side via the CORS origin whitelist and the // CSRF middleware, not by omitting credentials. @@ -53,6 +53,8 @@ export class Application extends BaseApplication< ...options.apiClient, appName: options.name || getSubAppName(options.publicPath), }); + apiClient.app = this; + return apiClient; } protected configureRuntimeAdapters() { diff --git a/packages/core/client-v2/src/__tests__/APIClient.test.tsx b/packages/core/client-v2/src/__tests__/APIClient.test.tsx new file mode 100644 index 00000000000..aebf29f54e4 --- /dev/null +++ b/packages/core/client-v2/src/__tests__/APIClient.test.tsx @@ -0,0 +1,58 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { APIClient } from '@nocobase/client-v2'; +import type { NotificationInstance } from 'antd/es/notification/interface'; +import MockAdapter from 'axios-mock-adapter'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +function createAPIClient() { + const notification = { + error: vi.fn(), + success: vi.fn(), + } as unknown as NotificationInstance; + const apiClient = new APIClient(); + apiClient.app = { context: { notification } }; + const apiMock = new MockAdapter(apiClient.axios); + return { apiClient, apiMock, notification }; +} + +describe('APIClient response notifications', () => { + it('shows error messages returned by failed requests', async () => { + const { apiClient, apiMock, notification } = createAPIClient(); + apiMock.onPost('/posts:create').reply(400, { errors: [{ message: 'Request rejected by workflow' }] }); + + await expect(apiClient.request({ url: '/posts:create', method: 'post' })).rejects.toBeDefined(); + + expect(notification.error).toHaveBeenCalledOnce(); + const [{ message }] = vi.mocked(notification.error).mock.calls[0]; + expect(renderToStaticMarkup(<>{message})).toContain('Request rejected by workflow'); + }); + + it('does not show errors when skipNotify is enabled', async () => { + const { apiClient, apiMock, notification } = createAPIClient(); + apiMock.onGet('/health').reply(500, { errors: [{ message: 'Health check failed' }] }); + + await expect(apiClient.request({ url: '/health', skipNotify: true })).rejects.toBeDefined(); + + expect(notification.error).not.toHaveBeenCalled(); + }); + + it('shows messages returned by successful requests', async () => { + const { apiClient, apiMock, notification } = createAPIClient(); + apiMock.onPost('/posts:create').reply(200, { messages: [{ message: 'Created successfully' }] }); + + await apiClient.request({ url: '/posts:create', method: 'post' }); + + expect(notification.success).toHaveBeenCalledOnce(); + const [{ message }] = vi.mocked(notification.success).mock.calls[0]; + expect(renderToStaticMarkup(<>{message})).toContain('Created successfully'); + }); +});