-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #92 from vbihun/feauture/owner-team-column-with-se…
…lector Owner team column with team selector
- Loading branch information
Showing
21 changed files
with
974 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import React, { FunctionComponent } from 'react'; | ||
import Avatar from '@atlaskit/avatar'; | ||
import { LabelWrapper, OptionWrapper, IconWrapper } from './styles'; | ||
import { SelectOwnerTeamOption } from './types'; | ||
|
||
export const OwnerTeamOption: FunctionComponent<SelectOwnerTeamOption> = ({ label, iconUrl }) => { | ||
return ( | ||
<OptionWrapper data-testid={`owner-team-option`}> | ||
<IconWrapper> | ||
<Avatar appearance='circle' size='small' src={iconUrl} /> | ||
</IconWrapper> | ||
<LabelWrapper>{label}</LabelWrapper> | ||
</OptionWrapper> | ||
); | ||
}; |
155 changes: 155 additions & 0 deletions
155
ui/src/components/OwnerTeamSelect/OwnerTeamSelect.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,155 @@ | ||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; | ||
import { select, openMenu } from 'react-select-event'; | ||
import { OwnerTeamSelect, Props as OwnerTeamSelectProps } from './OwnerTeamSelect'; | ||
import { MappedTeam } from '../../types'; | ||
import { otherTeamsGroupLabel, teamsWithMembershipGroupLabel } from '../../constants'; | ||
|
||
const mockSearchTeams = jest.fn(); | ||
const mockSelectTeam = jest.fn(); | ||
const selectInputAreaLabel = 'Owner team selector'; | ||
const optionLabelTestId = 'owner-team-option'; | ||
const noOptionsTextRegExp = /No teams exist./i; | ||
|
||
const getMockedTeams = (labels: string[]): MappedTeam[] => { | ||
return labels.map((label, key) => ({ | ||
teamId: `${label}-${key}`, | ||
displayName: label, | ||
imageUrl: 'https://test', | ||
})); | ||
}; | ||
const renderOwnerTeamSelect = (props: Partial<OwnerTeamSelectProps> = {}) => { | ||
return render( | ||
<OwnerTeamSelect | ||
teams={{ teamsWithMembership: [], otherTeams: [] }} | ||
isDisabled={false} | ||
selectKey={'test'} | ||
isLoadingTeams={false} | ||
selectTeam={mockSelectTeam} | ||
selectedTeamOption={null} | ||
{...props} | ||
/>, | ||
); | ||
}; | ||
describe('OwnerTeamSelect', () => { | ||
beforeEach(() => { | ||
jest.resetAllMocks(); | ||
}); | ||
test('renders OwnerTeamSelect', async () => { | ||
const { findByLabelText } = renderOwnerTeamSelect(); | ||
const teamSelectInput = await findByLabelText(selectInputAreaLabel); | ||
expect(teamSelectInput).toBeDefined(); | ||
}); | ||
test('renders groups with options', async () => { | ||
const group1 = getMockedTeams(['Team1', 'Team2']); | ||
const group2 = getMockedTeams(['Team3', 'Team4']); | ||
const { findByLabelText, findByText, queryAllByTestId, queryByText } = renderOwnerTeamSelect({ | ||
teams: { | ||
teamsWithMembership: group1, | ||
otherTeams: group2, | ||
}, | ||
}); | ||
const teamSelectInput = await findByLabelText(selectInputAreaLabel); | ||
openMenu(teamSelectInput); | ||
const teamsWithMembershipGroup = await findByText(teamsWithMembershipGroupLabel); | ||
const otherTeamsGroup = await findByText(otherTeamsGroupLabel); | ||
expect(teamsWithMembershipGroup).toBeDefined(); | ||
expect(otherTeamsGroup).toBeDefined(); | ||
const options = queryAllByTestId(optionLabelTestId); | ||
const option1 = queryByText('Team1'); | ||
const option2 = queryByText('Team2'); | ||
const option3 = queryByText('Team3'); | ||
const option4 = queryByText('Team4'); | ||
expect(options.length).toBe(4); | ||
expect(option1).not.toBeNull(); | ||
expect(option2).not.toBeNull(); | ||
expect(option3).not.toBeNull(); | ||
expect(option4).not.toBeNull(); | ||
}); | ||
test('renders only "Your teams" group of options', async () => { | ||
const group1 = getMockedTeams(['Team1', 'Team2']); | ||
const group2 = getMockedTeams([]); | ||
const { findByLabelText, findByText, queryByText } = renderOwnerTeamSelect({ | ||
teams: { | ||
teamsWithMembership: group1, | ||
otherTeams: group2, | ||
}, | ||
}); | ||
const teamSelectInput = await findByLabelText(selectInputAreaLabel); | ||
openMenu(teamSelectInput); | ||
const teamsWithMembershipGroup = await findByText(teamsWithMembershipGroupLabel); | ||
const otherTeamsGroup = queryByText(otherTeamsGroupLabel); | ||
expect(teamsWithMembershipGroup).toBeDefined(); | ||
expect(otherTeamsGroup).toBeNull(); | ||
}); | ||
test('renders only "All teams" group of options', async () => { | ||
const group1 = getMockedTeams([]); | ||
const group2 = getMockedTeams(['Team3', 'Team4']); | ||
const { findByLabelText, findByText, queryByText } = renderOwnerTeamSelect({ | ||
teams: { | ||
teamsWithMembership: group1, | ||
otherTeams: group2, | ||
}, | ||
}); | ||
const teamSelectInput = await findByLabelText(selectInputAreaLabel); | ||
openMenu(teamSelectInput); | ||
const teamsWithMembershipGroup = await queryByText(teamsWithMembershipGroupLabel); | ||
const otherTeamsGroup = findByText(otherTeamsGroupLabel); | ||
expect(teamsWithMembershipGroup).toBeNull(); | ||
expect(otherTeamsGroup).toBeDefined(); | ||
}); | ||
test('renders empty menu without groups', async () => { | ||
const group1 = getMockedTeams([]); | ||
const group2 = getMockedTeams([]); | ||
const { findByLabelText, queryByText } = renderOwnerTeamSelect({ | ||
teams: { | ||
teamsWithMembership: group1, | ||
otherTeams: group2, | ||
}, | ||
}); | ||
const teamSelectInput = await findByLabelText(selectInputAreaLabel); | ||
openMenu(teamSelectInput); | ||
const teamsWithMembershipGroup = queryByText(teamsWithMembershipGroupLabel); | ||
const otherTeamsGroup = queryByText(otherTeamsGroupLabel); | ||
const emptyState = await screen.findByText(noOptionsTextRegExp); | ||
expect(emptyState).toBeDefined(); | ||
expect(teamsWithMembershipGroup).toBeNull(); | ||
expect(otherTeamsGroup).toBeNull(); | ||
}); | ||
test('selects option', async () => { | ||
const group1 = getMockedTeams(['Team1']); | ||
const expectedOption = { iconUrl: 'https://test', label: 'Team1', value: 'Team1-0' }; | ||
const { findByLabelText } = renderOwnerTeamSelect({ | ||
teams: { | ||
teamsWithMembership: group1, | ||
otherTeams: [], | ||
}, | ||
selectedTeamOption: expectedOption, | ||
}); | ||
const teamSelectInput = await findByLabelText(selectInputAreaLabel); | ||
openMenu(teamSelectInput); | ||
await select(teamSelectInput, 'Team1'); | ||
expect(mockSelectTeam).toHaveBeenCalledWith(expectedOption); | ||
}); | ||
|
||
test('shows loading state on search', async () => { | ||
const group1 = getMockedTeams(['member-team-1, member-team-2, member-team-3']); | ||
const group2 = getMockedTeams(['other-team-1, other-team-2, other-team-3']); | ||
const { findByLabelText, queryByText } = renderOwnerTeamSelect({ | ||
teams: { | ||
teamsWithMembership: group1, | ||
otherTeams: group2, | ||
}, | ||
}); | ||
const teamSelectInput = await findByLabelText(selectInputAreaLabel); | ||
|
||
openMenu(teamSelectInput); | ||
|
||
const searchInput = '2'; | ||
|
||
fireEvent.change(teamSelectInput, { target: { value: searchInput } }); | ||
|
||
const loadingMessage = queryByText('Loading...'); | ||
|
||
expect(loadingMessage).not.toBeNull(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
import Select from '@atlaskit/select'; | ||
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'; | ||
import { debounce } from 'lodash'; | ||
import { TeamsWithMembershipStatus } from '../../types'; | ||
import { OwnerTeamOption } from './OwnerTeamOption'; | ||
import { getOwnerTeamSelectOptions } from './buildSelectOptions'; | ||
import { EmptyStateDescription, EmptyStateWrapper } from './styles'; | ||
import { SelectOwnerTeamOption, InputActionMeta } from './types'; | ||
|
||
export type Props = { | ||
selectKey: string; | ||
selectedTeamOption: SelectOwnerTeamOption | null; | ||
teams: TeamsWithMembershipStatus | undefined; | ||
isDisabled: boolean; | ||
isLoadingTeams: boolean; | ||
selectTeam: (ownerTeamOption: SelectOwnerTeamOption | null) => void; | ||
}; | ||
|
||
export const OwnerTeamSelect: FunctionComponent<Props> = ({ | ||
selectedTeamOption, | ||
isDisabled, | ||
selectKey, | ||
teams, | ||
isLoadingTeams, | ||
selectTeam, | ||
}) => { | ||
const [searchTeamsResult, setSearchTeamsResult] = useState<TeamsWithMembershipStatus>(); | ||
const [searchTeamsQuery, setSearchTeamsQuery] = useState<string>(); | ||
const [isSearchTeamsLoading, setIsSearchTeamsLoading] = useState<boolean>(false); | ||
|
||
const actualSearchInput = useRef<string | null>(null); | ||
|
||
useEffect(() => { | ||
if (searchTeamsQuery) { | ||
// TBD Call teams request after search | ||
} | ||
}, [searchTeamsQuery]); | ||
|
||
const currentTeams = searchTeamsQuery ? searchTeamsResult : teams; | ||
const isLoadingTeamsData = isLoadingTeams || isSearchTeamsLoading; | ||
|
||
const buildOptions = () => { | ||
const undefinedToEnableSelectLoadingState = undefined; | ||
|
||
return isLoadingTeamsData ? undefinedToEnableSelectLoadingState : getOwnerTeamSelectOptions(currentTeams); | ||
}; | ||
|
||
const clearSearch = () => { | ||
setSearchTeamsQuery(undefined); | ||
setIsSearchTeamsLoading(false); | ||
actualSearchInput.current = null; | ||
}; | ||
|
||
const triggerTeamsSearch = (value: string, actionMeta: InputActionMeta) => { | ||
const { action } = actionMeta; | ||
const isOnClearAction = action === 'input-blur' || action === 'menu-close'; | ||
const shouldClearSearch = isOnClearAction || !value; | ||
const isSameSearchInput = value.trim() === searchTeamsQuery; | ||
|
||
if (shouldClearSearch) { | ||
clearSearch(); | ||
return; | ||
} | ||
|
||
if (isSameSearchInput) { | ||
setIsSearchTeamsLoading(false); | ||
return; | ||
} | ||
|
||
setSearchTeamsQuery(value.trim()); | ||
}; | ||
|
||
const debouncedTriggerTeamsSearch = useCallback(debounce(triggerTeamsSearch, 1000), [searchTeamsQuery]); | ||
|
||
const handleOptionChange = (option: SelectOwnerTeamOption | null) => { | ||
selectTeam(option); | ||
}; | ||
|
||
const handleInputChange = (value: string, actionMeta: InputActionMeta) => { | ||
setIsSearchTeamsLoading(true); | ||
actualSearchInput.current = value ? value.trim() : null; | ||
debouncedTriggerTeamsSearch(value, actionMeta); | ||
}; | ||
|
||
const NoOptionsMessage = () => { | ||
if (searchTeamsQuery) { | ||
return ( | ||
<EmptyStateWrapper> | ||
<EmptyStateDescription> | ||
No matching team found. <br /> | ||
Try a different team name or create a team from the <b>Teams</b> page. | ||
</EmptyStateDescription> | ||
</EmptyStateWrapper> | ||
); | ||
} | ||
|
||
return ( | ||
<EmptyStateDescription> | ||
No teams exist. | ||
<br /> | ||
Create a team from the <b>Teams</b> page. | ||
</EmptyStateDescription> | ||
); | ||
}; | ||
|
||
return ( | ||
<Select | ||
aria-label='Owner team selector' | ||
key={selectKey} | ||
classNamePrefix='team-selector' | ||
components={{ NoOptionsMessage }} | ||
isDisabled={isDisabled} | ||
formatOptionLabel={OwnerTeamOption} | ||
options={buildOptions()} | ||
value={selectedTeamOption} | ||
onChange={handleOptionChange} | ||
onInputChange={handleInputChange} | ||
placeholder='Choose team' | ||
menuPosition='fixed' | ||
isClearable={true} | ||
isLoading={isLoadingTeamsData} | ||
loadingMessage={() => 'Loading...'} | ||
backspaceRemovesValue={false} | ||
onMenuClose={clearSearch} | ||
/> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import { GroupType } from '@atlaskit/select'; | ||
import { otherTeamsGroupLabel, teamsWithMembershipGroupLabel } from '../../constants'; | ||
|
||
import { MappedTeam, TeamsWithMembershipStatus } from '../../types'; | ||
import { SelectOwnerTeamOption } from './types'; | ||
|
||
const mapTeamsToOptions = (teams: MappedTeam[]): SelectOwnerTeamOption[] => { | ||
return teams.map(({ teamId, displayName, imageUrl }) => ({ | ||
label: displayName, | ||
value: teamId, | ||
iconUrl: imageUrl, | ||
})); | ||
}; | ||
|
||
const getOptionsGroup = (groupLabel: string, teams: MappedTeam[]): GroupType<SelectOwnerTeamOption> => ({ | ||
label: groupLabel, | ||
options: mapTeamsToOptions(teams), | ||
}); | ||
|
||
export const getOwnerTeamSelectOptions = ( | ||
teams: TeamsWithMembershipStatus | undefined, | ||
): GroupType<SelectOwnerTeamOption>[] | undefined => { | ||
if (!teams) { | ||
return undefined; | ||
} | ||
|
||
const { teamsWithMembership, otherTeams } = teams; | ||
|
||
const options: GroupType<SelectOwnerTeamOption>[] = []; | ||
|
||
if (teamsWithMembership.length) { | ||
const teamsWithMembershipGroup = getOptionsGroup(teamsWithMembershipGroupLabel, teamsWithMembership); | ||
options.push(teamsWithMembershipGroup); | ||
} | ||
|
||
if (otherTeams.length) { | ||
const otherTeamsOptions = getOptionsGroup(otherTeamsGroupLabel, otherTeams); | ||
options.push(otherTeamsOptions); | ||
} | ||
|
||
return options; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export { OwnerTeamSelect } from './OwnerTeamSelect'; | ||
export type { Props } from './OwnerTeamSelect'; |
Oops, something went wrong.