diff --git a/src/types.ts b/src/types.ts index 55dabc0..f598592 100644 --- a/src/types.ts +++ b/src/types.ts @@ -377,6 +377,14 @@ type ComponentTierField = Array | undefined; type ComponentLifecycleField = Array | undefined; +type MappedTeam = { + teamId: string; + displayName: string; + imageUrl: string; +}; + +type TeamsWithMembershipStatus = { teamsWithMembership: MappedTeam[]; otherTeams: MappedTeam[] }; + export type { WebtriggerRequest, WebtriggerResponse, @@ -415,6 +423,8 @@ export type { ComponentLifecycleField, ComponentSyncDetails, ModifiedFilePayload, + MappedTeam, + TeamsWithMembershipStatus, }; export { diff --git a/ui/package.json b/ui/package.json index a46338e..8859dd1 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,6 +4,7 @@ "private": true, "homepage": ".", "dependencies": { + "@atlaskit/avatar": "^21.4.2", "@atlaskit/button": "^16.2.2", "@atlaskit/checkbox": "^12.3.12", "@atlaskit/css-reset": "^6.3.8", @@ -28,7 +29,8 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "react-router-dom": "^6.3.0", - "styled-components": "^5.3.7" + "styled-components": "^5.3.7", + "lodash": "^4.17.21" }, "devDependencies": { "@testing-library/jest-dom": "^5.16.2", @@ -40,7 +42,8 @@ "@types/react-dom": "^17.0.13", "@types/styled-components": "^5.1.24", "react-scripts": "^5.0.0", - "typescript": "~4.5.5" + "typescript": "~4.5.5", + "react-select-event": "^5.5.1" }, "scripts": { "start": "SKIP_PREFLIGHT_CHECK=true BROWSER=none PORT=3001 react-scripts start", diff --git a/ui/src/components/OwnerTeamSelect/OwnerTeamOption.tsx b/ui/src/components/OwnerTeamSelect/OwnerTeamOption.tsx new file mode 100644 index 0000000..a4134b3 --- /dev/null +++ b/ui/src/components/OwnerTeamSelect/OwnerTeamOption.tsx @@ -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 = ({ label, iconUrl }) => { + return ( + + + + + {label} + + ); +}; diff --git a/ui/src/components/OwnerTeamSelect/OwnerTeamSelect.test.tsx b/ui/src/components/OwnerTeamSelect/OwnerTeamSelect.test.tsx new file mode 100644 index 0000000..70eadfa --- /dev/null +++ b/ui/src/components/OwnerTeamSelect/OwnerTeamSelect.test.tsx @@ -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 = {}) => { + return render( + , + ); +}; +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(); + }); +}); diff --git a/ui/src/components/OwnerTeamSelect/OwnerTeamSelect.tsx b/ui/src/components/OwnerTeamSelect/OwnerTeamSelect.tsx new file mode 100644 index 0000000..5c8fea2 --- /dev/null +++ b/ui/src/components/OwnerTeamSelect/OwnerTeamSelect.tsx @@ -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 = ({ + selectedTeamOption, + isDisabled, + selectKey, + teams, + isLoadingTeams, + selectTeam, +}) => { + const [searchTeamsResult, setSearchTeamsResult] = useState(); + const [searchTeamsQuery, setSearchTeamsQuery] = useState(); + const [isSearchTeamsLoading, setIsSearchTeamsLoading] = useState(false); + + const actualSearchInput = useRef(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 ( + + + No matching team found.
+ Try a different team name or create a team from the Teams page. +
+
+ ); + } + + return ( + + No teams exist. +
+ Create a team from the Teams page. +
+ ); + }; + + return ( +