* 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';
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',
}),
];
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: {
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);
* 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[]>([]);
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) {
const { paging: nextPage, users: nextUsers } = await searchUsers({
q: search,
managed,
+ ...usersActivityParams,
p: paging.pageIndex + 1,
});
setPaging(nextPage);
} finally {
setLoading(false);
}
- }, [search, managed, paging, users]);
+ }, [search, managed, usersActivityParams, paging, users]);
useEffect(() => {
(async () => {
useEffect(() => {
fetchUsers();
- }, [search, managed]);
+ }, [fetchUsers]);
return (
<main className="page page-limited" id="users-page">
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
* 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';
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/),
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', () => {
handler.setIsManaged(false);
});
+ afterAll(() => {
+ handler.reset();
+ });
+
it('should allow the creation of user', async () => {
renderUsersApp();
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);
});
});
--- /dev/null
+/*
+ * 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') },
+];
--- /dev/null
+/*
+ * 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',
+}
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?