Skip to content

Commit d769cfd

Browse files
evanpurkhisergetsantry[bot]
authored andcommitted
ref(ui): Extract useOwner{s,Options} (#69348)
This hook may be used to load member and team options for a select component. This is a spin off of #69269 --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent 726fe9d commit d769cfd

File tree

7 files changed

+401
-119
lines changed

7 files changed

+401
-119
lines changed

static/app/components/forms/fields/sentryMemberTeamSelectorField.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ describe('SentryMemberTeamSelectorField', () => {
139139
<SentryMemberTeamSelectorField
140140
label="Select Owner"
141141
onChange={mock}
142-
memberOfProjectSlug={project.slug}
142+
memberOfProjectSlugs={[project.slug]}
143143
name="team-or-member"
144144
multiple
145145
/>

static/app/components/forms/fields/sentryMemberTeamSelectorField.tsx

Lines changed: 14 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
1-
import {useContext, useEffect, useMemo} from 'react';
2-
import groupBy from 'lodash/groupBy';
1+
import {useContext, useMemo} from 'react';
32

4-
import Avatar from 'sentry/components/avatar';
53
import {t} from 'sentry/locale';
6-
import type {DetailedTeam, Team} from 'sentry/types';
7-
import {useMembers} from 'sentry/utils/useMembers';
8-
import {useTeams} from 'sentry/utils/useTeams';
9-
import {useTeamsById} from 'sentry/utils/useTeamsById';
4+
import {useOwnerOptions} from 'sentry/utils/useOwnerOptions';
5+
import {useOwners} from 'sentry/utils/useOwners';
106

117
import FormContext from '../formContext';
128

@@ -20,7 +16,7 @@ export interface RenderFieldProps extends SelectFieldProps<any> {
2016
/**
2117
* Ensures the only selectable teams are members of the given project.
2218
*/
23-
memberOfProjectSlug?: string;
19+
memberOfProjectSlugs?: string[];
2420
/**
2521
* Use the slug as the select field value. Without setting this the numeric id
2622
* of the project will be used.
@@ -31,7 +27,7 @@ export interface RenderFieldProps extends SelectFieldProps<any> {
3127
function SentryMemberTeamSelectorField({
3228
avatarSize = 20,
3329
placeholder = t('Choose Teams and Members'),
34-
memberOfProjectSlug,
30+
memberOfProjectSlugs,
3531
...props
3632
}: RenderFieldProps) {
3733
const {form} = useContext(FormContext);
@@ -45,89 +41,16 @@ function SentryMemberTeamSelectorField({
4541
[fieldValue]
4642
);
4743

48-
// Ensure the current value of the fields members is loaded
49-
const ensureUserIds = useMemo(
50-
() =>
51-
currentValue?.filter(item => item.startsWith('user:')).map(user => user.slice(7)),
52-
[currentValue]
53-
);
54-
useMembers({ids: ensureUserIds});
55-
56-
const {
57-
members,
58-
fetching: fetchingMembers,
59-
onSearch: onMemberSearch,
60-
loadMore: loadMoreMembers,
61-
} = useMembers();
62-
63-
// XXX(epurkhiser): It would be nice to use an object as the value, but
64-
// frustratingly that is difficult likely because we're recreating this
65-
// object on every re-render.
66-
const memberOptions = members?.map(member => ({
67-
value: `user:${member.id}`,
68-
label: member.name,
69-
leadingItems: <Avatar user={member} size={avatarSize} />,
70-
}));
71-
72-
// Ensure the current value of the fields teams is loaded
73-
const ensureTeamIds = useMemo(
74-
() =>
75-
currentValue?.filter(item => item.startsWith('team:')).map(user => user.slice(5)),
76-
[currentValue]
77-
);
78-
useTeamsById({ids: ensureTeamIds});
79-
80-
const {
81-
teams,
82-
fetching: fetchingTeams,
83-
onSearch: onTeamSearch,
84-
loadMore: loadMoreTeams,
85-
} = useTeams();
86-
87-
const makeTeamOption = (team: Team) => ({
88-
value: `team:${team.id}`,
89-
label: `#${team.slug}`,
90-
leadingItems: <Avatar team={team} size={avatarSize} />,
44+
const {teams, members, fetching, onTeamSearch, onMemberSearch} = useOwners({
45+
currentValue,
9146
});
92-
93-
const makeDisabledTeamOption = (team: Team) => ({
94-
...makeTeamOption(team),
95-
disabled: true,
96-
tooltip: t('%s is not a member of the selected project', `#${team.slug}`),
97-
tooltipOptions: {position: 'left'},
47+
const options = useOwnerOptions({
48+
teams,
49+
members,
50+
avatarProps: {size: avatarSize},
51+
memberOfProjectSlugs,
9852
});
9953

100-
// TODO(davidenwang): Fix the team type here to avoid this type cast: `as DetailedTeam[]`
101-
const {disabledTeams, memberTeams, otherTeams} = groupBy(
102-
teams as DetailedTeam[],
103-
team =>
104-
memberOfProjectSlug && !team.projects.some(({slug}) => memberOfProjectSlug === slug)
105-
? 'disabledTeams'
106-
: team.isMember
107-
? 'memberTeams'
108-
: 'otherTeams'
109-
);
110-
111-
const myTeamOptions = memberTeams?.map(makeTeamOption) ?? [];
112-
const otherTeamOptions = otherTeams?.map(makeTeamOption) ?? [];
113-
const disabledTeamOptions = disabledTeams?.map(makeDisabledTeamOption) ?? [];
114-
115-
// TODO(epurkhiser): This is an unfortunate hack right now since we don't
116-
// actually load members anywhere and the useMembers and useTeams hook don't
117-
// handle initial loading of data.
118-
//
119-
// In the future when these things use react query we should be able to clean
120-
// this up.
121-
useEffect(
122-
() => {
123-
loadMoreMembers();
124-
loadMoreTeams();
125-
},
126-
// Only ensure things are loaded at mount
127-
// eslint-disable-next-line react-hooks/exhaustive-deps
128-
[]
129-
);
130-
13154
return (
13255
<SelectField
13356
placeholder={placeholder}
@@ -136,25 +59,8 @@ function SentryMemberTeamSelectorField({
13659
onMemberSearch(value);
13760
onTeamSearch(value);
13861
}}
139-
isLoading={fetchingMembers || fetchingTeams}
140-
options={[
141-
{
142-
label: t('Members'),
143-
options: memberOptions,
144-
},
145-
{
146-
label: t('My Teams'),
147-
options: myTeamOptions,
148-
},
149-
{
150-
label: t('Other Teams'),
151-
options: otherTeamOptions,
152-
},
153-
{
154-
label: t('Disabled Teams'),
155-
options: disabledTeamOptions,
156-
},
157-
]}
62+
isLoading={fetching}
63+
options={options}
15864
{...props}
15965
/>
16066
);
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import {ProjectFixture} from 'sentry-fixture/project';
2+
import {TeamFixture} from 'sentry-fixture/team';
3+
import {UserFixture} from 'sentry-fixture/user';
4+
5+
import {renderHook} from 'sentry-test/reactTestingLibrary';
6+
7+
import {useOwnerOptions} from './useOwnerOptions';
8+
9+
describe('useOwnerOptions', () => {
10+
const mockUsers = [UserFixture()];
11+
const mockTeams = [TeamFixture()];
12+
13+
it('includes members and teams', () => {
14+
const {result} = renderHook(useOwnerOptions, {
15+
initialProps: {
16+
teams: mockTeams,
17+
members: mockUsers,
18+
},
19+
});
20+
21+
expect(result.current).toEqual([
22+
{
23+
label: 'Members',
24+
options: [
25+
{
26+
label: 'Foo Bar',
27+
value: 'user:1',
28+
leadingItems: expect.anything(),
29+
},
30+
],
31+
},
32+
{
33+
label: 'My Teams',
34+
options: [
35+
{
36+
label: '#team-slug',
37+
value: 'team:1',
38+
leadingItems: expect.anything(),
39+
},
40+
],
41+
},
42+
{label: 'Other Teams', options: []},
43+
{label: 'Disabled Teams', options: []},
44+
]);
45+
});
46+
47+
it('separates my teams and other teams', () => {
48+
const teams = [
49+
TeamFixture(),
50+
TeamFixture({id: '2', slug: 'other-team', isMember: false}),
51+
];
52+
53+
const {result} = renderHook(useOwnerOptions, {
54+
initialProps: {teams},
55+
});
56+
57+
expect(result.current).toEqual([
58+
{
59+
label: 'Members',
60+
options: [],
61+
},
62+
{
63+
label: 'My Teams',
64+
options: [
65+
{
66+
label: '#team-slug',
67+
value: 'team:1',
68+
leadingItems: expect.anything(),
69+
},
70+
],
71+
},
72+
{
73+
label: 'Other Teams',
74+
options: [
75+
{
76+
label: '#other-team',
77+
value: 'team:2',
78+
leadingItems: expect.anything(),
79+
},
80+
],
81+
},
82+
{label: 'Disabled Teams', options: []},
83+
]);
84+
});
85+
86+
it('disables teams not associated with the projects', () => {
87+
const project1 = ProjectFixture();
88+
const project2 = ProjectFixture({id: '2', slug: 'other-project'});
89+
const teamWithProject1 = TeamFixture({projects: [project1], slug: 'my-team'});
90+
const teamWithProject2 = TeamFixture({
91+
id: '2',
92+
projects: [project2],
93+
slug: 'other-team',
94+
isMember: false,
95+
});
96+
const teamWithoutProject = TeamFixture({id: '3', slug: 'disabled-team'});
97+
const teams = [teamWithProject1, teamWithProject2, teamWithoutProject];
98+
99+
const {result} = renderHook(useOwnerOptions, {
100+
initialProps: {
101+
memberOfProjectSlugs: [project1.slug, project2.slug],
102+
teams,
103+
},
104+
});
105+
106+
expect(result.current).toEqual([
107+
{
108+
label: 'Members',
109+
options: [],
110+
},
111+
{
112+
label: 'My Teams',
113+
options: [
114+
{
115+
label: '#my-team',
116+
value: 'team:1',
117+
leadingItems: expect.anything(),
118+
},
119+
],
120+
},
121+
{
122+
label: 'Other Teams',
123+
options: [
124+
{
125+
label: '#other-team',
126+
value: 'team:2',
127+
leadingItems: expect.anything(),
128+
},
129+
],
130+
},
131+
{
132+
label: 'Disabled Teams',
133+
options: [
134+
{
135+
label: '#disabled-team',
136+
value: 'team:3',
137+
leadingItems: expect.anything(),
138+
disabled: true,
139+
tooltip: '#disabled-team is not a member of the selected projects',
140+
tooltipOptions: {position: 'left'},
141+
},
142+
],
143+
},
144+
]);
145+
});
146+
});

0 commit comments

Comments
 (0)