Fixes #10354 Filter test suites by owner / name (#15826)

* feat: add list from search for test suite

* feat: add list from search and owner/name filter for test suites

* style: ran java linting

* fix: es log failure detail

* #10354 Filter test suites by owner / name

* added filter logic, cypress and fixed unit test

---------

Co-authored-by: Teddy Crepineau <teddy.crepineau@gmail.com>
This commit is contained in:
Shailesh Parmar 2024-04-10 19:28:55 +05:30 committed by GitHub
parent ce18766b96
commit 073d9ea57b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 259 additions and 51 deletions

View File

@ -31,7 +31,7 @@ import {
scheduleIngestion,
} from '../../common/Utils/Ingestion';
import { getToken } from '../../common/Utils/LocalStorage';
import { addOwner, removeOwner, updateOwner } from '../../common/Utils/Owner';
import { removeOwner, updateOwner } from '../../common/Utils/Owner';
import { goToServiceListingPage, Services } from '../../common/Utils/Services';
import {
DATA_QUALITY_SAMPLE_DATA_TABLE,
@ -67,25 +67,11 @@ const goToProfilerTab = () => {
cy.get('[data-testid="profiler"]').should('be.visible').click();
};
const clickOnTestSuite = (testSuiteName) => {
cy.get('[data-testid="test-suite-container"]').then(($body) => {
if ($body.find(`[data-testid="${testSuiteName}"]`).length) {
cy.get(`[data-testid="${testSuiteName}"]`).scrollIntoView().click();
} else {
if ($body.find('[data-testid="next"]').length) {
cy.get('[data-testid="next"]').click();
verifyResponseStatusCode('@testSuite', 200);
clickOnTestSuite(testSuiteName);
} else {
throw new Error('Test Suite not found');
}
}
});
};
const visitTestSuiteDetailsPage = (testSuiteName) => {
const visitTestSuiteDetailsPage = (testSuiteName: string) => {
interceptURL(
'GET',
'/api/v1/dataQuality/testSuites?*testSuiteType=logical*',
'/api/v1/dataQuality/testSuites/search/list?*testSuiteType=logical*',
'testSuite'
);
interceptURL('GET', '/api/v1/dataQuality/testCases?fields=*', 'testCase');
@ -94,7 +80,14 @@ const visitTestSuiteDetailsPage = (testSuiteName) => {
cy.get('[data-testid="by-test-suites"]').click();
verifyResponseStatusCode('@testSuite', 200);
clickOnTestSuite(testSuiteName);
interceptURL(
'GET',
`/api/v1/dataQuality/testSuites/search/list?*${testSuiteName}*testSuiteType=logical*`,
'testSuiteBySearch'
);
cy.get('[data-testid="search-bar-container"]').type(testSuiteName);
verifyResponseStatusCode('@testSuiteBySearch', 200);
cy.get(`[data-testid="${testSuiteName}"]`).scrollIntoView().click();
};
const verifyFilterTestCase = () => {
@ -476,7 +469,7 @@ describe(
const testCaseName = 'column_value_max_to_be_between';
interceptURL(
'GET',
'/api/v1/dataQuality/testSuites?*testSuiteType=logical*',
'/api/v1/dataQuality/testSuites/search/list?*testSuiteType=logical*',
'testSuite'
);
interceptURL(
@ -523,9 +516,9 @@ describe(
visitTestSuiteDetailsPage(NEW_TEST_SUITE.name);
addOwner(OWNER1);
updateOwner(OWNER2);
removeOwner(OWNER2);
updateOwner(OWNER1);
});
it('Add test case to logical test suite', () => {
@ -557,7 +550,7 @@ describe(
verifyResponseStatusCode('@putTestCase', 200);
});
it.skip('Remove test case from logical test suite', () => {
it('Remove test case from logical test suite', () => {
interceptURL('GET', '/api/v1/dataQuality/testCases?fields=*', 'testCase');
interceptURL(
'GET',
@ -582,7 +575,44 @@ describe(
verifyResponseStatusCode('@removeTestCase', 200);
});
it.skip('Delete test suite', () => {
it('Test suite filters', () => {
interceptURL(
'GET',
'/api/v1/dataQuality/testSuites/search/list?*testSuiteType=logical*',
'testSuite'
);
interceptURL(
'GET',
'/api/v1/dataQuality/testSuites/search/list?*owner=*',
'testSuiteByOwner'
);
cy.sidebarClick(SidebarItem.DATA_QUALITY);
cy.get('[data-testid="by-test-suites"]').click();
verifyResponseStatusCode('@testSuite', 200);
// owner filter
cy.get('[data-testid="owner-select-filter"]').click();
cy.get("[data-testid='select-owner-tabs']").should('be.visible');
cy.get('.ant-tabs [id*=tab-users]').click();
interceptURL(
'GET',
`api/v1/search/query?q=*&index=user_search_index*`,
'searchOwner'
);
cy.get('[data-testid="owner-select-users-search-bar"]').type(OWNER1);
verifyResponseStatusCode('@searchOwner', 200);
cy.get(`.ant-popover [title="${OWNER1}"]`).click();
verifyResponseStatusCode('@testSuiteByOwner', 200);
cy.get(`[data-testid="${NEW_TEST_SUITE.name}"]`)
.scrollIntoView()
.should('be.visible');
});
it('Delete test suite', () => {
visitTestSuiteDetailsPage(NEW_TEST_SUITE.name);
cy.get('[data-testid="manage-button"]').should('be.visible').click();
@ -613,7 +643,7 @@ describe(
.click();
verifyResponseStatusCode('@deleteTestSuite', 200);
toastNotification('Test Suite deleted successfully!');
toastNotification('"mysql_matrix" deleted successfully!');
});
it('delete created service', () => {

View File

@ -14,4 +14,5 @@ export type DataQualitySearchParams = {
searchValue: string;
status: string;
type: string;
owner: string;
};

View File

@ -10,10 +10,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Button, Col, Row } from 'antd';
import { Button, Col, Form, Row, Select, Space } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { AxiosError } from 'axios';
import { isString } from 'lodash';
import { isEmpty } from 'lodash';
import QueryString from 'qs';
import React, {
ReactNode,
@ -23,11 +23,18 @@ import React, {
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useParams } from 'react-router-dom';
import { getEntityDetailsPath, ROUTES } from '../../../../constants/constants';
import { Link, useHistory, useLocation, useParams } from 'react-router-dom';
import {
getEntityDetailsPath,
INITIAL_PAGING_VALUE,
ROUTES,
} from '../../../../constants/constants';
import { PROGRESS_BAR_COLOR } from '../../../../constants/TestSuite.constant';
import { usePermissionProvider } from '../../../../context/PermissionProvider/PermissionProvider';
import { ERROR_PLACEHOLDER_TYPE } from '../../../../enums/common.enum';
import {
ERROR_PLACEHOLDER_TYPE,
SORT_ORDER,
} from '../../../../enums/common.enum';
import { EntityTabs, EntityType } from '../../../../enums/entity.enum';
import { TestSummary } from '../../../../generated/entity/data/table';
import { EntityReference } from '../../../../generated/entity/type';
@ -35,8 +42,8 @@ import { TestSuite } from '../../../../generated/tests/testCase';
import { usePaging } from '../../../../hooks/paging/usePaging';
import { DataQualityPageTabs } from '../../../../pages/DataQuality/DataQualityPage.interface';
import {
getListTestSuites,
ListTestSuitePrams,
getListTestSuitesBySearch,
ListTestSuitePramsBySearch,
TestSuiteType,
} from '../../../../rest/testAPI';
import { getEntityName } from '../../../../utils/EntityUtils';
@ -47,14 +54,34 @@ import FilterTablePlaceHolder from '../../../common/ErrorWithPlaceholder/FilterT
import NextPrevious from '../../../common/NextPrevious/NextPrevious';
import { PagingHandlerParams } from '../../../common/NextPrevious/NextPrevious.interface';
import { OwnerLabel } from '../../../common/OwnerLabel/OwnerLabel.component';
import Searchbar from '../../../common/SearchBarComponent/SearchBar.component';
import Table from '../../../common/Table/Table';
import { UserTeamSelectableList } from '../../../common/UserTeamSelectableList/UserTeamSelectableList.component';
import { TableProfilerTab } from '../../../Database/Profiler/ProfilerDashboard/profilerDashboard.interface';
import ProfilerProgressWidget from '../../../Database/Profiler/TableProfiler/ProfilerProgressWidget/ProfilerProgressWidget';
import { DataQualitySearchParams } from '../../DataQuality.interface';
export const TestSuites = ({ summaryPanel }: { summaryPanel: ReactNode }) => {
const { t } = useTranslation();
const { tab = DataQualityPageTabs.TABLES } =
useParams<{ tab: DataQualityPageTabs }>();
const history = useHistory();
const location = useLocation();
const params = useMemo(() => {
const search = location.search;
const params = QueryString.parse(
search.startsWith('?') ? search.substring(1) : search
);
return params as DataQualitySearchParams;
}, [location]);
const { searchValue, owner } = params;
const selectedOwner = useMemo(
() => (owner ? JSON.parse(owner) : undefined),
[owner]
);
const { permissions } = usePermissionProvider();
const { testSuite: testSuitePermission } = permissions;
@ -71,6 +98,14 @@ export const TestSuites = ({ summaryPanel }: { summaryPanel: ReactNode }) => {
const [isLoading, setIsLoading] = useState<boolean>(true);
const ownerFilterValue = useMemo(() => {
return selectedOwner
? {
key: selectedOwner.fullyQualifiedName ?? selectedOwner.name,
label: getEntityName(selectedOwner),
}
: undefined;
}, [selectedOwner]);
const columns = useMemo(() => {
const data: ColumnsType<TestSuite> = [
{
@ -153,17 +188,27 @@ export const TestSuites = ({ summaryPanel }: { summaryPanel: ReactNode }) => {
return data;
}, []);
const fetchTestSuites = async (params?: ListTestSuitePrams) => {
const fetchTestSuites = async (
currentPage = INITIAL_PAGING_VALUE,
params?: ListTestSuitePramsBySearch
) => {
setIsLoading(true);
try {
const result = await getListTestSuites({
const result = await getListTestSuitesBySearch({
...params,
fields: 'owner,summary',
includeEmptyTestSuites: !(tab === DataQualityPageTabs.TABLES),
q: searchValue ? `*${searchValue}*` : undefined,
owner: ownerFilterValue?.key,
offset: (currentPage - 1) * pageSize,
includeEmptyTestSuites: tab !== DataQualityPageTabs.TABLES,
testSuiteType:
tab === DataQualityPageTabs.TABLES
? TestSuiteType.executable
: TestSuiteType.logical,
sortField: 'testCaseResultSummary.timestamp',
sortType: SORT_ORDER.DESC,
sortNestedPath: 'testCaseResultSummary',
sortNestedMode: ['max'],
});
setTestSuites(result.data);
handlePagingChange(result.paging);
@ -175,25 +220,38 @@ export const TestSuites = ({ summaryPanel }: { summaryPanel: ReactNode }) => {
};
const handleTestSuitesPageChange = useCallback(
({ cursorType, currentPage }: PagingHandlerParams) => {
if (isString(cursorType)) {
fetchTestSuites({
[cursorType]: paging?.[cursorType],
limit: pageSize,
});
}
({ currentPage }: PagingHandlerParams) => {
fetchTestSuites(currentPage, { limit: pageSize });
handlePageChange(currentPage);
},
[pageSize, paging]
);
const handleSearchParam = (
value: string,
key: keyof DataQualitySearchParams
) => {
history.push({
search: QueryString.stringify({
...params,
[key]: isEmpty(value) ? undefined : value,
}),
});
};
const handleOwnerSelect = (owner?: EntityReference) => {
handleSearchParam(owner ? JSON.stringify(owner) : '', 'owner');
};
useEffect(() => {
if (testSuitePermission?.ViewAll || testSuitePermission?.ViewBasic) {
fetchTestSuites({ limit: pageSize });
fetchTestSuites(INITIAL_PAGING_VALUE, {
limit: pageSize,
});
} else {
setIsLoading(false);
}
}, [testSuitePermission, pageSize]);
}, [testSuitePermission, pageSize, searchValue, owner]);
if (!testSuitePermission?.ViewAll && !testSuitePermission?.ViewBasic) {
return <ErrorPlaceHolder type={ERROR_PLACEHOLDER_TYPE.PERMISSION} />;
@ -201,11 +259,45 @@ export const TestSuites = ({ summaryPanel }: { summaryPanel: ReactNode }) => {
return (
<Row
className="p-x-lg p-t-md"
className="p-x-lg p-y-md"
data-testid="test-suite-container"
gutter={[16, 16]}>
<Col span={24}>
<Row justify="end">
<Row justify="space-between">
<Col>
<Form layout="inline">
<Space
align="center"
className="w-full justify-between"
size={16}>
<Form.Item className="m-0 w-80">
<Searchbar
removeMargin
searchValue={searchValue}
onSearch={(value) =>
handleSearchParam(value, 'searchValue')
}
/>
</Form.Item>
<Form.Item
className="m-0 w-52"
label={t('label.owner')}
name="owner">
<UserTeamSelectableList
hasPermission
owner={selectedOwner}
onUpdate={handleOwnerSelect}>
<Select
data-testid="owner-select-filter"
open={false}
placeholder={t('label.owner')}
value={ownerFilterValue}
/>
</UserTeamSelectableList>
</Form.Item>
</Space>
</Form>
</Col>
<Col>
{tab === DataQualityPageTabs.TEST_SUITES &&
testSuitePermission?.Create && (
@ -239,6 +331,7 @@ export const TestSuites = ({ summaryPanel }: { summaryPanel: ReactNode }) => {
<Col span={24}>
{showPagination && (
<NextPrevious
isNumberBased
currentPage={currentPage}
pageSize={pageSize}
paging={paging}

View File

@ -14,7 +14,7 @@ import { render, screen } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { DataQualityPageTabs } from '../../../../pages/DataQuality/DataQualityPage.interface';
import { getListTestSuites } from '../../../../rest/testAPI';
import { getListTestSuitesBySearch } from '../../../../rest/testAPI';
import { TestSuites } from './TestSuites.component';
const testSuitePermission = {
@ -29,6 +29,9 @@ const testSuitePermission = {
const mockUseParam = { tab: DataQualityPageTabs.TABLES } as {
tab?: DataQualityPageTabs;
};
const mockLocation = {
search: '',
};
jest.mock('../../../../context/PermissionProvider/PermissionProvider', () => ({
usePermissionProvider: jest.fn().mockImplementation(() => ({
@ -40,7 +43,7 @@ jest.mock('../../../../context/PermissionProvider/PermissionProvider', () => ({
jest.mock('../../../../rest/testAPI', () => {
return {
...jest.requireActual('../../../../rest/testAPI'),
getListTestSuites: jest
getListTestSuitesBySearch: jest
.fn()
.mockImplementation(() =>
Promise.resolve({ data: [], paging: { total: 0 } })
@ -49,13 +52,30 @@ jest.mock('../../../../rest/testAPI', () => {
});
jest.mock('react-router-dom', () => {
return {
...jest.requireActual('react-router-dom'),
Link: jest
.fn()
.mockImplementation(({ children, ...rest }) => (
<div {...rest}>{children}</div>
)),
useLocation: jest.fn().mockImplementation(() => mockLocation),
useHistory: jest.fn(),
useParams: jest.fn().mockImplementation(() => mockUseParam),
};
});
jest.mock('../../../common/NextPrevious/NextPrevious', () => {
return jest.fn().mockImplementation(() => <div>NextPrevious.component</div>);
});
jest.mock(
'../../../common/UserTeamSelectableList/UserTeamSelectableList.component',
() => ({
UserTeamSelectableList: jest
.fn()
.mockImplementation(({ children }) => <div>{children}</div>),
})
);
jest.mock('../../../common/SearchBarComponent/SearchBar.component', () => {
return jest.fn().mockImplementation(() => <div>SearchBar.component</div>);
});
jest.mock('../../../common/ErrorWithPlaceholder/ErrorPlaceHolder', () => {
return jest
.fn()
@ -84,13 +104,17 @@ describe('TestSuites component', () => {
'label.owner',
]);
expect(await screen.findByTestId('test-suite-table')).toBeInTheDocument();
expect(
await screen.findByTestId('owner-select-filter')
).toBeInTheDocument();
expect(await screen.findByText('SearchBar.component')).toBeInTheDocument();
expect(
await screen.findByText('SummaryPanel.component')
).toBeInTheDocument();
});
it('should send testSuiteType executable in api, if active tab is tables', async () => {
const mockGetListTestSuites = getListTestSuites as jest.Mock;
const mockGetListTestSuites = getListTestSuitesBySearch as jest.Mock;
render(<TestSuites {...mockProps} />);
@ -101,12 +125,41 @@ describe('TestSuites component', () => {
fields: 'owner,summary',
includeEmptyTestSuites: false,
limit: 15,
offset: 0,
owner: undefined,
q: undefined,
sortField: 'testCaseResultSummary.timestamp',
sortNestedMode: ['max'],
sortNestedPath: 'testCaseResultSummary',
sortType: 'desc',
testSuiteType: 'executable',
});
});
it('filters API call should be made, if owner is selected', async () => {
mockLocation.search =
'?owner={"id":"84c3e66f-a4a6-42ab-b85c-b578f46d3bca","type":"user","name":"admin","fullyQualifiedName":"admin"}&searchValue=sales';
testSuitePermission.ViewAll = true;
const mockGetListTestSuites = getListTestSuitesBySearch as jest.Mock;
render(<TestSuites {...mockProps} />, { wrapper: MemoryRouter });
expect(mockGetListTestSuites).toHaveBeenCalledWith({
fields: 'owner,summary',
includeEmptyTestSuites: false,
limit: 15,
offset: 0,
owner: 'admin',
q: '*sales*',
sortField: 'testCaseResultSummary.timestamp',
sortNestedMode: ['max'],
sortNestedPath: 'testCaseResultSummary',
sortType: 'desc',
testSuiteType: 'executable',
});
});
it('pagination should visible if total is grater than 15', async () => {
(getListTestSuites as jest.Mock).mockImplementationOnce(() =>
(getListTestSuitesBySearch as jest.Mock).mockImplementationOnce(() =>
Promise.resolve({ data: [], paging: { total: 16 } })
);
@ -126,8 +179,9 @@ describe('TestSuites component', () => {
});
it('should send testSuiteType logical in api, if active tab is tables', async () => {
mockLocation.search = '';
mockUseParam.tab = DataQualityPageTabs.TEST_SUITES;
const mockGetListTestSuites = getListTestSuites as jest.Mock;
const mockGetListTestSuites = getListTestSuitesBySearch as jest.Mock;
render(<TestSuites {...mockProps} />, { wrapper: MemoryRouter });
@ -138,6 +192,13 @@ describe('TestSuites component', () => {
fields: 'owner,summary',
includeEmptyTestSuites: true,
limit: 15,
offset: 0,
owner: undefined,
q: undefined,
sortField: 'testCaseResultSummary.timestamp',
sortNestedMode: ['max'],
sortNestedPath: 'testCaseResultSummary',
sortType: 'desc',
testSuiteType: 'logical',
});
});

View File

@ -145,6 +145,7 @@ export const UserTeamSelectableList = ({
id: updateItems[0].id,
type: activeTab === 'teams' ? EntityType.TEAM : EntityType.USER,
name: updateItems[0].name,
fullyQualifiedName: updateItems[0].fullyQualifiedName,
displayName: updateItems[0].displayName,
}
);

View File

@ -47,6 +47,15 @@ export type ListTestSuitePrams = ListParams & {
testSuiteType?: TestSuiteType;
includeEmptyTestSuites?: boolean;
};
export type ListTestSuitePramsBySearch = ListTestSuitePrams & {
q?: string;
sortType?: SORT_ORDER;
sortNestedMode?: string[];
sortNestedPath?: string;
sortField?: string;
owner?: string;
offset?: number;
};
export type ListTestCaseParams = ListParams & {
entityLink?: string;
@ -245,6 +254,19 @@ export const getListTestSuites = async (params?: ListTestSuitePrams) => {
return response.data;
};
export const getListTestSuitesBySearch = async (
params?: ListTestSuitePramsBySearch
) => {
const response = await APIClient.get<PagingResponse<TestSuite[]>>(
`${testSuiteUrl}/search/list`,
{
params,
}
);
return response.data;
};
export const createTestSuites = async (data: CreateTestSuite) => {
const response = await APIClient.post<
CreateTestSuite,