]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-18964 Add User Activity filter on users list
authorAmbroise C <ambroise.christea@sonarsource.com>
Tue, 11 Apr 2023 06:56:38 +0000 (08:56 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 17 Apr 2023 20:02:52 +0000 (20:02 +0000)
server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts
server/sonar-web/src/main/js/api/users.ts
server/sonar-web/src/main/js/apps/users/UsersApp.tsx
server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx
server/sonar-web/src/main/js/apps/users/constants.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/types.ts [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index efa961414bcd3a1eb19b57d9b1e10d750b27fd87..a46e8bb94bf3d48e9167f839966bd8fe5a91ef92 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
+import { isAfter, isBefore } from 'date-fns';
 import { cloneDeep } from 'lodash';
 import { mockClusterSysInfo, mockIdentityProvider, mockUser } from '../../helpers/testMocks';
 import { IdentityProvider, Paging, SysInfoCluster } from '../../types/types';
@@ -30,11 +31,40 @@ const DEFAULT_USERS = [
     managed: true,
     login: 'bob.marley',
     name: 'Bob Marley',
+    lastConnectionDate: '2023-06-27T17:08:59+0200',
+    sonarLintLastConnectionDate: '2023-06-27T17:08:59+0200',
   }),
   mockUser({
     managed: false,
     login: 'alice.merveille',
     name: 'Alice Merveille',
+    lastConnectionDate: '2023-06-27T17:08:59+0200',
+    sonarLintLastConnectionDate: '2023-05-27T17:08:59+0200',
+  }),
+  mockUser({
+    managed: false,
+    login: 'charlie.cox',
+    name: 'Charlie Cox',
+    lastConnectionDate: '2023-06-25T17:08:59+0200',
+    sonarLintLastConnectionDate: '2023-06-20T12:10:59+0200',
+  }),
+  mockUser({
+    managed: true,
+    login: 'denis.villeneuve',
+    name: 'Denis Villeneuve',
+    lastConnectionDate: '2023-06-20T15:08:59+0200',
+    sonarLintLastConnectionDate: '2023-05-25T10:08:59+0200',
+  }),
+  mockUser({
+    managed: true,
+    login: 'eva.green',
+    name: 'Eva Green',
+    lastConnectionDate: '2023-05-27T17:08:59+0200',
+  }),
+  mockUser({
+    managed: false,
+    login: 'franck.grillo',
+    name: 'Franck Grillo',
   }),
 ];
 
@@ -52,31 +82,100 @@ export default class UsersServiceMock {
     this.isManaged = managed;
   }
 
+  getFilteredUsers = (filterParams: {
+    managed: boolean;
+    q: string;
+    lastConnectedAfter?: string;
+    lastConnectedBefore?: string;
+    slLastConnectedAfter?: string;
+    slLastConnectedBefore?: string;
+  }) => {
+    const {
+      managed,
+      q,
+      lastConnectedAfter,
+      lastConnectedBefore,
+      slLastConnectedAfter,
+      slLastConnectedBefore,
+    } = filterParams;
+
+    return this.users.filter((user) => {
+      if (this.isManaged && managed !== undefined && user.managed !== managed) {
+        return false;
+      }
+
+      if (q && (!user.login.includes(q) || (user.name && !user.name.includes(q)))) {
+        return false;
+      }
+
+      if (
+        lastConnectedAfter &&
+        (user.lastConnectionDate === undefined ||
+          isBefore(new Date(user.lastConnectionDate), new Date(lastConnectedAfter)))
+      ) {
+        return false;
+      }
+
+      if (
+        lastConnectedBefore &&
+        user.lastConnectionDate !== undefined &&
+        isAfter(new Date(user.lastConnectionDate), new Date(lastConnectedBefore))
+      ) {
+        return false;
+      }
+
+      if (
+        slLastConnectedAfter &&
+        (user.sonarLintLastConnectionDate === undefined ||
+          isBefore(new Date(user.sonarLintLastConnectionDate), new Date(slLastConnectedAfter)))
+      ) {
+        return false;
+      }
+
+      if (
+        slLastConnectedBefore &&
+        user.sonarLintLastConnectionDate !== undefined &&
+        isAfter(new Date(user.sonarLintLastConnectionDate), new Date(slLastConnectedBefore))
+      ) {
+        return false;
+      }
+
+      return true;
+    });
+  };
+
   handleSearchUsers = (data: any): Promise<{ paging: Paging; users: User[] }> => {
     let paging = {
       pageIndex: 1,
-      pageSize: 2,
-      total: 6,
+      pageSize: 0,
+      total: 10,
     };
 
     if (data.p !== undefined && data.p !== paging.pageIndex) {
-      paging = { pageIndex: 2, pageSize: 2, total: 6 };
+      paging = { pageIndex: 2, pageSize: 2, total: 10 };
       const users = [
-        mockUser({ name: `local-user ${this.users.length + 4}` }),
-        mockUser({ name: `local-user ${this.users.length + 5}` }),
+        mockUser({
+          name: `Local User ${this.users.length + 4}`,
+          login: `local-user-${this.users.length + 4}`,
+        }),
+        mockUser({
+          name: `Local User ${this.users.length + 5}`,
+          login: `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 });
-      }
-      const users = this.users.filter((user) => user.managed === data.managed);
-      return this.reply({ paging, users });
-    }
-    return this.reply({ paging, users: this.users });
+    const users = this.getFilteredUsers(data);
+    return this.reply({
+      paging: {
+        pageIndex: 1,
+        pageSize: users.length,
+        total: 10,
+      },
+      users,
+    });
   };
 
   handleCreateUser = (data: {
index d3fbb2f7e8bdea41d6dd181e8ad8ce8d1066e866..444092043f7c86e6959082657eae11b2a1994248 100644 (file)
@@ -72,6 +72,10 @@ export function searchUsers(data: {
   ps?: number;
   q?: string;
   managed?: boolean;
+  lastConnectedAfter?: string;
+  lastConnectedBefore?: string;
+  slLastConnectedAfter?: string;
+  slLastConnectedBefore?: string;
 }): Promise<{ paging: Paging; users: User[] }> {
   data.q = data.q || undefined;
   return getJSON('/api/users/search', data).catch(throwGlobalError);
index 7cec61d1f3b2463cc0ae9ad8905e342cf268c0c4..25cfbadde0bfca50707f491fe2ade8efe33302d7 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 React, { useCallback, useEffect, useState } from 'react';
+import { subDays, subSeconds } from 'date-fns';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
 import { Helmet } from 'react-helmet-async';
+import { FormattedMessage } from 'react-intl';
 import { getIdentityProviders, searchUsers } from '../../api/users';
+import HelpTooltip from '../../components/controls/HelpTooltip';
 import ListFooter from '../../components/controls/ListFooter';
 import { ManagedFilter } from '../../components/controls/ManagedFilter';
 import SearchBox from '../../components/controls/SearchBox';
+import Select, { LabelValueSelectOption } from '../../components/controls/Select';
 import Suggestions from '../../components/embed-docs-modal/Suggestions';
 import { useManageProvider } from '../../components/hooks/useManageProvider';
 import DeferredSpinner from '../../components/ui/DeferredSpinner';
+import { now, toNotSoISOString } from '../../helpers/dates';
 import { translate } from '../../helpers/l10n';
 import { IdentityProvider, Paging } from '../../types/types';
 import { User } from '../../types/users';
 import Header from './Header';
 import UsersList from './UsersList';
+import { USERS_ACTIVITY_OPTIONS, USER_INACTIVITY_DAYS_THRESHOLD } from './constants';
+import { UserActivity } from './types';
 
 export default function UsersApp() {
   const [identityProviders, setIdentityProviders] = useState<IdentityProvider[]>([]);
@@ -40,20 +47,49 @@ export default function UsersApp() {
   const [users, setUsers] = useState<User[]>([]);
 
   const [search, setSearch] = useState('');
+  const [usersActivity, setUsersActivity] = useState<UserActivity>(UserActivity.AnyActivity);
   const [managed, setManaged] = useState<boolean | undefined>(undefined);
 
   const manageProvider = useManageProvider();
 
+  const usersActivityParams = useMemo(() => {
+    const nowDate = now();
+    const nowDateMinus30Days = subDays(nowDate, USER_INACTIVITY_DAYS_THRESHOLD);
+    const nowDateMinus30DaysAnd1Second = subSeconds(nowDateMinus30Days, 1);
+
+    switch (usersActivity) {
+      case UserActivity.ActiveSonarLintUser:
+        return {
+          slLastConnectedAfter: toNotSoISOString(nowDateMinus30Days),
+        };
+      case UserActivity.ActiveSonarQubeUser:
+        return {
+          lastConnectedAfter: toNotSoISOString(nowDateMinus30Days),
+          slLastConnectedBefore: toNotSoISOString(nowDateMinus30DaysAnd1Second),
+        };
+      case UserActivity.InactiveUser:
+        return {
+          lastConnectedBefore: toNotSoISOString(nowDateMinus30DaysAnd1Second),
+        };
+      default:
+        return {};
+    }
+  }, [usersActivity]);
+
   const fetchUsers = useCallback(async () => {
     setLoading(true);
     try {
-      const { paging, users } = await searchUsers({ q: search, managed });
+      const { paging, users } = await searchUsers({
+        q: search,
+        managed,
+        ...usersActivityParams,
+      });
       setPaging(paging);
       setUsers(users);
     } finally {
       setLoading(false);
     }
-  }, [search, managed]);
+  }, [search, managed, usersActivityParams]);
 
   const fetchMoreUsers = useCallback(async () => {
     if (!paging) {
@@ -64,6 +100,7 @@ export default function UsersApp() {
       const { paging: nextPage, users: nextUsers } = await searchUsers({
         q: search,
         managed,
+        ...usersActivityParams,
         p: paging.pageIndex + 1,
       });
       setPaging(nextPage);
@@ -71,7 +108,7 @@ export default function UsersApp() {
     } finally {
       setLoading(false);
     }
-  }, [search, managed, paging, users]);
+  }, [search, managed, usersActivityParams, paging, users]);
 
   useEffect(() => {
     (async () => {
@@ -82,7 +119,7 @@ export default function UsersApp() {
 
   useEffect(() => {
     fetchUsers();
-  }, [search, managed]);
+  }, [fetchUsers]);
 
   return (
     <main className="page page-limited" id="users-page">
@@ -103,6 +140,83 @@ export default function UsersApp() {
           placeholder={translate('search.search_by_login_or_name')}
           value={search}
         />
+        <div className="sw-ml-4">
+          <Select
+            id="users-activity-filter"
+            className="input-large"
+            isDisabled={loading}
+            onChange={(userActivity: LabelValueSelectOption<UserActivity>) => {
+              setUsersActivity(userActivity.value);
+            }}
+            options={USERS_ACTIVITY_OPTIONS}
+            isSearchable={false}
+            placeholder={translate('users.activity_filter.placeholder')}
+            aria-label={translate('users.activity_filter.label')}
+            value={USERS_ACTIVITY_OPTIONS.find((option) => option.value === usersActivity) ?? null}
+          />
+          <HelpTooltip
+            className="sw-ml-1"
+            overlay={
+              <>
+                <p>{translate('users.activity_filter.helptext')}</p>
+                <ul className="spacer-top">
+                  <li>
+                    <FormattedMessage
+                      defaultMessage={translate('users.activity_filter.helptext.all_users')}
+                      id="users.activity_filter.helptext.all_users"
+                      values={{
+                        allUsersLabel: (
+                          <strong>{translate('users.activity_filter.all_users')}</strong>
+                        ),
+                      }}
+                    />
+                  </li>
+                  <li>
+                    <FormattedMessage
+                      defaultMessage={translate(
+                        'users.activity_filter.helptext.active_sonarlint_users'
+                      )}
+                      id="users.activity_filter.helptext.active_sonarlint_users"
+                      values={{
+                        activeSonarLintUsersLabel: (
+                          <strong>
+                            {translate('users.activity_filter.active_sonarlint_users')}
+                          </strong>
+                        ),
+                      }}
+                    />
+                  </li>
+                  <li>
+                    <FormattedMessage
+                      defaultMessage={translate(
+                        'users.activity_filter.helptext.active_sonarqube_users'
+                      )}
+                      id="users.activity_filter.helptext.active_sonarqube_users"
+                      values={{
+                        activeSonarQubeUsersLabel: (
+                          <strong>
+                            {translate('users.activity_filter.active_sonarqube_users')}
+                          </strong>
+                        ),
+                      }}
+                    />
+                  </li>
+                  <li>
+                    <FormattedMessage
+                      defaultMessage={translate('users.activity_filter.helptext.inactive_users')}
+                      id="users.activity_filter.helptext.inactive_users"
+                      values={{
+                        inactiveUsersLabel: (
+                          <strong>{translate('users.activity_filter.inactive_users')}</strong>
+                        ),
+                      }}
+                    />
+                  </li>
+                </ul>
+              </>
+            }
+          />
+        </div>
       </div>
       <DeferredSpinner loading={loading}>
         <UsersList
index e3df7e0e1eb7ce98e728fd63ef249dc313a0b42d..3d458a894f96a7b9bf5ea23f0b9e0af82eb2cfb8 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-import { act, screen } from '@testing-library/react';
+import { act } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import * as React from 'react';
+import selectEvent from 'react-select-event';
 import { byLabelText, byRole, byText } from 'testing-library-selector';
 import UsersServiceMock from '../../../api/mocks/UsersServiceMock';
 import { renderApp } from '../../../helpers/testReactTestingUtils';
@@ -38,17 +39,39 @@ const ui = {
   allFilter: byRole('button', { name: 'all' }),
   managedFilter: byRole('button', { name: 'managed' }),
   localFilter: byRole('button', { name: 'local' }),
+  searchInput: byRole('searchbox', { name: 'search.search_by_login_or_name' }),
+  activityFilter: byRole('combobox', { name: 'users.activity_filter.label' }),
+  userRows: byRole('row', {
+    name: (accessibleName) => /^[A-Z]+ /.test(accessibleName),
+  }),
   showMore: byRole('button', { name: 'show_more' }),
-  aliceRow: byRole('row', { name: 'AM Alice Merveille alice.merveille never never' }),
+  aliceRow: byRole('row', {
+    name: (accessibleName) => accessibleName.startsWith('AM Alice Merveille alice.merveille '),
+  }),
   aliceRowWithLocalBadge: byRole('row', {
-    name: 'AM Alice Merveille alice.merveille local never never',
+    name: (accessibleName) =>
+      accessibleName.startsWith('AM Alice Merveille alice.merveille local '),
   }),
   aliceUpdateGroupButton: byRole('button', { name: 'users.update_users_groups.alice.merveille' }),
   aliceUpdateButton: byRole('button', { name: 'users.manage_user.alice.merveille' }),
   alicedDeactivateButton: byRole('button', { name: 'users.deactivate' }),
   bobUpdateGroupButton: byRole('button', { name: 'users.update_users_groups.bob.marley' }),
   bobUpdateButton: byRole('button', { name: 'users.manage_user.bob.marley' }),
-  bobRow: byRole('row', { name: 'BM Bob Marley bob.marley never never' }),
+  bobRow: byRole('row', {
+    name: (accessibleName) => accessibleName.startsWith('BM Bob Marley bob.marley '),
+  }),
+  charlieRow: byRole('row', {
+    name: (accessibleName) => accessibleName.startsWith('CC Charlie Cox charlie.cox local '),
+  }),
+  denisRow: byRole('row', {
+    name: (accessibleName) => accessibleName.startsWith('DV Denis Villeneuve denis.villeneuve '),
+  }),
+  evaRow: byRole('row', {
+    name: (accessibleName) => accessibleName.startsWith('EG Eva Green eva.green '),
+  }),
+  franckRow: byRole('row', {
+    name: (accessibleName) => accessibleName.startsWith('FG Franck Grillo franck.grillo local '),
+  }),
   loginInput: byRole('textbox', { name: /login/ }),
   userNameInput: byRole('textbox', { name: /name/ }),
   passwordInput: byLabelText(/password/),
@@ -64,8 +87,91 @@ const ui = {
   jackRow: byRole('row', { name: /Jack/ }),
 };
 
-afterAll(() => {
-  handler.reset();
+describe('different filters combinations', () => {
+  beforeAll(() => {
+    jest.useFakeTimers({
+      advanceTimers: true,
+      now: new Date('2023-07-05T07:08:59Z'),
+    });
+  });
+
+  afterAll(() => {
+    jest.useRealTimers();
+  });
+
+  it('should display all users with default filters', async () => {
+    renderUsersApp();
+
+    expect(await ui.userRows.findAll()).toHaveLength(6);
+  });
+
+  it('should display users filtered with text search', async () => {
+    renderUsersApp();
+
+    await userEvent.type(await ui.searchInput.find(), 'ar');
+
+    expect(await ui.userRows.findAll()).toHaveLength(2);
+    expect(ui.bobRow.get()).toBeInTheDocument();
+    expect(ui.charlieRow.get()).toBeInTheDocument();
+  });
+
+  it('should display local active SonarLint users', async () => {
+    renderUsersApp();
+
+    await userEvent.click(await ui.localFilter.find());
+    await act(async () => {
+      await selectEvent.select(ui.activityFilter.get(), (content, element) => {
+        return (
+          // eslint-disable-next-line jest/no-conditional-in-test
+          content === 'users.activity_filter.active_sonarlint_users' &&
+          element?.tagName === 'DIV' &&
+          element?.className.split(' ').includes('react-select__option')
+        );
+      });
+    });
+
+    expect(await ui.userRows.findAll()).toHaveLength(1);
+    expect(ui.charlieRow.get()).toBeInTheDocument();
+  });
+
+  it('should display managed active SonarQube users', async () => {
+    renderUsersApp();
+
+    await userEvent.click(await ui.managedFilter.find());
+    await act(async () => {
+      await selectEvent.select(ui.activityFilter.get(), (content, element) => {
+        return (
+          // eslint-disable-next-line jest/no-conditional-in-test
+          content === 'users.activity_filter.active_sonarqube_users' &&
+          element?.tagName === 'DIV' &&
+          element?.className.split(' ').includes('react-select__option')
+        );
+      });
+    });
+
+    expect(await ui.userRows.findAll()).toHaveLength(1);
+    expect(ui.denisRow.get()).toBeInTheDocument();
+  });
+
+  it('should display all inactive users', async () => {
+    renderUsersApp();
+
+    await userEvent.click(await ui.allFilter.find());
+    await act(async () => {
+      await selectEvent.select(ui.activityFilter.get(), (content, element) => {
+        return (
+          // eslint-disable-next-line jest/no-conditional-in-test
+          content === 'users.activity_filter.inactive_users' &&
+          element?.tagName === 'DIV' &&
+          element?.className.split(' ').includes('react-select__option')
+        );
+      });
+    });
+
+    expect(await ui.userRows.findAll()).toHaveLength(2);
+    expect(ui.evaRow.get()).toBeInTheDocument();
+    expect(ui.franckRow.get()).toBeInTheDocument();
+  });
 });
 
 describe('in non managed mode', () => {
@@ -73,6 +179,10 @@ describe('in non managed mode', () => {
     handler.setIsManaged(false);
   });
 
+  afterAll(() => {
+    handler.reset();
+  });
+
   it('should allow the creation of user', async () => {
     renderUsersApp();
 
@@ -125,13 +235,13 @@ describe('in non managed mode', () => {
 
     expect(await ui.aliceRow.find()).toBeInTheDocument();
     expect(ui.bobRow.get()).toBeInTheDocument();
-    expect(screen.getAllByRole('row')).toHaveLength(4);
+    expect(ui.userRows.getAll()).toHaveLength(7);
 
     await act(async () => {
       await user.click(await ui.showMore.find());
     });
 
-    expect(screen.getAllByRole('row')).toHaveLength(6);
+    expect(ui.userRows.getAll()).toHaveLength(9);
   });
 });
 
diff --git a/server/sonar-web/src/main/js/apps/users/constants.ts b/server/sonar-web/src/main/js/apps/users/constants.ts
new file mode 100644 (file)
index 0000000..3138603
--- /dev/null
@@ -0,0 +1,39 @@
+/*
+ * 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 { LabelValueSelectOption } from '../../components/controls/Select';
+import { translate } from '../../helpers/l10n';
+import { UserActivity } from './types';
+
+// Nb of days without connection to SQ after which a user is considered inactive:
+export const USER_INACTIVITY_DAYS_THRESHOLD = 30;
+
+export const USERS_ACTIVITY_OPTIONS: LabelValueSelectOption[] = [
+  { value: UserActivity.AnyActivity, label: translate('users.activity_filter.all_users') },
+  {
+    value: UserActivity.ActiveSonarLintUser,
+    label: translate('users.activity_filter.active_sonarlint_users'),
+  },
+  {
+    value: UserActivity.ActiveSonarQubeUser,
+    label: translate('users.activity_filter.active_sonarqube_users'),
+  },
+  { value: UserActivity.InactiveUser, label: translate('users.activity_filter.inactive_users') },
+];
diff --git a/server/sonar-web/src/main/js/apps/users/types.ts b/server/sonar-web/src/main/js/apps/users/types.ts
new file mode 100644 (file)
index 0000000..d2ec36e
--- /dev/null
@@ -0,0 +1,26 @@
+/*
+ * 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.
+ */
+
+export enum UserActivity {
+  AnyActivity = 'ANY_ACTIVITY',
+  ActiveSonarLintUser = 'ACTIVE_SONARLINT_USER',
+  ActiveSonarQubeUser = 'ACTIVE_SONARQUBE_USER',
+  InactiveUser = 'INACTIVE_USER',
+}
index 00a380dd05aa81caa86639e40b35bf8c759ae2ac..e270012c337c6b04e178e664ddc0ed3af1ca62ff 100644 (file)
@@ -4411,6 +4411,17 @@ users.update_tokens_for_x=Update tokens for user {0}
 users.add=Add user
 users.remove=Remove user
 users.search_description=Search users by login or name
+users.activity_filter.label=Filter users by activity
+users.activity_filter.placeholder=All users
+users.activity_filter.helptext=A user is considered active if they connected to SonarLint or SonarQube at least once in the past 30 days.
+users.activity_filter.helptext.all_users={allUsersLabel}: All users whether they connected to SonarLint/SonarQube in the 30 days.
+users.activity_filter.helptext.active_sonarlint_users={activeSonarLintUsersLabel}: Users that connected to SonarLint in the past 30 days.
+users.activity_filter.helptext.active_sonarqube_users={activeSonarQubeUsersLabel}: Users that connected to SonarQube but not to SonarLint in the past 30 days.
+users.activity_filter.helptext.inactive_users={inactiveUsersLabel}: Users that didn't connect to SonarQube in the past 30 days.
+users.activity_filter.all_users=All users
+users.activity_filter.active_sonarlint_users=Active SonarLint users
+users.activity_filter.active_sonarqube_users=Active users without SonarLint
+users.activity_filter.inactive_users=Inactive users
 users.tokens=Tokens
 users.user_X_tokens=Tokens of {user}
 users.tokens.sure=Sure?