Skip to content

Commit

Permalink
[Cases] Assign users when creating a case (#139754)
Browse files Browse the repository at this point in the history
* Init

* Render users

* Assign yourself

* Add tests

* Fix tests

* PR feedback
  • Loading branch information
cnasikas authored Sep 6, 2022
1 parent 8103bd3 commit 01006bf
Show file tree
Hide file tree
Showing 11 changed files with 618 additions and 249 deletions.
4 changes: 4 additions & 0 deletions x-pack/plugins/cases/public/common/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,3 +296,7 @@ export const READ_ACTIONS_PERMISSIONS_ERROR_MSG = i18n.translate(
'You do not have permission to view connectors. If you would like to view connectors, contact your Kibana administrator.',
}
);

export const ASSIGNEES = i18n.translate('xpack.cases.common.assigneesTitle', {
defaultMessage: 'Assignees',
});
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,6 @@ export const SEND_EMAIL_ARIA = (user: string) =>
defaultMessage: 'click to send an email to {user}',
});

export const ASSIGNEES = i18n.translate('xpack.cases.caseView.editAssigneesTitle', {
defaultMessage: 'Assignees',
});

export const EDIT_ASSIGNEES_ARIA_LABEL = i18n.translate(
'xpack.cases.caseView.editAssigneesAriaLabel',
{
Expand Down
105 changes: 105 additions & 0 deletions x-pack/plugins/cases/public/components/create/assignees.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import userEvent from '@testing-library/user-event';
import { AppMockRenderer, createAppMockRenderer } from '../../common/mock';

import { useForm, Form, FormHook } from '../../common/shared_imports';
import { userProfiles } from '../../containers/user_profiles/api.mock';
import { Assignees } from './assignees';
import { FormProps } from './schema';
import { act, waitFor } from '@testing-library/react';

jest.mock('../../containers/user_profiles/api');

const currentUserProfile = userProfiles[0];

describe('Assignees', () => {
let globalForm: FormHook;
let appMockRender: AppMockRenderer;

const MockHookWrapperComponent: React.FC = ({ children }) => {
const { form } = useForm<FormProps>();
globalForm = form;

return <Form form={form}>{children}</Form>;
};

beforeEach(() => {
jest.clearAllMocks();
appMockRender = createAppMockRenderer();
});

it('renders', async () => {
const result = appMockRender.render(
<MockHookWrapperComponent>
<Assignees isLoading={false} />
</MockHookWrapperComponent>
);

await waitFor(() => {
expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled();
});

expect(result.getByTestId('createCaseAssigneesComboBox')).toBeInTheDocument();
});

it('selects the current user correctly', async () => {
const result = appMockRender.render(
<MockHookWrapperComponent>
<Assignees isLoading={false} />
</MockHookWrapperComponent>
);

await waitFor(() => {
expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled();
});

act(() => {
userEvent.click(result.getByTestId('create-case-assign-yourself-link'));
});

await waitFor(() => {
expect(globalForm.getFormData()).toEqual({ assignees: [{ uid: currentUserProfile.uid }] });
});
});

it('assignees users correctly', async () => {
const result = appMockRender.render(
<MockHookWrapperComponent>
<Assignees isLoading={false} />
</MockHookWrapperComponent>
);

await waitFor(() => {
expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled();
});

await act(async () => {
await userEvent.type(result.getByTestId('comboBoxSearchInput'), 'dr', { delay: 1 });
});

await waitFor(() => {
expect(
result.getByTestId('comboBoxOptionsList createCaseAssigneesComboBox-optionsList')
).toBeInTheDocument();
});

await waitFor(async () => {
expect(result.getByText(`${currentUserProfile.user.full_name}`)).toBeInTheDocument();
});

act(() => {
userEvent.click(result.getByText(`${currentUserProfile.user.full_name}`));
});

await waitFor(() => {
expect(globalForm.getFormData()).toEqual({ assignees: [{ uid: currentUserProfile.uid }] });
});
});
});
187 changes: 187 additions & 0 deletions x-pack/plugins/cases/public/components/create/assignees.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { isEmpty } from 'lodash';
import React, { memo, useCallback, useState } from 'react';
import {
EuiComboBox,
EuiComboBoxOptionOption,
EuiFormRow,
EuiLink,
EuiSelectableListItem,
EuiTextColor,
} from '@elastic/eui';
import {
UserProfileWithAvatar,
UserAvatar,
getUserDisplayName,
UserProfile,
} from '@kbn/user-profile-components';
import { UseField, FieldConfig, FieldHook } from '../../common/shared_imports';
import { useSuggestUserProfiles } from '../../containers/user_profiles/use_suggest_user_profiles';
import { useCasesContext } from '../cases_context/use_cases_context';
import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile';
import { OptionalFieldLabel } from './optional_field_label';
import * as i18n from './translations';

interface Props {
isLoading: boolean;
}

interface FieldProps {
field: FieldHook;
options: EuiComboBoxOptionOption[];
isLoading: boolean;
currentUserProfile: UserProfile;
selectedOptions: EuiComboBoxOptionOption[];
setSelectedOptions: React.Dispatch<React.SetStateAction<EuiComboBoxOptionOption[]>>;
onSearchComboChange: (value: string) => void;
}

const getConfig = (): FieldConfig => ({
label: i18n.ASSIGNEES,
defaultValue: [],
});

const userProfileToComboBoxOption = (userProfile: UserProfileWithAvatar) => ({
label: getUserDisplayName(userProfile.user),
value: userProfile.uid,
user: userProfile.user,
data: userProfile.data,
});

const comboBoxOptionToAssignee = (option: EuiComboBoxOptionOption) => ({ uid: option.value });

const AssigneesFieldComponent: React.FC<FieldProps> = React.memo(
({
field,
isLoading,
options,
currentUserProfile,
selectedOptions,
setSelectedOptions,
onSearchComboChange,
}) => {
const { setValue } = field;

const onComboChange = useCallback(
(currentOptions: EuiComboBoxOptionOption[]) => {
setSelectedOptions(currentOptions);
setValue(currentOptions.map((option) => comboBoxOptionToAssignee(option)));
},
[setSelectedOptions, setValue]
);

const onSelfAssign = useCallback(() => {
if (!currentUserProfile) {
return;
}

setSelectedOptions((prev) => [
...(prev ?? []),
userProfileToComboBoxOption(currentUserProfile),
]);

setValue([
...(selectedOptions?.map((option) => comboBoxOptionToAssignee(option)) ?? []),
{ uid: currentUserProfile.uid },
]);
}, [currentUserProfile, selectedOptions, setSelectedOptions, setValue]);

const renderOption = useCallback(
(option: EuiComboBoxOptionOption, searchValue: string, contentClassName: string) => {
const { user, data, value } = option as EuiComboBoxOptionOption<string> &
UserProfileWithAvatar;

return (
<EuiSelectableListItem
key={value}
prepend={<UserAvatar user={user} avatar={data.avatar} size="s" />}
className={contentClassName}
append={<EuiTextColor color="subdued">{user.email}</EuiTextColor>}
>
{getUserDisplayName(user)}
</EuiSelectableListItem>
);
},
[]
);

return (
<EuiFormRow
id="createCaseAssignees"
fullWidth
label={i18n.ASSIGNEES}
labelAppend={OptionalFieldLabel}
helpText={
<EuiLink data-test-subj="create-case-assign-yourself-link" onClick={onSelfAssign}>
{i18n.ASSIGN_YOURSELF}
</EuiLink>
}
>
<EuiComboBox
fullWidth
async
isLoading={isLoading}
options={options}
data-test-subj="createCaseAssigneesComboBox"
selectedOptions={selectedOptions}
isDisabled={isLoading}
onChange={onComboChange}
onSearchChange={onSearchComboChange}
renderOption={renderOption}
/>
</EuiFormRow>
);
}
);

AssigneesFieldComponent.displayName = 'AssigneesFieldComponent';

const AssigneesComponent: React.FC<Props> = ({ isLoading: isLoadingForm }) => {
const { owner } = useCasesContext();
const [searchTerm, setSearchTerm] = useState('');
const [selectedOptions, setSelectedOptions] = useState<EuiComboBoxOptionOption[]>();
const { data: currentUserProfile, isLoading: isLoadingCurrentUserProfile } =
useGetCurrentUserProfile();

const { data: userProfiles, isLoading: isLoadingSuggest } = useSuggestUserProfiles({
name: searchTerm,
owners: owner,
});

const options =
userProfiles?.map((userProfile) => userProfileToComboBoxOption(userProfile)) ?? [];

const onSearchComboChange = (value: string) => {
if (!isEmpty(value)) {
setSearchTerm(value);
}
};

const isLoading = isLoadingForm || isLoadingCurrentUserProfile || isLoadingSuggest;

return (
<UseField
path="assignees"
config={getConfig()}
component={AssigneesFieldComponent}
componentProps={{
isLoading,
selectedOptions,
setSelectedOptions,
options,
onSearchComboChange,
currentUserProfile,
}}
/>
);
};

AssigneesComponent.displayName = 'AssigneesComponent';

export const Assignees = memo(AssigneesComponent);
4 changes: 4 additions & 0 deletions x-pack/plugins/cases/public/components/create/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { useCasesContext } from '../cases_context/use_cases_context';
import { useAvailableCasesOwners } from '../app/use_available_owners';
import { CaseAttachmentsWithoutOwner } from '../../types';
import { Severity } from './severity';
import { Assignees } from './assignees';

interface ContainerProps {
big?: boolean;
Expand Down Expand Up @@ -86,6 +87,9 @@ export const CreateCaseFormFields: React.FC<CreateCaseFormFieldsProps> = React.m
children: (
<>
<Title isLoading={isSubmitting} />
<Container>
<Assignees isLoading={isSubmitting} />
</Container>
<Container>
<Tags isLoading={isSubmitting} />
</Container>
Expand Down
Loading

0 comments on commit 01006bf

Please sign in to comment.