From 24aa3a5c83080679ee5c25dfa9aa4bd6786c46e3 Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Mon, 8 Jul 2024 12:02:04 +0800 Subject: [PATCH] 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 ## 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. --- Tiltfile | 2 +- engine/apps/api/views/user.py | 41 +++++++++ .../e2e-tests/users/usersActions.test.ts | 10 ++- .../src/models/user/user.helpers.tsx | 5 +- grafana-plugin/src/models/user/user.ts | 7 +- .../src/pages/users/Users.styles.ts | 4 + grafana-plugin/src/pages/users/Users.tsx | 87 ++++++++----------- 7 files changed, 97 insertions(+), 59 deletions(-) diff --git a/Tiltfile b/Tiltfile index ebb036dcaa..a2cd3a5141 100644 --- a/Tiltfile +++ b/Tiltfile @@ -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"], diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index 940e08a6e7..85b8182578 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -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 @@ def export_token(self, request, pk) -> Response: 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: diff --git a/grafana-plugin/e2e-tests/users/usersActions.test.ts b/grafana-plugin/e2e-tests/users/usersActions.test.ts index 3134f26220..f106abd950 100644 --- a/grafana-plugin/e2e-tests/users/usersActions.test.ts +++ b/grafana-plugin/e2e-tests/users/usersActions.test.ts @@ -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"]`); diff --git a/grafana-plugin/src/models/user/user.helpers.tsx b/grafana-plugin/src/models/user/user.helpers.tsx index 3588bb875f..501b8e1bcd 100644 --- a/grafana-plugin/src/models/user/user.helpers.tsx +++ b/grafana-plugin/src/models/user/user.helpers.tsx @@ -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) { diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index 34a42f2522..df5afbdb90 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -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 { + async fetchItems( + f: RemoteFiltersType | string = { searchTerm: '', type: undefined, used: undefined }, + page = 1, + invalidateFn?: () => boolean + ): Promise { const response = await UserHelper.search(f, page); if (invalidateFn && invalidateFn()) { diff --git a/grafana-plugin/src/pages/users/Users.styles.ts b/grafana-plugin/src/pages/users/Users.styles.ts index bd439484d7..b8b48c2209 100644 --- a/grafana-plugin/src/pages/users/Users.styles.ts +++ b/grafana-plugin/src/pages/users/Users.styles.ts @@ -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; diff --git a/grafana-plugin/src/pages/users/Users.tsx b/grafana-plugin/src/pages/users/Users.tsx index ed9c981779..cf18d7895e 100644 --- a/grafana-plugin/src/pages/users/Users.tsx +++ b/grafana-plugin/src/pages/users/Users.tsx @@ -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 { 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 { 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 { } 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 { 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 ? ( <> -
- - -
- + {this.renderFilters()} { ); } + renderFilters() { + const { query, store, theme } = this.props; + const styles = getUsersStyles(theme); + + return ( +
+ +
+ ); + } + + 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 { return user.notification_chain_verbal.important; }; - renderContacts = (user: ApiSchemas['User']) => { - const { store } = this.props; - return ( -
-
Slack: {user.slack_user_identity?.name || '-'}
- {store.hasFeature(AppFeature.Telegram) && ( -
Telegram: {user.telegram_configuration?.telegram_nick_name || '-'}
- )} -
- ); - }; - renderButtons = (user: ApiSchemas['User']) => { const { store } = this.props; const { userStore } = store; @@ -442,16 +437,6 @@ class Users extends React.Component { 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 });