diff --git a/dashboard/src/components/ImportDialog.tsx b/dashboard/src/components/ImportDialog.tsx index 10d82aa1d08..e4ed19fd208 100644 --- a/dashboard/src/components/ImportDialog.tsx +++ b/dashboard/src/components/ImportDialog.tsx @@ -39,6 +39,11 @@ import ListItemText from "@mui/material/ListItemText"; import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew"; import { postGlossaryImportFormData } from "@utils/glossaryImportFlow"; import { getApiErrorToastMessage } from "@utils/apiErrorToastMessage"; +import { + formatImportFailureForDisplay, + getImportFailureToastMessage, + GlossaryImportFailure +} from "@utils/glossaryImportUtils"; const BootstrapDialog = styled(Dialog)(({ theme }) => ({ "& .MuiDialogContent-root": { @@ -74,6 +79,7 @@ export const ImportDialog: React.FC = ({ const [errorDetails, setErrorDetails] = useState(false); const [importData, setImportData] = useState(null); const toastId: any = useRef(null); + const isGlossaryImport = title !== "Import Business Metadata"; const onUpload = async () => { if (fileData) { @@ -113,7 +119,7 @@ export const ImportDialog: React.FC = ({ if (importResp.data.failedImportInfoList != undefined) { toast.dismiss(toastId.current); toastId.current = toast.error( - importResp.data.failedImportInfoList[0].remarks + getImportFailureToastMessage(isGlossaryImport, importResp.data) ); setErrorDetails(true); @@ -200,17 +206,15 @@ export const ImportDialog: React.FC = ({ > {importData.failedImportInfoList.map( - ( - value: { - index: number; - remarks: string; - }, - index: number - ) => ( - + (value: GlossaryImportFailure, index: number) => ( + ) diff --git a/dashboard/src/components/__tests__/ImportDialog.test.tsx b/dashboard/src/components/__tests__/ImportDialog.test.tsx index d504ce78523..a3d9ab6f456 100644 --- a/dashboard/src/components/__tests__/ImportDialog.test.tsx +++ b/dashboard/src/components/__tests__/ImportDialog.test.tsx @@ -53,14 +53,14 @@ jest.mock('../muiComponents', () => ({ })) const uploadMock = jest.fn() -const glossaryMock = jest.fn() +const glossaryImportMock = jest.fn() jest.mock('../../api/apiMethods/entitiesApiMethods', () => ({ getBusinessMetadataImport: (...args: any[]) => uploadMock(...args) })) -jest.mock('../../api/apiMethods/glossaryApiMethod', () => ({ - getGlossaryImport: (...args: any[]) => glossaryMock(...args) +jest.mock('@utils/glossaryImportFlow', () => ({ + postGlossaryImportFormData: (...args: any[]) => glossaryImportMock(...args) })) jest.mock('../../views/SideBar/Import/ImportLayout', () => ({ @@ -100,10 +100,16 @@ describe('ImportDialog', () => { }) }) - it('shows error details when import returns failed info', async () => { - glossaryMock.mockResolvedValue({ + it('shows glossary-specific error details when glossary import returns failed info', async () => { + glossaryImportMock.mockResolvedValue({ data: { - failedImportInfoList: [{ index: 1, remarks: 'Bad row' }] + failedImportInfoList: [ + { + childObjectName: 'Patient', + parentObjectName: 'Healthcare Glossary', + remarks: 'Bad row' + } + ] } }) @@ -113,12 +119,48 @@ describe('ImportDialog', () => { fireEvent.click(screen.getByText('Upload')) await waitFor(() => { - expect(glossaryMock).toHaveBeenCalled() - expect(toastError).toHaveBeenCalledWith('Bad row') + expect(glossaryImportMock).toHaveBeenCalled() + expect(toastError).toHaveBeenCalledWith( + 'Glossary import completed with 1 failure(s) out of 1 term(s). See error details.' + ) }) expect(screen.getByText('Error Details')).toBeTruthy() - expect(screen.getByText('1. Bad row')).toBeTruthy() + expect( + screen.getByText('1. Patient@Healthcare Glossary: Bad row') + ).toBeTruthy() + }) + + it('shows business metadata errors without glossary formatting', async () => { + uploadMock.mockResolvedValue({ + data: { + failedImportInfoList: [ + { + parentObjectName: 'guid-123', + childObjectName: 'attr1', + remarks: 'Invalid attribute' + } + ] + } + }) + + render( + + ) + + fireEvent.click(screen.getByText('Select File')) + fireEvent.click(screen.getByText('Upload')) + + await waitFor(() => { + expect(uploadMock).toHaveBeenCalled() + expect(toastError).toHaveBeenCalledWith('Invalid attribute') + }) + + expect(screen.getByText('Error Details')).toBeTruthy() + expect(screen.getByText('1. Invalid attribute')).toBeTruthy() + expect( + screen.queryByText('1. attr1@guid-123: Invalid attribute') + ).toBeNull() }) it('handles upload errors', async () => { @@ -147,9 +189,9 @@ describe('ImportDialog', () => { }) it('returns to upload view when back is clicked', async () => { - glossaryMock.mockResolvedValue({ + glossaryImportMock.mockResolvedValue({ data: { - failedImportInfoList: [{ index: 1, remarks: 'Bad row' }] + failedImportInfoList: [{ remarks: 'Bad row' }] } }) diff --git a/dashboard/src/utils/__tests__/glossaryImportUtils.test.ts b/dashboard/src/utils/__tests__/glossaryImportUtils.test.ts new file mode 100644 index 00000000000..78bad538166 --- /dev/null +++ b/dashboard/src/utils/__tests__/glossaryImportUtils.test.ts @@ -0,0 +1,134 @@ +/* + * 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 { + buildGenericImportFailureSummary, + buildGlossaryImportFailureSummary, + formatGenericImportFailure, + formatGlossaryImportFailure, + formatImportFailureForDisplay, + getGlossaryImportTermLabel, + getImportFailureToastMessage +} from "../glossaryImportUtils"; + +describe("glossaryImportUtils", () => { + it("formats failure with glossary term label when both names exist", () => { + expect( + formatGlossaryImportFailure({ + childObjectName: "Patient", + parentObjectName: "Healthcare Glossary", + remarks: "Reference not found" + }) + ).toBe("Patient@Healthcare Glossary: Reference not found"); + }); + + it("formats failure with childObjectName only (same-glossary shorthand)", () => { + expect( + formatGlossaryImportFailure({ + childObjectName: "Patient", + remarks: "Invalid relation" + }) + ).toBe("Patient: Invalid relation"); + expect(getGlossaryImportTermLabel({ childObjectName: "Patient" })).toBe( + "Patient" + ); + }); + + it("formats failure with remarks only (no term names)", () => { + expect( + formatGlossaryImportFailure({ + remarks: "Bad row" + }) + ).toBe("Unknown term: Bad row"); + }); + + it("falls back to Import failed when remarks are empty", () => { + expect(formatGlossaryImportFailure({ childObjectName: "Patient" })).toBe( + "Patient: Import failed" + ); + expect(formatGenericImportFailure({})).toBe("Import failed"); + }); + + it("builds import summary with success and failure counts", () => { + expect( + buildGlossaryImportFailureSummary({ + successImportInfoList: [{ childObjectName: "A" }], + failedImportInfoList: [ + { + childObjectName: "Patient", + parentObjectName: "Healthcare Glossary", + remarks: "Invalid relation" + } + ] + }) + ).toBe( + "Glossary import completed with 1 failure(s) out of 2 term(s). See error details." + ); + }); + + it("builds summary with failures only (no successes)", () => { + expect( + buildGlossaryImportFailureSummary({ + failedImportInfoList: [{ remarks: "Bad row" }] + }) + ).toBe( + "Glossary import completed with 1 failure(s) out of 1 term(s). See error details." + ); + }); + + it("builds summary with empty success and failure lists", () => { + expect(buildGlossaryImportFailureSummary({})).toBe( + "Glossary import completed with 0 failure(s) out of 0 term(s). See error details." + ); + }); + + it("uses generic import helpers for business metadata", () => { + const response = { + successImportInfoList: [{ remarks: "ok" }], + failedImportInfoList: [ + { + parentObjectName: "guid-123", + childObjectName: "attr1", + remarks: "Invalid attribute" + } + ] + }; + + expect(getImportFailureToastMessage(false, response)).toBe( + "Invalid attribute" + ); + expect(formatImportFailureForDisplay(false, response.failedImportInfoList[0])).toBe( + "Invalid attribute" + ); + expect(buildGenericImportFailureSummary(response)).toBe( + "Import completed with 1 failure(s) out of 2 item(s). See error details." + ); + }); + + it("uses generic summary toast for multiple business metadata failures", () => { + expect( + getImportFailureToastMessage(false, { + failedImportInfoList: [ + { remarks: "First error" }, + { remarks: "Second error" } + ] + }) + ).toBe( + "Import completed with 2 failure(s) out of 2 item(s). See error details." + ); + }); +}); diff --git a/dashboard/src/utils/glossaryImportUtils.ts b/dashboard/src/utils/glossaryImportUtils.ts new file mode 100644 index 00000000000..51c43636ec8 --- /dev/null +++ b/dashboard/src/utils/glossaryImportUtils.ts @@ -0,0 +1,105 @@ +/* + * 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 interface GlossaryImportFailure { + childObjectName?: string; + parentObjectName?: string; + remarks?: string; + rowNumber?: number; +} + +export interface GlossaryImportResponse { + failedImportInfoList?: GlossaryImportFailure[]; + successImportInfoList?: GlossaryImportFailure[]; +} + +/** Glossary-only: term label for TermName@GlossaryName when both names exist. */ +export const getGlossaryImportTermLabel = ( + failure: GlossaryImportFailure +): string => { + if (failure.childObjectName && failure.parentObjectName) { + return `${failure.childObjectName}@${failure.parentObjectName}`; + } + if (failure.childObjectName) { + return failure.childObjectName; + } + return "Unknown term"; +}; + +/** Glossary-only: one formatted failure line (term label + reason). */ +export const formatGlossaryImportFailure = ( + failure: GlossaryImportFailure +): string => { + return `${getGlossaryImportTermLabel(failure)}: ${failure.remarks || "Import failed"}`; +}; + +/** Glossary-only: summary toast for bulk glossary term import failures. */ +export const buildGlossaryImportFailureSummary = ( + response: GlossaryImportResponse +): string => { + const failedCount = response.failedImportInfoList?.length || 0; + const successCount = response.successImportInfoList?.length || 0; + const totalCount = failedCount + successCount; + + return `Glossary import completed with ${failedCount} failure(s) out of ${totalCount} term(s). See error details.`; +}; + +/** Generic import (e.g. Business Metadata): use raw remarks, not glossary @ labels. */ +export const formatGenericImportFailure = ( + failure: GlossaryImportFailure +): string => { + return failure.remarks || "Import failed"; +}; + +/** Generic import summary when multiple failures exist (non-glossary imports). */ +export const buildGenericImportFailureSummary = ( + response: GlossaryImportResponse +): string => { + const failedCount = response.failedImportInfoList?.length || 0; + const successCount = response.successImportInfoList?.length || 0; + const totalCount = failedCount + successCount; + + return `Import completed with ${failedCount} failure(s) out of ${totalCount} item(s). See error details.`; +}; + +/** Pick toast message based on import type — glossary vs shared/BM dialog. */ +export const getImportFailureToastMessage = ( + isGlossaryImport: boolean, + response: GlossaryImportResponse +): string => { + if (isGlossaryImport) { + return buildGlossaryImportFailureSummary(response); + } + + const failedList = response.failedImportInfoList; + if (failedList && failedList.length === 1) { + return failedList[0]?.remarks ?? "Import failed"; + } + + return buildGenericImportFailureSummary(response); +}; + +/** Pick error-detail line based on import type. */ +export const formatImportFailureForDisplay = ( + isGlossaryImport: boolean, + failure: GlossaryImportFailure +): string => { + if (isGlossaryImport) { + return formatGlossaryImportFailure(failure); + } + return formatGenericImportFailure(failure); +}; diff --git a/dashboard/src/views/Glossary/AddUpdateGlossaryForm.tsx b/dashboard/src/views/Glossary/AddUpdateGlossaryForm.tsx index 5f4a2285406..43833603990 100644 --- a/dashboard/src/views/Glossary/AddUpdateGlossaryForm.tsx +++ b/dashboard/src/views/Glossary/AddUpdateGlossaryForm.tsx @@ -28,6 +28,11 @@ import { postGlossaryImportFormData } from "@utils/glossaryImportFlow"; import { getApiErrorToastMessage } from "@utils/apiErrorToastMessage"; +import { + buildGlossaryImportFailureSummary, + formatGlossaryImportFailure, + GlossaryImportFailure +} from "@utils/glossaryImportUtils"; import { toast } from "react-toastify"; import { useCallback, useEffect, useRef, useState } from "react"; import type { MouseEvent } from "react"; @@ -163,7 +168,7 @@ const AddUpdateGlossaryForm = (props: { if (importResp.data.failedImportInfoList != undefined) { toast.dismiss(toastId.current); toastId.current = toast.error( - importResp.data.failedImportInfoList[0].remarks + buildGlossaryImportFailureSummary(importResp.data) ); setImportErrorDetails(true); } @@ -361,17 +366,15 @@ const AddUpdateGlossaryForm = (props: { > {importData.failedImportInfoList.map( - ( - value: { - index: number; - remarks: string; - }, - index: number - ) => ( - + (value: GlossaryImportFailure, index: number) => ( + ) diff --git a/dashboard/src/views/Glossary/__tests__/AddUpdateGlossaryForm.test.tsx b/dashboard/src/views/Glossary/__tests__/AddUpdateGlossaryForm.test.tsx index fbb8e57b5cb..ec89306f251 100644 --- a/dashboard/src/views/Glossary/__tests__/AddUpdateGlossaryForm.test.tsx +++ b/dashboard/src/views/Glossary/__tests__/AddUpdateGlossaryForm.test.tsx @@ -32,8 +32,11 @@ const mockEditGlossary = jest.fn(); const mockFetchGlossaryData = jest.fn(); const mockOnClose = jest.fn(); const mockToastSuccess = jest.fn(); +const mockToastError = jest.fn(); const mockToastDismiss = jest.fn(); const mockServerError = jest.fn(); +const mockPostGlossaryImportFormData = jest.fn(); +const mockDownloadGlossaryImportTemplate = jest.fn(); // Mock glossary data const mockGlossaryData = [ @@ -57,10 +60,33 @@ const mockGlossaryData = [ jest.mock('react-toastify', () => ({ toast: { success: (...args: any[]) => mockToastSuccess(...args), + error: (...args: any[]) => mockToastError(...args), dismiss: (...args: any[]) => mockToastDismiss(...args) } })); +jest.mock('@utils/glossaryImportFlow', () => ({ + postGlossaryImportFormData: (...args: any[]) => + mockPostGlossaryImportFormData(...args), + downloadGlossaryImportTemplate: (...args: any[]) => + mockDownloadGlossaryImportTemplate(...args) +})); + +jest.mock('@views/SideBar/Import/ImportLayout', () => ({ + __esModule: true, + default: ({ setFileData, setProgress }: any) => ( + + ) +})); + // Mock API methods jest.mock('@api/apiMethods/glossaryApiMethod', () => ({ createGlossary: (...args: any[]) => mockCreateGlossary(...args), @@ -156,10 +182,10 @@ jest.mock('@utils/Utils', () => { // Mock Modal component jest.mock('@components/Modal', () => ({ __esModule: true, - default: ({ open, onClose, children, title, button1Label, button1Handler, button2Label, button2Handler, disableButton2 }: any) => + default: ({ open, onClose, children, title, titleIcon, button1Label, button1Handler, button2Label, button2Handler, disableButton2 }: any) => open ? (
-
{title}
+
{titleIcon}{title}
{children}