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:
parent
026127281d
commit
24aa3a5c83
7 changed files with 97 additions and 59 deletions
2
Tiltfile
2
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"],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"]`);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue