fix(client-v2): show API response notifications (#10376)

This commit is contained in:
Junyi
2026-08-17 00:24:22 +08:00
committed by GitHub
parent 456809d775
commit 233ce56021
3 changed files with 153 additions and 1 deletions
+92
View File
@@ -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<string, number>();
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));
}
}
+3 -1
View File
@@ -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() {
@@ -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');
});
});