mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-09-09 00:51:42 +00:00
UI: Refector explore page (#925)
* UI: refactor explore page * rename index to exlore.component for mock file and test file
This commit is contained in:
parent
42c4840f06
commit
6f46ebbfef
@ -16,21 +16,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { findAllByTestId, findByTestId, render } from '@testing-library/react';
|
import { findAllByTestId, findByTestId, render } from '@testing-library/react';
|
||||||
|
import { SearchResponse } from 'Models';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { MemoryRouter } from 'react-router';
|
import { MemoryRouter } from 'react-router';
|
||||||
import ExplorePage from './index';
|
import { mockResponse } from './exlore.mock';
|
||||||
import { mockResponse } from './index.mock';
|
import Explore from './Explore.component';
|
||||||
jest.mock('react-router-dom', () => ({
|
jest.mock('react-router-dom', () => ({
|
||||||
useHistory: jest.fn(),
|
useHistory: jest.fn(),
|
||||||
useParams: jest.fn().mockImplementation(() => ({ searchQuery: '' })),
|
|
||||||
useLocation: jest.fn().mockImplementation(() => ({ search: '' })),
|
useLocation: jest.fn().mockImplementation(() => ({ search: '' })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../axiosAPIs/miscAPI', () => ({
|
|
||||||
searchData: jest
|
|
||||||
.fn()
|
|
||||||
.mockImplementation(() => Promise.resolve({ data: mockResponse })),
|
|
||||||
}));
|
|
||||||
jest.mock('../../utils/FilterUtils', () => ({
|
jest.mock('../../utils/FilterUtils', () => ({
|
||||||
getFilterString: jest.fn().mockImplementation(() => 'user.address'),
|
getFilterString: jest.fn().mockImplementation(() => 'user.address'),
|
||||||
}));
|
}));
|
||||||
@ -45,15 +40,47 @@ jest.mock('../../components/searched-data/SearchedData', () => {
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.mock('../../hooks/useToastContext', () => {
|
const handleSearchText = jest.fn();
|
||||||
return () => jest.fn();
|
const updateTableCount = jest.fn();
|
||||||
});
|
const updateTopicCount = jest.fn();
|
||||||
|
const updateDashboardCount = jest.fn();
|
||||||
|
const updatePipelineCount = jest.fn();
|
||||||
|
const fetchData = jest.fn();
|
||||||
|
|
||||||
describe('Test Explore page', () => {
|
const mockSearchResult = {
|
||||||
|
resSearchResults: mockResponse as unknown as SearchResponse,
|
||||||
|
resAggServiceType: mockResponse as unknown as SearchResponse,
|
||||||
|
resAggTier: mockResponse as unknown as SearchResponse,
|
||||||
|
resAggTag: mockResponse as unknown as SearchResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Test Explore component', () => {
|
||||||
it('Component should render', async () => {
|
it('Component should render', async () => {
|
||||||
const { container } = render(<ExplorePage />, {
|
const { container } = render(
|
||||||
|
<Explore
|
||||||
|
error=""
|
||||||
|
fetchData={fetchData}
|
||||||
|
handleSearchText={handleSearchText}
|
||||||
|
isLoading={false}
|
||||||
|
searchQuery=""
|
||||||
|
searchResult={mockSearchResult}
|
||||||
|
searchText=""
|
||||||
|
tab=""
|
||||||
|
tabCounts={{
|
||||||
|
table: 15,
|
||||||
|
topic: 2,
|
||||||
|
dashboard: 8,
|
||||||
|
pipeline: 5,
|
||||||
|
}}
|
||||||
|
updateDashboardCount={updateDashboardCount}
|
||||||
|
updatePipelineCount={updatePipelineCount}
|
||||||
|
updateTableCount={updateTableCount}
|
||||||
|
updateTopicCount={updateTopicCount}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
wrapper: MemoryRouter,
|
wrapper: MemoryRouter,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
const pageContainer = await findByTestId(container, 'fluid-container');
|
const pageContainer = await findByTestId(container, 'fluid-container');
|
||||||
const searchData = await findByTestId(container, 'search-data');
|
const searchData = await findByTestId(container, 'search-data');
|
||||||
const wrappedContent = await findByTestId(container, 'wrapped-content');
|
const wrappedContent = await findByTestId(container, 'wrapped-content');
|
@ -15,20 +15,17 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { AxiosError } from 'axios';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { cloneDeep } from 'lodash';
|
import { cloneDeep } from 'lodash';
|
||||||
import {
|
import {
|
||||||
AggregationType,
|
AggregationType,
|
||||||
Bucket,
|
|
||||||
FilterObject,
|
FilterObject,
|
||||||
FormatedTableData,
|
FormatedTableData,
|
||||||
SearchResponse,
|
SearchResponse,
|
||||||
} from 'Models';
|
} from 'Models';
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useHistory, useLocation, useParams } from 'react-router-dom';
|
import { useHistory, useLocation } from 'react-router-dom';
|
||||||
import AppState from '../../AppState';
|
import AppState from '../../AppState';
|
||||||
import { searchData } from '../../axiosAPIs/miscAPI';
|
|
||||||
import { Button } from '../../components/buttons/Button/Button';
|
import { Button } from '../../components/buttons/Button/Button';
|
||||||
import ErrorPlaceHolderES from '../../components/common/error-with-placeholder/ErrorPlaceHolderES';
|
import ErrorPlaceHolderES from '../../components/common/error-with-placeholder/ErrorPlaceHolderES';
|
||||||
import FacetFilter from '../../components/common/facetfilter/FacetFilter';
|
import FacetFilter from '../../components/common/facetfilter/FacetFilter';
|
||||||
@ -36,7 +33,6 @@ import PageContainer from '../../components/containers/PageContainer';
|
|||||||
import DropDownList from '../../components/dropdown/DropDownList';
|
import DropDownList from '../../components/dropdown/DropDownList';
|
||||||
import SearchedData from '../../components/searched-data/SearchedData';
|
import SearchedData from '../../components/searched-data/SearchedData';
|
||||||
import {
|
import {
|
||||||
ERROR500,
|
|
||||||
getExplorePathWithSearch,
|
getExplorePathWithSearch,
|
||||||
PAGE_SIZE,
|
PAGE_SIZE,
|
||||||
tableSortingFields,
|
tableSortingFields,
|
||||||
@ -44,16 +40,14 @@ import {
|
|||||||
} from '../../constants/constants';
|
} from '../../constants/constants';
|
||||||
import { SearchIndex } from '../../enums/search.enum';
|
import { SearchIndex } from '../../enums/search.enum';
|
||||||
import { usePrevious } from '../../hooks/usePrevious';
|
import { usePrevious } from '../../hooks/usePrevious';
|
||||||
import useToastContext from '../../hooks/useToastContext';
|
|
||||||
import { getAggregationList } from '../../utils/AggregationUtils';
|
import { getAggregationList } from '../../utils/AggregationUtils';
|
||||||
import { formatDataResponse } from '../../utils/APIUtils';
|
import { formatDataResponse } from '../../utils/APIUtils';
|
||||||
import { getCountBadge } from '../../utils/CommonUtils';
|
import { getCountBadge } from '../../utils/CommonUtils';
|
||||||
import { getFilterString } from '../../utils/FilterUtils';
|
import { getFilterString } from '../../utils/FilterUtils';
|
||||||
import { getTotalEntityCountByService } from '../../utils/ServiceUtils';
|
|
||||||
import { dropdownIcon as DropDownIcon } from '../../utils/svgconstant';
|
import { dropdownIcon as DropDownIcon } from '../../utils/svgconstant';
|
||||||
import SVGIcons from '../../utils/SvgUtils';
|
import SVGIcons from '../../utils/SvgUtils';
|
||||||
import { getAggrWithDefaultValue, tabsInfo } from './explore.constants';
|
import { getAggrWithDefaultValue, tabsInfo } from './explore.constants';
|
||||||
import { Params } from './explore.interface';
|
import { ExploreProps } from './explore.interface';
|
||||||
|
|
||||||
const getQueryParam = (urlSearchQuery = ''): FilterObject => {
|
const getQueryParam = (urlSearchQuery = ''): FilterObject => {
|
||||||
const arrSearchQuery = urlSearchQuery
|
const arrSearchQuery = urlSearchQuery
|
||||||
@ -125,36 +119,43 @@ const getCurrentIndex = (tab: string) => {
|
|||||||
return currentIndex;
|
return currentIndex;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ExplorePage: React.FC = (): React.ReactElement => {
|
const Explore: React.FC<ExploreProps> = ({
|
||||||
|
tabCounts,
|
||||||
|
searchText,
|
||||||
|
tab,
|
||||||
|
searchQuery,
|
||||||
|
searchResult,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
handleSearchText,
|
||||||
|
fetchData,
|
||||||
|
updateTableCount,
|
||||||
|
updateTopicCount,
|
||||||
|
updateDashboardCount,
|
||||||
|
updatePipelineCount,
|
||||||
|
}: ExploreProps) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const filterObject: FilterObject = {
|
const filterObject: FilterObject = {
|
||||||
...{ tags: [], service: [], tier: [] },
|
...{ tags: [], service: [], tier: [] },
|
||||||
...getQueryParam(location.search),
|
...getQueryParam(location.search),
|
||||||
};
|
};
|
||||||
const showToast = useToastContext();
|
|
||||||
const { searchQuery, tab } = useParams<Params>();
|
|
||||||
const [searchText, setSearchText] = useState<string>(searchQuery || '');
|
|
||||||
const [data, setData] = useState<Array<FormatedTableData>>([]);
|
const [data, setData] = useState<Array<FormatedTableData>>([]);
|
||||||
const [filters, setFilters] = useState<FilterObject>(filterObject);
|
const [filters, setFilters] = useState<FilterObject>(filterObject);
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
const [totalNumberOfValue, setTotalNumberOfValues] = useState<number>(0);
|
const [totalNumberOfValue, setTotalNumberOfValues] = useState<number>(0);
|
||||||
const [aggregations, setAggregations] = useState<Array<AggregationType>>([]);
|
const [aggregations, setAggregations] = useState<Array<AggregationType>>([]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
||||||
const [searchTag, setSearchTag] = useState<string>(location.search);
|
const [searchTag, setSearchTag] = useState<string>(location.search);
|
||||||
const [error, setError] = useState<string>('');
|
|
||||||
const [fieldListVisible, setFieldListVisible] = useState<boolean>(false);
|
const [fieldListVisible, setFieldListVisible] = useState<boolean>(false);
|
||||||
const [sortField, setSortField] = useState<string>('');
|
const [sortField, setSortField] = useState<string>('');
|
||||||
const [sortOrder, setSortOrder] = useState<string>('desc');
|
const [sortOrder, setSortOrder] = useState<string>('desc');
|
||||||
const [searchIndex, setSearchIndex] = useState<string>(getCurrentIndex(tab));
|
const [searchIndex, setSearchIndex] = useState<string>(getCurrentIndex(tab));
|
||||||
const [currentTab, setCurrentTab] = useState<number>(getCurrentTab(tab));
|
const [currentTab, setCurrentTab] = useState<number>(getCurrentTab(tab));
|
||||||
const [tableCount, setTableCount] = useState<number>(0);
|
|
||||||
const [topicCount, setTopicCount] = useState<number>(0);
|
|
||||||
const [dashboardCount, setDashboardCount] = useState<number>(0);
|
|
||||||
const [pipelineCount, setPipelineCount] = useState<number>(0);
|
|
||||||
const [fieldList, setFieldList] =
|
const [fieldList, setFieldList] =
|
||||||
useState<Array<{ name: string; value: string }>>(tableSortingFields);
|
useState<Array<{ name: string; value: string }>>(tableSortingFields);
|
||||||
const isMounting = useRef(true);
|
const isMounting = useRef(true);
|
||||||
|
const forceSetAgg = useRef(false);
|
||||||
const previsouIndex = usePrevious(searchIndex);
|
const previsouIndex = usePrevious(searchIndex);
|
||||||
|
|
||||||
const handleSelectedFilter = (
|
const handleSelectedFilter = (
|
||||||
@ -238,19 +239,19 @@ const ExplorePage: React.FC = (): React.ReactElement => {
|
|||||||
const setCount = (count = 0) => {
|
const setCount = (count = 0) => {
|
||||||
switch (searchIndex) {
|
switch (searchIndex) {
|
||||||
case SearchIndex.TABLE:
|
case SearchIndex.TABLE:
|
||||||
setTableCount(count);
|
updateTableCount(count);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case SearchIndex.DASHBOARD:
|
case SearchIndex.DASHBOARD:
|
||||||
setDashboardCount(count);
|
updateDashboardCount(count);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case SearchIndex.TOPIC:
|
case SearchIndex.TOPIC:
|
||||||
setTopicCount(count);
|
updateTopicCount(count);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case SearchIndex.PIPELINE:
|
case SearchIndex.PIPELINE:
|
||||||
setPipelineCount(count);
|
updatePipelineCount(count);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@ -258,176 +259,47 @@ const ExplorePage: React.FC = (): React.ReactElement => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchCounts = () => {
|
const fetchTableData = () => {
|
||||||
const emptyValue = '';
|
const fetchParams = [
|
||||||
const tableCount = searchData(
|
{
|
||||||
searchText,
|
queryString: searchText,
|
||||||
0,
|
from: currentPage,
|
||||||
0,
|
size: PAGE_SIZE,
|
||||||
emptyValue,
|
filters: getFilterString(filters),
|
||||||
emptyValue,
|
sortField: sortField,
|
||||||
emptyValue,
|
sortOrder: sortOrder,
|
||||||
SearchIndex.TABLE
|
searchIndex: searchIndex,
|
||||||
);
|
},
|
||||||
const topicCount = searchData(
|
{
|
||||||
searchText,
|
queryString: searchText,
|
||||||
0,
|
from: currentPage,
|
||||||
0,
|
size: 0,
|
||||||
emptyValue,
|
filters: getFilterString(filters, ['service']),
|
||||||
emptyValue,
|
sortField: sortField,
|
||||||
emptyValue,
|
sortOrder: sortOrder,
|
||||||
SearchIndex.TOPIC
|
searchIndex: searchIndex,
|
||||||
);
|
},
|
||||||
const dashboardCount = searchData(
|
{
|
||||||
searchText,
|
queryString: searchText,
|
||||||
0,
|
from: currentPage,
|
||||||
0,
|
size: 0,
|
||||||
emptyValue,
|
filters: getFilterString(filters, ['tier']),
|
||||||
emptyValue,
|
sortField: sortField,
|
||||||
emptyValue,
|
sortOrder: sortOrder,
|
||||||
SearchIndex.DASHBOARD
|
searchIndex: searchIndex,
|
||||||
);
|
},
|
||||||
const pipelineCount = searchData(
|
{
|
||||||
searchText,
|
queryString: searchText,
|
||||||
0,
|
from: currentPage,
|
||||||
0,
|
size: 0,
|
||||||
emptyValue,
|
filters: getFilterString(filters, ['tags']),
|
||||||
emptyValue,
|
sortField: sortField,
|
||||||
emptyValue,
|
sortOrder: sortOrder,
|
||||||
SearchIndex.PIPELINE
|
searchIndex: searchIndex,
|
||||||
);
|
},
|
||||||
Promise.allSettled([
|
];
|
||||||
tableCount,
|
|
||||||
topicCount,
|
|
||||||
dashboardCount,
|
|
||||||
pipelineCount,
|
|
||||||
]).then(
|
|
||||||
([
|
|
||||||
table,
|
|
||||||
topic,
|
|
||||||
dashboard,
|
|
||||||
pipeline,
|
|
||||||
]: PromiseSettledResult<SearchResponse>[]) => {
|
|
||||||
setTableCount(
|
|
||||||
table.status === 'fulfilled'
|
|
||||||
? getTotalEntityCountByService(
|
|
||||||
table.value.data.aggregations?.['sterms#Service']
|
|
||||||
?.buckets as Bucket[]
|
|
||||||
)
|
|
||||||
: 0
|
|
||||||
);
|
|
||||||
setTopicCount(
|
|
||||||
topic.status === 'fulfilled'
|
|
||||||
? getTotalEntityCountByService(
|
|
||||||
topic.value.data.aggregations?.['sterms#Service']
|
|
||||||
?.buckets as Bucket[]
|
|
||||||
)
|
|
||||||
: 0
|
|
||||||
);
|
|
||||||
setDashboardCount(
|
|
||||||
dashboard.status === 'fulfilled'
|
|
||||||
? getTotalEntityCountByService(
|
|
||||||
dashboard.value.data.aggregations?.['sterms#Service']
|
|
||||||
?.buckets as Bucket[]
|
|
||||||
)
|
|
||||||
: 0
|
|
||||||
);
|
|
||||||
setPipelineCount(
|
|
||||||
pipeline.status === 'fulfilled'
|
|
||||||
? getTotalEntityCountByService(
|
|
||||||
pipeline.value.data.aggregations?.['sterms#Service']
|
|
||||||
?.buckets as Bucket[]
|
|
||||||
)
|
|
||||||
: 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchTableData = (forceSetAgg: boolean) => {
|
fetchData(fetchParams);
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const searchResults = searchData(
|
|
||||||
searchText,
|
|
||||||
currentPage,
|
|
||||||
PAGE_SIZE,
|
|
||||||
getFilterString(filters),
|
|
||||||
sortField,
|
|
||||||
sortOrder,
|
|
||||||
searchIndex
|
|
||||||
);
|
|
||||||
const serviceTypeAgg = searchData(
|
|
||||||
searchText,
|
|
||||||
currentPage,
|
|
||||||
0,
|
|
||||||
getFilterString(filters, ['service']),
|
|
||||||
sortField,
|
|
||||||
sortOrder,
|
|
||||||
searchIndex
|
|
||||||
);
|
|
||||||
const tierAgg = searchData(
|
|
||||||
searchText,
|
|
||||||
currentPage,
|
|
||||||
0,
|
|
||||||
getFilterString(filters, ['tier']),
|
|
||||||
sortField,
|
|
||||||
sortOrder,
|
|
||||||
searchIndex
|
|
||||||
);
|
|
||||||
const tagAgg = searchData(
|
|
||||||
searchText,
|
|
||||||
currentPage,
|
|
||||||
0,
|
|
||||||
getFilterString(filters, ['tags']),
|
|
||||||
sortField,
|
|
||||||
sortOrder,
|
|
||||||
searchIndex
|
|
||||||
);
|
|
||||||
|
|
||||||
Promise.all([searchResults, serviceTypeAgg, tierAgg, tagAgg])
|
|
||||||
.then(
|
|
||||||
([
|
|
||||||
resSearchResults,
|
|
||||||
resAggServiceType,
|
|
||||||
resAggTier,
|
|
||||||
resAggTag,
|
|
||||||
]: Array<SearchResponse>) => {
|
|
||||||
updateSearchResults(resSearchResults);
|
|
||||||
setCount(resSearchResults.data.hits.total.value);
|
|
||||||
if (forceSetAgg) {
|
|
||||||
setAggregations(
|
|
||||||
resSearchResults.data.hits.hits.length > 0
|
|
||||||
? getAggregationList(resSearchResults.data.aggregations)
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const aggServiceType = getAggregationList(
|
|
||||||
resAggServiceType.data.aggregations,
|
|
||||||
'service'
|
|
||||||
);
|
|
||||||
const aggTier = getAggregationList(
|
|
||||||
resAggTier.data.aggregations,
|
|
||||||
'tier'
|
|
||||||
);
|
|
||||||
const aggTag = getAggregationList(
|
|
||||||
resAggTag.data.aggregations,
|
|
||||||
'tags'
|
|
||||||
);
|
|
||||||
|
|
||||||
updateAggregationCount([...aggServiceType, ...aggTier, ...aggTag]);
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.catch((err: AxiosError) => {
|
|
||||||
setError(err.response?.data?.responseMessage);
|
|
||||||
showToast({
|
|
||||||
variant: 'error',
|
|
||||||
body: err.response?.data?.responseMessage ?? ERROR500,
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFacetedFilter = () => {
|
const getFacetedFilter = () => {
|
||||||
@ -515,13 +387,13 @@ const ExplorePage: React.FC = (): React.ReactElement => {
|
|||||||
const getTabCount = (index: string) => {
|
const getTabCount = (index: string) => {
|
||||||
switch (index) {
|
switch (index) {
|
||||||
case SearchIndex.TABLE:
|
case SearchIndex.TABLE:
|
||||||
return getCountBadge(tableCount);
|
return getCountBadge(tabCounts.table);
|
||||||
case SearchIndex.TOPIC:
|
case SearchIndex.TOPIC:
|
||||||
return getCountBadge(topicCount);
|
return getCountBadge(tabCounts.topic);
|
||||||
case SearchIndex.DASHBOARD:
|
case SearchIndex.DASHBOARD:
|
||||||
return getCountBadge(dashboardCount);
|
return getCountBadge(tabCounts.dashboard);
|
||||||
case SearchIndex.PIPELINE:
|
case SearchIndex.PIPELINE:
|
||||||
return getCountBadge(pipelineCount);
|
return getCountBadge(tabCounts.pipeline);
|
||||||
default:
|
default:
|
||||||
return getCountBadge();
|
return getCountBadge();
|
||||||
}
|
}
|
||||||
@ -543,23 +415,23 @@ const ExplorePage: React.FC = (): React.ReactElement => {
|
|||||||
<div className="tw-mb-3 tw--mt-4">
|
<div className="tw-mb-3 tw--mt-4">
|
||||||
<nav className="tw-flex tw-flex-row tw-gh-tabs-container tw-px-4 tw-justify-between">
|
<nav className="tw-flex tw-flex-row tw-gh-tabs-container tw-px-4 tw-justify-between">
|
||||||
<div>
|
<div>
|
||||||
{tabsInfo.map((tab, index) => (
|
{tabsInfo.map((tabDetail, index) => (
|
||||||
<button
|
<button
|
||||||
className={`tw-pb-2 tw-px-4 tw-gh-tabs ${getActiveTabClass(
|
className={`tw-pb-2 tw-px-4 tw-gh-tabs ${getActiveTabClass(
|
||||||
tab.tab
|
tabDetail.tab
|
||||||
)}`}
|
)}`}
|
||||||
data-testid="tab"
|
data-testid="tab"
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onTabChange(tab.tab);
|
onTabChange(tabDetail.tab);
|
||||||
}}>
|
}}>
|
||||||
<SVGIcons
|
<SVGIcons
|
||||||
alt="icon"
|
alt="icon"
|
||||||
className="tw-h-4 tw-w-4 tw-mr-2"
|
className="tw-h-4 tw-w-4 tw-mr-2"
|
||||||
icon={tab.icon}
|
icon={tabDetail.icon}
|
||||||
/>
|
/>
|
||||||
{tab.label}
|
{tabDetail.label}
|
||||||
{getTabCount(tab.index)}
|
{getTabCount(tabDetail.index)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -570,7 +442,7 @@ const ExplorePage: React.FC = (): React.ReactElement => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchText(searchQuery || '');
|
handleSearchText(searchQuery || '');
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
@ -579,7 +451,6 @@ const ExplorePage: React.FC = (): React.ReactElement => {
|
|||||||
setFieldList(tabsInfo[getCurrentTab(tab) - 1].sortingFields);
|
setFieldList(tabsInfo[getCurrentTab(tab) - 1].sortingFields);
|
||||||
setSortField(tabsInfo[getCurrentTab(tab) - 1].sortField);
|
setSortField(tabsInfo[getCurrentTab(tab) - 1].sortField);
|
||||||
setSortOrder('desc');
|
setSortOrder('desc');
|
||||||
setError('');
|
|
||||||
setCurrentTab(getCurrentTab(tab));
|
setCurrentTab(getCurrentTab(tab));
|
||||||
setSearchIndex(getCurrentIndex(tab));
|
setSearchIndex(getCurrentIndex(tab));
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
@ -592,18 +463,47 @@ const ExplorePage: React.FC = (): React.ReactElement => {
|
|||||||
}, [searchText, filters]);
|
}, [searchText, filters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTableData(true);
|
forceSetAgg.current = true;
|
||||||
|
fetchTableData();
|
||||||
}, [searchText, searchIndex]);
|
}, [searchText, searchIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMounting.current && previsouIndex === getCurrentIndex(tab)) {
|
if (searchResult) {
|
||||||
fetchTableData(false);
|
updateSearchResults(searchResult.resSearchResults);
|
||||||
|
setCount(searchResult.resSearchResults.data.hits.total.value);
|
||||||
|
if (forceSetAgg.current) {
|
||||||
|
setAggregations(
|
||||||
|
searchResult.resSearchResults.data.hits.hits.length > 0
|
||||||
|
? getAggregationList(
|
||||||
|
searchResult.resSearchResults.data.aggregations
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const aggServiceType = getAggregationList(
|
||||||
|
searchResult.resAggServiceType.data.aggregations,
|
||||||
|
'service'
|
||||||
|
);
|
||||||
|
const aggTier = getAggregationList(
|
||||||
|
searchResult.resAggTier.data.aggregations,
|
||||||
|
'tier'
|
||||||
|
);
|
||||||
|
const aggTag = getAggregationList(
|
||||||
|
searchResult.resAggTag.data.aggregations,
|
||||||
|
'tags'
|
||||||
|
);
|
||||||
|
|
||||||
|
updateAggregationCount([...aggServiceType, ...aggTier, ...aggTag]);
|
||||||
}
|
}
|
||||||
}, [currentPage, filters, sortField, sortOrder]);
|
}
|
||||||
|
}, [searchResult]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCounts();
|
if (!isMounting.current && previsouIndex === getCurrentIndex(tab)) {
|
||||||
}, [searchText]);
|
forceSetAgg.current = false;
|
||||||
|
fetchTableData();
|
||||||
|
}
|
||||||
|
}, [currentPage, filters, sortField, sortOrder]);
|
||||||
|
|
||||||
// alwyas Keep this useEffect at the end...
|
// alwyas Keep this useEffect at the end...
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -647,4 +547,4 @@ const ExplorePage: React.FC = (): React.ReactElement => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ExplorePage;
|
export default Explore;
|
@ -14,6 +14,7 @@
|
|||||||
* See the License for the specific language governing permissions and
|
* See the License for the specific language governing permissions and
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
/* eslint-disable @typescript-eslint/camelcase */
|
||||||
|
|
||||||
const todayData = [
|
const todayData = [
|
||||||
{
|
{
|
||||||
@ -209,17 +210,75 @@ export const entitiesData = [
|
|||||||
|
|
||||||
export const tiers = ['Tier1', 'Tier2', 'Tier3', 'Tier4', 'Tier5'];
|
export const tiers = ['Tier1', 'Tier2', 'Tier3', 'Tier4', 'Tier5'];
|
||||||
export const mockResponse = {
|
export const mockResponse = {
|
||||||
|
data: {
|
||||||
hits: {
|
hits: {
|
||||||
hits: {
|
|
||||||
tableId: '09ac866c-a18d-4470-abc8-52deed3d90d6',
|
|
||||||
database: 'dwh',
|
|
||||||
tableName: 'fact_sale',
|
|
||||||
serviceName: 'MYSQL',
|
|
||||||
description: 'this is the table to hold data on fact_sale',
|
|
||||||
tableType: 'null',
|
|
||||||
},
|
|
||||||
total: {
|
total: {
|
||||||
value: 128,
|
value: 15,
|
||||||
|
relation: 'eq',
|
||||||
|
},
|
||||||
|
max_score: 5,
|
||||||
|
hits: [
|
||||||
|
{
|
||||||
|
_index: 'table_search_index',
|
||||||
|
_type: '_doc',
|
||||||
|
_id: 'b5860f51-a197-48c8-9506-ee67da190d83',
|
||||||
|
_score: 5,
|
||||||
|
_source: {
|
||||||
|
table_id: 'b5860f51-a197-48c8-9506-ee67da190d83',
|
||||||
|
database: 'bigquery.shopify',
|
||||||
|
service: 'bigquery',
|
||||||
|
service_type: 'BigQuery',
|
||||||
|
table_name: 'dim_address',
|
||||||
|
suggest: [
|
||||||
|
{
|
||||||
|
input: ['bigquery.shopify.dim_address'],
|
||||||
|
weight: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: ['dim_address'],
|
||||||
|
weight: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
'This dimension table contains the billing and shipping addresses of customers. You can join this table.',
|
||||||
|
table_type: 'Regular',
|
||||||
|
last_updated_timestamp: 1634886627,
|
||||||
|
column_names: ['address_id', 'shop_id'],
|
||||||
|
column_descriptions: [
|
||||||
|
'Unique identifier for the address.',
|
||||||
|
'The ID of the store. This column is a foreign key reference to the shop_id column in the dim_shop table.',
|
||||||
|
],
|
||||||
|
tags: [],
|
||||||
|
fqdn: 'bigquery.shopify.dim_address',
|
||||||
|
tier: null,
|
||||||
|
schema_description: null,
|
||||||
|
owner: '',
|
||||||
|
followers: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
aggregations: {
|
||||||
|
'sterms#Tier': {
|
||||||
|
doc_count_error_upper_bound: 0,
|
||||||
|
sum_other_doc_count: 0,
|
||||||
|
buckets: [],
|
||||||
|
},
|
||||||
|
'sterms#Service': {
|
||||||
|
doc_count_error_upper_bound: 0,
|
||||||
|
sum_other_doc_count: 0,
|
||||||
|
buckets: [
|
||||||
|
{
|
||||||
|
key: 'BigQuery',
|
||||||
|
doc_count: 15,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'sterms#Tags': {
|
||||||
|
doc_count_error_upper_bound: 0,
|
||||||
|
sum_other_doc_count: 0,
|
||||||
|
buckets: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
* 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 { SearchResponse } from 'Models';
|
||||||
|
|
||||||
|
export type Params = {
|
||||||
|
searchQuery: string;
|
||||||
|
tab: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Service = {
|
||||||
|
collection: {
|
||||||
|
name: string;
|
||||||
|
documentation: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export type Team = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
description: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExploreSearchData = {
|
||||||
|
resSearchResults: SearchResponse;
|
||||||
|
resAggServiceType: SearchResponse;
|
||||||
|
resAggTier: SearchResponse;
|
||||||
|
resAggTag: SearchResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FetchData {
|
||||||
|
queryString: string;
|
||||||
|
from: number;
|
||||||
|
size: number;
|
||||||
|
filters: string;
|
||||||
|
sortField: string;
|
||||||
|
sortOrder: string;
|
||||||
|
searchIndex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExploreProps {
|
||||||
|
tabCounts: {
|
||||||
|
table: number;
|
||||||
|
topic: number;
|
||||||
|
dashboard: number;
|
||||||
|
pipeline: number;
|
||||||
|
};
|
||||||
|
searchText: string;
|
||||||
|
tab: string;
|
||||||
|
error: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
searchQuery: string;
|
||||||
|
handleSearchText: (text: string) => void;
|
||||||
|
updateTableCount: (count: number) => void;
|
||||||
|
updateTopicCount: (count: number) => void;
|
||||||
|
updateDashboardCount: (count: number) => void;
|
||||||
|
updatePipelineCount: (count: number) => void;
|
||||||
|
fetchData: (value: FetchData[]) => void;
|
||||||
|
searchResult: ExploreSearchData | undefined;
|
||||||
|
}
|
@ -15,22 +15,24 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type Params = {
|
import { findByTestId, render } from '@testing-library/react';
|
||||||
searchQuery: string;
|
import React from 'react';
|
||||||
tab: string;
|
import ExplorePage from './ExplorePage.component';
|
||||||
};
|
|
||||||
|
|
||||||
export type Service = {
|
jest.mock('react-router-dom', () => ({
|
||||||
collection: {
|
useParams: jest.fn().mockImplementation(() => ({ searchQuery: '' })),
|
||||||
name: string;
|
}));
|
||||||
documentation: string;
|
|
||||||
href: string;
|
jest.mock('../../components/Explore/Explore.component', () => {
|
||||||
};
|
return jest.fn().mockReturnValue(<p>Explore Component</p>);
|
||||||
};
|
});
|
||||||
export type Team = {
|
|
||||||
id: string;
|
describe('Test Explore page', () => {
|
||||||
name: string;
|
it('Page Should render', async () => {
|
||||||
displayName: string;
|
const { container } = render(<ExplorePage />);
|
||||||
description: string;
|
|
||||||
href: string;
|
const explorePage = await findByTestId(container, 'explore-page');
|
||||||
};
|
|
||||||
|
expect(explorePage).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,228 @@
|
|||||||
|
/*
|
||||||
|
* 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 { AxiosError } from 'axios';
|
||||||
|
import { Bucket, SearchResponse } from 'Models';
|
||||||
|
import React, { FunctionComponent, useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { searchData } from '../../axiosAPIs/miscAPI';
|
||||||
|
import Explore from '../../components/Explore/Explore.component';
|
||||||
|
import {
|
||||||
|
ExploreSearchData,
|
||||||
|
FetchData,
|
||||||
|
Params,
|
||||||
|
} from '../../components/Explore/explore.interface';
|
||||||
|
import Loader from '../../components/Loader/Loader';
|
||||||
|
import { ERROR500 } from '../../constants/constants';
|
||||||
|
import { SearchIndex } from '../../enums/search.enum';
|
||||||
|
import useToastContext from '../../hooks/useToastContext';
|
||||||
|
import { getTotalEntityCountByService } from '../../utils/ServiceUtils';
|
||||||
|
|
||||||
|
const ExplorePage: FunctionComponent = () => {
|
||||||
|
const showToast = useToastContext();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const { searchQuery, tab } = useParams<Params>();
|
||||||
|
const [searchText, setSearchText] = useState<string>(searchQuery || '');
|
||||||
|
const [tableCount, setTableCount] = useState<number>(0);
|
||||||
|
const [topicCount, setTopicCount] = useState<number>(0);
|
||||||
|
const [dashboardCount, setDashboardCount] = useState<number>(0);
|
||||||
|
const [pipelineCount, setPipelineCount] = useState<number>(0);
|
||||||
|
const [searchResult, setSearchResult] = useState<ExploreSearchData>();
|
||||||
|
|
||||||
|
const handleSearchText = (text: string) => {
|
||||||
|
setSearchText(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTableCount = (count: number) => {
|
||||||
|
setTableCount(count);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTopicCount = (count: number) => {
|
||||||
|
setTopicCount(count);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDashboardCount = (count: number) => {
|
||||||
|
setDashboardCount(count);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePipelineCount = (count: number) => {
|
||||||
|
setPipelineCount(count);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCounts = () => {
|
||||||
|
const emptyValue = '';
|
||||||
|
const tableCount = searchData(
|
||||||
|
searchText,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
emptyValue,
|
||||||
|
emptyValue,
|
||||||
|
emptyValue,
|
||||||
|
SearchIndex.TABLE
|
||||||
|
);
|
||||||
|
const topicCount = searchData(
|
||||||
|
searchText,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
emptyValue,
|
||||||
|
emptyValue,
|
||||||
|
emptyValue,
|
||||||
|
SearchIndex.TOPIC
|
||||||
|
);
|
||||||
|
const dashboardCount = searchData(
|
||||||
|
searchText,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
emptyValue,
|
||||||
|
emptyValue,
|
||||||
|
emptyValue,
|
||||||
|
SearchIndex.DASHBOARD
|
||||||
|
);
|
||||||
|
const pipelineCount = searchData(
|
||||||
|
searchText,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
emptyValue,
|
||||||
|
emptyValue,
|
||||||
|
emptyValue,
|
||||||
|
SearchIndex.PIPELINE
|
||||||
|
);
|
||||||
|
|
||||||
|
Promise.allSettled([
|
||||||
|
tableCount,
|
||||||
|
topicCount,
|
||||||
|
dashboardCount,
|
||||||
|
pipelineCount,
|
||||||
|
]).then(
|
||||||
|
([
|
||||||
|
table,
|
||||||
|
topic,
|
||||||
|
dashboard,
|
||||||
|
pipeline,
|
||||||
|
]: PromiseSettledResult<SearchResponse>[]) => {
|
||||||
|
setTableCount(
|
||||||
|
table.status === 'fulfilled'
|
||||||
|
? getTotalEntityCountByService(
|
||||||
|
table.value.data.aggregations?.['sterms#Service']
|
||||||
|
?.buckets as Bucket[]
|
||||||
|
)
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
setTopicCount(
|
||||||
|
topic.status === 'fulfilled'
|
||||||
|
? getTotalEntityCountByService(
|
||||||
|
topic.value.data.aggregations?.['sterms#Service']
|
||||||
|
?.buckets as Bucket[]
|
||||||
|
)
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
setDashboardCount(
|
||||||
|
dashboard.status === 'fulfilled'
|
||||||
|
? getTotalEntityCountByService(
|
||||||
|
dashboard.value.data.aggregations?.['sterms#Service']
|
||||||
|
?.buckets as Bucket[]
|
||||||
|
)
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
setPipelineCount(
|
||||||
|
pipeline.status === 'fulfilled'
|
||||||
|
? getTotalEntityCountByService(
|
||||||
|
pipeline.value.data.aggregations?.['sterms#Service']
|
||||||
|
?.buckets as Bucket[]
|
||||||
|
)
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = (value: FetchData[]) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const promiseValue = value.map((d) => {
|
||||||
|
return searchData(
|
||||||
|
d.queryString,
|
||||||
|
d.from,
|
||||||
|
d.size,
|
||||||
|
d.filters,
|
||||||
|
d.sortField,
|
||||||
|
d.sortOrder,
|
||||||
|
d.searchIndex
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all(promiseValue)
|
||||||
|
.then(
|
||||||
|
([
|
||||||
|
resSearchResults,
|
||||||
|
resAggServiceType,
|
||||||
|
resAggTier,
|
||||||
|
resAggTag,
|
||||||
|
]: Array<SearchResponse>) => {
|
||||||
|
setSearchResult({
|
||||||
|
resSearchResults,
|
||||||
|
resAggServiceType,
|
||||||
|
resAggTier,
|
||||||
|
resAggTag,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.catch((err: AxiosError) => {
|
||||||
|
setError(err.response?.data?.responseMessage);
|
||||||
|
showToast({
|
||||||
|
variant: 'error',
|
||||||
|
body: err.response?.data?.responseMessage ?? ERROR500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCounts();
|
||||||
|
}, [searchText]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="explore-page">
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader />
|
||||||
|
) : (
|
||||||
|
<Explore
|
||||||
|
error={error}
|
||||||
|
fetchData={fetchData}
|
||||||
|
handleSearchText={handleSearchText}
|
||||||
|
isLoading={isLoading}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
searchResult={searchResult}
|
||||||
|
searchText={searchText}
|
||||||
|
tab={tab}
|
||||||
|
tabCounts={{
|
||||||
|
table: tableCount,
|
||||||
|
topic: topicCount,
|
||||||
|
dashboard: dashboardCount,
|
||||||
|
pipeline: pipelineCount,
|
||||||
|
}}
|
||||||
|
updateDashboardCount={handleDashboardCount}
|
||||||
|
updatePipelineCount={handlePipelineCount}
|
||||||
|
updateTableCount={handleTableCount}
|
||||||
|
updateTopicCount={handleTopicCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExplorePage;
|
@ -24,7 +24,7 @@ import { ROUTES } from '../constants/constants';
|
|||||||
import MyDashBoardPage from '../pages/dashboard-details';
|
import MyDashBoardPage from '../pages/dashboard-details';
|
||||||
import DatabaseDetails from '../pages/database-details/index';
|
import DatabaseDetails from '../pages/database-details/index';
|
||||||
import DatasetDetailsPage from '../pages/DatasetDetailsPage/DatasetDetailsPage.component';
|
import DatasetDetailsPage from '../pages/DatasetDetailsPage/DatasetDetailsPage.component';
|
||||||
import ExplorePage from '../pages/explore';
|
import ExplorePage from '../pages/explore/ExplorePage.component';
|
||||||
import MyDataPage from '../pages/my-data';
|
import MyDataPage from '../pages/my-data';
|
||||||
import MyPipelinePage from '../pages/Pipeline-details';
|
import MyPipelinePage from '../pages/Pipeline-details';
|
||||||
import ReportsPage from '../pages/reports';
|
import ReportsPage from '../pages/reports';
|
||||||
|
Loading…
x
Reference in New Issue
Block a user