This is an automated email from the ASF dual-hosted git repository.

dengliming pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu-dashboard.git


The following commit(s) were added to refs/heads/master by this push:
     new 2e355d79 fix: handle config import and export callbacks (#661)
2e355d79 is described below

commit 2e355d79ca205db89f0c7ee6c2aeb2e0a3355da6
Author: joyboy <[email protected]>
AuthorDate: Sun Sep 27 09:57:38 2026 +0530

    fix: handle config import and export callbacks (#661)
    
    * fix: handle config import and export callbacks
    
    * addressing review comments, adding tests for download and handling it 
better
    
    ---------
    
    Co-authored-by: Mukesh Babu <[email protected]>
---
 src/components/GlobalHeader/index.js               |   9 +-
 src/components/GlobalHeader/parseImportResult.js   |  20 ++++
 .../GlobalHeader/parseImportResult.test.js         |  30 +++++
 src/models/common.js                               |  16 ++-
 src/models/common.test.js                          | 126 +++++++++++++++++++++
 src/utils/download.js                              |  31 ++++-
 src/utils/download.test.js                         | 110 ++++++++++++++++++
 7 files changed, 336 insertions(+), 6 deletions(-)

diff --git a/src/components/GlobalHeader/index.js 
b/src/components/GlobalHeader/index.js
index 5e5824f8..4584de43 100644
--- a/src/components/GlobalHeader/index.js
+++ b/src/components/GlobalHeader/index.js
@@ -22,6 +22,7 @@ import { withRouter } from "dva/router";
 import ImportModal from "./ImportModal";
 import ExportModal from "./ExportModal";
 import ImportResultModal from "./ImportResultModal";
+import parseImportResult from "./parseImportResult";
 import styles from "./index.less";
 import { getCurrentLocale, getIntlContent } from "../../utils/IntlUtils";
 import { checkUserPassword } from "../../services/api";
@@ -188,7 +189,10 @@ class GlobalHeader extends PureComponent {
               payload: values,
               callback: (res) => {
                 this.closeModal(true);
-                this.showImportRestlt(JSON.parse(res));
+                const importResult = parseImportResult(res);
+                if (importResult) {
+                  this.showImportRestlt(importResult);
+                }
               },
             });
           }}
@@ -232,9 +236,8 @@ class GlobalHeader extends PureComponent {
             dispatch({
               type: "common/exportByNamespace",
               payload: values,
-              callback: (res) => {
+              callback: () => {
                 this.closeModal(true);
-                this.showImportRestlt(JSON.parse(res));
               },
             });
           }}
diff --git a/src/components/GlobalHeader/parseImportResult.js 
b/src/components/GlobalHeader/parseImportResult.js
new file mode 100644
index 00000000..cd94f0d0
--- /dev/null
+++ b/src/components/GlobalHeader/parseImportResult.js
@@ -0,0 +1,20 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export default function parseImportResult(result) {
+  return result ? JSON.parse(result) : null;
+}
diff --git a/src/components/GlobalHeader/parseImportResult.test.js 
b/src/components/GlobalHeader/parseImportResult.test.js
new file mode 100644
index 00000000..ce386cea
--- /dev/null
+++ b/src/components/GlobalHeader/parseImportResult.test.js
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import parseImportResult from "./parseImportResult";
+
+describe("parseImportResult", () => {
+  it("returns null when a successful import has no detail", () => {
+    expect(parseImportResult()).toBeNull();
+  });
+
+  it("parses import details when the backend returns them", () => {
+    expect(parseImportResult('{"pluginImportSuccessCount":1}')).toEqual({
+      pluginImportSuccessCount: 1,
+    });
+  });
+});
diff --git a/src/models/common.js b/src/models/common.js
index 7935bbff..fff87f26 100644
--- a/src/models/common.js
+++ b/src/models/common.js
@@ -254,11 +254,23 @@ export default {
     },
 
     *exportAll(_, { call }) {
-      yield call(asyncConfigExport);
+      try {
+        yield call(asyncConfigExport);
+      } catch (error) {
+        message.error(error.message);
+      }
     },
 
     *exportByNamespace(params, { call }) {
-      yield call(asyncConfigExportByNamespace, params);
+      const { callback } = params;
+      try {
+        yield call(asyncConfigExportByNamespace, params);
+        if (callback) {
+          callback();
+        }
+      } catch (error) {
+        message.error(error.message);
+      }
     },
 
     *import(params, { call }) {
diff --git a/src/models/common.test.js b/src/models/common.test.js
new file mode 100644
index 00000000..ea237559
--- /dev/null
+++ b/src/models/common.test.js
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { message } from "antd";
+import fetch from "dva/fetch";
+
+jest.mock("antd", () => ({
+  message: {
+    error: jest.fn(),
+    success: jest.fn(),
+    warn: jest.fn(),
+  },
+}));
+
+jest.mock("dva/fetch", () => jest.fn());
+jest.mock("../utils/request", () => jest.fn());
+
+document.body.innerHTML = '<div id="httpPath"></div>';
+
+const common = require("./common").default;
+const { asyncConfigExportByNamespace } = require("../services/api");
+
+const createErrorResponse = ({ ok, status, statusText, body }) => ({
+  ok,
+  status,
+  statusText,
+  headers: {
+    get: jest.fn(() => null),
+  },
+  json: jest.fn().mockResolvedValue(body),
+});
+
+describe("common export effects", () => {
+  it("invokes its callback after the export completes", () => {
+    const callback = jest.fn();
+    const params = {
+      payload: { namespace: "default" },
+      callback,
+    };
+    const call = jest.fn((fn, args) => ({ fn, args }));
+    const iterator = common.effects.exportByNamespace(params, { call });
+
+    expect(iterator.next().value).toEqual({
+      fn: asyncConfigExportByNamespace,
+      args: params,
+    });
+    expect(callback).not.toHaveBeenCalled();
+
+    expect(iterator.next().done).toBe(true);
+    expect(callback).toHaveBeenCalledTimes(1);
+  });
+
+  it.each([
+    [
+      "an HTTP error",
+      createErrorResponse({
+        ok: false,
+        status: 401,
+        statusText: "Unauthorized",
+        body: { code: 401, message: "Authentication failed", data: null },
+      }),
+      "Authentication failed",
+    ],
+    [
+      "an HTTP 200 Admin error",
+      createErrorResponse({
+        ok: true,
+        status: 200,
+        statusText: "OK",
+        body: { code: 601, message: "Export failed", data: null },
+      }),
+      "Export failed",
+    ],
+  ])("keeps the modal open for %s", async (_, response, expectedMessage) => {
+    const callback = jest.fn();
+    const params = {
+      payload: { namespace: "default" },
+      callback,
+    };
+    const call = jest.fn((fn, args) => fn(args));
+    const iterator = common.effects.exportByNamespace(params, { call });
+    fetch.mockResolvedValue(response);
+
+    const exportRequest = iterator.next().value;
+    let exportError;
+    try {
+      await exportRequest;
+    } catch (error) {
+      exportError = error;
+    }
+
+    expect(exportError).toBeInstanceOf(Error);
+    const result = iterator.throw(exportError);
+
+    expect(result.done).toBe(true);
+    expect(callback).not.toHaveBeenCalled();
+    expect(message.error).toHaveBeenCalledWith(
+      expect.stringContaining(expectedMessage),
+    );
+  });
+
+  it("surfaces failures when exporting all configuration", () => {
+    const call = jest.fn((fn, args) => ({ fn, args }));
+    const iterator = common.effects.exportAll({}, { call });
+
+    iterator.next();
+    const result = iterator.throw(new Error("Export failed"));
+
+    expect(result.done).toBe(true);
+    expect(message.error).toHaveBeenCalledWith("Export failed");
+  });
+});
diff --git a/src/utils/download.js b/src/utils/download.js
index fbe18f78..9da35238 100644
--- a/src/utils/download.js
+++ b/src/utils/download.js
@@ -17,6 +17,25 @@
 
 import fetch from "dva/fetch";
 
+async function getDownloadErrorMessage(response) {
+  const fallbackMessage =
+    !response.ok && response.statusText
+      ? response.statusText
+      : `Export failed (${response.status})`;
+
+  try {
+    const result = await response.json();
+    if (result && result.message) {
+      return result.message;
+    }
+    return result && result.code
+      ? `Export failed (${result.code})`
+      : fallbackMessage;
+  } catch (error) {
+    return fallbackMessage;
+  }
+}
+
 /**
  * Requests a URL, for downloading.
  *
@@ -42,6 +61,16 @@ export default async function download(url, options) {
   try {
     const response = await fetch(url, newOptions);
     const disposition = response.headers.get("Content-Disposition");
+    const isAttachment =
+      response.ok &&
+      disposition &&
+      disposition.toLowerCase().includes("attachment");
+
+    if (!isAttachment) {
+      const errorMessage = await getDownloadErrorMessage(response);
+      throw new Error(errorMessage);
+    }
+
     const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
     const matches = filenameRegex.exec(disposition);
     let filename = "download";
@@ -58,6 +87,6 @@ export default async function download(url, options) {
     a.click();
     document.body.removeChild(a);
   } catch (error) {
-    throw new Error(`下载文件失败:${error}`);
+    throw new Error(`下载文件失败:${error.message || error}`);
   }
 }
diff --git a/src/utils/download.test.js b/src/utils/download.test.js
new file mode 100644
index 00000000..e72e9e76
--- /dev/null
+++ b/src/utils/download.test.js
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import fetch from "dva/fetch";
+import download from "./download";
+
+jest.mock("dva/fetch", () => jest.fn());
+
+const createHeaders = (headers = {}) => ({
+  get: jest.fn((name) => headers[name.toLowerCase()] || null),
+});
+
+const createResponse = ({
+  ok = true,
+  status = 200,
+  statusText = "OK",
+  headers = {},
+  json,
+  blob,
+}) => ({
+  ok,
+  status,
+  statusText,
+  headers: createHeaders(headers),
+  json: jest.fn().mockImplementation(() => Promise.resolve(json)),
+  blob: jest.fn().mockImplementation(() => Promise.resolve(blob)),
+});
+
+describe("download", () => {
+  let clickSpy;
+
+  beforeEach(() => {
+    window.sessionStorage.clear();
+    Object.defineProperty(window.URL, "createObjectURL", {
+      configurable: true,
+      value: jest.fn(() => "blob:config"),
+    });
+    clickSpy = jest
+      .spyOn(HTMLAnchorElement.prototype, "click")
+      .mockImplementation(() => {});
+  });
+
+  it("downloads a successful attachment", async () => {
+    const blob = new Blob(["config"], { type: "application/zip" });
+    const response = createResponse({
+      headers: {
+        "content-disposition": 'attachment;filename="config.zip"',
+      },
+      blob,
+    });
+    fetch.mockResolvedValue(response);
+
+    await expect(download("/configs/export", { method: "GET" })).resolves.toBe(
+      undefined,
+    );
+
+    expect(response.blob).toHaveBeenCalledTimes(1);
+    expect(response.json).not.toHaveBeenCalled();
+    expect(window.URL.createObjectURL).toHaveBeenCalledWith(blob);
+    expect(clickSpy).toHaveBeenCalledTimes(1);
+  });
+
+  it("rejects an HTTP error without downloading it", async () => {
+    const response = createResponse({
+      ok: false,
+      status: 401,
+      statusText: "Unauthorized",
+      json: { code: 401, message: "Authentication failed", data: null },
+    });
+    fetch.mockResolvedValue(response);
+
+    await expect(
+      download("/configs/export", { method: "GET" }),
+    ).rejects.toThrow("Authentication failed");
+
+    expect(response.blob).not.toHaveBeenCalled();
+    expect(clickSpy).not.toHaveBeenCalled();
+  });
+
+  it.each([601, 500])(
+    "rejects an HTTP 200 Admin error with code %s",
+    async (code) => {
+      const response = createResponse({
+        json: { code, message: "Export failed", data: null },
+      });
+      fetch.mockResolvedValue(response);
+
+      await expect(
+        download("/configs/export", { method: "GET" }),
+      ).rejects.toThrow("Export failed");
+
+      expect(response.blob).not.toHaveBeenCalled();
+      expect(clickSpy).not.toHaveBeenCalled();
+    },
+  );
+});

Reply via email to