]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-20184 Remove groupsCount and tokensCount from user
authorViktor Vorona <viktor.vorona@sonarsource.com>
Fri, 18 Aug 2023 13:44:48 +0000 (15:44 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 22 Aug 2023 20:03:05 +0000 (20:03 +0000)
12 files changed:
server/sonar-web/src/main/js/api/mocks/UserTokensMock.ts
server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts
server/sonar-web/src/main/js/apps/account/__tests__/Account-it.tsx
server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx
server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx
server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx
server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx
server/sonar-web/src/main/js/apps/users/components/TokensFormModal.tsx
server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx
server/sonar-web/src/main/js/helpers/testMocks.ts
server/sonar-web/src/main/js/queries/users.ts
server/sonar-web/src/main/js/types/users.ts

index f0f9f523a9e5d39748cd15a201c9adf79811c272..5acdf231a278617e6f8a982084b56967b80aa5b7 100644 (file)
@@ -41,9 +41,9 @@ export default class UserTokensMock {
   constructor() {
     this.tokens = cloneDeep(defaultTokens);
 
-    (getTokens as jest.Mock).mockImplementation(this.handleGetTokens);
-    (generateToken as jest.Mock).mockImplementation(this.handleGenerateToken);
-    (revokeToken as jest.Mock).mockImplementation(this.handleRevokeToken);
+    jest.mocked(getTokens).mockImplementation(this.handleGetTokens);
+    jest.mocked(generateToken).mockImplementation(this.handleGenerateToken);
+    jest.mocked(revokeToken).mockImplementation(this.handleRevokeToken);
   }
 
   handleGetTokens = () => {
@@ -58,7 +58,7 @@ export default class UserTokensMock {
     expirationDate,
   }: {
     name: string;
-    login?: string;
+    login: string;
     type: TokenType;
     projectKey: string;
     expirationDate?: string;
index 13b5e1e0d0cab9414ca3c94d435a589acdc6a3fd..ed92fd94dff56826b1ab0b3381ea21994af9dd4d 100644 (file)
@@ -55,7 +55,6 @@ const DEFAULT_USERS = [
     sonarQubeLastConnectionDate: '2023-06-27T17:08:59+0200',
     sonarLintLastConnectionDate: '2023-05-27T17:08:59+0200',
     email: 'alice.merveille@wonderland.com',
-    groupsCount: 2,
   }),
   mockRestUser({
     managed: false,
@@ -109,6 +108,13 @@ const DEFAULT_GROUPS: UserGroup[] = [
     id: 1003,
     name: 'test3',
     description: 'test3',
+    selected: true,
+    default: false,
+  },
+  {
+    id: 1004,
+    name: 'test4',
+    description: 'test4',
     selected: false,
     default: false,
   },
@@ -314,18 +320,25 @@ export default class UsersServiceMock {
   };
 
   handleGetUserGroups: typeof getUserGroups = (data) => {
+    if (data.login !== 'alice.merveille') {
+      return this.reply({
+        paging: { pageIndex: 1, pageSize: 10, total: 0 },
+        groups: [],
+      });
+    }
     const filteredGroups = this.groups
       .filter((g) => g.name.includes(data.q ?? ''))
       .filter((g) => {
         switch (data.selected) {
-          case 'selected':
-            return g.selected;
+          case 'all':
+            return true;
           case 'deselected':
             return !g.selected;
           default:
-            return true;
+            return g.selected;
         }
       });
+
     return this.reply({
       paging: { pageIndex: 1, pageSize: 10, total: filteredGroups.length },
       groups: filteredGroups,
@@ -334,7 +347,6 @@ export default class UsersServiceMock {
 
   handleAddUserToGroup: typeof addUserToGroup = ({ name }) => {
     this.groups = this.groups.map((g) => (g.name === name ? { ...g, selected: true } : g));
-    this.users.find((u) => u.login === 'alice.merveille')!.groupsCount++;
     return this.reply({});
   };
 
@@ -350,7 +362,6 @@ export default class UsersServiceMock {
       }
       return g;
     });
-    this.users.find((u) => u.login === 'alice.merveille')!.groupsCount--;
     return isDefault
       ? Promise.reject({
           errors: [{ msg: 'Cannot remove Default group' }],
index e050c593a6634ac7da270b5ba39b56b21d8ae952..32567821c963d11ef0ce591751e8a60fc523f3be 100644 (file)
@@ -17,7 +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 { screen, within } from '@testing-library/react';
+import { screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup';
 import selectEvent from 'react-select-event';
@@ -274,7 +274,7 @@ describe('security page', () => {
       );
 
       expect(await screen.findByText('users.tokens')).toBeInTheDocument();
-      expect(screen.getAllByRole('row')).toHaveLength(3); // 2 tokens + header
+      await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(3)); // 2 tokens + header
 
       // Add the token
       const newTokenName = 'importantToken';
@@ -356,7 +356,7 @@ describe('security page', () => {
 
       await user.click(screen.getByRole('button', { name: 'yes' }));
 
-      expect(screen.getAllByRole('row')).toHaveLength(3); // 2 tokens + header
+      await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(3)); // 2 tokens + header
     }
   );
 
@@ -377,7 +377,7 @@ describe('security page', () => {
     expect(await screen.findByText('users.tokens')).toBeInTheDocument();
 
     // expired token is flagged as such
-    const expiredTokenRow = screen.getByRole('row', { name: /expired token/ });
+    const expiredTokenRow = await screen.findByRole('row', { name: /expired token/ });
     expect(within(expiredTokenRow).getByText('my_account.tokens.expired')).toBeInTheDocument();
 
     // unexpired token is not flagged
index d7a0eda66dee57a6cd78f79011aafea73b3a3cda..c8d2d0957e3db1ea7a0cd1f6725a8920beb679da 100644 (file)
@@ -283,15 +283,15 @@ describe('in non managed mode', () => {
   it('should be able to edit the groups of a user', async () => {
     const user = userEvent.setup();
     renderUsersApp();
-    expect(await within(await ui.aliceRow.find()).findByText('2')).toBeInTheDocument();
+    expect(await within(await ui.aliceRow.find()).findByText('3')).toBeInTheDocument();
 
     await act(async () => user.click(await ui.aliceUpdateGroupButton.find()));
     expect(await ui.dialogGroups.find()).toBeInTheDocument();
 
-    expect(ui.getGroups()).toHaveLength(2);
+    expect(ui.getGroups()).toHaveLength(3);
 
     await act(async () => user.click(await ui.allFilter.find()));
-    expect(ui.getGroups()).toHaveLength(3);
+    expect(ui.getGroups()).toHaveLength(4);
 
     await act(() => user.click(ui.unselectedFilter.get()));
     expect(ui.reloadButton.query()).not.toBeInTheDocument();
@@ -299,11 +299,11 @@ describe('in non managed mode', () => {
     expect(await ui.reloadButton.find()).toBeInTheDocument();
 
     await act(() => user.click(ui.selectedFilter.get()));
-    expect(ui.getGroups()).toHaveLength(3);
+    expect(ui.getGroups()).toHaveLength(4);
 
     await act(() => user.click(ui.doneButton.get()));
     expect(ui.dialogGroups.query()).not.toBeInTheDocument();
-    expect(await within(await ui.aliceRow.find()).findByText('3')).toBeInTheDocument();
+    expect(await within(await ui.aliceRow.find()).findByText('4')).toBeInTheDocument();
 
     await act(async () => user.click(await ui.aliceUpdateGroupButton.find()));
 
@@ -312,15 +312,15 @@ describe('in non managed mode', () => {
     await act(() => user.click(ui.getGroups()[1]));
     expect(await ui.reloadButton.find()).toBeInTheDocument();
     await act(() => user.click(ui.reloadButton.get()));
-    expect(ui.getGroups()).toHaveLength(2);
+    expect(ui.getGroups()).toHaveLength(3);
 
-    await act(() => user.type(within(ui.dialogGroups.get()).getByRole('searchbox'), '3'));
+    await act(() => user.type(within(ui.dialogGroups.get()).getByRole('searchbox'), '4'));
 
     expect(ui.getGroups()).toHaveLength(1);
 
     await act(() => user.click(ui.doneButton.get()));
     expect(ui.dialogGroups.query()).not.toBeInTheDocument();
-    expect(await within(await ui.aliceRow.find()).findByText('2')).toBeInTheDocument();
+    expect(await within(await ui.aliceRow.find()).findByText('3')).toBeInTheDocument();
   });
 
   it('should update user', async () => {
@@ -388,7 +388,7 @@ describe('in non managed mode', () => {
     expect(
       screen.queryByText(`user.${ChangePasswordResults.OldPasswordIncorrect}`)
     ).not.toBeInTheDocument();
-    await user.click(ui.changeButton.get());
+    await act(() => user.click(ui.changeButton.get()));
     expect(
       await within(ui.dialogPasswords.get()).findByText(
         `user.${ChangePasswordResults.OldPasswordIncorrect}`
@@ -405,7 +405,7 @@ describe('in non managed mode', () => {
     expect(
       screen.queryByText(`user.${ChangePasswordResults.NewPasswordSameAsOld}`)
     ).not.toBeInTheDocument();
-    await user.click(ui.changeButton.get());
+    await act(() => user.click(ui.changeButton.get()));
     expect(
       await screen.findByText(`user.${ChangePasswordResults.NewPasswordSameAsOld}`)
     ).toBeInTheDocument();
@@ -415,7 +415,7 @@ describe('in non managed mode', () => {
     await user.type(ui.newPassword.get(), 'test2');
     await user.type(ui.confirmPassword.get(), 'test2');
 
-    await user.click(ui.changeButton.get());
+    await act(() => user.click(ui.changeButton.get()));
 
     expect(ui.dialogPasswords.query()).not.toBeInTheDocument();
   });
index e27d00ec0d34462eb627b0fefc25f7a2ec57e573..aa47555884c988ae29dfeb92911d3669eec820e6 100644 (file)
@@ -19,7 +19,6 @@
  */
 import { find, without } from 'lodash';
 import * as React from 'react';
-import { addUserToGroup, removeUserFromGroup } from '../../../api/user_groups';
 import { UserGroup, getUserGroups } from '../../../api/users';
 import Modal from '../../../components/controls/Modal';
 import SelectList, {
@@ -28,7 +27,7 @@ import SelectList, {
 } from '../../../components/controls/SelectList';
 import { ResetButtonLink } from '../../../components/controls/buttons';
 import { translate } from '../../../helpers/l10n';
-import { useInvalidateUsersList } from '../../../queries/users';
+import { useAddUserToGroupMutation, useRemoveUserToGroupMutation } from '../../../queries/users';
 import { RestUserDetailed } from '../../../types/users';
 
 interface Props {
@@ -45,8 +44,8 @@ export default function GroupsForm(props: Props) {
   const [groups, setGroups] = React.useState<UserGroup[]>([]);
   const [groupsTotalCount, setGroupsTotalCount] = React.useState<number | undefined>(undefined);
   const [selectedGroups, setSelectedGroups] = React.useState<string[]>([]);
-
-  const invalidateUserList = useInvalidateUsersList();
+  const { mutateAsync: addUserToGroup } = useAddUserToGroupMutation();
+  const { mutateAsync: removeUserFromGroup } = useRemoveUserToGroupMutation();
 
   const fetchUsers = (searchParams: SelectListSearchParams) =>
     getUserGroups({
@@ -86,11 +85,6 @@ export default function GroupsForm(props: Props) {
       setSelectedGroups(without(selectedGroups, name));
     });
 
-  const handleClose = () => {
-    invalidateUserList();
-    props.onClose();
-  };
-
   const renderElement = (name: string): React.ReactNode => {
     const group = find(groups, { name });
     return (
@@ -111,7 +105,7 @@ export default function GroupsForm(props: Props) {
   const header = translate('users.update_groups');
 
   return (
-    <Modal contentLabel={header} onRequestClose={handleClose}>
+    <Modal contentLabel={header} onRequestClose={props.onClose}>
       <div className="modal-head">
         <h2>{header}</h2>
       </div>
@@ -133,7 +127,7 @@ export default function GroupsForm(props: Props) {
       </div>
 
       <footer className="modal-foot">
-        <ResetButtonLink onClick={handleClose}>{translate('done')}</ResetButtonLink>
+        <ResetButtonLink onClick={props.onClose}>{translate('done')}</ResetButtonLink>
       </footer>
     </Modal>
   );
index 079006f6469fbafeac42648ce81bd59824d55ef3..626ecb118e80e96bdf080bed160ca3739d543afb 100644 (file)
@@ -20,7 +20,6 @@
 import { isEmpty } from 'lodash';
 import * as React from 'react';
 import { getScannableProjects } from '../../../api/components';
-import { generateToken, getTokens } from '../../../api/user-tokens';
 import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext';
 import Select, { LabelValueSelectOption } from '../../../components/controls/Select';
 import { SubmitButton } from '../../../components/controls/buttons';
@@ -32,8 +31,9 @@ import {
   getAvailableExpirationOptions,
 } from '../../../helpers/tokens';
 import { hasGlobalPermission } from '../../../helpers/users';
+import { useGenerateTokenMutation, useUserTokensQuery } from '../../../queries/users';
 import { Permissions } from '../../../types/permissions';
-import { TokenExpiration, TokenType, UserToken } from '../../../types/token';
+import { TokenExpiration, TokenType } from '../../../types/token';
 import { CurrentUser } from '../../../types/users';
 import TokensFormItem, { TokenDeleteConfirmation } from './TokensFormItem';
 import TokensFormNewToken from './TokensFormNewToken';
@@ -41,199 +41,99 @@ import TokensFormNewToken from './TokensFormNewToken';
 interface Props {
   deleteConfirmation: TokenDeleteConfirmation;
   login: string;
-  updateTokensCount?: (login: string, tokensCount: number) => void;
   displayTokenTypeInput: boolean;
   currentUser: CurrentUser;
 }
 
-interface State {
-  generating: boolean;
-  loading: boolean;
-  newToken?: { name: string; token: string };
-  newTokenName: string;
-  newTokenType?: TokenType;
-  tokens: UserToken[];
-  projects: LabelValueSelectOption[];
-  selectedProject?: LabelValueSelectOption;
-  newTokenExpiration: TokenExpiration;
-  tokenExpirationOptions: { value: TokenExpiration; label: string }[];
-  tokenTypeOptions: Array<{ label: string; value: TokenType }>;
-}
-
-export class TokensForm extends React.PureComponent<Props, State> {
-  mounted = false;
-  state: State = {
-    generating: false,
-    loading: true,
-    newTokenName: '',
-    newTokenType: this.props.displayTokenTypeInput ? undefined : TokenType.User,
-    tokens: [],
-    projects: [],
-    newTokenExpiration: TokenExpiration.OneMonth,
-    tokenExpirationOptions: EXPIRATION_OPTIONS,
-    tokenTypeOptions: [],
-  };
-
-  componentDidMount() {
-    this.mounted = true;
-    this.loadData();
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  loadData = async () => {
-    this.fetchTokens();
-    this.fetchTokenSettings();
-
-    if (this.props.displayTokenTypeInput) {
-      const projects = await this.fetchProjects();
-      this.constructTokenTypeOptions(projects);
-    }
-  };
-
-  fetchTokens = () => {
-    this.setState({ loading: true });
-    getTokens(this.props.login).then(
-      (tokens) => {
-        if (this.mounted) {
-          this.setState({ loading: false, tokens });
-        }
-      },
-      () => {
-        if (this.mounted) {
-          this.setState({ loading: false });
-        }
-      }
-    );
-  };
-
-  fetchTokenSettings = async () => {
-    const tokenExpirationOptions = await getAvailableExpirationOptions();
-    if (this.mounted) {
-      this.setState({ tokenExpirationOptions });
-    }
-  };
-
-  fetchProjects = async () => {
-    const { projects: projectArray } = await getScannableProjects();
-    const projects = projectArray.map((project) => ({ label: project.name, value: project.key }));
-
-    this.setState({
-      projects,
-      selectedProject: projects.length === 1 ? projects[0] : undefined,
-    });
-
-    return projects;
-  };
-
-  constructTokenTypeOptions = (projects: LabelValueSelectOption[]) => {
-    const { currentUser } = this.props;
+export function TokensForm(props: Props) {
+  const { currentUser, deleteConfirmation, displayTokenTypeInput, login } = props;
+  const { data: tokens, isLoading: loading } = useUserTokensQuery(login);
+  const [newToken, setNewToken] = React.useState<{ name: string; token: string }>();
+  const [newTokenName, setNewTokenName] = React.useState('');
+  const [newTokenType, setNewTokenType] = React.useState<TokenType>();
+  const [projects, setProjects] = React.useState<LabelValueSelectOption[]>([]);
+  const [selectedProject, setSelectedProject] = React.useState<LabelValueSelectOption>();
+  const [newTokenExpiration, setNewTokenExpiration] = React.useState<TokenExpiration>(
+    TokenExpiration.OneMonth
+  );
+  const [tokenExpirationOptions, setTokenExpirationOptions] =
+    React.useState<{ value: TokenExpiration; label: string }[]>(EXPIRATION_OPTIONS);
+
+  const { mutateAsync: generate, isLoading: generating } = useGenerateTokenMutation();
+
+  const tokenTypeOptions = React.useMemo(() => {
+    const value = [{ label: translate('users.tokens', TokenType.User), value: TokenType.User }];
 
-    const tokenTypeOptions = [
-      { label: translate('users.tokens', TokenType.User), value: TokenType.User },
-    ];
     if (hasGlobalPermission(currentUser, Permissions.Scan)) {
-      tokenTypeOptions.unshift({
+      value.unshift({
         label: translate('users.tokens', TokenType.Global),
         value: TokenType.Global,
       });
     }
     if (!isEmpty(projects)) {
-      tokenTypeOptions.unshift({
+      value.unshift({
         label: translate('users.tokens', TokenType.Project),
         value: TokenType.Project,
       });
     }
 
+    return value;
+  }, [projects, currentUser]);
+
+  React.useEffect(() => {
     if (tokenTypeOptions.length === 1) {
-      this.setState({
-        newTokenType: tokenTypeOptions[0].value,
-        tokenTypeOptions,
-      });
-    } else {
-      this.setState({ tokenTypeOptions });
+      setNewTokenType(tokenTypeOptions[0].value);
     }
-  };
-
-  updateTokensCount = () => {
-    if (this.props.updateTokensCount) {
-      this.props.updateTokensCount(this.props.login, this.state.tokens.length);
+  }, [tokenTypeOptions]);
+
+  React.useEffect(() => {
+    getAvailableExpirationOptions()
+      .then((options) => {
+        setTokenExpirationOptions(options);
+      })
+      .catch(() => {});
+
+    if (displayTokenTypeInput) {
+      getScannableProjects()
+        .then(({ projects: projectArray }) => {
+          const projects = projectArray.map((project) => ({
+            label: project.name,
+            value: project.key,
+          }));
+          setProjects(projects);
+          setSelectedProject(projects.length === 1 ? projects[0] : undefined);
+        })
+        .catch(() => {});
     }
-  };
+  }, [displayTokenTypeInput, currentUser]);
 
-  handleGenerateToken = async (event: React.SyntheticEvent<HTMLFormElement>) => {
+  const handleGenerateToken = (event: React.SyntheticEvent<HTMLFormElement>) => {
     event.preventDefault();
-    const { login } = this.props;
-    const {
-      newTokenName,
-      newTokenType = TokenType.User,
-      selectedProject,
-      tokenTypeOptions,
-      newTokenExpiration,
-    } = this.state;
-    this.setState({ generating: true });
 
-    try {
-      const newToken = await generateToken({
-        name: newTokenName,
-        login,
-        type: newTokenType,
-        ...(newTokenType === TokenType.Project &&
-          selectedProject !== undefined && { projectKey: selectedProject.value }),
-        ...(newTokenExpiration !== TokenExpiration.NoExpiration && {
-          expirationDate: computeTokenExpirationDate(newTokenExpiration),
+    generate({
+      name: newTokenName,
+      login,
+      type: newTokenType,
+      ...(newTokenType === TokenType.Project &&
+        selectedProject !== undefined && {
+          projectKey: selectedProject.value,
+          projectName: selectedProject.label,
         }),
-      });
-
-      if (this.mounted) {
-        this.setState((state) => {
-          const tokens: UserToken[] = [
-            ...state.tokens,
-            {
-              name: newToken.name,
-              createdAt: newToken.createdAt,
-              isExpired: false,
-              expirationDate: newToken.expirationDate,
-              type: newTokenType,
-              ...(newTokenType === TokenType.Project &&
-                selectedProject !== undefined && {
-                  project: { key: selectedProject.value, name: selectedProject.label },
-                }),
-            },
-          ];
-          return {
-            generating: false,
-            newToken,
-            newTokenName: '',
-            selectedProject: undefined,
-            newTokenType: tokenTypeOptions.length === 1 ? tokenTypeOptions[0].value : undefined,
-            newTokenExpiration: TokenExpiration.OneMonth,
-            tokens,
-          };
-        }, this.updateTokensCount);
-      }
-    } catch (e) {
-      if (this.mounted) {
-        this.setState({ generating: false });
-      }
-    }
-  };
-
-  handleRevokeToken = (revokedToken: UserToken) => {
-    this.setState(
-      (state) => ({
-        tokens: state.tokens.filter((token) => token.name !== revokedToken.name),
+      ...(newTokenExpiration !== TokenExpiration.NoExpiration && {
+        expirationDate: computeTokenExpirationDate(newTokenExpiration),
       }),
-      this.updateTokensCount
-    );
+    })
+      .then((newToken) => {
+        setNewToken(newToken);
+        setNewTokenName('');
+        setSelectedProject(undefined);
+        setNewTokenType(tokenTypeOptions.length === 1 ? tokenTypeOptions[0].value : undefined);
+        setNewTokenExpiration(TokenExpiration.OneMonth);
+      })
+      .catch(() => {});
   };
 
-  isSubmitButtonDisabled = () => {
-    const { displayTokenTypeInput } = this.props;
-    const { generating, newTokenName, newTokenType, selectedProject } = this.state;
-
+  const isSubmitButtonDisabled = () => {
     if (!displayTokenTypeInput) {
       return generating || newTokenName.length <= 0;
     }
@@ -248,36 +148,34 @@ export class TokensForm extends React.PureComponent<Props, State> {
     return !newTokenType;
   };
 
-  handleNewTokenChange = (evt: React.SyntheticEvent<HTMLInputElement>) => {
-    this.setState({ newTokenName: evt.currentTarget.value });
+  const handleNewTokenChange = (evt: React.SyntheticEvent<HTMLInputElement>) => {
+    setNewTokenName(evt.currentTarget.value);
   };
 
-  handleNewTokenTypeChange = ({ value }: { value: TokenType }) => {
-    this.setState({ newTokenType: value });
+  const handleNewTokenTypeChange = ({ value }: { value: TokenType }) => {
+    setNewTokenType(value);
   };
 
-  handleProjectChange = (selectedProject: LabelValueSelectOption) => {
-    this.setState({ selectedProject });
+  const handleProjectChange = (selectedProject: LabelValueSelectOption) => {
+    setSelectedProject(selectedProject);
   };
 
-  handleNewTokenExpirationChange = ({ value }: { value: TokenExpiration }) => {
-    this.setState({ newTokenExpiration: value });
+  const handleNewTokenExpirationChange = ({ value }: { value: TokenExpiration }) => {
+    setNewTokenExpiration(value);
   };
 
-  renderForm() {
-    const {
-      newTokenName,
-      newTokenType,
-      projects,
-      selectedProject,
-      newTokenExpiration,
-      tokenExpirationOptions,
-      tokenTypeOptions,
-    } = this.state;
-    const { displayTokenTypeInput } = this.props;
-
-    return (
-      <form autoComplete="off" className="display-flex-center" onSubmit={this.handleGenerateToken}>
+  const customSpinner = (
+    <tr>
+      <td>
+        <i className="spinner" />
+      </td>
+    </tr>
+  );
+
+  return (
+    <>
+      <h3 className="spacer-bottom">{translate('users.tokens.generate')}</h3>
+      <form autoComplete="off" className="display-flex-center" onSubmit={handleGenerateToken}>
         <div className="display-flex-column input-large spacer-right ">
           <label htmlFor="token-name" className="text-bold">
             {translate('users.tokens.name')}
@@ -286,7 +184,7 @@ export class TokensForm extends React.PureComponent<Props, State> {
             id="token-name"
             className="spacer-top it__token-name"
             maxLength={100}
-            onChange={this.handleNewTokenChange}
+            onChange={handleNewTokenChange}
             placeholder={translate('users.tokens.enter_name')}
             required
             type="text"
@@ -303,7 +201,7 @@ export class TokensForm extends React.PureComponent<Props, State> {
                 inputId="token-select-type"
                 className="spacer-top it__token-type"
                 isSearchable={false}
-                onChange={this.handleNewTokenTypeChange}
+                onChange={handleNewTokenTypeChange}
                 options={tokenTypeOptions}
                 placeholder={translate('users.tokens.select_type')}
                 value={
@@ -321,7 +219,7 @@ export class TokensForm extends React.PureComponent<Props, State> {
                 <Select
                   inputId="token-select-project"
                   className="spacer-top it__project"
-                  onChange={this.handleProjectChange}
+                  onChange={handleProjectChange}
                   options={projects}
                   placeholder={translate('users.tokens.select_project')}
                   value={selectedProject}
@@ -338,7 +236,7 @@ export class TokensForm extends React.PureComponent<Props, State> {
             inputId="token-select-expiration"
             className="spacer-top"
             isSearchable={false}
-            onChange={this.handleNewTokenExpirationChange}
+            onChange={handleNewTokenExpirationChange}
             options={tokenExpirationOptions}
             value={tokenExpirationOptions.find((option) => option.value === newTokenExpiration)}
           />
@@ -346,73 +244,48 @@ export class TokensForm extends React.PureComponent<Props, State> {
         <SubmitButton
           className="it__generate-token"
           style={{ marginTop: 'auto' }}
-          disabled={this.isSubmitButtonDisabled()}
+          disabled={isSubmitButtonDisabled()}
         >
           {translate('users.generate')}
         </SubmitButton>
       </form>
-    );
-  }
-
-  renderItems() {
-    const { tokens } = this.state;
-    if (tokens.length <= 0) {
-      return (
-        <tr>
-          <td className="note" colSpan={7}>
-            {translate('users.no_tokens')}
-          </td>
-        </tr>
-      );
-    }
-    return tokens.map((token) => (
-      <TokensFormItem
-        deleteConfirmation={this.props.deleteConfirmation}
-        key={token.name}
-        login={this.props.login}
-        onRevokeToken={this.handleRevokeToken}
-        token={token}
-      />
-    ));
-  }
-
-  render() {
-    const { loading, newToken, tokens } = this.state;
-    const customSpinner = (
-      <tr>
-        <td>
-          <i className="spinner" />
-        </td>
-      </tr>
-    );
-
-    return (
-      <>
-        <h3 className="spacer-bottom">{translate('users.tokens.generate')}</h3>
-        {this.renderForm()}
-        {newToken && <TokensFormNewToken token={newToken} />}
-
-        <table className="data zebra big-spacer-top fixed">
-          <thead>
-            <tr>
-              <th>{translate('name')}</th>
-              <th>{translate('my_account.token_type')}</th>
-              <th>{translate('my_account.project_name')}</th>
-              <th>{translate('my_account.tokens_last_usage')}</th>
-              <th className="text-right">{translate('created')}</th>
-              <th className="text-right">{translate('my_account.tokens.expiration')}</th>
-              <th className="text-right">{translate('actions')}</th>
-            </tr>
-          </thead>
-          <tbody>
-            <Spinner customSpinner={customSpinner} loading={loading && tokens.length <= 0}>
-              {this.renderItems()}
-            </Spinner>
-          </tbody>
-        </table>
-      </>
-    );
-  }
+      {newToken && <TokensFormNewToken token={newToken} />}
+
+      <table className="data zebra big-spacer-top fixed">
+        <thead>
+          <tr>
+            <th>{translate('name')}</th>
+            <th>{translate('my_account.token_type')}</th>
+            <th>{translate('my_account.project_name')}</th>
+            <th>{translate('my_account.tokens_last_usage')}</th>
+            <th className="text-right">{translate('created')}</th>
+            <th className="text-right">{translate('my_account.tokens.expiration')}</th>
+            <th className="text-right">{translate('actions')}</th>
+          </tr>
+        </thead>
+        <tbody>
+          <Spinner customSpinner={customSpinner} loading={!!loading}>
+            {tokens && tokens.length <= 0 ? (
+              <tr>
+                <td className="note" colSpan={7}>
+                  {translate('users.no_tokens')}
+                </td>
+              </tr>
+            ) : (
+              tokens?.map((token) => (
+                <TokensFormItem
+                  deleteConfirmation={deleteConfirmation}
+                  key={token.name}
+                  login={login}
+                  token={token}
+                />
+              ))
+            )}
+          </Spinner>
+        </tbody>
+      </table>
+    </>
+  );
 }
 
 export default withCurrentUserContext(TokensForm);
index 8e05a173ed8fbf7ef4898f4963952513433e204b..a45aff21f6cc8ccdd4881801ca47e2854441b45d 100644 (file)
@@ -20,7 +20,6 @@
 import classNames from 'classnames';
 import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
-import { revokeToken } from '../../../api/user-tokens';
 import ConfirmButton from '../../../components/controls/ConfirmButton';
 import { Button } from '../../../components/controls/buttons';
 import WarningIcon from '../../../components/icons/WarningIcon';
@@ -28,6 +27,7 @@ import DateFormatter from '../../../components/intl/DateFormatter';
 import DateFromNow from '../../../components/intl/DateFromNow';
 import Spinner from '../../../components/ui/Spinner';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { useRevokeTokenMutation } from '../../../queries/users';
 import { UserToken } from '../../../types/token';
 
 export type TokenDeleteConfirmation = 'inline' | 'modal';
@@ -35,136 +35,107 @@ export type TokenDeleteConfirmation = 'inline' | 'modal';
 interface Props {
   deleteConfirmation: TokenDeleteConfirmation;
   login: string;
-  onRevokeToken: (token: UserToken) => void;
   token: UserToken;
 }
 
-interface State {
-  loading: boolean;
-  showConfirmation: boolean;
-}
-
-export default class TokensFormItem extends React.PureComponent<Props, State> {
-  mounted = false;
-  state: State = { loading: false, showConfirmation: false };
-
-  componentDidMount() {
-    this.mounted = true;
-  }
+export default function TokensFormItem(props: Props) {
+  const { token, deleteConfirmation, login } = props;
+  const [showConfirmation, setShowConfirmation] = React.useState(false);
+  const { mutateAsync, isLoading } = useRevokeTokenMutation();
 
-  componentWillUnmount() {
-    this.mounted = false;
-  }
+  const handleRevoke = () => mutateAsync({ login, name: token.name });
 
-  handleClick = () => {
-    if (this.state.showConfirmation) {
-      this.handleRevoke().then(() => {
-        if (this.mounted) {
-          this.setState({ showConfirmation: false });
-        }
-      });
+  const handleClick = () => {
+    if (showConfirmation) {
+      handleRevoke()
+        .then(() => setShowConfirmation(false))
+        .catch(() => setShowConfirmation(false));
     } else {
-      this.setState({ showConfirmation: true });
+      setShowConfirmation(true);
     }
   };
 
-  handleRevoke = () => {
-    this.setState({ loading: true });
-    return revokeToken({ login: this.props.login, name: this.props.token.name }).then(
-      () => this.props.onRevokeToken(this.props.token),
-      () => {
-        if (this.mounted) {
-          this.setState({ loading: false });
-        }
-      }
-    );
-  };
-
-  render() {
-    const { deleteConfirmation, token } = this.props;
-    const { loading, showConfirmation } = this.state;
-    return (
-      <tr className={classNames({ 'text-muted-2': token.isExpired })}>
-        <td title={token.name} className="hide-overflow nowrap">
-          {token.name}
-          {token.isExpired && (
-            <div className="spacer-top text-warning">
-              <WarningIcon className="little-spacer-right" />
-              {translate('my_account.tokens.expired')}
-            </div>
-          )}
-        </td>
-        <td title={translate('users.tokens', token.type)} className="hide-overflow thin">
-          {translate('users.tokens', token.type, 'short')}
-        </td>
-        <td title={token.project?.name} className="hide-overflow">
-          {token.project?.name}
-        </td>
-        <td className="thin nowrap">
-          <DateFromNow date={token.lastConnectionDate} hourPrecision />
-        </td>
-        <td className="thin nowrap text-right">
-          <DateFormatter date={token.createdAt} long />
-        </td>
-        <td className={classNames('thin nowrap text-right', { 'text-warning': token.isExpired })}>
-          {token.expirationDate ? <DateFormatter date={token.expirationDate} long /> : '–'}
-        </td>
-        <td className="thin nowrap text-right">
-          {token.isExpired && (
-            <Button
-              className="button-red input-small"
-              disabled={loading}
-              onClick={this.handleRevoke}
-              aria-label={translateWithParameters('users.tokens.remove_label', token.name)}
-            >
-              <Spinner className="little-spacer-right" loading={loading}>
-                {translate('remove')}
-              </Spinner>
-            </Button>
-          )}
-          {!token.isExpired && deleteConfirmation === 'modal' && (
-            <ConfirmButton
-              confirmButtonText={translate('yes')}
-              isDestructive
-              modalBody={
-                <FormattedMessage
-                  defaultMessage={translate('users.tokens.sure_X')}
-                  id="users.tokens.sure_X"
-                  values={{ token: <strong>{token.name}</strong> }}
-                />
-              }
-              modalHeader={translateWithParameters('users.tokens.revoke_label', token.name)}
-              onConfirm={this.handleRevoke}
-            >
-              {({ onClick }) => (
-                <Button
-                  className="button-red input-small"
-                  disabled={loading}
-                  onClick={onClick}
-                  aria-label={translateWithParameters('users.tokens.revoke_label', token.name)}
-                >
-                  {translate('users.tokens.revoke')}
-                </Button>
-              )}
-            </ConfirmButton>
-          )}
-          {!token.isExpired && deleteConfirmation === 'inline' && (
-            <Button
-              className="button-red input-small"
-              disabled={loading}
-              aria-label={
-                showConfirmation
-                  ? translate('users.tokens.sure')
-                  : translateWithParameters('users.tokens.revoke_label', token.name)
-              }
-              onClick={this.handleClick}
-            >
-              <Spinner className="little-spacer-right" loading={loading} />
-              {showConfirmation ? translate('users.tokens.sure') : translate('users.tokens.revoke')}
-            </Button>
-          )}
-        </td>
-      </tr>
-    );
-  }
+  return (
+    <tr className={classNames({ 'text-muted-2': token.isExpired })}>
+      <td title={token.name} className="hide-overflow nowrap">
+        {token.name}
+        {token.isExpired && (
+          <div className="spacer-top text-warning">
+            <WarningIcon className="little-spacer-right" />
+            {translate('my_account.tokens.expired')}
+          </div>
+        )}
+      </td>
+      <td title={translate('users.tokens', token.type)} className="hide-overflow thin">
+        {translate('users.tokens', token.type, 'short')}
+      </td>
+      <td title={token.project?.name} className="hide-overflow">
+        {token.project?.name}
+      </td>
+      <td className="thin nowrap">
+        <DateFromNow date={token.lastConnectionDate} hourPrecision />
+      </td>
+      <td className="thin nowrap text-right">
+        <DateFormatter date={token.createdAt} long />
+      </td>
+      <td className={classNames('thin nowrap text-right', { 'text-warning': token.isExpired })}>
+        {token.expirationDate ? <DateFormatter date={token.expirationDate} long /> : '–'}
+      </td>
+      <td className="thin nowrap text-right">
+        {token.isExpired && (
+          <Button
+            className="button-red input-small"
+            disabled={isLoading}
+            onClick={handleRevoke}
+            aria-label={translateWithParameters('users.tokens.remove_label', token.name)}
+          >
+            <Spinner className="little-spacer-right" loading={isLoading}>
+              {translate('remove')}
+            </Spinner>
+          </Button>
+        )}
+        {!token.isExpired && deleteConfirmation === 'modal' && (
+          <ConfirmButton
+            confirmButtonText={translate('yes')}
+            isDestructive
+            modalBody={
+              <FormattedMessage
+                defaultMessage={translate('users.tokens.sure_X')}
+                id="users.tokens.sure_X"
+                values={{ token: <strong>{token.name}</strong> }}
+              />
+            }
+            modalHeader={translateWithParameters('users.tokens.revoke_label', token.name)}
+            onConfirm={handleRevoke}
+          >
+            {({ onClick }) => (
+              <Button
+                className="button-red input-small"
+                disabled={isLoading}
+                onClick={onClick}
+                aria-label={translateWithParameters('users.tokens.revoke_label', token.name)}
+              >
+                {translate('users.tokens.revoke')}
+              </Button>
+            )}
+          </ConfirmButton>
+        )}
+        {!token.isExpired && deleteConfirmation === 'inline' && (
+          <Button
+            className="button-red input-small"
+            disabled={isLoading}
+            aria-label={
+              showConfirmation
+                ? translate('users.tokens.sure')
+                : translateWithParameters('users.tokens.revoke_label', token.name)
+            }
+            onClick={handleClick}
+          >
+            <Spinner className="little-spacer-right" loading={isLoading} />
+            {showConfirmation ? translate('users.tokens.sure') : translate('users.tokens.revoke')}
+          </Button>
+        )}
+      </td>
+    </tr>
+  );
 }
index 0524be649a78321f6e51c4abdba65eac00b37d02..7969250be5eeaa85d61736faf180a86459f403a2 100644 (file)
@@ -22,7 +22,6 @@ import { FormattedMessage } from 'react-intl';
 import Modal from '../../../components/controls/Modal';
 import { ResetButtonLink } from '../../../components/controls/buttons';
 import { translate } from '../../../helpers/l10n';
-import { useInvalidateUsersList } from '../../../queries/users';
 import { RestUserDetailed } from '../../../types/users';
 import TokensForm from './TokensForm';
 
@@ -32,37 +31,24 @@ interface Props {
 }
 
 export default function TokensFormModal(props: Props) {
-  const [hasTokenCountChanged, setHasTokenCountChanged] = React.useState(false);
-  const invalidateUserList = useInvalidateUsersList();
-
-  const handleClose = () => {
-    if (hasTokenCountChanged) {
-      invalidateUserList();
-    }
-    props.onClose();
-  };
+  const { user } = props;
 
   return (
-    <Modal size="large" contentLabel={translate('users.tokens')} onRequestClose={handleClose}>
+    <Modal size="large" contentLabel={translate('users.tokens')} onRequestClose={props.onClose}>
       <header className="modal-head">
         <h2>
           <FormattedMessage
             defaultMessage={translate('users.user_X_tokens')}
             id="users.user_X_tokens"
-            values={{ user: <em>{props.user.name}</em> }}
+            values={{ user: <em>{user.name}</em> }}
           />
         </h2>
       </header>
       <div className="modal-body modal-container">
-        <TokensForm
-          deleteConfirmation="inline"
-          login={props.user.login}
-          updateTokensCount={() => setHasTokenCountChanged(true)}
-          displayTokenTypeInput={false}
-        />
+        <TokensForm deleteConfirmation="inline" login={user.login} displayTokenTypeInput={false} />
       </div>
       <footer className="modal-foot">
-        <ResetButtonLink onClick={handleClose}>{translate('done')}</ResetButtonLink>
+        <ResetButtonLink onClick={props.onClose}>{translate('done')}</ResetButtonLink>
       </footer>
     </Modal>
   );
index e1b74efb7a4c62902b21f348abd3ebc52ba99f6e..59cddecdc253d03896495aa0c2d26b1af4d2718e 100644 (file)
@@ -22,7 +22,9 @@ import { ButtonIcon } from '../../../components/controls/buttons';
 import BulletListIcon from '../../../components/icons/BulletListIcon';
 import DateFromNow from '../../../components/intl/DateFromNow';
 import LegacyAvatar from '../../../components/ui/LegacyAvatar';
+import Spinner from '../../../components/ui/Spinner';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { useUserGroupsCountQuery, useUserTokensQuery } from '../../../queries/users';
 import { IdentityProvider } from '../../../types/types';
 import { RestUserDetailed } from '../../../types/users';
 import GroupsForm from './GroupsForm';
@@ -42,8 +44,6 @@ export default function UserListItem(props: UserListItemProps) {
   const {
     name,
     login,
-    groupsCount,
-    tokensCount,
     avatar,
     sonarQubeLastConnectionDate,
     sonarLintLastConnectionDate,
@@ -52,6 +52,8 @@ export default function UserListItem(props: UserListItemProps) {
 
   const [openTokenForm, setOpenTokenForm] = React.useState(false);
   const [openGroupForm, setOpenGroupForm] = React.useState(false);
+  const { data: tokens, isLoading: tokensAreLoading } = useUserTokensQuery(login);
+  const { data: groupsCount, isLoading: groupsAreLoading } = useUserGroupsCountQuery(login);
 
   return (
     <tr>
@@ -75,28 +77,32 @@ export default function UserListItem(props: UserListItemProps) {
         <DateFromNow date={sonarLintLastConnectionDate ?? ''} hourPrecision />
       </td>
       <td className="thin nowrap text-middle">
-        {groupsCount}
-        {manageProvider === undefined && (
+        <Spinner loading={groupsAreLoading}>
+          {groupsCount}
+          {manageProvider === undefined && (
+            <ButtonIcon
+              aria-label={translateWithParameters('users.update_users_groups', user.login)}
+              className="js-user-groups spacer-left button-small"
+              onClick={() => setOpenGroupForm(true)}
+              tooltip={translate('users.update_groups')}
+            >
+              <BulletListIcon />
+            </ButtonIcon>
+          )}
+        </Spinner>
+      </td>
+      <td className="thin nowrap text-middle">
+        <Spinner loading={tokensAreLoading}>
+          {tokens?.length}
           <ButtonIcon
-            aria-label={translateWithParameters('users.update_users_groups', user.login)}
-            className="js-user-groups spacer-left button-small"
-            onClick={() => setOpenGroupForm(true)}
-            tooltip={translate('users.update_groups')}
+            className="js-user-tokens spacer-left button-small"
+            onClick={() => setOpenTokenForm(true)}
+            tooltip={translateWithParameters('users.update_tokens')}
+            aria-label={translateWithParameters('users.update_tokens_for_x', name ?? login)}
           >
             <BulletListIcon />
           </ButtonIcon>
-        )}
-      </td>
-      <td className="thin nowrap text-middle">
-        {tokensCount}
-        <ButtonIcon
-          className="js-user-tokens spacer-left button-small"
-          onClick={() => setOpenTokenForm(true)}
-          tooltip={translateWithParameters('users.update_tokens')}
-          aria-label={translateWithParameters('users.update_tokens_for_x', name ?? login)}
-        >
-          <BulletListIcon />
-        </ButtonIcon>
+        </Spinner>
       </td>
 
       <td className="thin nowrap text-right text-middle">
index 5c59fb9e9f573a60d55465d2e6abc469a32e1698..4b55fdf9281d2b07b84ec88b384d24b849fba914 100644 (file)
@@ -714,8 +714,6 @@ export function mockRestUser(overrides: Partial<RestUserDetailed> = {}): RestUse
     sonarQubeLastConnectionDate: null,
     sonarLintLastConnectionDate: null,
     scmAccounts: [],
-    tokensCount: 0,
-    groupsCount: 0,
     avatar: 'buzzonthemoon',
     ...overrides,
   };
index 6b1a95e8243001d200a3f6f2069a4b367b03ba57..38657ab82a224188f26fd5e09b8a6a9c9f7a38ee 100644 (file)
@@ -22,10 +22,14 @@ import {
   QueryFunctionContext,
   useMutation,
   useQueries,
+  useQuery,
   useQueryClient,
 } from '@tanstack/react-query';
 import { range } from 'lodash';
-import { deleteUser, getUsers, postUser, updateUser } from '../api/users';
+import { generateToken, getTokens, revokeToken } from '../api/user-tokens';
+import { addUserToGroup, removeUserFromGroup } from '../api/user_groups';
+import { deleteUser, getUserGroups, getUsers, postUser, updateUser } from '../api/users';
+import { UserToken } from '../types/token';
 import { RestUserBase } from '../types/users';
 
 const STALE_TIME = 4 * 60 * 1000;
@@ -59,19 +63,27 @@ export function useUsersQueries<U extends RestUserBase>(
   );
 }
 
-export function useInvalidateUsersList() {
-  const queryClient = useQueryClient();
+export function useUserTokensQuery(login: string) {
+  return useQuery({
+    queryKey: ['user', login, 'tokens'],
+    queryFn: () => getTokens(login),
+    staleTime: STALE_TIME,
+  });
+}
 
-  return () => queryClient.invalidateQueries({ queryKey: ['user', 'list'] });
+export function useUserGroupsCountQuery(login: string) {
+  return useQuery({
+    queryKey: ['user', login, 'groups', 'total'],
+    queryFn: () => getUserGroups({ login, ps: 1 }).then((r) => r.paging.total),
+    staleTime: STALE_TIME,
+  });
 }
 
 export function usePostUserMutation() {
   const queryClient = useQueryClient();
 
   return useMutation({
-    mutationFn: async (data: Parameters<typeof postUser>[0]) => {
-      await postUser(data);
-    },
+    mutationFn: (data: Parameters<typeof postUser>[0]) => postUser(data),
     onSuccess() {
       queryClient.invalidateQueries({ queryKey: ['user', 'list'] });
     },
@@ -82,9 +94,7 @@ export function useUpdateUserMutation() {
   const queryClient = useQueryClient();
 
   return useMutation({
-    mutationFn: async (data: Parameters<typeof updateUser>[0]) => {
-      await updateUser(data);
-    },
+    mutationFn: (data: Parameters<typeof updateUser>[0]) => updateUser(data),
     onSuccess() {
       queryClient.invalidateQueries({ queryKey: ['user', 'list'] });
     },
@@ -95,11 +105,67 @@ export function useDeactivateUserMutation() {
   const queryClient = useQueryClient();
 
   return useMutation({
-    mutationFn: async (data: Parameters<typeof deleteUser>[0]) => {
-      await deleteUser(data);
-    },
+    mutationFn: (data: Parameters<typeof deleteUser>[0]) => deleteUser(data),
     onSuccess() {
       queryClient.invalidateQueries({ queryKey: ['user', 'list'] });
     },
   });
 }
+
+export function useGenerateTokenMutation() {
+  const queryClient = useQueryClient();
+
+  return useMutation({
+    mutationFn: (data: Parameters<typeof generateToken>[0] & { projectName?: string }) =>
+      generateToken(data),
+    onSuccess(data, variables) {
+      queryClient.setQueryData<UserToken[]>(['user', data.login, 'tokens'], (oldData) => {
+        const newData = {
+          ...data,
+          project:
+            variables.projectKey && variables.projectName
+              ? { key: variables.projectKey, name: variables.projectName }
+              : undefined,
+        };
+        return oldData ? [...oldData, newData] : [newData];
+      });
+    },
+  });
+}
+
+export function useRevokeTokenMutation() {
+  const queryClient = useQueryClient();
+
+  return useMutation({
+    mutationFn: (data: Parameters<typeof revokeToken>[0]) => revokeToken(data),
+    onSuccess(_, data) {
+      queryClient.setQueryData<UserToken[]>(['user', data.login, 'tokens'], (oldData) =>
+        oldData ? oldData.filter((token) => token.name !== data.name) : undefined
+      );
+    },
+  });
+}
+
+export function useAddUserToGroupMutation() {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: (data: Parameters<typeof addUserToGroup>[0]) => addUserToGroup(data),
+    onSuccess(_, data) {
+      queryClient.setQueryData<number>(['user', data.login, 'groups', 'total'], (oldData) =>
+        oldData !== undefined ? oldData + 1 : undefined
+      );
+    },
+  });
+}
+
+export function useRemoveUserToGroupMutation() {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: (data: Parameters<typeof removeUserFromGroup>[0]) => removeUserFromGroup(data),
+    onSuccess(_, data) {
+      queryClient.setQueryData<number>(['user', data.login, 'groups', 'total'], (oldData) =>
+        oldData !== undefined ? oldData - 1 : undefined
+      );
+    },
+  });
+}
index d7acd57f448af05377a7776bd39a13250c812c43..a556d6c1a7c529fc9a11c142c983d3c9300fd937 100644 (file)
@@ -112,8 +112,6 @@ export interface RestUserDetailed extends RestUser {
   sonarQubeLastConnectionDate: string | null;
   sonarLintLastConnectionDate: string | null;
   scmAccounts: string[];
-  groupsCount: number;
-  tokensCount: number;
 }
 
 export const enum ChangePasswordResults {