Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import PermissionPlaceholder from '../common/EmptyPlaceholder/PermissionPlacehol
import { UserTeamSelectableList } from '../common/UserTeamSelectableList/UserTeamSelectableList.component';
import { IncidentManagerProps } from './IncidentManager.interface';
import IncidentManagerTable from './IncidentManagerTable.component';
import { useIncidentAssignees } from './useIncidentFilterOptions';
import { useIncidentManagerListPage } from './useIncidentManagerListPage';

const toSelection = (value?: string) => (value ? [value] : []);
Expand Down Expand Up @@ -81,6 +82,7 @@ const IncidentManager = ({

const captionId = useId();
const queryClient = useQueryClient();
const assignees = useIncidentAssignees();
const [isTestCasePickerOpen, setIsTestCasePickerOpen] = useState(false);
const [testCaseSearch, setTestCaseSearch] = useState('');
const debouncedSearch = useDebouncedValue(testCaseSearch, 300);
Expand Down Expand Up @@ -222,6 +224,7 @@ const IncidentManager = ({
<UserTeamSelectableList
hasPermission
label={t('label.assignee')}
options={assignees}
owner={selectedAssignee ? [selectedAssignee] : []}
onUpdate={onAssigneeFilterUpdate}>
<TriggerButton
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import React, { act } from 'react';
import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider';
import { Table } from '../../generated/entity/data/table';
import { TestCasePageTabs } from '../../pages/IncidentManager/IncidentManager.interface';

Check warning on line 18 in openmetadata-ui/src/main/resources/ui/src/components/IncidentManager/IncidentManager.test.tsx

View workflow job for this annotation

GitHub Actions / checkstyle

Pages are route-level composition modules. Move the shared implementation/type to a lower layer instead of importing a page from here
import { getListTestCaseIncidentStatusFromSearch } from '../../rest/incidentManagerAPI';
import {
renderWithQueryClient as render,
Expand Down Expand Up @@ -285,6 +285,37 @@
getUserAndTeamSearch: jest
.fn()
.mockImplementation(() => Promise.resolve({ data: [] })),
postAggregateFieldOptions: jest.fn().mockResolvedValue({
data: {
aggregations: {
'sterms#testCaseResolutionStatusDetails.assignee.id': {
buckets: [
{
'top_hits#top': {
hits: {
hits: [
{
_source: {
testCaseResolutionStatusDetails: {
assignee: {
id: 'user-1',
type: 'user',
name: 'user1',
displayName: 'User One',
deleted: false,
},
},
},
},
],
},
},
},
],
},
},
},
}),
}));
jest.mock('../../rest/userAPI', () => ({
getUsers: jest.fn().mockImplementation(() => Promise.resolve({ data: [] })),
Expand Down Expand Up @@ -535,6 +566,43 @@
expect(screen.queryByTestId('select-owner-tabs')).not.toBeInTheDocument();
});

it('offers only the users and teams some incident is assigned to', async () => {
const mockSearchQuery = require('../../rest/searchAPI').searchQuery;
await act(async () => {
render(<IncidentManager />);
});
await act(async () => {
fireEvent.click(screen.getByTestId('select-assignee'));
});

expect(
screen.getByRole('tab', { name: /label.team-plural/ })
).toHaveTextContent('0');
expect(
screen.getByRole('tab', { name: /label.user-plural/ })
).toHaveTextContent('1');

await act(async () => {
fireEvent.click(screen.getByRole('tab', { name: /label.user-plural/ }));
});

expect(
within(screen.getByTestId('owner-select-users-panel')).getAllByTestId(
'owner-option'
)
).toHaveLength(1);
expect(screen.getByTestId('owner-select-users-panel')).toHaveTextContent(
'User One'
);
// The picker lists the assignees it was given, not the user/team indexes.
expect(mockSearchQuery).not.toHaveBeenCalledWith(
expect.objectContaining({ searchIndex: 'user' })
);
expect(mockSearchQuery).not.toHaveBeenCalledWith(
expect.objectContaining({ searchIndex: 'team' })
);
});

it('clears a URL-selected assignee after reopening the picker without needing a combobox', async () => {
const navigate = jest.fn();
require('react-router-dom').useNavigate.mockReturnValue(navigate);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,100 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useQuery } from '@tanstack/react-query';
import { sortBy } from 'lodash';
import { useCallback, useMemo, useState } from 'react';
import { WILD_CARD_CHAR } from '../../constants/char.constants';
import { PAGE_SIZE_BASE } from '../../constants/constants';
import {
AGGREGATE_PAGE_SIZE_LARGE,
DEFAULT_DOMAIN_VALUE,
PAGE_SIZE_BASE,
} from '../../constants/constants';
import { SearchIndex } from '../../enums/search.enum';
import { EntityReference } from '../../generated/entity/type';
import { SortOrder } from '../../generated/search/aggregationRequest';
import { useDomainStore } from '../../hooks/useDomainStore';
import {
SearchHitBody,
TestCaseSearchSource,
} from '../../interface/search.interface';
import { Option } from '../../pages/TasksPage/TasksPage.interface';

Check warning on line 30 in openmetadata-ui/src/main/resources/ui/src/components/IncidentManager/useIncidentFilterOptions.ts

View workflow job for this annotation

GitHub Actions / checkstyle

Pages are route-level composition modules. Move the shared implementation/type to a lower layer instead of importing a page from here
import { TestCaseIncidentStatusParams } from '../../rest/incidentManagerAPI';
import { getUserAndTeamSearch } from '../../rest/miscAPI';
import {
getUserAndTeamSearch,
postAggregateFieldOptions,
} from '../../rest/miscAPI';
import { searchQuery } from '../../rest/searchAPI';
import { getQueryFilterForDataProducts } from '../../utils/DomainFilterUtils';
import { getEntityName } from '../../utils/EntityNameUtils';
import { getAggregations } from '../../utils/ExplorePureUtils';

const ASSIGNEE_FIELD = 'testCaseResolutionStatusDetails.assignee';
const NO_ASSIGNEES: EntityReference[] = [];

type AssigneeBucket = {
'top_hits#top'?: {
hits?: {
hits?: {
_source?: {
testCaseResolutionStatusDetails?: { assignee?: EntityReference };
};
}[];
};
};
};

const fetchIncidentAssignees = async (domain?: string) => {
const { data } = await postAggregateFieldOptions({
index: 'testCaseResolutionStatus',
fieldName: `${ASSIGNEE_FIELD}.id`,
fieldValue: '',
// A terms aggregation cannot page, so read the whole set up to this cap.
// Composite aggregation in /search/aggregate if catalogs ever exceed it.
size: AGGREGATE_PAGE_SIZE_LARGE,
// Incident documents carry no top-level `deleted`, so the default
// `deleted: false` term would match none of them.
deleted: null as unknown as boolean,
query: domain
? JSON.stringify(getQueryFilterForDataProducts(domain))
: undefined,
// The latest record names the assignee as they are now.
sourceFields: [ASSIGNEE_FIELD],
topHits: { size: 1, sortField: 'timestamp', sortOrder: SortOrder.Desc },
});
const buckets = (getAggregations(data.aggregations)[`${ASSIGNEE_FIELD}.id`]
?.buckets ?? []) as AssigneeBucket[];

const assignees = buckets.reduce<EntityReference[]>((list, bucket) => {
const assignee =
bucket['top_hits#top']?.hits?.hits?.[0]?._source
?.testCaseResolutionStatusDetails?.assignee;

return assignee && !assignee.deleted ? [...list, assignee] : list;
}, []);

return sortBy(assignees, (assignee) => getEntityName(assignee).toLowerCase());
};

/**
* The users and teams assigned to at least one incident in the active domain,
* read as a terms aggregation over the incident index so they never depend on
* the page of incidents on screen. The assignee picker searches and counts
* this list itself: sent as a filter, a large set of ids outgrows the URL of
* the user/team search. Empty while it loads and when the read fails, so the
* picker offers no one rather than everyone.
*/
export const useIncidentAssignees = () => {
const { activeDomain } = useDomainStore();
const domain =
activeDomain === DEFAULT_DOMAIN_VALUE ? undefined : activeDomain;
const { data = NO_ASSIGNEES } = useQuery({
queryKey: ['incident-assignees', domain],
queryFn: () => fetchIncidentAssignees(domain),
});

return data;
};

export interface UseIncidentFilterOptionsProps {
filters: TestCaseIncidentStatusParams;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import { EditIconButton } from '../IconButtons/EditIconButton';
import { SelectableList } from '../SelectableList/SelectableList.component';
import { UserSelectDropdownProps } from './UserTeamSelectableList.interface';
import { getTabCounts, searchOptions } from './UserTeamSelectableList.utils';

export const TeamListItemRenderer = (props: EntityReference) => {
return (
Expand All @@ -74,6 +75,7 @@
overlayClassName,
triggerDataTestId = 'edit-owner',
triggerClassName,
options,
}: UserSelectDropdownProps) => {
const { t } = useTranslation();
const triggerRef = useRef<HTMLSpanElement>(null);
Expand Down Expand Up @@ -116,6 +118,11 @@
return EntityType.TEAM;
}, [owner]);

// Ids that arrive after the picker opened must re-read the open lists.
const optionsKey = options?.map((option) => option.id).join(',');

const tabCounts = getTabCounts(count, options);

const isMultiUser = multiple.user;
const isMultiTeam = multiple.team;

Expand All @@ -129,6 +136,9 @@
}, [selectedUsers]);

const fetchUserOptions = async (searchText: string, after?: string) => {
if (options) {
return searchOptions(options, EntityType.USER, searchText);
}
const afterPage = isNaN(Number(after)) ? 1 : Number(after);
const isInitialPage = !searchText && afterPage <= 1;

Expand Down Expand Up @@ -172,6 +182,9 @@
};

const fetchTeamOptions = async (searchText: string, after?: string) => {
if (options) {
return searchOptions(options, EntityType.TEAM, searchText);
}
const afterPage = isNaN(Number(after)) ? 1 : Number(after);
const isInitialPage = !searchText && afterPage <= 1;

Expand Down Expand Up @@ -253,6 +266,9 @@
};

const getUserCount = async () => {
if (options) {
return;
}
const res = await searchQuery({
query: '',
pageNumber: 1,
Expand All @@ -265,6 +281,9 @@
};

const getTeamCount = async () => {
if (options) {
return;
}
const res = await searchQuery({
query: '',
pageNumber: 1,
Expand Down Expand Up @@ -334,7 +353,7 @@

useEffect(() => {
init();
}, [popupVisible]);

Check warning on line 356 in openmetadata-ui/src/main/resources/ui/src/components/common/UserTeamSelectableList/UserTeamSelectableList.component.tsx

View workflow job for this annotation

GitHub Actions / checkstyle

React Hook useEffect has a missing dependency: 'init'. Either include it or remove the dependency array

// Flip the mounted flag in a layout effect (not a passive effect) so the
// false -> true transition — and therefore the forced-open popover — happens
Expand Down Expand Up @@ -456,6 +475,7 @@
<SelectableList
fetchOptions={fetchUserOptions}
height={listHeight}
key={optionsKey}
multiSelect={isMultiUser}
searchBarDataTestId="owner-select-users-search-bar"
searchPlaceholder={t('label.search-for-type', {
Expand All @@ -482,10 +502,10 @@
selectedKey={activeTab}
onSelectionChange={(key) => setActiveTab(key as 'teams' | 'users')}>
<Tabs.List className="tw:px-2 tw:pt-2" size="sm" type="underline">
<Tabs.Item badge={count.team} id="teams">
<Tabs.Item badge={tabCounts.team} id="teams">
{t('label.team-plural')}
</Tabs.Item>
<Tabs.Item badge={count.user} id="users">
<Tabs.Item badge={tabCounts.user} id="users">
{t('label.user-plural')}
</Tabs.Item>
</Tabs.List>
Expand All @@ -494,6 +514,7 @@
customTagRenderer={TeamListItemRenderer}
fetchOptions={fetchTeamOptions}
height={listHeight}
key={optionsKey}
multiSelect={isMultiTeam}
searchBarDataTestId="owner-select-teams-search-bar"
searchPlaceholder={t('label.search-for-type', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,9 @@ export interface UserSelectDropdownProps {
triggerDataTestId?: string;
/** Classes for the span wrapping a `children` trigger. */
triggerClassName?: string;
/**
* Offer only these users and teams: the tabs, their counts and the search
* work on this list instead of the user and team search indexes.
*/
options?: EntityReference[];
}
Loading
Loading