Add filtering by team, is_currently_oncall and search on the user page (#4575)

# What this PR does

This PR adds filtering by team and is_currently_oncall on the user page

## Which issue(s) this PR closes

Closes https://github.com/grafana/oncall/issues/4353

<!--
*Note*: if you have more than one GitHub issue that this PR closes, be
sure to preface
each issue link with a [closing
keyword](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/using-keywords-in-issues-and-pull-requests#linking-a-pull-request-to-an-issue).
This ensures that the issue(s) are auto-closed once the PR has been
merged.
-->

## Checklist

- [ ] Unit, integration, and e2e (if applicable) tests updated
- [ ] Documentation added (or `pr:no public docs` PR label added if not
required)
- [ ] Added the relevant release notes label (see labels prefixed w/
`release:`). These labels dictate how your PR will
    show up in the autogenerated release notes.
This commit is contained in:
Ildar Iskhakov 2024-07-08 12:02:04 +08:00 committed by GitHub
parent 026127281d
commit 24aa3a5c83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 97 additions and 59 deletions

View file

@ -46,7 +46,7 @@ docker_build_sub(
"localhost:63628/oncall/engine:dev",
context="./engine",
cache_from=["grafana/oncall:latest", "grafana/oncall:dev"],
ignore=["./test-results/", "./grafana-plugin/dist/", "./grafana-plugin/e2e-tests/"],
ignore=["./test-results/", "./grafana-plugin/dist/", "./grafana-plugin/e2e-tests/", "./grafana-plugin/node_modules/"],
child_context=".",
target="dev",
extra_cmds=["ADD ./grafana-plugin/src/plugin.json /etc/grafana-plugin/src/plugin.json"],

View file

@ -235,6 +235,7 @@ class UserView(
"send_test_sms": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"export_token": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"upcoming_shifts": [RBACPermission.Permissions.USER_SETTINGS_READ],
"filters": [RBACPermission.Permissions.USER_SETTINGS_READ],
}
rbac_object_permissions = {
@ -846,6 +847,46 @@ class UserView(
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
@extend_schema(
responses=inline_serializer(
name="UserFilters",
fields={
"name": serializers.CharField(),
"type": serializers.CharField(),
"href": serializers.CharField(required=False),
"global": serializers.BooleanField(required=False),
"default": serializers.JSONField(required=False),
"description": serializers.CharField(required=False),
"options": inline_serializer(
name="UserFiltersOptions",
fields={
"value": serializers.CharField(),
"display_name": serializers.IntegerField(),
},
),
},
many=True,
)
)
@action(methods=["get"], detail=False)
def filters(self, request):
filter_name = request.query_params.get("search", None)
api_root = "/api/internal/v1/"
filter_options = [
{
"name": "team",
"type": "team_select",
"href": api_root + "teams/",
"global": True,
},
]
if filter_name is not None:
filter_options = list(filter(lambda f: filter_name in f["name"], filter_options))
return Response(filter_options)
def handle_phone_notificator_failed(exc: BaseFailed) -> Response:
if exc.graceful_msg:

View file

@ -56,9 +56,13 @@ test.describe('Users screen actions', () => {
await page.waitForTimeout(2000);
const searchInput = page.locator(`[data-testid="search-users"]`);
await searchInput.fill(userName);
await page
.locator('div')
.filter({ hasText: /^Search or filter results\.\.\.$/ })
.nth(1)
.click();
await page.keyboard.insertText(userName);
await page.keyboard.press('Enter');
await page.waitForTimeout(2000);
const result = page.locator(`[data-testid="users-username"]`);

View file

@ -35,9 +35,8 @@ export class UserHelper {
* NOTE: if is_currently_oncall=all the backend will not paginate the results, it will send back an array of ALL users
*/
static async search(f: any = { searchTerm: '' }, page = 1) {
const filters = typeof f === 'string' ? { searchTerm: f } : f; // for GSelect compatibility
const { searchTerm: search, ...restFilters } = filters;
return (await onCallApi().GET('/users/', { params: { query: { search, page, ...restFilters } } })).data;
const filters = typeof f === 'string' ? { search: f } : f; // for GSelect compatibility
return (await onCallApi().GET('/users/', { params: { query: { ...filters, page } } })).data;
}
static getSearchResult(userStore: UserStore) {

View file

@ -3,6 +3,7 @@ import dayjs from 'dayjs';
import { get } from 'lodash-es';
import { action, computed, runInAction, makeAutoObservable } from 'mobx';
import { RemoteFiltersType } from 'containers/RemoteFilters/RemoteFilters.types';
import { ActionKey } from 'models/loader/action-keys';
import { NotificationPolicyType } from 'models/notification_policy/notification_policy';
import { makeRequest } from 'network/network';
@ -36,7 +37,11 @@ export class UserStore {
this.rootStore = rootStore;
}
async fetchItems(f: any = { searchTerm: '' }, page = 1, invalidateFn?: () => boolean): Promise<any> {
async fetchItems(
f: RemoteFiltersType | string = { searchTerm: '', type: undefined, used: undefined },
page = 1,
invalidateFn?: () => boolean
): Promise<any> {
const response = await UserHelper.search(f, page);
if (invalidateFn && invalidateFn()) {

View file

@ -3,6 +3,10 @@ import { GrafanaTheme2 } from '@grafana/data';
export const getUsersStyles = (theme: GrafanaTheme2) => {
return {
filters: css`
margin-bottom: 20px;
`,
usersTtitle: css`
display: flex;
align-items: center;

View file

@ -18,7 +18,8 @@ import {
import { PluginLink } from 'components/PluginLink/PluginLink';
import { Text } from 'components/Text/Text';
import { TooltipBadge } from 'components/TooltipBadge/TooltipBadge';
import { UsersFilters } from 'components/UsersFilters/UsersFilters';
import { RemoteFilters } from 'containers/RemoteFilters/RemoteFilters';
import { RemoteFiltersType } from 'containers/RemoteFilters/RemoteFilters.types';
import { UserSettings } from 'containers/UserSettings/UserSettings';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { UserHelper } from 'models/user/user.helpers';
@ -44,9 +45,8 @@ const REQUIRED_PERMISSION_TO_VIEW_USERS = UserActions.UserSettingsWrite;
interface UsersState extends PageBaseState {
isWrongTeam: boolean;
userPkToEdit?: ApiSchemas['User']['pk'] | 'new';
usersFilters?: {
searchTerm: string;
};
filters: RemoteFiltersType;
}
@observer
@ -62,9 +62,7 @@ class Users extends React.Component<UsersProps, UsersState> {
this.state = {
isWrongTeam: false,
userPkToEdit: undefined,
usersFilters: {
searchTerm: '',
},
filters: { searchTerm: '', type: undefined, used: undefined, mine: undefined },
errorData: initErrorDataState(),
};
@ -80,7 +78,7 @@ class Users extends React.Component<UsersProps, UsersState> {
updateUsers = debounce(async (invalidateFn?: () => boolean) => {
const { store } = this.props;
const { usersFilters } = this.state;
const { filters } = this.state;
const { userStore, filtersStore } = store;
const page = filtersStore.currentTablePageNum[PAGE.Users];
@ -89,7 +87,7 @@ class Users extends React.Component<UsersProps, UsersState> {
}
LocationHelper.update({ p: page }, 'partial');
await userStore.fetchItems(usersFilters, page, invalidateFn);
await userStore.fetchItems(filters, page, invalidateFn);
this.forceUpdate();
}, DEBOUNCE_MS);
@ -184,38 +182,20 @@ class Users extends React.Component<UsersProps, UsersState> {
renderContentIfAuthorized(authorizedToViewUsers: boolean) {
const {
store: { userStore, filtersStore },
theme,
} = this.props;
const { usersFilters, userPkToEdit } = this.state;
const { userPkToEdit } = this.state;
const page = filtersStore.currentTablePageNum[PAGE.Users];
const { count, results, page_size } = UserHelper.getSearchResult(userStore);
const columns = this.getTableColumns();
const handleClear = () =>
this.setState({ usersFilters: { searchTerm: '' } }, () => {
this.updateUsers();
});
const styles = getUsersStyles(theme);
return (
<>
{authorizedToViewUsers ? (
<>
<div className={styles.userFiltersContainer} data-testid="users-filters">
<UsersFilters
className={styles.usersFilters}
value={usersFilters}
isLoading={results === undefined}
onChange={this.handleUsersFiltersChange}
/>
<Button variant="secondary" icon="times" onClick={handleClear}>
Clear filters
</Button>
</div>
{this.renderFilters()}
<GTable
data-testid="users-table"
emptyText={results ? 'No users found' : 'Loading...'}
@ -250,6 +230,33 @@ class Users extends React.Component<UsersProps, UsersState> {
);
}
renderFilters() {
const { query, store, theme } = this.props;
const styles = getUsersStyles(theme);
return (
<div className={styles.filters}>
<RemoteFilters
query={query}
page={PAGE.Users}
grafanaTeamStore={store.grafanaTeamStore}
onChange={this.handleFiltersChange}
/>
</div>
);
}
handleFiltersChange = (filters: RemoteFiltersType, _isOnMount: boolean) => {
const { filtersStore } = this.props.store;
const currentTablePage = filtersStore.currentTablePageNum[PAGE.Users];
LocationHelper.update({ p: currentTablePage }, 'partial');
this.setState({ filters }, () => {
this.updateUsers();
});
};
renderTitle = (user: ApiSchemas['User']) => {
const {
store: { userStore },
@ -288,18 +295,6 @@ class Users extends React.Component<UsersProps, UsersState> {
return user.notification_chain_verbal.important;
};
renderContacts = (user: ApiSchemas['User']) => {
const { store } = this.props;
return (
<div>
<div>Slack: {user.slack_user_identity?.name || '-'}</div>
{store.hasFeature(AppFeature.Telegram) && (
<div>Telegram: {user.telegram_configuration?.telegram_nick_name || '-'}</div>
)}
</div>
);
};
renderButtons = (user: ApiSchemas['User']) => {
const { store } = this.props;
const { userStore } = store;
@ -442,16 +437,6 @@ class Users extends React.Component<UsersProps, UsersState> {
this.updateUsers();
};
handleUsersFiltersChange = (usersFilters: any, invalidateFn: () => boolean) => {
const { filtersStore } = this.props.store;
filtersStore.currentTablePageNum[PAGE.Users] = 1;
this.setState({ usersFilters }, () => {
this.updateUsers(invalidateFn);
});
};
handleHideUserSettings = () => {
const { history } = this.props;
this.setState({ userPkToEdit: undefined });