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

feat(ui): Add searchbar to team settings (#29098)

With recent changes to lightweight organization, we no longer have any pages which load all teams for the user. This means we need to add a searchbar to the teams page in order to access more than the initial 100 teams that are fetched.
David Wang 3 лет назад
Родитель
Сommit
a3fbfca0a2

+ 14 - 4
static/app/utils/useTeams.tsx

@@ -21,6 +21,10 @@ type State = {
    * The error that occurred if fetching failed
    */
   fetchError: null | RequestError;
+  /**
+   * Reflects whether or not the initial fetch for the requested teams was fulfilled
+   */
+  initiallyLoaded: boolean;
   /**
    * Indicates that Team results (from API) are paginated and there are more
    * Teams that are not in the initial response.
@@ -48,7 +52,7 @@ export type Result = {
    * Will always add new options into the store.
    */
   onSearch: (searchTerm: string) => Promise<void>;
-} & Pick<State, 'fetching' | 'hasMore' | 'fetchError'>;
+} & Pick<State, 'fetching' | 'hasMore' | 'fetchError' | 'initiallyLoaded'>;
 
 type Options = {
   /**
@@ -126,7 +130,11 @@ function useTeams({limit, slugs, provideUserTeams}: Options = {}) {
   const {organization} = useLegacyStore(OrganizationStore);
   const store = useLegacyStore(TeamStore);
 
+  // If we need to make a request either for slugs or user teams, set initiallyLoaded to false
+  const initiallyLoaded =
+    slugs || (provideUserTeams && !store.loadedUserTeams) ? false : true;
   const [state, setState] = useState<State>({
+    initiallyLoaded,
     fetching: false,
     hasMore: null,
     lastSearch: null,
@@ -159,11 +167,11 @@ function useTeams({limit, slugs, provideUserTeams}: Options = {}) {
     try {
       await fetchUserTeams(api, {orgId});
 
-      setState({...state, fetching: false});
+      setState({...state, fetching: false, initiallyLoaded: true});
     } catch (err) {
       console.error(err); // eslint-disable-line no-console
 
-      setState({...state, fetching: false, fetchError: err});
+      setState({...state, fetching: false, initiallyLoaded: true, fetchError: err});
     }
   }
 
@@ -194,12 +202,13 @@ function useTeams({limit, slugs, provideUserTeams}: Options = {}) {
         ...state,
         hasMore,
         fetching: false,
+        initiallyLoaded: true,
         nextCursor,
       });
     } catch (err) {
       console.error(err); // eslint-disable-line no-console
 
-      setState({...state, fetching: false, fetchError: err});
+      setState({...state, fetching: false, initiallyLoaded: true, fetchError: err});
     }
   }
 
@@ -273,6 +282,7 @@ function useTeams({limit, slugs, provideUserTeams}: Options = {}) {
   const result: Result = {
     teams: filteredTeams,
     fetching: state.fetching || store.loading,
+    initiallyLoaded: state.initiallyLoaded,
     fetchError: state.fetchError,
     hasMore: state.hasMore,
     onSearch: handleSearch,

+ 2 - 8
static/app/views/settings/organizationTeams/index.tsx

@@ -4,10 +4,8 @@ import {loadStats} from 'app/actionCreators/projects';
 import TeamActions from 'app/actions/teamActions';
 import {Client} from 'app/api';
 import {AccessRequest, Organization, Team} from 'app/types';
-import {sortArray} from 'app/utils';
 import withApi from 'app/utils/withApi';
 import withOrganization from 'app/utils/withOrganization';
-import withTeams from 'app/utils/withTeams';
 import AsyncView from 'app/views/asyncView';
 
 import OrganizationTeams from './organizationTeams';
@@ -59,13 +57,11 @@ class OrganizationTeamsContainer extends AsyncView<Props, State> {
   };
 
   renderBody() {
-    const {organization, teams} = this.props;
+    const {organization} = this.props;
 
     if (!organization) {
       return null;
     }
-    const allTeams = sortArray(teams, team => team.name);
-    const activeTeams = allTeams.filter(team => team.isMember);
 
     return (
       <OrganizationTeams
@@ -73,8 +69,6 @@ class OrganizationTeamsContainer extends AsyncView<Props, State> {
         access={new Set(organization.access)}
         features={new Set(organization.features)}
         organization={organization}
-        allTeams={allTeams}
-        activeTeams={activeTeams}
         requestList={this.state.requestList}
         onRemoveAccessRequest={this.removeAccessRequest}
       />
@@ -84,4 +78,4 @@ class OrganizationTeamsContainer extends AsyncView<Props, State> {
 
 export {OrganizationTeamsContainer};
 
-export default withApi(withOrganization(withTeams(OrganizationTeamsContainer)));
+export default withApi(withOrganization(OrganizationTeamsContainer));

+ 45 - 14
static/app/views/settings/organizationTeams/organizationTeams.tsx

@@ -1,13 +1,22 @@
+import {useState} from 'react';
 import {RouteComponentProps} from 'react-router';
+import styled from '@emotion/styled';
+import debounce from 'lodash/debounce';
+import partition from 'lodash/partition';
 
 import {openCreateTeamModal} from 'app/actionCreators/modal';
 import Button from 'app/components/button';
+import LoadingIndicator from 'app/components/loadingIndicator';
 import {Panel, PanelBody, PanelHeader} from 'app/components/panels';
+import SearchBar from 'app/components/searchBar';
 import SentryDocumentTitle from 'app/components/sentryDocumentTitle';
+import {DEFAULT_DEBOUNCE_DURATION} from 'app/constants';
 import {IconAdd} from 'app/icons';
 import {t} from 'app/locale';
-import {AccessRequest, Organization, Team} from 'app/types';
+import space from 'app/styles/space';
+import {AccessRequest, Organization} from 'app/types';
 import recreateRoute from 'app/utils/recreateRoute';
+import useTeams from 'app/utils/useTeams';
 import SettingsPageHeader from 'app/views/settings/components/settingsPageHeader';
 
 import AllTeamsList from './allTeamsList';
@@ -16,16 +25,12 @@ import OrganizationAccessRequests from './organizationAccessRequests';
 type Props = {
   access: Set<string>;
   features: Set<string>;
-  allTeams: Team[];
-  activeTeams: Team[];
   organization: Organization;
   requestList: AccessRequest[];
   onRemoveAccessRequest: (id: string, isApproved: boolean) => void;
 } & RouteComponentProps<{orgId: string}, {}>;
 
 function OrganizationTeams({
-  allTeams,
-  activeTeams,
   organization,
   access,
   features,
@@ -63,10 +68,23 @@ function OrganizationTeams({
     ? recreateRoute(teamRoute, {routes, params, stepBack: -2})
     : '';
 
-  const activeTeamIds = new Set(activeTeams.map(team => team.id));
-  const otherTeams = allTeams.filter(team => !activeTeamIds.has(team.id));
   const title = t('Teams');
 
+  const [teamQuery, setTeamQuery] = useState('');
+  const {initiallyLoaded} = useTeams({provideUserTeams: true});
+  const {teams, onSearch} = useTeams();
+
+  const debouncedSearch = debounce(onSearch, DEFAULT_DEBOUNCE_DURATION);
+  function handleSearch(query: string) {
+    setTeamQuery(query);
+    debouncedSearch(query);
+  }
+
+  const filteredTeams = teams.filter(team =>
+    `#${team.slug}`.toLowerCase().includes(teamQuery.toLowerCase())
+  );
+  const [userTeams, otherTeams] = partition(filteredTeams, team => team.isMember);
+
   return (
     <div data-test-id="team-list">
       <SentryDocumentTitle title={title} orgSlug={organization.slug} />
@@ -77,16 +95,25 @@ function OrganizationTeams({
         requestList={requestList}
         onRemoveAccessRequest={onRemoveAccessRequest}
       />
+      <StyledSearchBar
+        placeholder={t('Search teams')}
+        onChange={handleSearch}
+        query={teamQuery}
+      />
       <Panel>
         <PanelHeader>{t('Your Teams')}</PanelHeader>
         <PanelBody>
-          <AllTeamsList
-            urlPrefix={urlPrefix}
-            organization={organization}
-            teamList={activeTeams}
-            access={access}
-            openMembership={false}
-          />
+          {initiallyLoaded ? (
+            <AllTeamsList
+              urlPrefix={urlPrefix}
+              organization={organization}
+              teamList={userTeams.filter(team => team.slug.includes(teamQuery))}
+              access={access}
+              openMembership={false}
+            />
+          ) : (
+            <LoadingIndicator />
+          )}
         </PanelBody>
       </Panel>
       <Panel>
@@ -107,4 +134,8 @@ function OrganizationTeams({
   );
 }
 
+const StyledSearchBar = styled(SearchBar)`
+  margin-bottom: ${space(2)};
+`;
+
 export default OrganizationTeams;

+ 8 - 6
tests/js/spec/views/settings/organizationTeams.spec.jsx

@@ -1,7 +1,9 @@
 import {mountWithTheme} from 'sentry-test/enzyme';
 import {initializeOrg} from 'sentry-test/initializeOrg';
+import {act} from 'sentry-test/reactTestingLibrary';
 
 import {openCreateTeamModal} from 'app/actionCreators/modal';
+import TeamStore from 'app/stores/teamStore';
 import recreateRoute from 'app/utils/recreateRoute';
 import OrganizationTeams from 'app/views/settings/organizationTeams/organizationTeams';
 
@@ -25,7 +27,6 @@ describe('OrganizationTeams', function () {
         openMembership: true,
       },
     });
-    const teams = [TestStubs.Team()];
 
     const createWrapper = props =>
       mountWithTheme(
@@ -34,8 +35,6 @@ describe('OrganizationTeams', function () {
           routes={[]}
           features={new Set(['open-membership'])}
           access={new Set(['project:admin'])}
-          allTeams={teams}
-          activeTeams={[]}
           organization={organization}
           {...props}
         />,
@@ -59,8 +58,9 @@ describe('OrganizationTeams', function () {
     });
 
     it('can join team and have link to details', function () {
+      const mockTeams = [TestStubs.Team({hasAccess: true, isMember: false})];
+      act(() => void TeamStore.loadInitialData(mockTeams));
       const wrapper = createWrapper({
-        allTeams: [TestStubs.Team({hasAccess: true, isMember: false})],
         access: new Set([]),
       });
       expect(wrapper.find('button[aria-label="Join Team"]')).toHaveLength(1);
@@ -92,8 +92,9 @@ describe('OrganizationTeams', function () {
       );
 
     it('can request access to team and does not have link to details', function () {
+      const mockTeams = [TestStubs.Team({hasAccess: false, isMember: false})];
+      act(() => void TeamStore.loadInitialData(mockTeams));
       const wrapper = createWrapper({
-        allTeams: [TestStubs.Team({hasAccess: false, isMember: false})],
         access: new Set([]),
       });
       expect(wrapper.find('button[aria-label="Request Access"]')).toHaveLength(1);
@@ -103,8 +104,9 @@ describe('OrganizationTeams', function () {
     });
 
     it('can leave team when you are a member', function () {
+      const mockTeams = [TestStubs.Team({hasAccess: true, isMember: true})];
+      act(() => void TeamStore.loadInitialData(mockTeams));
       const wrapper = createWrapper({
-        allTeams: [TestStubs.Team({hasAccess: true, isMember: true})],
         access: new Set([]),
       });
       expect(wrapper.find('button[aria-label="Leave Team"]')).toHaveLength(1);