Users search fix (#2847)
# Changes - Prevent duplicated call to /users on search - Fix the issue described at #2842 where there was a concurrency issue between the page load request and the search request ## Which issue(s) this PR fixes Fix for https://github.com/grafana/oncall/issues/2842 ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required)
This commit is contained in:
parent
b9a9cd2659
commit
b8a04b69aa
6 changed files with 137 additions and 119 deletions
|
|
@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- Changed HTTP Endpoint to Email for inbound email integrations
|
- Changed HTTP Endpoint to Email for inbound email integrations
|
||||||
([#2816](https://github.com/grafana/oncall/issues/2816))
|
([#2816](https://github.com/grafana/oncall/issues/2816))
|
||||||
- Enable inbound email feature flag by default by @vadimkerr ([#2846](https://github.com/grafana/oncall/pull/2846))
|
- Enable inbound email feature flag by default by @vadimkerr ([#2846](https://github.com/grafana/oncall/pull/2846))
|
||||||
|
- Fixed initial search on Users page ([#2842](https://github.com/grafana/oncall/issues/2842))
|
||||||
|
|
||||||
## v1.3.25 (2023-08-18)
|
## v1.3.25 (2023-08-18)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,20 @@ test.describe('Users screen actions', () => {
|
||||||
await _testButtons(editorRolePage.page, 'button.edit-other-profile-button:not([disabled])');
|
await _testButtons(editorRolePage.page, 'button.edit-other-profile-button:not([disabled])');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Search updates the table view', async ({ adminRolePage }) => {
|
||||||
|
const { page } = adminRolePage;
|
||||||
|
await goToOnCallPage(page, 'users');
|
||||||
|
|
||||||
|
const searchInput = page.locator(`[data-testid="search-users"]`);
|
||||||
|
|
||||||
|
await searchInput.fill('oncall');
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
|
||||||
|
const result = page.locator(`[data-testid="users-username"]`);
|
||||||
|
|
||||||
|
expect(await result.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Helper methods
|
* Helper methods
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,11 @@ interface UsersFiltersProps {
|
||||||
value: any;
|
value: any;
|
||||||
onChange: (filters: any) => void;
|
onChange: (filters: any) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UsersFilters = (props: UsersFiltersProps) => {
|
const UsersFilters = (props: UsersFiltersProps) => {
|
||||||
const { value = { searchTerm: '' }, onChange, className } = props;
|
const { value = { searchTerm: '' }, onChange, className, isLoading } = props;
|
||||||
|
|
||||||
const onSearchTermChangeCallback = useCallback(
|
const onSearchTermChangeCallback = useCallback(
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -31,11 +32,13 @@ const UsersFilters = (props: UsersFiltersProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={cx('root', className)}>
|
<div className={cx('root', className)}>
|
||||||
<Input
|
<Input
|
||||||
|
loading={isLoading}
|
||||||
prefix={<Icon name="search" />}
|
prefix={<Icon name="search" />}
|
||||||
className={cx('search', 'control')}
|
className={cx('search', 'control')}
|
||||||
placeholder="Search users..."
|
placeholder="Search users..."
|
||||||
value={value.searchTerm}
|
value={value.searchTerm}
|
||||||
onChange={onSearchTermChangeCallback}
|
onChange={onSearchTermChangeCallback}
|
||||||
|
data-testid="search-users"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import {
|
import { Button, HorizontalGroup, VerticalGroup, IconButton, ToolbarButton, Icon, Modal } from '@grafana/ui';
|
||||||
Button,
|
|
||||||
HorizontalGroup,
|
|
||||||
VerticalGroup,
|
|
||||||
IconButton,
|
|
||||||
ToolbarButton,
|
|
||||||
Icon,
|
|
||||||
Modal,
|
|
||||||
LoadingPlaceholder,
|
|
||||||
} from '@grafana/ui';
|
|
||||||
import cn from 'classnames/bind';
|
import cn from 'classnames/bind';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
|
|
@ -48,9 +39,7 @@ import styles from './Schedule.module.css';
|
||||||
|
|
||||||
const cx = cn.bind(styles);
|
const cx = cn.bind(styles);
|
||||||
|
|
||||||
interface SchedulePageProps extends PageProps, WithStoreProps, RouteComponentProps<{ id: string }> {
|
interface SchedulePageProps extends PageProps, WithStoreProps, RouteComponentProps<{ id: string }> {}
|
||||||
basicDataLoaded: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SchedulePageState extends PageBaseState {
|
interface SchedulePageState extends PageBaseState {
|
||||||
startMoment: dayjs.Dayjs;
|
startMoment: dayjs.Dayjs;
|
||||||
|
|
@ -123,7 +112,6 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
||||||
match: {
|
match: {
|
||||||
params: { id: scheduleId },
|
params: { id: scheduleId },
|
||||||
},
|
},
|
||||||
basicDataLoaded,
|
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -160,9 +148,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
||||||
!!shiftIdToShowOverridesForm ||
|
!!shiftIdToShowOverridesForm ||
|
||||||
shiftIdToShowRotationForm;
|
shiftIdToShowRotationForm;
|
||||||
|
|
||||||
return !basicDataLoaded ? (
|
return (
|
||||||
<LoadingPlaceholder text="Loading..." />
|
|
||||||
) : (
|
|
||||||
<PageErrorHandlingWrapper errorData={errorData} objectName="schedule" pageName="schedules">
|
<PageErrorHandlingWrapper errorData={errorData} objectName="schedule" pageName="schedules">
|
||||||
{() => (
|
{() => (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -47,28 +47,34 @@ interface UsersState extends PageBaseState {
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
};
|
};
|
||||||
initialUsersLoaded: boolean;
|
initialUsersLoaded: boolean;
|
||||||
|
queuedUpdateUsers: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class Users extends React.Component<UsersProps, UsersState> {
|
class Users extends React.Component<UsersProps, UsersState> {
|
||||||
state: UsersState = {
|
constructor(props: UsersProps) {
|
||||||
page: 1,
|
super(props);
|
||||||
isWrongTeam: false,
|
|
||||||
userPkToEdit: undefined,
|
|
||||||
usersFilters: {
|
|
||||||
searchTerm: '',
|
|
||||||
},
|
|
||||||
|
|
||||||
errorData: initErrorDataState(),
|
|
||||||
initialUsersLoaded: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
async componentDidMount() {
|
|
||||||
const {
|
const {
|
||||||
query: { p },
|
query: { p },
|
||||||
} = this.props;
|
} = props;
|
||||||
this.setState({ page: p ? Number(p) : 1 }, this.updateUsers);
|
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
page: p ? Number(p) : 1,
|
||||||
|
isWrongTeam: false,
|
||||||
|
userPkToEdit: undefined,
|
||||||
|
usersFilters: {
|
||||||
|
searchTerm: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
errorData: initErrorDataState(),
|
||||||
|
initialUsersLoaded: false,
|
||||||
|
queuedUpdateUsers: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async componentDidMount() {
|
||||||
|
this.updateUsers();
|
||||||
this.parseParams();
|
this.parseParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,14 +90,15 @@ class Users extends React.Component<UsersProps, UsersState> {
|
||||||
LocationHelper.update({ p: page }, 'partial');
|
LocationHelper.update({ p: page }, 'partial');
|
||||||
await userStore.updateItems(usersFilters, page);
|
await userStore.updateItems(usersFilters, page);
|
||||||
|
|
||||||
this.setState({ initialUsersLoaded: true });
|
const { queuedUpdateUsers } = this.state;
|
||||||
|
this.setState({ initialUsersLoaded: true, queuedUpdateUsers: false }, () => {
|
||||||
|
if (queuedUpdateUsers) {
|
||||||
|
this.updateUsers();
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidUpdate(prevProps: UsersProps) {
|
componentDidUpdate(prevProps: UsersProps) {
|
||||||
if (!this.state.initialUsersLoaded) {
|
|
||||||
this.updateUsers();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prevProps.match.params.id !== this.props.match.params.id) {
|
if (prevProps.match.params.id !== this.props.match.params.id) {
|
||||||
this.parseParams();
|
this.parseParams();
|
||||||
}
|
}
|
||||||
|
|
@ -176,14 +183,15 @@ class Users extends React.Component<UsersProps, UsersState> {
|
||||||
const {
|
const {
|
||||||
store: { userStore },
|
store: { userStore },
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { usersFilters, page, initialUsersLoaded, userPkToEdit } = this.state;
|
|
||||||
|
const { usersFilters, page, initialUsersLoaded, userPkToEdit, queuedUpdateUsers } = this.state;
|
||||||
|
|
||||||
const { count, results } = userStore.getSearchResult();
|
const { count, results } = userStore.getSearchResult();
|
||||||
const columns = this.getTableColumns();
|
const columns = this.getTableColumns();
|
||||||
|
|
||||||
const handleClear = () =>
|
const handleClear = () =>
|
||||||
this.setState({ usersFilters: { searchTerm: '' } }, () => {
|
this.setState({ usersFilters: { searchTerm: '' } }, () => {
|
||||||
this.debouncedUpdateUsers();
|
this.updateUsers();
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -194,6 +202,7 @@ class Users extends React.Component<UsersProps, UsersState> {
|
||||||
<UsersFilters
|
<UsersFilters
|
||||||
className={cx('users-filters')}
|
className={cx('users-filters')}
|
||||||
value={usersFilters}
|
value={usersFilters}
|
||||||
|
isLoading={queuedUpdateUsers}
|
||||||
onChange={this.handleUsersFiltersChange}
|
onChange={this.handleUsersFiltersChange}
|
||||||
/>
|
/>
|
||||||
<Button variant="secondary" icon="times" onClick={handleClear} className={cx('searchIntegrationClear')}>
|
<Button variant="secondary" icon="times" onClick={handleClear} className={cx('searchIntegrationClear')}>
|
||||||
|
|
@ -425,6 +434,11 @@ class Users extends React.Component<UsersProps, UsersState> {
|
||||||
|
|
||||||
handleUsersFiltersChange = (usersFilters: any) => {
|
handleUsersFiltersChange = (usersFilters: any) => {
|
||||||
this.setState({ usersFilters, page: 1 }, () => {
|
this.setState({ usersFilters, page: 1 }, () => {
|
||||||
|
if (!this.state.initialUsersLoaded) {
|
||||||
|
// queue delayed users update
|
||||||
|
return this.setState({ queuedUpdateUsers: true });
|
||||||
|
}
|
||||||
|
|
||||||
this.debouncedUpdateUsers();
|
this.debouncedUpdateUsers();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -435,10 +449,6 @@ class Users extends React.Component<UsersProps, UsersState> {
|
||||||
|
|
||||||
history.push(`${PLUGIN_ROOT}/users`);
|
history.push(`${PLUGIN_ROOT}/users`);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleUserUpdate = () => {
|
|
||||||
this.updateUsers();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(withMobXProviderContext(Users));
|
export default withRouter(withMobXProviderContext(Users));
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { LoadingPlaceholder } from '@grafana/ui';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||||
|
|
@ -76,6 +77,8 @@ export const Root = observer((props: AppRootProps) => {
|
||||||
updateBasicData();
|
updateBasicData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let link = document.createElement('link');
|
let link = document.createElement('link');
|
||||||
link.type = 'text/css';
|
link.type = 'text/css';
|
||||||
|
|
@ -101,13 +104,9 @@ export const Root = observer((props: AppRootProps) => {
|
||||||
setBasicDataLoaded(true);
|
setBasicDataLoaded(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const page = getMatchedPage(location.pathname);
|
const page = getMatchedPage(location.pathname);
|
||||||
|
|
||||||
const pagePermissionAction = pages[page]?.action;
|
const pagePermissionAction = pages[page]?.action;
|
||||||
const userHasAccess = pagePermissionAction ? isUserActionAllowed(pagePermissionAction) : true;
|
const userHasAccess = pagePermissionAction ? isUserActionAllowed(pagePermissionAction) : true;
|
||||||
|
|
||||||
const query = getQueryParams();
|
const query = getQueryParams();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -126,80 +125,85 @@ export const Root = observer((props: AppRootProps) => {
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{userHasAccess ? (
|
{userHasAccess ? (
|
||||||
<Switch>
|
// Otherwise we'll run into concurrency issues
|
||||||
<Route path={getRoutesForPage('alert-groups')} exact>
|
!basicDataLoaded ? (
|
||||||
<Incidents query={query} />
|
<LoadingPlaceholder text="Loading..." />
|
||||||
</Route>
|
) : (
|
||||||
<Route path={getRoutesForPage('alert-group')} exact>
|
<Switch>
|
||||||
<Incident query={query} />
|
<Route path={getRoutesForPage('alert-groups')} exact>
|
||||||
</Route>
|
<Incidents query={query} />
|
||||||
<Route path={getRoutesForPage('users')} exact>
|
</Route>
|
||||||
<Users query={query} />
|
<Route path={getRoutesForPage('alert-group')} exact>
|
||||||
</Route>
|
<Incident query={query} />
|
||||||
<Route path={getRoutesForPage('integrations')} exact>
|
</Route>
|
||||||
<Integrations query={query} />
|
<Route path={getRoutesForPage('users')} exact>
|
||||||
</Route>
|
<Users query={query} />
|
||||||
<Route path={getRoutesForPage('integration')} exact>
|
</Route>
|
||||||
<Integration query={query} />
|
<Route path={getRoutesForPage('integrations')} exact>
|
||||||
</Route>
|
<Integrations query={query} />
|
||||||
<Route path={getRoutesForPage('escalations')} exact>
|
</Route>
|
||||||
<EscalationChains query={query} />
|
<Route path={getRoutesForPage('integration')} exact>
|
||||||
</Route>
|
<Integration query={query} />
|
||||||
<Route path={getRoutesForPage('schedules')} exact>
|
</Route>
|
||||||
<Schedules query={query} />
|
<Route path={getRoutesForPage('escalations')} exact>
|
||||||
</Route>
|
<EscalationChains query={query} />
|
||||||
<Route path={getRoutesForPage('schedule')} exact>
|
</Route>
|
||||||
<Schedule query={query} basicDataLoaded={basicDataLoaded} />
|
<Route path={getRoutesForPage('schedules')} exact>
|
||||||
</Route>
|
<Schedules query={query} />
|
||||||
<Route path={getRoutesForPage('outgoing_webhooks')} exact>
|
</Route>
|
||||||
<OutgoingWebhooks query={query} />
|
<Route path={getRoutesForPage('schedule')} exact>
|
||||||
</Route>
|
<Schedule query={query} />
|
||||||
<Route path={getRoutesForPage('maintenance')} exact>
|
</Route>
|
||||||
<Maintenance />
|
<Route path={getRoutesForPage('outgoing_webhooks')} exact>
|
||||||
</Route>
|
<OutgoingWebhooks query={query} />
|
||||||
<Route path={getRoutesForPage('settings')} exact>
|
</Route>
|
||||||
<SettingsPage />
|
<Route path={getRoutesForPage('maintenance')} exact>
|
||||||
</Route>
|
<Maintenance />
|
||||||
<Route path={getRoutesForPage('chat-ops')} exact>
|
</Route>
|
||||||
<ChatOps query={query} />
|
<Route path={getRoutesForPage('settings')} exact>
|
||||||
</Route>
|
<SettingsPage />
|
||||||
<Route path={getRoutesForPage('live-settings')} exact>
|
</Route>
|
||||||
<LiveSettings />
|
<Route path={getRoutesForPage('chat-ops')} exact>
|
||||||
</Route>
|
<ChatOps query={query} />
|
||||||
<Route path={getRoutesForPage('cloud')} exact>
|
</Route>
|
||||||
<CloudPage />
|
<Route path={getRoutesForPage('live-settings')} exact>
|
||||||
</Route>
|
<LiveSettings />
|
||||||
|
</Route>
|
||||||
|
<Route path={getRoutesForPage('cloud')} exact>
|
||||||
|
<CloudPage />
|
||||||
|
</Route>
|
||||||
|
|
||||||
{/* Backwards compatibility redirect routes */}
|
{/* Backwards compatibility redirect routes */}
|
||||||
<Route
|
<Route
|
||||||
path={getRoutesForPage('incident')}
|
path={getRoutesForPage('incident')}
|
||||||
exact
|
exact
|
||||||
render={({ location }) => (
|
render={({ location }) => (
|
||||||
<Redirect
|
<Redirect
|
||||||
to={{
|
to={{
|
||||||
...location,
|
...location,
|
||||||
pathname: location.pathname.replace(/incident/, 'alert-group'),
|
pathname: location.pathname.replace(/incident/, 'alert-group'),
|
||||||
}}
|
}}
|
||||||
></Redirect>
|
></Redirect>
|
||||||
)}
|
)}
|
||||||
></Route>
|
></Route>
|
||||||
<Route
|
<Route
|
||||||
path={getRoutesForPage('incidents')}
|
path={getRoutesForPage('incidents')}
|
||||||
exact
|
exact
|
||||||
render={({ location }) => (
|
render={({ location }) => (
|
||||||
<Redirect
|
<Redirect
|
||||||
to={{
|
to={{
|
||||||
...location,
|
...location,
|
||||||
pathname: location.pathname.replace(/incidents/, 'alert-groups'),
|
pathname: location.pathname.replace(/incidents/, 'alert-groups'),
|
||||||
}}
|
}}
|
||||||
></Redirect>
|
></Redirect>
|
||||||
)}
|
)}
|
||||||
></Route>
|
></Route>
|
||||||
|
|
||||||
<Route path="*">
|
<Route path="*">
|
||||||
<NoMatch />
|
<NoMatch />
|
||||||
</Route>
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<Unauthorized requiredUserAction={pagePermissionAction} />
|
<Unauthorized requiredUserAction={pagePermissionAction} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue