]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-18888 UserApp and GroupApp refactorization
authorguillaume-peoch-sonarsource <guillaume.peoch@sonarsource.com>
Mon, 27 Mar 2023 08:06:34 +0000 (10:06 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 30 Mar 2023 20:03:07 +0000 (20:03 +0000)
25 files changed:
server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts
server/sonar-web/src/main/js/app/components/current-user/CurrentUserContext.ts
server/sonar-web/src/main/js/apps/groups/components/App.tsx [deleted file]
server/sonar-web/src/main/js/apps/groups/components/DeleteForm.tsx [deleted file]
server/sonar-web/src/main/js/apps/groups/components/DeleteGroupForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/groups/components/Form.tsx [deleted file]
server/sonar-web/src/main/js/apps/groups/components/GroupForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/groups/components/GroupsApp.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/groups/components/Header.tsx
server/sonar-web/src/main/js/apps/groups/components/List.tsx
server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx
server/sonar-web/src/main/js/apps/groups/components/__tests__/GroupsApp-it.tsx
server/sonar-web/src/main/js/apps/groups/components/__tests__/List-test.tsx
server/sonar-web/src/main/js/apps/groups/components/__tests__/ListItem-test.tsx
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/List-test.tsx.snap
server/sonar-web/src/main/js/apps/groups/routes.tsx
server/sonar-web/src/main/js/apps/users/UsersApp.tsx
server/sonar-web/src/main/js/apps/users/UsersList.tsx
server/sonar-web/src/main/js/apps/users/__tests__/Header-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx
server/sonar-web/src/main/js/apps/users/__tests__/UsersList-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/Header-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/components/controls/ManagedFilter.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/hooks/useManageProvider.ts [new file with mode: 0644]

index c22c47bff132691464feee2543a44866147df688..efa961414bcd3a1eb19b57d9b1e10d750b27fd87 100644 (file)
@@ -41,7 +41,6 @@ const DEFAULT_USERS = [
 export default class UsersServiceMock {
   isManaged = true;
   users = cloneDeep(DEFAULT_USERS);
-
   constructor() {
     jest.mocked(getSystemInfo).mockImplementation(this.handleGetSystemInfo);
     jest.mocked(getIdentityProviders).mockImplementation(this.handleGetIdentityProviders);
@@ -54,12 +53,22 @@ export default class UsersServiceMock {
   }
 
   handleSearchUsers = (data: any): Promise<{ paging: Paging; users: User[] }> => {
-    const paging = {
+    let paging = {
       pageIndex: 1,
-      pageSize: 100,
-      total: 0,
+      pageSize: 2,
+      total: 6,
     };
 
+    if (data.p !== undefined && data.p !== paging.pageIndex) {
+      paging = { pageIndex: 2, pageSize: 2, total: 6 };
+      const users = [
+        mockUser({ name: `local-user ${this.users.length + 4}` }),
+        mockUser({ name: `local-user ${this.users.length + 5}` }),
+      ];
+
+      return this.reply({ paging, users });
+    }
+
     if (this.isManaged) {
       if (data.managed === undefined) {
         return this.reply({ paging, users: this.users });
index 3ab9c00fa6ce8ca549c60e96a01ee310dea01d2a..3bc8792dc042b9fe0f89dad57cd76a213807a2e5 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { noop } from 'lodash';
 import * as React from 'react';
 import { CurrentUser, HomePage, NoticeType } from '../../../types/users';
 
@@ -26,6 +27,11 @@ export interface CurrentUserContextInterface {
   updateDismissedNotices: (key: NoticeType, value: boolean) => void;
 }
 
-export const CurrentUserContext = React.createContext<CurrentUserContextInterface | undefined>(
-  undefined
-);
+export const CurrentUserContext = React.createContext<CurrentUserContextInterface>({
+  currentUser: {
+    isLoggedIn: false,
+    dismissedNotices: {},
+  },
+  updateCurrentUserHomepage: noop,
+  updateDismissedNotices: noop,
+});
diff --git a/server/sonar-web/src/main/js/apps/groups/components/App.tsx b/server/sonar-web/src/main/js/apps/groups/components/App.tsx
deleted file mode 100644 (file)
index 4652527..0000000
+++ /dev/null
@@ -1,294 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import { omit } from 'lodash';
-import * as React from 'react';
-import { Helmet } from 'react-helmet-async';
-import { getSystemInfo } from '../../../api/system';
-import { createGroup, deleteGroup, searchUsersGroups, updateGroup } from '../../../api/user_groups';
-import ButtonToggle from '../../../components/controls/ButtonToggle';
-import ListFooter from '../../../components/controls/ListFooter';
-import SearchBox from '../../../components/controls/SearchBox';
-import Suggestions from '../../../components/embed-docs-modal/Suggestions';
-import { translate } from '../../../helpers/l10n';
-import { omitNil } from '../../../helpers/request';
-import { Group, Paging, SysInfoCluster } from '../../../types/types';
-import '../groups.css';
-import DeleteForm from './DeleteForm';
-import Form from './Form';
-import Header from './Header';
-import List from './List';
-
-interface State {
-  groups?: Group[];
-  editedGroup?: Group;
-  groupToBeDeleted?: Group;
-  loading: boolean;
-  paging?: Paging;
-  query: string;
-  manageProvider?: string;
-  managed: boolean | undefined;
-}
-
-export default class App extends React.PureComponent<{}, State> {
-  mounted = false;
-  state: State = {
-    loading: true,
-    query: '',
-    managed: undefined,
-    paging: { pageIndex: 1, pageSize: 100, total: 1000 },
-  };
-
-  componentDidMount() {
-    this.mounted = true;
-    this.fetchGroups();
-    this.fetchManageInstance();
-  }
-
-  componentDidUpdate(_prevProps: {}, prevState: State) {
-    if (prevState.query !== this.state.query || prevState.managed !== this.state.managed) {
-      this.fetchGroups();
-    }
-    if (prevState !== undefined && prevState.paging?.pageIndex !== this.state.paging?.pageIndex) {
-      this.fetchMoreGroups();
-    }
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  async fetchManageInstance() {
-    const info = (await getSystemInfo()) as SysInfoCluster;
-    if (this.mounted) {
-      this.setState({
-        manageProvider: info.System['External Users and Groups Provisioning'],
-      });
-    }
-  }
-
-  stopLoading = () => {
-    if (this.mounted) {
-      this.setState({ loading: false });
-    }
-  };
-
-  fetchGroups = async () => {
-    const { query: q, managed } = this.state;
-    this.setState({ loading: true });
-    try {
-      const { groups, paging } = await searchUsersGroups({
-        q,
-        managed,
-      });
-      if (this.mounted) {
-        this.setState({ groups, loading: false, paging });
-      }
-    } catch {
-      this.stopLoading();
-    }
-  };
-
-  fetchMoreGroups = async () => {
-    const { query: q, managed, paging: currentPaging } = this.state;
-    if (currentPaging && currentPaging.total > currentPaging.pageIndex * currentPaging.pageSize) {
-      try {
-        const { groups, paging } = await searchUsersGroups({
-          p: currentPaging.pageIndex,
-          q,
-          managed,
-        });
-        if (this.mounted) {
-          this.setState(({ groups: existingGroups = [] }) => ({
-            groups: [...existingGroups, ...groups],
-            loading: false,
-            paging,
-          }));
-        }
-      } catch {
-        this.stopLoading();
-      }
-    }
-  };
-
-  refresh = async () => {
-    const { paging } = this.state;
-
-    await this.fetchGroups();
-
-    // reload all pages in order
-    if (paging && paging.pageIndex > 1) {
-      for (let p = 1; p < paging.pageIndex; p++) {
-        // eslint-disable-next-line no-await-in-loop
-        await this.fetchMoreGroups(); // This is a intentional promise chain
-      }
-    }
-  };
-
-  handleCreate = async (data: { description: string; name: string }) => {
-    await createGroup({ ...data });
-
-    await this.refresh();
-  };
-
-  handleDelete = async () => {
-    const { groupToBeDeleted } = this.state;
-
-    if (!groupToBeDeleted) {
-      return;
-    }
-
-    await deleteGroup({ name: groupToBeDeleted.name });
-
-    await this.refresh();
-
-    if (this.mounted) {
-      this.setState({ groupToBeDeleted: undefined });
-    }
-  };
-
-  handleEdit = async ({ name, description }: { name?: string; description: string }) => {
-    const { editedGroup } = this.state;
-
-    if (!editedGroup) {
-      return;
-    }
-
-    const data = {
-      currentName: editedGroup.name,
-      description,
-      // pass `name` only if it has changed, otherwise the WS fails
-      ...omitNil({ name: name !== editedGroup.name ? name : undefined }),
-    };
-
-    await updateGroup(data);
-
-    if (this.mounted) {
-      this.setState(({ groups = [] }: State) => ({
-        editedGroup: undefined,
-        groups: groups.map((group) =>
-          group.name === editedGroup.name
-            ? {
-                ...group,
-                ...omit(data, ['currentName']),
-              }
-            : group
-        ),
-      }));
-    }
-  };
-
-  render() {
-    const {
-      editedGroup,
-      groupToBeDeleted,
-      groups,
-      loading,
-      paging,
-      query,
-      manageProvider,
-      managed,
-    } = this.state;
-
-    return (
-      <>
-        <Suggestions suggestions="user_groups" />
-        <Helmet defer={false} title={translate('user_groups.page')} />
-        <main className="page page-limited" id="groups-page">
-          <Header onCreate={this.handleCreate} manageProvider={manageProvider} />
-
-          <div className="display-flex-justify-start big-spacer-bottom big-spacer-top">
-            {manageProvider !== undefined && (
-              <div className="big-spacer-right">
-                <ButtonToggle
-                  value={managed === undefined ? 'all' : managed}
-                  disabled={loading}
-                  options={[
-                    { label: translate('all'), value: 'all' },
-                    { label: translate('managed'), value: true },
-                    { label: translate('local'), value: false },
-                  ]}
-                  onCheck={(filterOption) => {
-                    if (filterOption === 'all') {
-                      this.setState({ managed: undefined });
-                    } else {
-                      this.setState({ managed: filterOption as boolean });
-                    }
-                  }}
-                />
-              </div>
-            )}
-            <SearchBox
-              className="big-spacer-bottom"
-              id="groups-search"
-              minLength={2}
-              onChange={(q) => this.setState({ query: q })}
-              placeholder={translate('search.search_by_name')}
-              value={query}
-            />
-          </div>
-
-          {groups !== undefined && (
-            <List
-              groups={groups}
-              onDelete={(groupToBeDeleted) => this.setState({ groupToBeDeleted })}
-              onEdit={(editedGroup) => this.setState({ editedGroup })}
-              onEditMembers={this.refresh}
-              manageProvider={manageProvider}
-            />
-          )}
-
-          {groups !== undefined && paging !== undefined && (
-            <div id="groups-list-footer">
-              <ListFooter
-                count={groups.length}
-                loading={loading}
-                loadMore={() => {
-                  if (paging.total > paging.pageIndex * paging.pageSize) {
-                    this.setState({ paging: { ...paging, pageIndex: paging.pageIndex + 1 } });
-                  }
-                }}
-                ready={!loading}
-                total={paging.total}
-              />
-            </div>
-          )}
-
-          {groupToBeDeleted && (
-            <DeleteForm
-              group={groupToBeDeleted}
-              onClose={() => this.setState({ groupToBeDeleted: undefined })}
-              onSubmit={this.handleDelete}
-            />
-          )}
-
-          {editedGroup && (
-            <Form
-              confirmButtonText={translate('update_verb')}
-              group={editedGroup}
-              header={translate('groups.update_group')}
-              onClose={() => this.setState({ editedGroup: undefined })}
-              onSubmit={this.handleEdit}
-            />
-          )}
-        </main>
-      </>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/groups/components/DeleteForm.tsx b/server/sonar-web/src/main/js/apps/groups/components/DeleteForm.tsx
deleted file mode 100644 (file)
index 99ee7d0..0000000
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
-import SimpleModal from '../../../components/controls/SimpleModal';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { Group } from '../../../types/types';
-
-interface Props {
-  group: Group;
-  onClose: () => void;
-  onSubmit: () => Promise<void>;
-}
-
-export default function DeleteForm({ group, onClose, onSubmit }: Props) {
-  const header = translate('groups.delete_group');
-
-  return (
-    <SimpleModal header={header} onClose={onClose} onSubmit={onSubmit}>
-      {({ onCloseClick, onFormSubmit, submitting }) => (
-        <form onSubmit={onFormSubmit}>
-          <header className="modal-head">
-            <h2>{header}</h2>
-          </header>
-
-          <div className="modal-body">
-            {translateWithParameters('groups.delete_group.confirmation', group.name)}
-          </div>
-
-          <footer className="modal-foot">
-            <DeferredSpinner className="spacer-right" loading={submitting} />
-            <SubmitButton className="button-red" disabled={submitting}>
-              {translate('delete')}
-            </SubmitButton>
-            <ResetButtonLink disabled={submitting} onClick={onCloseClick}>
-              {translate('cancel')}
-            </ResetButtonLink>
-          </footer>
-        </form>
-      )}
-    </SimpleModal>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/groups/components/DeleteGroupForm.tsx b/server/sonar-web/src/main/js/apps/groups/components/DeleteGroupForm.tsx
new file mode 100644 (file)
index 0000000..fd71693
--- /dev/null
@@ -0,0 +1,70 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { useCallback } from 'react';
+import { deleteGroup } from '../../../api/user_groups';
+import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
+import SimpleModal from '../../../components/controls/SimpleModal';
+import DeferredSpinner from '../../../components/ui/DeferredSpinner';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { Group } from '../../../types/types';
+
+interface Props {
+  group: Group;
+  onClose: () => void;
+  reload: () => void;
+}
+
+export default function DeleteGroupForm(props: Props) {
+  const header = translate('groups.delete_group');
+  const { group, reload, onClose } = props;
+
+  const onSubmit = useCallback(async () => {
+    await deleteGroup({ name: group.name });
+    reload();
+    onClose();
+  }, [group, reload, onClose]);
+
+  return (
+    <SimpleModal header={header} onClose={props.onClose} onSubmit={onSubmit}>
+      {({ onCloseClick, onFormSubmit, submitting }) => (
+        <form onSubmit={onFormSubmit}>
+          <header className="modal-head">
+            <h2>{header}</h2>
+          </header>
+
+          <div className="modal-body">
+            {translateWithParameters('groups.delete_group.confirmation', group.name)}
+          </div>
+
+          <footer className="modal-foot">
+            <DeferredSpinner className="spacer-right" loading={submitting} />
+            <SubmitButton className="button-red" disabled={submitting}>
+              {translate('delete')}
+            </SubmitButton>
+            <ResetButtonLink disabled={submitting} onClick={onCloseClick}>
+              {translate('cancel')}
+            </ResetButtonLink>
+          </footer>
+        </form>
+      )}
+    </SimpleModal>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/groups/components/Form.tsx b/server/sonar-web/src/main/js/apps/groups/components/Form.tsx
deleted file mode 100644 (file)
index 91ec41a..0000000
+++ /dev/null
@@ -1,119 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
-import SimpleModal from '../../../components/controls/SimpleModal';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import MandatoryFieldMarker from '../../../components/ui/MandatoryFieldMarker';
-import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation';
-import { translate } from '../../../helpers/l10n';
-import { Group } from '../../../types/types';
-
-interface Props {
-  confirmButtonText: string;
-  group?: Group;
-  header: string;
-  onClose: () => void;
-  onSubmit: (data: { description: string; name: string }) => Promise<void>;
-}
-
-interface State {
-  description: string;
-  name: string;
-}
-
-export default class Form extends React.PureComponent<Props, State> {
-  constructor(props: Props) {
-    super(props);
-    this.state = {
-      description: (props.group && props.group.description) || '',
-      name: (props.group && props.group.name) || '',
-    };
-  }
-
-  handleSubmit = () => {
-    return this.props
-      .onSubmit({ description: this.state.description, name: this.state.name })
-      .then(this.props.onClose);
-  };
-
-  handleDescriptionChange = (event: React.SyntheticEvent<HTMLTextAreaElement>) => {
-    this.setState({ description: event.currentTarget.value });
-  };
-
-  handleNameChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
-    this.setState({ name: event.currentTarget.value });
-  };
-
-  render() {
-    return (
-      <SimpleModal
-        header={this.props.header}
-        onClose={this.props.onClose}
-        onSubmit={this.handleSubmit}
-        size="small"
-      >
-        {({ onCloseClick, onFormSubmit, submitting }) => (
-          <form onSubmit={onFormSubmit}>
-            <header className="modal-head">
-              <h2>{this.props.header}</h2>
-            </header>
-
-            <div className="modal-body">
-              <MandatoryFieldsExplanation className="modal-field" />
-              <div className="modal-field">
-                <label htmlFor="create-group-name">
-                  {translate('name')}
-                  <MandatoryFieldMarker />
-                </label>
-                <input
-                  autoFocus={true}
-                  id="create-group-name"
-                  maxLength={255}
-                  name="name"
-                  onChange={this.handleNameChange}
-                  required={true}
-                  size={50}
-                  type="text"
-                  value={this.state.name}
-                />
-              </div>
-              <div className="modal-field">
-                <label htmlFor="create-group-description">{translate('description')}</label>
-                <textarea
-                  id="create-group-description"
-                  name="description"
-                  onChange={this.handleDescriptionChange}
-                  value={this.state.description}
-                />
-              </div>
-            </div>
-
-            <footer className="modal-foot">
-              <DeferredSpinner className="spacer-right" loading={submitting} />
-              <SubmitButton disabled={submitting}>{this.props.confirmButtonText}</SubmitButton>
-              <ResetButtonLink onClick={onCloseClick}>{translate('cancel')}</ResetButtonLink>
-            </footer>
-          </form>
-        )}
-      </SimpleModal>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/groups/components/GroupForm.tsx b/server/sonar-web/src/main/js/apps/groups/components/GroupForm.tsx
new file mode 100644 (file)
index 0000000..ddeee0e
--- /dev/null
@@ -0,0 +1,136 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { useCallback, useEffect, useState } from 'react';
+import { createGroup, updateGroup } from '../../../api/user_groups';
+import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
+import SimpleModal from '../../../components/controls/SimpleModal';
+import DeferredSpinner from '../../../components/ui/DeferredSpinner';
+import MandatoryFieldMarker from '../../../components/ui/MandatoryFieldMarker';
+import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation';
+import { translate } from '../../../helpers/l10n';
+import { omitNil } from '../../../helpers/request';
+import { Group } from '../../../types/types';
+
+type Props =
+  | {
+      create: true;
+      group?: undefined;
+      onClose: () => void;
+      reload: () => void;
+    }
+  | {
+      create: false;
+      group: Group;
+      onClose: () => void;
+      reload: () => void;
+    };
+
+export default function GroupForm(props: Props) {
+  const { group, create, reload, onClose } = props;
+
+  const [name, setName] = useState<string>('');
+  const [description, setDescription] = useState<string>('');
+
+  const handleSubmit = useCallback(async () => {
+    try {
+      if (create) {
+        await createGroup({ name, description });
+      } else {
+        const data = {
+          currentName: group.name,
+          description,
+          // pass `name` only if it has changed, otherwise the WS fails
+          ...omitNil({ name: name !== group.name ? name : undefined }),
+        };
+        await updateGroup(data);
+      }
+    } finally {
+      reload();
+      onClose();
+    }
+  }, [name, description, group, create, reload, onClose]);
+
+  useEffect(() => {
+    if (!create) {
+      setDescription(group.description ?? '');
+      setName(group.name);
+    }
+  }, []);
+
+  return (
+    <SimpleModal
+      header={create ? translate('groups.create_group') : translate('groups.update_group')}
+      onClose={props.onClose}
+      onSubmit={handleSubmit}
+      size="small"
+    >
+      {({ onCloseClick, onFormSubmit, submitting }) => (
+        <form onSubmit={onFormSubmit}>
+          <header className="modal-head">
+            <h2>{create ? translate('groups.create_group') : translate('groups.update_group')}</h2>
+          </header>
+
+          <div className="modal-body">
+            <MandatoryFieldsExplanation className="modal-field" />
+            <div className="modal-field">
+              <label htmlFor="create-group-name">
+                {translate('name')}
+                <MandatoryFieldMarker />
+              </label>
+              <input
+                autoFocus={true}
+                id="create-group-name"
+                maxLength={255}
+                name="name"
+                onChange={(event: React.SyntheticEvent<HTMLInputElement>) => {
+                  setName(event.currentTarget.value);
+                }}
+                required={true}
+                size={50}
+                type="text"
+                value={name}
+              />
+            </div>
+            <div className="modal-field">
+              <label htmlFor="create-group-description">{translate('description')}</label>
+              <textarea
+                id="create-group-description"
+                name="description"
+                onChange={(event: React.SyntheticEvent<HTMLTextAreaElement>) => {
+                  setDescription(event.currentTarget.value);
+                }}
+                value={description}
+              />
+            </div>
+          </div>
+
+          <footer className="modal-foot">
+            <DeferredSpinner className="spacer-right" loading={submitting} />
+            <SubmitButton disabled={submitting}>
+              {create ? translate('create') : translate('update_verb')}
+            </SubmitButton>
+            <ResetButtonLink onClick={onCloseClick}>{translate('cancel')}</ResetButtonLink>
+          </footer>
+        </form>
+      )}
+    </SimpleModal>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/groups/components/GroupsApp.tsx b/server/sonar-web/src/main/js/apps/groups/components/GroupsApp.tsx
new file mode 100644 (file)
index 0000000..3e72b40
--- /dev/null
@@ -0,0 +1,118 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { useCallback, useEffect, useState } from 'react';
+import { Helmet } from 'react-helmet-async';
+import { searchUsersGroups } from '../../../api/user_groups';
+import ListFooter from '../../../components/controls/ListFooter';
+import { ManagedFilter } from '../../../components/controls/ManagedFilter';
+import SearchBox from '../../../components/controls/SearchBox';
+import Suggestions from '../../../components/embed-docs-modal/Suggestions';
+import { useManageProvider } from '../../../components/hooks/useManageProvider';
+import { translate } from '../../../helpers/l10n';
+import { Group, Paging } from '../../../types/types';
+import '../groups.css';
+import Header from './Header';
+import List from './List';
+
+export default function App() {
+  const [loading, setLoading] = useState<boolean>(true);
+  const [paging, setPaging] = useState<Paging>();
+  const [search, setSearch] = useState<string>('');
+  const [groups, setGroups] = useState<Group[]>([]);
+  const [managed, setManaged] = useState<boolean | undefined>();
+  const manageProvider = useManageProvider();
+
+  const fetchGroups = useCallback(async () => {
+    setLoading(true);
+    try {
+      const { groups, paging } = await searchUsersGroups({
+        q: search,
+        managed,
+      });
+      setGroups(groups);
+      setPaging(paging);
+    } finally {
+      setLoading(false);
+    }
+  }, [search, managed]);
+
+  const fetchMoreGroups = useCallback(async () => {
+    if (!paging) {
+      return;
+    }
+    setLoading(true);
+    try {
+      const { groups: nextGroups, paging: nextPage } = await searchUsersGroups({
+        q: search,
+        managed,
+        p: paging.pageIndex + 1,
+      });
+      setPaging(nextPage);
+      setGroups([...groups, ...nextGroups]);
+    } finally {
+      setLoading(false);
+    }
+  }, [groups, search, managed, paging]);
+
+  useEffect(() => {
+    fetchGroups();
+  }, [search, managed]);
+
+  return (
+    <>
+      <Suggestions suggestions="user_groups" />
+      <Helmet defer={false} title={translate('user_groups.page')} />
+      <main className="page page-limited" id="groups-page">
+        <Header reload={fetchGroups} manageProvider={manageProvider} />
+
+        <div className="display-flex-justify-start big-spacer-bottom big-spacer-top">
+          <ManagedFilter
+            manageProvider={manageProvider}
+            loading={loading}
+            managed={managed}
+            setManaged={setManaged}
+          />
+          <SearchBox
+            id="groups-search"
+            minLength={2}
+            onChange={(q) => setSearch(q)}
+            placeholder={translate('search.search_by_name')}
+            value={search}
+          />
+        </div>
+
+        <List groups={groups} reload={fetchGroups} manageProvider={manageProvider} />
+
+        {paging !== undefined && (
+          <div id="groups-list-footer">
+            <ListFooter
+              count={groups.length}
+              loading={loading}
+              loadMore={fetchMoreGroups}
+              ready={!loading}
+              total={paging.total}
+            />
+          </div>
+        )}
+      </main>
+    </>
+  );
+}
index 8a681c42b5ed88533138bcaaf1bbf94429e8888e..bbfd8a1a4c90d1522996a05f9eb5c5155617fa72 100644 (file)
@@ -23,10 +23,10 @@ import DocLink from '../../../components/common/DocLink';
 import { Button } from '../../../components/controls/buttons';
 import { Alert } from '../../../components/ui/Alert';
 import { translate } from '../../../helpers/l10n';
-import Form from './Form';
+import GroupForm from './GroupForm';
 
 interface HeaderProps {
-  onCreate: (data: { description: string; name: string }) => Promise<void>;
+  reload: () => void;
   manageProvider?: string;
 }
 
@@ -69,12 +69,7 @@ export default function Header(props: HeaderProps) {
         )}
       </div>
       {createModal && (
-        <Form
-          confirmButtonText={translate('create')}
-          header={translate('groups.create_group')}
-          onClose={() => setCreateModal(false)}
-          onSubmit={props.onCreate}
-        />
+        <GroupForm onClose={() => setCreateModal(false)} create={true} reload={props.reload} />
       )}
     </>
   );
index 2a98562c17ef938753f0ecd7acd613bd8f4cb0d5..b15de183e9ca88856454b8843ad54ee6758d69e5 100644 (file)
@@ -25,9 +25,7 @@ import ListItem from './ListItem';
 
 interface Props {
   groups: Group[];
-  onDelete: (group: Group) => void;
-  onEdit: (group: Group) => void;
-  onEditMembers: () => void;
+  reload: () => void;
   manageProvider: string | undefined;
 }
 
@@ -54,9 +52,7 @@ export default function List(props: Props) {
             <ListItem
               group={group}
               key={group.name}
-              onDelete={props.onDelete}
-              onEdit={props.onEdit}
-              onEditMembers={props.onEditMembers}
+              reload={props.reload}
               manageProvider={manageProvider}
             />
           ))}
index 58a9d324e81cb807e34cbfe257a68a9997ce5396..93acc5ca969938664aa4688329248c6b8f3572fe 100644 (file)
  */
 import classNames from 'classnames';
 import * as React from 'react';
+import { useState } from 'react';
 import ActionsDropdown, {
   ActionsDropdownDivider,
   ActionsDropdownItem,
 } from '../../../components/controls/ActionsDropdown';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { Group } from '../../../types/types';
+import DeleteGroupForm from './DeleteGroupForm';
 import EditMembers from './EditMembers';
+import GroupForm from './GroupForm';
 
 export interface ListItemProps {
   group: Group;
-  onDelete: (group: Group) => void;
-  onEdit: (group: Group) => void;
-  onEditMembers: () => void;
+  reload: () => void;
   manageProvider: string | undefined;
 }
 
@@ -39,6 +40,9 @@ export default function ListItem(props: ListItemProps) {
   const { manageProvider, group } = props;
   const { name, managed, membersCount, description } = group;
 
+  const [groupToDelete, setGroupToDelete] = useState<Group | undefined>();
+  const [groupToEdit, setGroupToEdit] = useState<Group | undefined>();
+
   const isManaged = () => {
     return manageProvider !== undefined;
   };
@@ -61,9 +65,7 @@ export default function ListItem(props: ListItemProps) {
         >
           {membersCount}
         </span>
-        {!group.default && !isManaged() && (
-          <EditMembers group={group} onEdit={props.onEditMembers} />
-        )}
+        {!group.default && !isManaged() && <EditMembers group={group} onEdit={props.reload} />}
       </td>
 
       <td className="width-40" headers="list-group-description">
@@ -77,7 +79,7 @@ export default function ListItem(props: ListItemProps) {
               <>
                 <ActionsDropdownItem
                   className="js-group-update"
-                  onClick={() => props.onEdit(group)}
+                  onClick={() => setGroupToEdit(group)}
                 >
                   {translate('update_details')}
                 </ActionsDropdownItem>
@@ -88,13 +90,28 @@ export default function ListItem(props: ListItemProps) {
               <ActionsDropdownItem
                 className="js-group-delete"
                 destructive={true}
-                onClick={() => props.onDelete(group)}
+                onClick={() => setGroupToDelete(group)}
               >
                 {translate('delete')}
               </ActionsDropdownItem>
             )}
           </ActionsDropdown>
         )}
+        {groupToDelete && (
+          <DeleteGroupForm
+            group={groupToDelete}
+            reload={props.reload}
+            onClose={() => setGroupToDelete(undefined)}
+          />
+        )}
+        {groupToEdit && (
+          <GroupForm
+            create={false}
+            group={groupToEdit}
+            reload={props.reload}
+            onClose={() => setGroupToEdit(undefined)}
+          />
+        )}
       </td>
     </tr>
   );
index 1af97ddd5ed387a21953f8283fbfea94caee685a..cd66848bd9558a4480a8cd42d196e630ff95e63d 100644 (file)
@@ -25,7 +25,7 @@ import { act } from 'react-dom/test-utils';
 import { byRole, byText } from 'testing-library-selector';
 import GroupsServiceMock from '../../../../api/mocks/GroupsServiceMock';
 import { renderApp } from '../../../../helpers/testReactTestingUtils';
-import App from '../App';
+import App from '../GroupsApp';
 
 jest.mock('../../../../api/users');
 jest.mock('../../../../api/system');
@@ -89,14 +89,15 @@ describe('in non managed mode', () => {
     renderGroupsApp();
 
     expect(await ui.description.find()).toBeInTheDocument();
+    await act(async () => {
+      await user.click(ui.createGroupButton.get());
+    });
 
-    await user.click(ui.createGroupButton.get());
-    expect(ui.createGroupDialog.get()).toBeInTheDocument();
-
-    await user.type(ui.nameInput.get(), 'local-group 2');
-    await user.type(ui.descriptionInput.get(), 'group 2 is loco!');
+    expect(await ui.createGroupDialog.find()).toBeInTheDocument();
 
     await act(async () => {
+      await user.type(ui.nameInput.get(), 'local-group 2');
+      await user.type(ui.descriptionInput.get(), 'group 2 is loco!');
       await user.click(ui.createGroupDialogButton.get());
     });
 
@@ -107,8 +108,10 @@ describe('in non managed mode', () => {
     const user = userEvent.setup();
     renderGroupsApp();
 
-    await user.click(await ui.localEditButton.find());
-    await user.click(await ui.deleteButton.find());
+    await act(async () => {
+      await user.click(await ui.localEditButton.find());
+      await user.click(await ui.deleteButton.find());
+    });
 
     expect(await ui.deleteDialog.find()).toBeInTheDocument();
     await act(async () => {
@@ -123,15 +126,19 @@ describe('in non managed mode', () => {
     const user = userEvent.setup();
     renderGroupsApp();
 
-    await user.click(await ui.localEditButton.find());
-    await user.click(await ui.updateButton.find());
+    await act(async () => {
+      await user.click(await ui.localEditButton.find());
+      await user.click(await ui.updateButton.find());
+    });
 
     expect(ui.updateDialog.get()).toBeInTheDocument();
 
-    await user.clear(ui.nameInput.get());
-    await user.type(ui.nameInput.get(), 'local-group 3');
-    await user.clear(ui.descriptionInput.get());
-    await user.type(ui.descriptionInput.get(), 'group 3 rocks!');
+    await act(async () => {
+      await user.clear(ui.nameInput.get());
+      await user.type(ui.nameInput.get(), 'local-group 3');
+      await user.clear(ui.descriptionInput.get());
+      await user.type(ui.descriptionInput.get(), 'group 3 rocks!');
+    });
 
     expect(ui.updateDialog.get()).toBeInTheDocument();
 
@@ -150,7 +157,10 @@ describe('in non managed mode', () => {
     expect(await ui.localGroupRow.find()).toBeInTheDocument();
     expect(await ui.localGroupEditMembersButton.find()).toBeInTheDocument();
 
-    await user.click(ui.localGroupEditMembersButton.get());
+    await act(async () => {
+      await user.click(ui.localGroupEditMembersButton.get());
+    });
+
     expect(await ui.membersDialog.find()).toBeInTheDocument();
   });
 
@@ -161,7 +171,9 @@ describe('in non managed mode', () => {
     expect(await ui.localGroupRow.find()).toBeInTheDocument();
     expect(ui.managedGroupRow.get()).toBeInTheDocument();
 
-    await user.type(await ui.searchInput.find(), 'local');
+    await act(async () => {
+      await user.type(await ui.searchInput.find(), 'local');
+    });
 
     expect(await ui.localGroupRow.find()).toBeInTheDocument();
     expect(ui.managedGroupRow.query()).not.toBeInTheDocument();
@@ -171,9 +183,12 @@ describe('in non managed mode', () => {
     const user = userEvent.setup();
     renderGroupsApp();
 
+    expect(await ui.localGroupRow.find()).toBeInTheDocument();
     expect(await screen.findAllByRole('row')).toHaveLength(3);
 
-    await user.click(await ui.showMore.find());
+    await act(async () => {
+      await user.click(await ui.showMore.find());
+    });
 
     expect(await screen.findAllByRole('row')).toHaveLength(5);
   });
@@ -197,11 +212,15 @@ describe('in manage mode', () => {
 
     expect(await ui.localGroupRowWithLocalBadge.find()).toBeInTheDocument();
 
-    await user.click(await ui.localFilter.find());
-    await user.click(await ui.localEditButton.find());
+    await act(async () => {
+      await user.click(await ui.localFilter.find());
+      await user.click(await ui.localEditButton.find());
+    });
     expect(ui.updateButton.query()).not.toBeInTheDocument();
 
-    await user.click(await ui.deleteButton.find());
+    await act(async () => {
+      await user.click(await ui.deleteButton.find());
+    });
 
     expect(await ui.deleteDialog.find()).toBeInTheDocument();
     await act(async () => {
@@ -232,7 +251,9 @@ describe('in manage mode', () => {
     const user = userEvent.setup();
     renderGroupsApp();
 
-    await user.click(await ui.managedFilter.find());
+    await act(async () => {
+      await user.click(await ui.managedFilter.find());
+    });
 
     expect(ui.localGroupRow.query()).not.toBeInTheDocument();
     expect(ui.managedGroupRow.get()).toBeInTheDocument();
@@ -242,7 +263,9 @@ describe('in manage mode', () => {
     const user = userEvent.setup();
     renderGroupsApp();
 
-    await user.click(await ui.localFilter.find());
+    await act(async () => {
+      await user.click(await ui.localFilter.find());
+    });
 
     expect(ui.localGroupRowWithLocalBadge.get()).toBeInTheDocument();
     expect(ui.managedGroupRow.query()).not.toBeInTheDocument();
index 44607b3ab11993d08119b287af5b17c33e8f8c4e..d40207a484a1d22ca41454ba3078081168a24efa 100644 (file)
@@ -32,13 +32,5 @@ function shallowRender() {
     mockGroup({ name: 'foo', description: 'foobar', membersCount: 0, default: false }),
     mockGroup({ name: 'bar', description: 'barbar', membersCount: 1, default: false }),
   ];
-  return shallow(
-    <List
-      groups={groups}
-      onDelete={jest.fn()}
-      onEdit={jest.fn()}
-      onEditMembers={jest.fn()}
-      manageProvider={undefined}
-    />
-  );
+  return shallow(<List groups={groups} manageProvider={undefined} reload={jest.fn()} />);
 }
index 206257c15cdf913533c56ba0fcb4485470cbf88b..e51bc5eabd976247226121c1762a97cca5e537bc 100644 (file)
@@ -29,13 +29,6 @@ it('should render correctly', () => {
 
 function shallowRender(overrides: Partial<ListItemProps> = {}) {
   return shallow(
-    <ListItem
-      group={mockGroup()}
-      onDelete={jest.fn()}
-      onEdit={jest.fn()}
-      onEditMembers={jest.fn()}
-      manageProvider={undefined}
-      {...overrides}
-    />
+    <ListItem group={mockGroup()} reload={jest.fn()} manageProvider={undefined} {...overrides} />
   );
 }
index f9d93c9e5910d2bebe515d09082bd4eb70a05982..f79f1f69b9152fb94f94f21ef11e416f07c20a95 100644 (file)
@@ -46,9 +46,7 @@ exports[`should render 1`] = `
           }
         }
         key="bar"
-        onDelete={[MockFunction]}
-        onEdit={[MockFunction]}
-        onEditMembers={[MockFunction]}
+        reload={[MockFunction]}
       />
       <ListItem
         group={
@@ -61,9 +59,7 @@ exports[`should render 1`] = `
           }
         }
         key="foo"
-        onDelete={[MockFunction]}
-        onEdit={[MockFunction]}
-        onEditMembers={[MockFunction]}
+        reload={[MockFunction]}
       />
       <ListItem
         group={
@@ -76,9 +72,7 @@ exports[`should render 1`] = `
           }
         }
         key="sonar-users"
-        onDelete={[MockFunction]}
-        onEdit={[MockFunction]}
-        onEditMembers={[MockFunction]}
+        reload={[MockFunction]}
       />
     </tbody>
   </table>
index d6da3b58c94d5fa95b06e77ff5da2138486711a1..04bebdb5d648b5d3f2fbbfe619f02a75a535ae5f 100644 (file)
@@ -19,7 +19,7 @@
  */
 import React from 'react';
 import { Route } from 'react-router-dom';
-import App from './components/App';
+import App from './components/GroupsApp';
 
 const routes = () => <Route path="groups" element={<App />} />;
 
index 36b1daac07cb51189a4f4e5ee0986896c1650c62..7cec61d1f3b2463cc0ae9ad8905e342cf268c0c4 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import * as React from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
 import { Helmet } from 'react-helmet-async';
-import { getSystemInfo } from '../../api/system';
 import { getIdentityProviders, searchUsers } from '../../api/users';
-import withCurrentUserContext from '../../app/components/current-user/withCurrentUserContext';
-import ButtonToggle from '../../components/controls/ButtonToggle';
 import ListFooter from '../../components/controls/ListFooter';
+import { ManagedFilter } from '../../components/controls/ManagedFilter';
 import SearchBox from '../../components/controls/SearchBox';
 import Suggestions from '../../components/embed-docs-modal/Suggestions';
-import { Location, Router, withRouter } from '../../components/hoc/withRouter';
+import { useManageProvider } from '../../components/hooks/useManageProvider';
+import DeferredSpinner from '../../components/ui/DeferredSpinner';
 import { translate } from '../../helpers/l10n';
-import { IdentityProvider, Paging, SysInfoCluster } from '../../types/types';
-import { CurrentUser, User } from '../../types/users';
+import { IdentityProvider, Paging } from '../../types/types';
+import { User } from '../../types/users';
 import Header from './Header';
 import UsersList from './UsersList';
-import { parseQuery, Query, serializeQuery } from './utils';
 
-interface Props {
-  currentUser: CurrentUser;
-  location: Location;
-  router: Router;
-}
-
-interface State {
-  identityProviders: IdentityProvider[];
-  manageProvider?: string;
-  loading: boolean;
-  paging?: Paging;
-  users: User[];
-}
+export default function UsersApp() {
+  const [identityProviders, setIdentityProviders] = useState<IdentityProvider[]>([]);
 
-export class UsersApp extends React.PureComponent<Props, State> {
-  mounted = false;
-  state: State = { identityProviders: [], loading: true, users: [] };
+  const [loading, setLoading] = useState(true);
+  const [paging, setPaging] = useState<Paging>();
+  const [users, setUsers] = useState<User[]>([]);
 
-  componentDidMount() {
-    this.mounted = true;
-    this.fetchIdentityProviders();
-    this.fetchManageInstance();
-    this.fetchUsers();
-  }
+  const [search, setSearch] = useState('');
+  const [managed, setManaged] = useState<boolean | undefined>(undefined);
 
-  componentDidUpdate(prevProps: Props) {
-    if (
-      prevProps.location.query.search !== this.props.location.query.search ||
-      prevProps.location.query.managed !== this.props.location.query.managed
-    ) {
-      this.fetchUsers();
-    }
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
+  const manageProvider = useManageProvider();
 
-  finishLoading = () => {
-    if (this.mounted) {
-      this.setState({ loading: false });
+  const fetchUsers = useCallback(async () => {
+    setLoading(true);
+    try {
+      const { paging, users } = await searchUsers({ q: search, managed });
+      setPaging(paging);
+      setUsers(users);
+    } finally {
+      setLoading(false);
     }
-  };
+  }, [search, managed]);
 
-  async fetchManageInstance() {
-    const info = (await getSystemInfo()) as SysInfoCluster;
-    if (this.mounted) {
-      this.setState({
-        manageProvider: info.System['External Users and Groups Provisioning'],
-      });
+  const fetchMoreUsers = useCallback(async () => {
+    if (!paging) {
+      return;
     }
-  }
-
-  fetchIdentityProviders = () =>
-    getIdentityProviders().then(({ identityProviders }) => {
-      if (this.mounted) {
-        this.setState({ identityProviders });
-      }
-    });
-
-  fetchUsers = () => {
-    const { search, managed } = parseQuery(this.props.location.query);
-    this.setState({ loading: true });
-    searchUsers({
-      q: search,
-      managed,
-    }).then(({ paging, users }) => {
-      if (this.mounted) {
-        this.setState({ loading: false, paging, users });
-      }
-    }, this.finishLoading);
-  };
-
-  fetchMoreUsers = () => {
-    const { paging } = this.state;
-    if (paging) {
-      const { search, managed } = parseQuery(this.props.location.query);
-      this.setState({ loading: true });
-      searchUsers({
-        p: paging.pageIndex + 1,
+    setLoading(true);
+    try {
+      const { paging: nextPage, users: nextUsers } = await searchUsers({
         q: search,
         managed,
-      }).then(({ paging, users }) => {
-        if (this.mounted) {
-          this.setState((state) => ({ loading: false, users: [...state.users, ...users], paging }));
-        }
-      }, this.finishLoading);
+        p: paging.pageIndex + 1,
+      });
+      setPaging(nextPage);
+      setUsers([...users, ...nextUsers]);
+    } finally {
+      setLoading(false);
     }
-  };
-
-  updateQuery = (newQuery: Partial<Query>) => {
-    const query = serializeQuery({ ...parseQuery(this.props.location.query), ...newQuery });
-    this.props.router.push({ ...this.props.location, query });
-  };
-
-  updateTokensCount = (login: string, tokensCount: number) => {
-    this.setState((state) => ({
-      users: state.users.map((user) => (user.login === login ? { ...user, tokensCount } : user)),
-    }));
-  };
-
-  render() {
-    const { search, managed } = parseQuery(this.props.location.query);
-    const { loading, paging, users, manageProvider } = this.state;
-
-    return (
-      <main className="page page-limited" id="users-page">
-        <Suggestions suggestions="users" />
-        <Helmet defer={false} title={translate('users.page')} />
-        <Header onUpdateUsers={this.fetchUsers} manageProvider={manageProvider} />
-        <div className="display-flex-justify-start big-spacer-bottom big-spacer-top">
-          {manageProvider !== undefined && (
-            <div className="big-spacer-right">
-              <ButtonToggle
-                value={managed === undefined ? 'all' : managed}
-                disabled={loading}
-                options={[
-                  { label: translate('all'), value: 'all' },
-                  { label: translate('managed'), value: true },
-                  { label: translate('local'), value: false },
-                ]}
-                onCheck={(filterOption) => {
-                  if (filterOption === 'all') {
-                    this.updateQuery({ managed: undefined });
-                  } else {
-                    this.updateQuery({ managed: filterOption as boolean });
-                  }
-                }}
-              />
-            </div>
-          )}
-          <SearchBox
-            id="users-search"
-            onChange={(search: string) => this.updateQuery({ search })}
-            placeholder={translate('search.search_by_login_or_name')}
-            value={search}
-          />
-        </div>
+  }, [search, managed, paging, users]);
+
+  useEffect(() => {
+    (async () => {
+      const { identityProviders } = await getIdentityProviders();
+      setIdentityProviders(identityProviders);
+    })();
+  }, []);
+
+  useEffect(() => {
+    fetchUsers();
+  }, [search, managed]);
+
+  return (
+    <main className="page page-limited" id="users-page">
+      <Suggestions suggestions="users" />
+      <Helmet defer={false} title={translate('users.page')} />
+      <Header onUpdateUsers={fetchUsers} manageProvider={manageProvider} />
+      <div className="display-flex-justify-start big-spacer-bottom big-spacer-top">
+        <ManagedFilter
+          manageProvider={manageProvider}
+          loading={loading}
+          managed={managed}
+          setManaged={setManaged}
+        />
+        <SearchBox
+          id="users-search"
+          minLength={2}
+          onChange={(search: string) => setSearch(search)}
+          placeholder={translate('search.search_by_login_or_name')}
+          value={search}
+        />
+      </div>
+      <DeferredSpinner loading={loading}>
         <UsersList
-          currentUser={this.props.currentUser}
-          identityProviders={this.state.identityProviders}
-          onUpdateUsers={this.fetchUsers}
-          updateTokensCount={this.updateTokensCount}
+          identityProviders={identityProviders}
+          onUpdateUsers={fetchUsers}
+          updateTokensCount={fetchUsers}
           users={users}
           manageProvider={manageProvider}
         />
-        {paging !== undefined && (
-          <ListFooter
-            count={users.length}
-            loadMore={this.fetchMoreUsers}
-            ready={!loading}
-            total={paging.total}
-          />
-        )}
-      </main>
-    );
-  }
+      </DeferredSpinner>
+      {paging !== undefined && (
+        <ListFooter
+          count={users.length}
+          loadMore={fetchMoreUsers}
+          ready={!loading}
+          total={paging.total}
+        />
+      )}
+    </main>
+  );
 }
-
-export default withRouter(withCurrentUserContext(UsersApp));
index d0c4e130803c20e7def6c5903602bf3c119409d8..8d20e705bbebe73d1477e3fbfb0569552e614be8 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import { CurrentUserContext } from '../../app/components/current-user/CurrentUserContext';
 import { translate } from '../../helpers/l10n';
 import { IdentityProvider } from '../../types/types';
-import { User } from '../../types/users';
+import { isLoggedIn, User } from '../../types/users';
 import UserListItem from './components/UserListItem';
 
 interface Props {
-  currentUser: { isLoggedIn: boolean; login?: string };
   identityProviders: IdentityProvider[];
   onUpdateUsers: () => void;
   updateTokensCount: (login: string, tokensCount: number) => void;
@@ -33,13 +33,15 @@ interface Props {
 }
 
 export default function UsersList({
-  currentUser,
   identityProviders,
   onUpdateUsers,
   updateTokensCount,
   users,
   manageProvider,
 }: Props) {
+  const userContext = React.useContext(CurrentUserContext);
+  const currentUser = userContext?.currentUser;
+
   return (
     <div className="boxed-group boxed-group-inner">
       <table className="data zebra" id="users-list">
@@ -62,7 +64,7 @@ export default function UsersList({
               identityProvider={identityProviders.find(
                 (provider) => user.externalProvider === provider.key
               )}
-              isCurrentUser={currentUser.isLoggedIn && currentUser.login === user.login}
+              isCurrentUser={isLoggedIn(currentUser) && currentUser.login === user.login}
               key={user.login}
               onUpdateUsers={onUpdateUsers}
               updateTokensCount={updateTokensCount}
diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/Header-test.tsx b/server/sonar-web/src/main/js/apps/users/__tests__/Header-test.tsx
deleted file mode 100644 (file)
index cab66f2..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import { shallow } from 'enzyme';
-import * as React from 'react';
-import { click } from '../../../helpers/testUtils';
-import Header from '../Header';
-
-it('should render correctly', () => {
-  expect(getWrapper()).toMatchSnapshot();
-});
-
-it('should open the user creation form', () => {
-  const wrapper = getWrapper();
-  click(wrapper.find('#users-create'));
-  expect(wrapper.find('UserForm').exists()).toBe(true);
-});
-
-function getWrapper(props = {}) {
-  return shallow(<Header onUpdateUsers={jest.fn()} {...props} />);
-}
index 4661ee408db1ae703ff4cce3302db7cdd0ba35c7..23fce172487f8e961a532258d66a8e1f56c03174 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
+import { act, screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import * as React from 'react';
 import { byLabelText, byRole, byText } from 'testing-library-selector';
@@ -37,6 +38,7 @@ const ui = {
   allFilter: byRole('button', { name: 'all' }),
   managedFilter: byRole('button', { name: 'managed' }),
   localFilter: byRole('button', { name: 'local' }),
+  showMore: byRole('button', { name: 'show_more' }),
   aliceRow: byRole('row', { name: 'AM Alice Merveille alice.merveille never' }),
   aliceRowWithLocalBadge: byRole('row', {
     name: 'AM Alice Merveille alice.merveille local never',
@@ -99,22 +101,37 @@ describe('in non managed mode', () => {
     renderUsersApp();
 
     expect(await ui.aliceUpdateGroupButton.find()).toBeInTheDocument();
-    expect(await ui.bobUpdateGroupButton.find()).toBeInTheDocument();
+    expect(ui.bobUpdateGroupButton.get()).toBeInTheDocument();
   });
 
   it('should be able to update / change password / deactivate a user', async () => {
     renderUsersApp();
 
     expect(await ui.aliceUpdateButton.find()).toBeInTheDocument();
-    expect(await ui.bobUpdateButton.find()).toBeInTheDocument();
+    expect(ui.bobUpdateButton.get()).toBeInTheDocument();
   });
 
   it('should render all users', async () => {
     renderUsersApp();
 
+    expect(await ui.aliceRow.find()).toBeInTheDocument();
+    expect(ui.bobRow.get()).toBeInTheDocument();
     expect(ui.aliceRowWithLocalBadge.query()).not.toBeInTheDocument();
+  });
+
+  it('should be able load more users', async () => {
+    const user = userEvent.setup();
+    renderUsersApp();
+
     expect(await ui.aliceRow.find()).toBeInTheDocument();
-    expect(await ui.bobRow.find()).toBeInTheDocument();
+    expect(ui.bobRow.get()).toBeInTheDocument();
+    expect(screen.getAllByRole('row')).toHaveLength(4);
+
+    await act(async () => {
+      await user.click(await ui.showMore.find());
+    });
+
+    expect(screen.getAllByRole('row')).toHaveLength(6);
   });
 });
 
@@ -125,8 +142,9 @@ describe('in manage mode', () => {
 
   it('should not be able to create a user"', async () => {
     renderUsersApp();
-    expect(await ui.createUserButton.get()).toBeDisabled();
+
     expect(await ui.infoManageMode.find()).toBeInTheDocument();
+    expect(ui.createUserButton.get()).toBeDisabled();
   });
 
   it("should not be able to add/remove a user's group", async () => {
@@ -134,8 +152,7 @@ describe('in manage mode', () => {
 
     expect(await ui.aliceRowWithLocalBadge.find()).toBeInTheDocument();
     expect(ui.aliceUpdateGroupButton.query()).not.toBeInTheDocument();
-
-    expect(await ui.bobRow.find()).toBeInTheDocument();
+    expect(ui.bobRow.get()).toBeInTheDocument();
     expect(ui.bobUpdateGroupButton.query()).not.toBeInTheDocument();
   });
 
@@ -152,7 +169,7 @@ describe('in manage mode', () => {
 
     expect(await ui.aliceRowWithLocalBadge.find()).toBeInTheDocument();
     await user.click(ui.aliceUpdateButton.get());
-    expect(await ui.alicedDeactivateButton.get()).toBeInTheDocument();
+    expect(await ui.alicedDeactivateButton.find()).toBeInTheDocument();
   });
 
   it('should render list of all users', async () => {
@@ -168,19 +185,25 @@ describe('in manage mode', () => {
     const user = userEvent.setup();
     renderUsersApp();
 
-    await user.click(await ui.managedFilter.find());
+    expect(await ui.aliceRowWithLocalBadge.find()).toBeInTheDocument();
+
+    await act(async () => {
+      await user.click(await ui.managedFilter.find());
+    });
 
+    expect(await ui.bobRow.find()).toBeInTheDocument();
     expect(ui.aliceRowWithLocalBadge.query()).not.toBeInTheDocument();
-    expect(ui.bobRow.get()).toBeInTheDocument();
   });
 
   it('should render list of local users', async () => {
     const user = userEvent.setup();
     renderUsersApp();
 
-    await user.click(await ui.localFilter.find());
+    await act(async () => {
+      await user.click(await ui.localFilter.find());
+    });
 
-    expect(ui.aliceRowWithLocalBadge.get()).toBeInTheDocument();
+    expect(await ui.aliceRowWithLocalBadge.find()).toBeInTheDocument();
     expect(ui.bobRow.query()).not.toBeInTheDocument();
   });
 });
diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/UsersList-test.tsx b/server/sonar-web/src/main/js/apps/users/__tests__/UsersList-test.tsx
deleted file mode 100644 (file)
index 51b3d06..0000000
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import { shallow } from 'enzyme';
-import * as React from 'react';
-import UsersList from '../UsersList';
-
-const users = [
-  {
-    login: 'luke',
-    name: 'Luke',
-    active: true,
-    scmAccounts: [],
-    local: false,
-    managed: false,
-  },
-  {
-    login: 'obi',
-    name: 'One',
-    active: true,
-    scmAccounts: [],
-    local: false,
-    managed: false,
-  },
-];
-
-it('should render correctly', () => {
-  expect(getWrapper()).toMatchSnapshot();
-});
-
-function getWrapper(props = {}) {
-  return shallow(
-    <UsersList
-      currentUser={{ isLoggedIn: true, login: 'luke' }}
-      identityProviders={[
-        {
-          backgroundColor: 'blue',
-          iconPath: 'icon/path',
-          key: 'foo',
-          name: 'Foo Provider',
-        },
-      ]}
-      onUpdateUsers={jest.fn()}
-      updateTokensCount={jest.fn()}
-      users={users}
-      manageProvider={undefined}
-      {...props}
-    />
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/Header-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/Header-test.tsx.snap
deleted file mode 100644 (file)
index 88dc40c..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly 1`] = `
-<div
-  className="page-header null-spacer-bottom"
->
-  <h2
-    className="page-title"
-  >
-    users.page
-  </h2>
-  <div
-    className="page-actions"
-  >
-    <Button
-      disabled={false}
-      id="users-create"
-      onClick={[Function]}
-    >
-      users.create_user
-    </Button>
-  </div>
-  <p
-    className="page-description"
-  >
-    users.page.description
-  </p>
-</div>
-`;
diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap
deleted file mode 100644 (file)
index 64566ee..0000000
+++ /dev/null
@@ -1,80 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly 1`] = `
-<div
-  className="boxed-group boxed-group-inner"
->
-  <table
-    className="data zebra"
-    id="users-list"
-  >
-    <thead>
-      <tr>
-        <th />
-        <th
-          className="nowrap"
-        />
-        <th
-          className="nowrap"
-        >
-          my_profile.scm_accounts
-        </th>
-        <th
-          className="nowrap"
-        >
-          users.last_connection
-        </th>
-        <th
-          className="nowrap"
-        >
-          my_profile.groups
-        </th>
-        <th
-          className="nowrap"
-        >
-          users.tokens
-        </th>
-        <th
-          className="nowrap"
-        >
-           
-        </th>
-      </tr>
-    </thead>
-    <tbody>
-      <UserListItem
-        isCurrentUser={true}
-        key="luke"
-        onUpdateUsers={[MockFunction]}
-        updateTokensCount={[MockFunction]}
-        user={
-          {
-            "active": true,
-            "local": false,
-            "login": "luke",
-            "managed": false,
-            "name": "Luke",
-            "scmAccounts": [],
-          }
-        }
-      />
-      <UserListItem
-        isCurrentUser={false}
-        key="obi"
-        onUpdateUsers={[MockFunction]}
-        updateTokensCount={[MockFunction]}
-        user={
-          {
-            "active": true,
-            "local": false,
-            "login": "obi",
-            "managed": false,
-            "name": "One",
-            "scmAccounts": [],
-          }
-        }
-      />
-    </tbody>
-  </table>
-</div>
-`;
diff --git a/server/sonar-web/src/main/js/components/controls/ManagedFilter.tsx b/server/sonar-web/src/main/js/components/controls/ManagedFilter.tsx
new file mode 100644 (file)
index 0000000..846d595
--- /dev/null
@@ -0,0 +1,58 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { translate } from '../../helpers/l10n';
+import ButtonToggle from './ButtonToggle';
+
+interface ManagedFilterProps {
+  manageProvider: string | undefined;
+  loading: boolean;
+  managed: boolean | undefined;
+  setManaged: (managed: boolean | undefined) => void;
+}
+
+export function ManagedFilter(props: ManagedFilterProps) {
+  const { manageProvider, loading, managed } = props;
+
+  if (manageProvider === undefined) {
+    return null;
+  }
+
+  return (
+    <div className="big-spacer-right">
+      <ButtonToggle
+        value={managed === undefined ? 'all' : managed}
+        disabled={loading}
+        options={[
+          { label: translate('all'), value: 'all' },
+          { label: translate('managed'), value: true },
+          { label: translate('local'), value: false },
+        ]}
+        onCheck={(filterOption) => {
+          if (filterOption === 'all') {
+            props.setManaged(undefined);
+          } else {
+            props.setManaged(filterOption as boolean);
+          }
+        }}
+      />
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/hooks/useManageProvider.ts b/server/sonar-web/src/main/js/components/hooks/useManageProvider.ts
new file mode 100644 (file)
index 0000000..f27b576
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import * as React from 'react';
+import { useEffect } from 'react';
+import { getSystemInfo } from '../../api/system';
+import { SysInfoCluster } from '../../types/types';
+
+export function useManageProvider(): string | undefined {
+  const [manageProvider, setManageProvider] = React.useState<string | undefined>();
+
+  useEffect(() => {
+    (async () => {
+      const info = (await getSystemInfo()) as SysInfoCluster;
+      setManageProvider(info.System['External Users and Groups Provisioning']);
+    })();
+  }, []);
+
+  return manageProvider;
+}