Skip to content

Commit

Permalink
Owner team column with team selector
Browse files Browse the repository at this point in the history
  • Loading branch information
vbihun committed Jan 22, 2024
1 parent 8e97578 commit 9b012f0
Show file tree
Hide file tree
Showing 21 changed files with 975 additions and 19 deletions.
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,14 @@ type ComponentTierField = Array<string | null> | undefined;

type ComponentLifecycleField = Array<string | null> | undefined;

type MappedTeam = {
teamId: string;
displayName: string;
imageUrl: string;
};

type TeamsWithMembershipStatus = { teamsWithMembership: MappedTeam[]; otherTeams: MappedTeam[] };

export type {
WebtriggerRequest,
WebtriggerResponse,
Expand Down Expand Up @@ -415,6 +423,8 @@ export type {
ComponentLifecycleField,
ComponentSyncDetails,
ModifiedFilePayload,
MappedTeam,
TeamsWithMembershipStatus,
};

export {
Expand Down
7 changes: 5 additions & 2 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions ui/src/components/OwnerTeamSelect/OwnerTeamOption.tsx
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 ui/src/components/OwnerTeamSelect/OwnerTeamSelect.test.tsx
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();
});
});
127 changes: 127 additions & 0 deletions ui/src/components/OwnerTeamSelect/OwnerTeamSelect.tsx
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}
/>
);
};
42 changes: 42 additions & 0 deletions ui/src/components/OwnerTeamSelect/buildSelectOptions.ts
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;
};
2 changes: 2 additions & 0 deletions ui/src/components/OwnerTeamSelect/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { OwnerTeamSelect } from './OwnerTeamSelect';
export type { Props } from './OwnerTeamSelect';
Loading

0 comments on commit 9b012f0

Please sign in to comment.