mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-28 17:43:07 +08:00
fix(client-v2): show API response notifications (#10376)
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user