diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.test.tsx index a0c1cd83bf17..c25e658bfc90 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.test.tsx @@ -10,13 +10,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { AxiosError } from 'axios'; import { DEFAULT_DOMAIN_VALUE } from '../../../constants/constants'; import { EntityType } from '../../../enums/entity.enum'; import { Domain, DomainType } from '../../../generated/entity/domains/domain'; import { EntityReference } from '../../../generated/entity/type'; import * as domainAPI from '../../../rest/domainAPI'; import { convertDomainsToTreeOptions } from '../../../utils/DomainUtils'; +import { showErrorToast } from '../../../utils/ToastUtils'; import DomainSelectableTree from './DomainSelectableTree'; const mockDomains: Domain[] = [ @@ -108,6 +110,11 @@ jest.mock('../../../utils/EntityReferenceUtils', () => ({ jest.mock('../../../utils/StringUtils', () => ({ escapeESReservedCharacters: jest.fn().mockImplementation((value) => value), getEncodedFqn: jest.fn().mockImplementation((value) => value), + getErrorText: jest + .fn() + .mockImplementation( + (error, fallback) => error?.response?.data?.message ?? fallback + ), })); jest.mock('../../../utils/ToastUtils', () => ({ @@ -433,4 +440,111 @@ describe('DomainSelectableTree', () => { expect(screen.getByText('label.no-entity-available')).toBeInTheDocument(); }); }); + + it('should show an inline error inside the list when a search fails', async () => { + const error = new AxiosError('Request failed with status code 400'); + jest.spyOn(domainAPI, 'searchDomains').mockRejectedValueOnce(error); + + renderComponent(); + + await waitFor(() => { + expect(screen.queryByText('Loader')).not.toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('searchbar'), { + target: { value: 'a||||b' }, + }); + + await waitFor(() => { + expect(screen.getByTestId('domain-search-error')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('retry-domain-search')).toBeInTheDocument(); + expect(screen.queryByText('Loader')).not.toBeInTheDocument(); + expect(showErrorToast).not.toHaveBeenCalled(); + // no message on the error, so the static fallback is shown + expect(screen.getByText('server.entity-fetch-error')).toBeInTheDocument(); + }); + + it("should show the server's own message when the response carries one", async () => { + const error = new AxiosError('Request failed with status code 400'); + error.response = { + status: 400, + data: { code: 400, message: 'Invalid field name childrenCount' }, + } as never; + jest.spyOn(domainAPI, 'searchDomains').mockRejectedValueOnce(error); + + renderComponent(); + + fireEvent.change(screen.getByTestId('searchbar'), { + target: { value: 'eng' }, + }); + + await waitFor(() => { + expect( + screen.getByText('Invalid field name childrenCount') + ).toBeInTheDocument(); + }); + + expect( + screen.queryByText('server.entity-fetch-error') + ).not.toBeInTheDocument(); + }); + + it('should retry the search from the inline error and recover', async () => { + jest + .spyOn(domainAPI, 'searchDomains') + .mockRejectedValueOnce( + new AxiosError('Request failed with status code 400') + ) + .mockResolvedValueOnce([mockDomains[0]]); + + renderComponent(); + + fireEvent.change(screen.getByTestId('searchbar'), { + target: { value: 'Engineering' }, + }); + + await waitFor(() => { + expect(screen.getByTestId('domain-search-error')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('retry-domain-search')); + + await waitFor(() => { + expect( + screen.queryByTestId('domain-search-error') + ).not.toBeInTheDocument(); + }); + + expect(domainAPI.searchDomains).toHaveBeenCalledTimes(2); + }); + + it('should clear the inline error when the search box is cleared', async () => { + jest + .spyOn(domainAPI, 'searchDomains') + .mockRejectedValueOnce( + new AxiosError('Request failed with status code 400') + ); + + renderComponent(); + + fireEvent.change(screen.getByTestId('searchbar'), { + target: { value: 'a||||b' }, + }); + + await waitFor(() => { + expect(screen.getByTestId('domain-search-error')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('searchbar'), { + target: { value: '' }, + }); + + await waitFor(() => { + expect( + screen.queryByTestId('domain-search-error') + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.tsx index 5d062f6f0072..de9331a9f093 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.tsx @@ -52,6 +52,7 @@ import { getEntityReferenceFromEntity } from '../../../utils/EntityReferenceUtil import { escapeESReservedCharacters, getEncodedFqn, + getErrorText, } from '../../../utils/StringUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; import Loader from '../Loader/Loader'; @@ -100,6 +101,7 @@ const DomainSelectablTree: FC = ({ > >({}); const [domainMapper, setDomainMapper] = useState>({}); + const [searchError, setSearchError] = useState(); const { activeDomain } = useDomainStore(); const pagingRef = useRef(INITIAL_PAGING_STATE); @@ -352,6 +354,7 @@ const DomainSelectablTree: FC = ({ async (isLoadMore = false) => { const setLoadingState = isLoadMore ? setIsLoadingMore : setIsLoading; setLoadingState(true); + setSearchError(undefined); if (!isLoadMore) { setPaging(INITIAL_PAGING_STATE); @@ -449,6 +452,7 @@ const DomainSelectablTree: FC = ({ if (value) { try { setIsLoading(true); + setSearchError(undefined); const encodedValue = getEncodedFqn(escapeESReservedCharacters(value)); const results: Domain[] = await searchDomains(encodedValue); const filteredResults = restrictedDomains?.length @@ -475,6 +479,9 @@ const DomainSelectablTree: FC = ({ ); setTreeData(updatedTreeData); setDomains(uniqueData); + } catch (error) { + setSearchError(error as AxiosError); + setTreeData([]); } finally { setIsLoading(false); } @@ -531,6 +538,34 @@ const DomainSelectablTree: FC = ({ const treeContent = useMemo(() => { if (isLoading) { return ; + } else if (searchError) { + return ( + + + {getErrorText( + searchError, + t('server.entity-fetch-error', { + entity: t('label.domain-plural'), + }) + )} + + + + ); } else if (treeData.length === 0) { return ( = ({ ); } }, [ + searchError, isLoading, isLoadingMore, isSubmitLoading, + onSearch, treeData, value, onSelect, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTreeNew.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTreeNew.tsx index 7cc9b8ad576f..f945cafe7324 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTreeNew.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTreeNew.tsx @@ -54,6 +54,7 @@ import { getEntityReferenceFromEntity } from '../../../utils/EntityReferenceUtil import { escapeESReservedCharacters, getEncodedFqn, + getErrorText, } from '../../../utils/StringUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; import Loader from '../Loader/Loader'; @@ -99,6 +100,7 @@ const DomainSelectablTreeNew: FC = ({ > >({}); const [domainMapper, setDomainMapper] = useState>({}); + const [searchError, setSearchError] = useState(); const pagingRef = useRef(INITIAL_PAGING_STATE); const scrollContainerRef = useRef(null); @@ -346,6 +348,7 @@ const DomainSelectablTreeNew: FC = ({ async (isLoadMore = false) => { const setLoadingState = isLoadMore ? setIsLoadingMore : setIsLoading; setLoadingState(true); + setSearchError(undefined); if (!isLoadMore) { setPaging(INITIAL_PAGING_STATE); @@ -451,6 +454,7 @@ const DomainSelectablTreeNew: FC = ({ if (value) { try { setIsLoading(true); + setSearchError(undefined); const encodedValue = getEncodedFqn(escapeESReservedCharacters(value)); const results: Domain[] = await searchDomains(encodedValue); @@ -473,6 +477,9 @@ const DomainSelectablTreeNew: FC = ({ ); setTreeData(updatedTreeData); setDomains(uniqueData); + } catch (error) { + setSearchError(error as AxiosError); + setTreeData([]); } finally { setIsLoading(false); } @@ -523,6 +530,29 @@ const DomainSelectablTreeNew: FC = ({ const treeContent = useMemo(() => { if (isLoading) { return ; + } else if (searchError) { + return ( +
+
+ {getErrorText( + searchError, + t('server.entity-fetch-error', { + entity: t('label.domain-plural'), + }) + )} +
+ +
+ ); } else if (treeData.length === 0) { return (
@@ -564,8 +594,10 @@ const DomainSelectablTreeNew: FC = ({ ); } }, [ + searchError, isLoading, isLoadingMore, + onSearch, treeData, selectedKeys, onSelect, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/StringUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/StringUtils.test.ts index 238d9dbccb1b..4eaea67e9536 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/StringUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/StringUtils.test.ts @@ -31,6 +31,7 @@ jest.mock('./i18next/LocalUtil', () => ({ import { AxiosError } from 'axios'; import { decodeHtmlEntities, + escapeESReservedCharacters, formatJsonString, getDecodedFqn, getEncodedFqn, @@ -452,4 +453,33 @@ describe('StringUtils', () => { expect(result).not.toContain('line-four'); }); }); + + describe('escapeESReservedCharacters', () => { + it('should escape every Lucene reserved character', () => { + expect(escapeESReservedCharacters('a+b-c=d&e')).toBe( + String.raw`a\+b\-c\=d\&e` + ); + expect(escapeESReservedCharacters('(a){b}[c]')).toBe( + String.raw`\(a\)\{b\}\[c\]` + ); + expect(escapeESReservedCharacters('a*b?c:d/e')).toBe( + String.raw`a\*b\?c\:d\/e` + ); + }); + + it('should escape a single pipe so consecutive pipes cannot form an OR operator', () => { + expect(escapeESReservedCharacters('a|b')).toBe(String.raw`a\|b`); + expect(escapeESReservedCharacters('a||||b')).toBe(String.raw`a\|\|\|\|b`); + }); + + it('should leave a plain term untouched', () => { + expect(escapeESReservedCharacters('Customer Support')).toBe( + 'Customer Support' + ); + }); + + it('should return an empty string for an undefined term', () => { + expect(escapeESReservedCharacters()).toBe(''); + }); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/StringUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/StringUtils.ts index 3d5660be117a..8388967a378e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/StringUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/StringUtils.ts @@ -267,6 +267,7 @@ export const ES_RESERVED_CHARACTERS: Record = { '=': String.raw`\=`, '&': String.raw`\&`, '&&': String.raw`\&&`, + '|': String.raw`\|`, '||': String.raw`\||`, '>': String.raw`\>`, '<': String.raw`\<`,