Просмотр исходного кода

feat(project-create): More obvious create team (#54931)

* Extend the `<TeamSelector />` component with the prop `allowCreate`
which adds an "Create team" option to the dropdown.
* Set the `allowCreate` prop in project creation

Closes https://github.com/getsentry/sentry/issues/54667
ArthurKnaus 1 год назад
Родитель
Сommit
39c5dd484c

+ 68 - 8
static/app/components/teamSelector.spec.tsx

@@ -1,5 +1,8 @@
+import selectEvent from 'react-select-event';
+
 import {act, render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
 
+import {openCreateTeamModal} from 'sentry/actionCreators/modal';
 import {addTeamToProject} from 'sentry/actionCreators/projects';
 import {TeamSelector} from 'sentry/components/teamSelector';
 import OrganizationStore from 'sentry/stores/organizationStore';
@@ -8,6 +11,9 @@ import TeamStore from 'sentry/stores/teamStore';
 jest.mock('sentry/actionCreators/projects', () => ({
   addTeamToProject: jest.fn(),
 }));
+jest.mock('sentry/actionCreators/modal', () => ({
+  openCreateTeamModal: jest.fn(),
+}));
 
 const teamData = [
   {
@@ -64,10 +70,7 @@ describe('Team Selector', function () {
 
     const option = screen.getByText('#team1');
     await userEvent.click(option);
-    expect(onChangeMock).toHaveBeenCalledWith(
-      expect.objectContaining({value: 'team1'}),
-      expect.anything()
-    );
+    expect(onChangeMock).toHaveBeenCalledWith(expect.objectContaining({value: 'team1'}));
   });
 
   it('respects the team filter', async function () {
@@ -134,12 +137,69 @@ describe('Team Selector', function () {
 
     expect(screen.getByText('#team2')).toBeInTheDocument();
     await userEvent.click(screen.getByText('#team2'));
-    expect(onChangeMock).toHaveBeenCalledWith(
-      expect.objectContaining({value: '2'}),
-      expect.anything()
-    );
+    expect(onChangeMock).toHaveBeenCalledWith(expect.objectContaining({value: '2'}));
 
     // Wait for store to be updated from API response
     await act(tick);
   });
+
+  it('allows to create a new team if org admin', async function () {
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/teams/`,
+    });
+    const onChangeMock = jest.fn();
+    const orgWithAccess = TestStubs.Organization({access: ['project:admin']});
+
+    createWrapper({
+      allowCreate: true,
+      onChange: onChangeMock,
+      organization: orgWithAccess,
+    });
+
+    await userEvent.type(screen.getByText('Select...'), '{keyDown}');
+    await userEvent.click(screen.getByText('Create team'));
+    // it opens the create team modal
+    expect(openCreateTeamModal).toHaveBeenCalled();
+  });
+
+  it('allows to create a new team if org admin (multiple select)', async function () {
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/teams/`,
+    });
+    const onChangeMock = jest.fn();
+    const orgWithAccess = TestStubs.Organization({access: ['project:admin']});
+
+    createWrapper({
+      allowCreate: true,
+      onChange: onChangeMock,
+      organization: orgWithAccess,
+    });
+
+    await selectEvent.select(screen.getByText('Select...'), '#team1');
+    // it does no open the create team modal yet
+    expect(openCreateTeamModal).not.toHaveBeenCalled();
+
+    await selectEvent.select(screen.getByText('#team1'), ['#team2', 'Create team']);
+    // it opens the create team modal since the create team option is selected
+    expect(openCreateTeamModal).toHaveBeenCalled();
+  });
+
+  it('does not allow to create a new team if not org owner', async function () {
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/teams/`,
+    });
+    const onChangeMock = jest.fn();
+    const orgWithoutAccess = TestStubs.Organization({access: ['project:write']});
+
+    createWrapper({
+      allowCreate: true,
+      onChange: onChangeMock,
+      organization: orgWithoutAccess,
+    });
+
+    await userEvent.type(screen.getByText('Select...'), '{keyDown}');
+    await userEvent.click(screen.getByText('Create team'));
+    // it does no open the create team modal
+    expect(openCreateTeamModal).not.toHaveBeenCalled();
+  });
 });

+ 73 - 3
static/app/components/teamSelector.tsx

@@ -1,8 +1,10 @@
 import {useRef} from 'react';
+import {createFilter} from 'react-select';
 import {Theme} from '@emotion/react';
 import styled from '@emotion/styled';
 import debounce from 'lodash/debounce';
 
+import {openCreateTeamModal} from 'sentry/actionCreators/modal';
 import {addTeamToProject} from 'sentry/actionCreators/projects';
 import {Button} from 'sentry/components/button';
 import SelectControl, {
@@ -46,6 +48,12 @@ const unassignedOption = {
   disabled: false,
 };
 
+const CREATE_TEAM_VALUE = 'CREATE_TEAM_VALUE';
+
+const optionFilter = createFilter({
+  stringify: option => `${option.label} ${option.value}`,
+});
+
 // Ensures that the svg icon is white when selected
 const unassignedSelectStyles: StylesConfig = {
   option: (provided, state) => {
@@ -88,6 +96,10 @@ const placeholderSelectStyles: StylesConfig = {
 type Props = {
   onChange: (value: any) => any;
   organization: Organization;
+  /**
+   * Controls whether the dropdown allows to create a new team
+   */
+  allowCreate?: boolean;
   includeUnassigned?: boolean;
   /**
    * Can be used to restrict teams to a certain project and allow for new teams to be add to that project
@@ -115,8 +127,8 @@ type TeamOption = GeneralSelectValue & {
 };
 
 function TeamSelector(props: Props) {
-  const {includeUnassigned, styles, ...extraProps} = props;
-  const {teamFilter, organization, project, multiple, value, useId, onChange} = props;
+  const {allowCreate, includeUnassigned, styles, onChange, ...extraProps} = props;
+  const {teamFilter, organization, project, multiple, value, useId} = props;
 
   const api = useApi();
   const {teams, fetching, onSearch} = useTeams();
@@ -124,6 +136,9 @@ function TeamSelector(props: Props) {
   // TODO(ts) This type could be improved when react-select types are better.
   const selectRef = useRef<any>(null);
 
+  const canCreateTeam = organization.access.includes('project:admin');
+  const canAddTeam = organization.access.includes('project:write');
+
   const createTeamOption = (team: Team): TeamOption => ({
     value: useId ? team.id : team.slug,
     label: `#${team.slug}`,
@@ -154,6 +169,46 @@ function TeamSelector(props: Props) {
     }
   }
 
+  const createTeam = () =>
+    new Promise<TeamOption>(resolve => {
+      openCreateTeamModal({
+        organization,
+        onClose: async team => {
+          if (project) {
+            await handleAddTeamToProject(team);
+          }
+          resolve(createTeamOption(team));
+        },
+      });
+    });
+
+  const handleChange = (newValue: TeamOption | TeamOption[]) => {
+    if (multiple) {
+      const options = newValue as TeamOption[];
+      const shouldCreate = options.find(option => option.value === CREATE_TEAM_VALUE);
+      if (shouldCreate) {
+        createTeam().then(newTeamOption => {
+          onChange?.([
+            ...options.filter(option => option.value !== CREATE_TEAM_VALUE),
+            newTeamOption,
+          ]);
+        });
+      } else {
+        onChange?.(options);
+      }
+      return;
+    }
+
+    const option = newValue as TeamOption;
+    if (option.value === CREATE_TEAM_VALUE) {
+      createTeam().then(newTramOption => {
+        onChange?.(newTramOption);
+      });
+    } else {
+      onChange?.(option);
+    }
+  };
+
   async function handleAddTeamToProject(team: Team) {
     if (!project) {
       closeSelectMenu();
@@ -180,7 +235,6 @@ function TeamSelector(props: Props) {
     if (value === (useId ? team.id : team.slug)) {
       return createTeamOption(team);
     }
-    const canAddTeam = organization.access.includes('project:write');
 
     return {
       ...createTeamOption(team),
@@ -212,6 +266,15 @@ function TeamSelector(props: Props) {
 
   function getOptions() {
     const filteredTeams = teamFilter ? teams.filter(teamFilter) : teams;
+    const createOption = {
+      value: CREATE_TEAM_VALUE,
+      label: t('Create team'),
+      leadingItems: <IconAdd isCircled />,
+      searchKey: 'create',
+      actor: null,
+      disabled: !canCreateTeam,
+      'data-test-id': 'create-team-option',
+    };
 
     if (project) {
       const teamsInProjectIdSet = new Set(project.teams.map(team => team.id));
@@ -223,6 +286,7 @@ function TeamSelector(props: Props) {
       );
 
       return [
+        ...(allowCreate ? [createOption] : []),
         ...teamsInProject.map(createTeamOption),
         ...teamsNotInProject.map(createTeamOutsideProjectOption),
         ...(includeUnassigned ? [unassignedOption] : []),
@@ -230,6 +294,7 @@ function TeamSelector(props: Props) {
     }
 
     return [
+      ...(allowCreate ? [createOption] : []),
       ...filteredTeams.map(createTeamOption),
       ...(includeUnassigned ? [unassignedOption] : []),
     ];
@@ -241,12 +306,17 @@ function TeamSelector(props: Props) {
       options={getOptions()}
       onInputChange={debounce(val => void onSearch(val), DEFAULT_DEBOUNCE_DURATION)}
       getOptionValue={option => option.searchKey}
+      filterOption={(canditate, input) =>
+        // Never filter out the create team option
+        canditate.data.value === CREATE_TEAM_VALUE || optionFilter(canditate, input)
+      }
       styles={{
         ...(includeUnassigned ? unassignedSelectStyles : {}),
         ...(multiple ? {} : placeholderSelectStyles),
         ...(styles ?? {}),
       }}
       isLoading={fetching}
+      onChange={handleChange}
       {...extraProps}
     />
   );

+ 0 - 90
static/app/views/projectInstall/createProject.spec.tsx

@@ -96,43 +96,6 @@ describe('CreateProject', function () {
     expect(container).toSnapshot();
   });
 
-  it('can create a new team as admin', async function () {
-    const {organization} = initializeOrg({
-      organization: {
-        access: ['project:admin'],
-      },
-    });
-    renderFrameworkModalMockRequests({organization, teamSlug: 'team-two'});
-    TeamStore.loadUserTeams([
-      TestStubs.Team({id: 2, slug: 'team-two', access: ['team:admin']}),
-    ]);
-
-    render(<CreateProject />, {
-      context: TestStubs.routerContext([
-        {
-          organization: {
-            id: '1',
-            slug: 'testOrg',
-            access: ['project:read'],
-          },
-        },
-      ]),
-      organization,
-    });
-
-    renderGlobalModal();
-
-    await userEvent.click(screen.getByRole('button', {name: 'Create a team'}));
-
-    expect(
-      await screen.findByText(
-        'Members of a team have access to specific areas, such as a new release or a new application feature.'
-      )
-    ).toBeInTheDocument();
-
-    await userEvent.click(screen.getByRole('button', {name: 'Close Modal'}));
-  });
-
   it('can create a new project without team as org member', async function () {
     const {organization} = initializeOrg({
       organization: {
@@ -168,59 +131,6 @@ describe('CreateProject', function () {
     expect(screen.getByRole('button', {name: 'Create Project'})).toBeEnabled();
   });
 
-  it('can create a new team before project creation if org owner', async function () {
-    const {organization} = initializeOrg({
-      organization: {
-        access: ['project:admin'],
-      },
-    });
-
-    render(<CreateProject />, {
-      context: TestStubs.routerContext([
-        {
-          organization: {
-            id: '1',
-            slug: 'testOrg',
-            access: ['project:read'],
-          },
-        },
-      ]),
-      organization,
-    });
-
-    renderGlobalModal();
-    await userEvent.click(screen.getByRole('button', {name: 'Create a team'}));
-
-    expect(
-      await screen.findByText(
-        'Members of a team have access to specific areas, such as a new release or a new application feature.'
-      )
-    ).toBeInTheDocument();
-
-    await userEvent.click(screen.getByRole('button', {name: 'Close Modal'}));
-  });
-
-  it('should not show create team button to team-admin with no org access', function () {
-    const {organization} = initializeOrg({
-      organization: {
-        access: ['project:read'],
-      },
-    });
-    renderFrameworkModalMockRequests({organization, teamSlug: 'team-two'});
-
-    OrganizationStore.onUpdate(organization);
-    TeamStore.loadUserTeams([
-      TestStubs.Team({id: 2, slug: 'team-two', access: ['team:admin']}),
-    ]);
-    render(<CreateProject />, {
-      context: TestStubs.routerContext([{organization}]),
-      organization,
-    });
-
-    const createTeamButton = screen.queryByRole('button', {name: 'Create a team'});
-    expect(createTeamButton).not.toBeInTheDocument();
-  });
-
   it('should only allow teams which the user is a team-admin', async function () {
     const organization = TestStubs.Organization();
     renderFrameworkModalMockRequests({organization, teamSlug: 'team-two'});

+ 2 - 17
static/app/views/projectInstall/createProject.tsx

@@ -7,7 +7,7 @@ import startCase from 'lodash/startCase';
 import {PlatformIcon} from 'platformicons';
 
 import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
-import {openCreateTeamModal, openModal} from 'sentry/actionCreators/modal';
+import {openModal} from 'sentry/actionCreators/modal';
 import Access from 'sentry/components/acl/access';
 import {Alert} from 'sentry/components/alert';
 import {Button} from 'sentry/components/button';
@@ -19,7 +19,6 @@ import PlatformPicker, {Platform} from 'sentry/components/platformPicker';
 import {useProjectCreationAccess} from 'sentry/components/projects/useProjectCreationAccess';
 import TeamSelector from 'sentry/components/teamSelector';
 import {Tooltip} from 'sentry/components/tooltip';
-import {IconAdd} from 'sentry/icons';
 import {t, tct} from 'sentry/locale';
 import ProjectsStore from 'sentry/stores/projectsStore';
 import {space} from 'sentry/styles/space';
@@ -346,6 +345,7 @@ function CreateProject() {
             <FormLabel>{t('Team')}</FormLabel>
             <TeamSelectInput>
               <TeamSelector
+                allowCreate
                 name="select-team"
                 aria-label={t('Select a Team')}
                 menuPlacement="auto"
@@ -355,21 +355,6 @@ function CreateProject() {
                 onChange={choice => setTeam(choice.value)}
                 teamFilter={(tm: Team) => tm.access.includes('team:admin')}
               />
-              {canCreateTeam && (
-                <Button
-                  borderless
-                  data-test-id="create-team"
-                  icon={<IconAdd isCircled />}
-                  onClick={() =>
-                    openCreateTeamModal({
-                      organization,
-                      onClose: ({slug}) => setTeam(slug),
-                    })
-                  }
-                  title={t('Create a team')}
-                  aria-label={t('Create a team')}
-                />
-              )}
             </TeamSelectInput>
           </div>
         )}

+ 2 - 1
tests/acceptance/test_create_project.py

@@ -18,7 +18,8 @@ class CreateProjectTest(AcceptanceTestCase):
         self.browser.get(self.path)
         self.browser.wait_until_not(".loading")
 
-        self.browser.click('[data-test-id="create-team"]')
+        self.browser.click(None, "//*[text()='Select a Team']")
+        self.browser.click('[data-test-id="create-team-option"]')
         self.browser.wait_until("[role='dialog']")
         input = self.browser.element('input[name="slug"]')
         input.send_keys("new-team")