Routing: Update more components using props.match to use hooks (#93918)

* RuleViewed: Get params from hook

* ProviderConfigPage: Use hooks for redux logic

* Update NewDashboardWithDS

* Update StorageFolderPage

* Update StoragePage

* Cleanup

* Update PublicDashboardPage

* Update RuleEditor

* Update BrowseFolderAlertingPage

* Update BrowseFolderLibraryPanelsPage

* Update SoloPanelPage

* Fix test

* Add useParams mocks

* Update ServiceAccountPage

* Simplify mocks

* Update SignupInvited

* Update Playlist pages

* Update AdminEditOrgPage

* Update UserAdminPage

* Update Silences

* Update BrowseDashboardsPage

* Update GrafanaModifyExport

* Update AppRootPage

* Remove useParams mock

* Update PublicDashboardsPages

* Cleanup

* Update PublicDashboardPage.test

* Cleanup

* Update PublicDashboardScenePage.test.tsx

* Update imports

* Revert AppRootPage changes

* Add back AppRootPage changes
This commit is contained in:
Alex Khomenko 2024-10-01 16:29:11 +03:00 committed by GitHub
parent bc3e1df5e3
commit 586c95654d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 275 additions and 318 deletions

View File

@ -1,12 +1,12 @@
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useParams } from 'react-router-dom-v5-compat';
import { useAsyncFn } from 'react-use';
import { NavModelItem } from '@grafana/data';
import { Field, Input, Button, Legend, Alert } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/core';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { OrgUser, AccessControlAction, OrgRole } from 'app/types';
import { OrgUsersTable } from './Users/OrgUsersTable';
@ -16,10 +16,9 @@ interface OrgNameDTO {
orgName: string;
}
interface Props extends GrafanaRouteComponentProps<{ id: string }> {}
const AdminEditOrgPage = ({ match }: Props) => {
const orgId = parseInt(match.params.id, 10);
const AdminEditOrgPage = () => {
const { id = '' } = useParams();
const orgId = parseInt(id, 10);
const canWriteOrg = contextSrv.hasPermission(AccessControlAction.OrgsWrite);
const canReadUsers = contextSrv.hasPermission(AccessControlAction.OrgUsersRead);

View File

@ -1,12 +1,12 @@
import { PureComponent } from 'react';
import { useEffect } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { useParams } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
import { featureEnabled } from '@grafana/runtime';
import { Stack } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/core';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { StoreState, UserDTO, UserOrg, UserSession, SyncInfo, UserAdminError, AccessControlAction } from 'app/types';
import { UserLdapSyncInfo } from './UserLdapSyncInfo';
@ -30,7 +30,7 @@ import {
syncLdapUser,
} from './state/actions';
interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> {
interface OwnProps {
user?: UserDTO;
orgs: UserOrg[];
sessions: UserSession[];
@ -39,136 +39,142 @@ interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> {
error?: UserAdminError;
}
export class UserAdminPage extends PureComponent<Props> {
async componentDidMount() {
const { match, loadAdminUserPage } = this.props;
loadAdminUserPage(parseInt(match.params.id, 10));
}
export const UserAdminPage = ({
loadAdminUserPage,
user,
orgs,
sessions,
ldapSyncInfo,
isLoading,
updateUser,
setUserPassword,
deleteUser,
disableUser,
enableUser,
updateUserPermissions,
deleteOrgUser,
updateOrgUserRole,
addOrgUser,
revokeSession,
revokeAllSessions,
syncLdapUser,
}: Props) => {
const { id = '' } = useParams();
useEffect(() => {
const userId = parseInt(id, 10);
loadAdminUserPage(userId);
}, [id, loadAdminUserPage]);
onUserUpdate = (user: UserDTO) => {
this.props.updateUser(user);
const onPasswordChange = (password: string) => {
if (user) {
setUserPassword(user.id, password);
}
};
onPasswordChange = (password: string) => {
const { user, setUserPassword } = this.props;
user && setUserPassword(user.id, password);
const onGrafanaAdminChange = (isGrafanaAdmin: boolean) => {
if (user) {
updateUserPermissions(user.id, isGrafanaAdmin);
}
};
onUserDelete = (userId: number) => {
this.props.deleteUser(userId);
const onOrgRemove = (orgId: number) => {
if (user) {
deleteOrgUser(user.id, orgId);
}
};
onUserDisable = (userId: number) => {
this.props.disableUser(userId);
const onOrgRoleChange = (orgId: number, newRole: string) => {
if (user) {
updateOrgUserRole(user.id, orgId, newRole);
}
};
onUserEnable = (userId: number) => {
this.props.enableUser(userId);
const onOrgAdd = (orgId: number, role: string) => {
if (user) {
addOrgUser(user, orgId, role);
}
};
onGrafanaAdminChange = (isGrafanaAdmin: boolean) => {
const { user, updateUserPermissions } = this.props;
user && updateUserPermissions(user.id, isGrafanaAdmin);
const onSessionRevoke = (tokenId: number) => {
if (user) {
revokeSession(tokenId, user.id);
}
};
onOrgRemove = (orgId: number) => {
const { user, deleteOrgUser } = this.props;
user && deleteOrgUser(user.id, orgId);
const onAllSessionsRevoke = () => {
if (user) {
revokeAllSessions(user.id);
}
};
onOrgRoleChange = (orgId: number, newRole: string) => {
const { user, updateOrgUserRole } = this.props;
user && updateOrgUserRole(user.id, orgId, newRole);
const onUserSync = () => {
if (user) {
syncLdapUser(user.id);
}
};
onOrgAdd = (orgId: number, role: string) => {
const { user, addOrgUser } = this.props;
user && addOrgUser(user, orgId, role);
const isLDAPUser = user?.isExternal && user?.authLabels?.includes('LDAP');
const canReadSessions = contextSrv.hasPermission(AccessControlAction.UsersAuthTokenList);
const canReadLDAPStatus = contextSrv.hasPermission(AccessControlAction.LDAPStatusRead);
const authSource = user?.authLabels?.[0];
const lockMessage = authSource ? `Synced via ${authSource}` : '';
const pageNav: NavModelItem = {
text: user?.login ?? '',
icon: 'shield',
subTitle: 'Manage settings for an individual user.',
};
onSessionRevoke = (tokenId: number) => {
const { user, revokeSession } = this.props;
user && revokeSession(tokenId, user.id);
};
onAllSessionsRevoke = () => {
const { user, revokeAllSessions } = this.props;
user && revokeAllSessions(user.id);
};
onUserSync = () => {
const { user, syncLdapUser } = this.props;
user && syncLdapUser(user.id);
};
render() {
const { user, orgs, sessions, ldapSyncInfo, isLoading } = this.props;
const isLDAPUser = user?.isExternal && user?.authLabels?.includes('LDAP');
const canReadSessions = contextSrv.hasPermission(AccessControlAction.UsersAuthTokenList);
const canReadLDAPStatus = contextSrv.hasPermission(AccessControlAction.LDAPStatusRead);
const authSource = user?.authLabels?.[0];
const lockMessage = authSource ? `Synced via ${authSource}` : '';
const pageNav: NavModelItem = {
text: user?.login ?? '',
icon: 'shield',
subTitle: 'Manage settings for an individual user.',
};
return (
<Page navId="global-users" pageNav={pageNav}>
<Page.Contents isLoading={isLoading}>
<Stack gap={5} direction="column">
{user && (
<>
<UserProfile
user={user}
onUserUpdate={this.onUserUpdate}
onUserDelete={this.onUserDelete}
onUserDisable={this.onUserDisable}
onUserEnable={this.onUserEnable}
onPasswordChange={this.onPasswordChange}
/>
{isLDAPUser &&
user?.isExternallySynced &&
featureEnabled('ldapsync') &&
ldapSyncInfo &&
canReadLDAPStatus && (
<UserLdapSyncInfo ldapSyncInfo={ldapSyncInfo} user={user} onUserSync={this.onUserSync} />
)}
<UserPermissions
isGrafanaAdmin={user.isGrafanaAdmin}
isExternalUser={user?.isGrafanaAdminExternallySynced}
lockMessage={lockMessage}
onGrafanaAdminChange={this.onGrafanaAdminChange}
/>
</>
)}
{orgs && (
<UserOrgs
return (
<Page navId="global-users" pageNav={pageNav}>
<Page.Contents isLoading={isLoading}>
<Stack gap={5} direction="column">
{user && (
<>
<UserProfile
user={user}
orgs={orgs}
isExternalUser={user?.isExternallySynced}
onOrgRemove={this.onOrgRemove}
onOrgRoleChange={this.onOrgRoleChange}
onOrgAdd={this.onOrgAdd}
onUserUpdate={updateUser}
onUserDelete={deleteUser}
onUserDisable={disableUser}
onUserEnable={enableUser}
onPasswordChange={onPasswordChange}
/>
)}
{sessions && canReadSessions && (
<UserSessions
sessions={sessions}
onSessionRevoke={this.onSessionRevoke}
onAllSessionsRevoke={this.onAllSessionsRevoke}
{isLDAPUser &&
user?.isExternallySynced &&
featureEnabled('ldapsync') &&
ldapSyncInfo &&
canReadLDAPStatus && (
<UserLdapSyncInfo ldapSyncInfo={ldapSyncInfo} user={user} onUserSync={onUserSync} />
)}
<UserPermissions
isGrafanaAdmin={user.isGrafanaAdmin}
isExternalUser={user?.isGrafanaAdminExternallySynced}
lockMessage={lockMessage}
onGrafanaAdminChange={onGrafanaAdminChange}
/>
)}
</Stack>
</Page.Contents>
</Page>
);
}
}
</>
)}
{orgs && (
<UserOrgs
user={user}
orgs={orgs}
isExternalUser={user?.isExternallySynced}
onOrgRemove={onOrgRemove}
onOrgRoleChange={onOrgRoleChange}
onOrgAdd={onOrgAdd}
/>
)}
{sessions && canReadSessions && (
<UserSessions
sessions={sessions}
onSessionRevoke={onSessionRevoke}
onAllSessionsRevoke={onAllSessionsRevoke}
/>
)}
</Stack>
</Page.Contents>
</Page>
);
};
const mapStateToProps = (state: StoreState) => ({
user: state.userAdmin.user,

View File

@ -1,3 +1,4 @@
import { useParams } from 'react-router-dom-v5-compat';
import { render, screen, userEvent, waitFor, within } from 'test/test-utils';
import { byLabelText, byPlaceholderText, byRole, byTestId, byText } from 'testing-library-selector';
@ -31,6 +32,11 @@ import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
jest.mock('app/core/services/context_srv');
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useParams: jest.fn(),
}));
const TEST_TIMEOUT = 60000;
const renderSilences = (location = '/alerting/silences/') => {
@ -314,17 +320,20 @@ describe('Silence create/edit', () => {
});
it('shows an error when existing silence cannot be found', async () => {
(useParams as jest.Mock).mockReturnValue({ id: 'foo-bar' });
renderSilences('/alerting/silence/foo-bar/edit');
expect(await ui.existingSilenceNotFound.find()).toBeInTheDocument();
});
it('shows an error when user cannot edit/recreate silence', async () => {
(useParams as jest.Mock).mockReturnValue({ id: MOCK_SILENCE_ID_LACKING_PERMISSIONS });
renderSilences(`/alerting/silence/${MOCK_SILENCE_ID_LACKING_PERMISSIONS}/edit`);
expect(await ui.noPermissionToEdit.find()).toBeInTheDocument();
});
it('populates form with existing silence information', async () => {
(useParams as jest.Mock).mockReturnValue({ id: MOCK_SILENCE_ID_EXISTING });
renderSilences(`/alerting/silence/${MOCK_SILENCE_ID_EXISTING}/edit`);
// Await the first value to be populated, after which we can expect that all of the other
@ -335,6 +344,7 @@ describe('Silence create/edit', () => {
});
it('populates form with existing silence information that has __alert_rule_uid__', async () => {
(useParams as jest.Mock).mockReturnValue({ id: MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID });
mockAlertRuleApi(server).getAlertRule(MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID, grafanaRulerRule);
renderSilences(`/alerting/silence/${MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID}/edit`);
expect(await screen.findByLabelText(/alert rule/i)).toHaveValue(grafanaRulerRule.grafana_alert.title);

View File

@ -1,4 +1,4 @@
import { Route, RouteChildrenProps, Switch } from 'react-router-dom';
import { Route, Switch } from 'react-router-dom';
import { withErrorBoundary } from '@grafana/ui';
import {
@ -51,13 +51,7 @@ const Silences = () => {
}}
</Route>
<Route exact path="/alerting/silence/:id/edit">
{({ match }: RouteChildrenProps<{ id: string }>) => {
return (
match?.params.id && (
<ExistingSilenceEditor silenceId={match.params.id} alertManagerSourceName={selectedAlertmanager} />
)
);
}}
<ExistingSilenceEditor alertManagerSourceName={selectedAlertmanager} />
</Route>
</Switch>
</>

View File

@ -1,5 +1,5 @@
import * as React from 'react';
import { Route } from 'react-router-dom';
import { Routes, Route } from 'react-router-dom-v5-compat';
import { Props } from 'react-virtualized-auto-sizer';
import { render, waitFor, waitForElementToBeRemoved, userEvent } from 'test/test-utils';
import { byRole, byTestId, byText } from 'testing-library-selector';
@ -55,9 +55,14 @@ const dataSources = {
};
function renderModifyExport(ruleId: string) {
render(<Route path="/alerting/:id/modify-export" component={GrafanaModifyExport} />, {
historyOptions: { initialEntries: [`/alerting/${ruleId}/modify-export`] },
});
render(
<Routes>
<Route path="/alerting/:id/modify-export" element={<GrafanaModifyExport />} />
</Routes>,
{
historyOptions: { initialEntries: [`/alerting/${ruleId}/modify-export`] },
}
);
}
const server = setupMswServer();

View File

@ -1,10 +1,10 @@
import * as React from 'react';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { locationService } from '@grafana/runtime';
import { Alert, LoadingPlaceholder } from '@grafana/ui';
import { GrafanaRouteComponentProps } from '../../../../../core/navigation/types';
import { RuleIdentifier } from '../../../../../types/unified-alerting';
import { useRuleWithLocation } from '../../hooks/useCombinedRule';
import { stringifyErrorLike } from '../../utils/misc';
@ -15,12 +15,11 @@ import { createRelativeUrl } from '../../utils/url';
import { AlertingPageWrapper } from '../AlertingPageWrapper';
import { ModifyExportRuleForm } from '../rule-editor/alert-rule-form/ModifyExportRuleForm';
interface GrafanaModifyExportProps extends GrafanaRouteComponentProps<{ id?: string }> {}
export default function GrafanaModifyExport({ match }: GrafanaModifyExportProps) {
export default function GrafanaModifyExport() {
const { id } = useParams();
const ruleIdentifier = useMemo<RuleIdentifier | undefined>(() => {
return ruleId.tryParse(match.params.id, true);
}, [match.params.id]);
return ruleId.tryParse(id, true);
}, [id]);
if (!ruleIdentifier) {
return (

View File

@ -2,6 +2,7 @@ import { css } from '@emotion/css';
import { pickBy } from 'lodash';
import { useMemo, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useParams } from 'react-router-dom-v5-compat';
import { useDebounce } from 'react-use';
import {
@ -41,7 +42,6 @@ import { SilencedInstancesPreview } from './SilencedInstancesPreview';
import { getDefaultSilenceFormValues, getFormFieldsForSilence } from './utils';
interface Props {
silenceId: string;
alertManagerSourceName: string;
}
@ -50,7 +50,8 @@ interface Props {
*
* Fetches silence details from API, based on `silenceId`
*/
const ExistingSilenceEditor = ({ silenceId, alertManagerSourceName }: Props) => {
const ExistingSilenceEditor = ({ alertManagerSourceName }: Props) => {
const { id: silenceId = '' } = useParams();
const {
data: silence,
isLoading: getSilenceIsLoading,
@ -61,7 +62,6 @@ const ExistingSilenceEditor = ({ silenceId, alertManagerSourceName }: Props) =>
ruleMetadata: true,
accessControl: true,
});
const ruleUid = silence?.matchers?.find((m) => m.name === MATCHER_ALERT_RULE_UID)?.value;
const isGrafanaAlertManager = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME;

View File

@ -4,15 +4,15 @@ import { HttpResponse, http } from 'msw';
import { setupServer, SetupServer } from 'msw/node';
import { ComponentProps } from 'react';
import * as React from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import AutoSizer from 'react-virtualized-auto-sizer';
import { TestProvider } from 'test/helpers/TestProvider';
import { selectors } from '@grafana/e2e-selectors';
import { contextSrv } from 'app/core/core';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { backendSrv } from 'app/core/services/backend_srv';
import BrowseDashboardsPage, { Props } from './BrowseDashboardsPage';
import BrowseDashboardsPage from './BrowseDashboardsPage';
import { wellFormedTree } from './fixtures/dashboardsTreeItem.fixture';
import * as permissions from './permissions';
const [mockTree, { dashbdD, folderA, folderA_folderA }] = wellFormedTree();
@ -44,6 +44,11 @@ jest.mock('react-virtualized-auto-sizer', () => {
};
});
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useParams: jest.fn().mockReturnValue({}),
}));
function render(...[ui, options]: Parameters<typeof rtlRender>) {
const { rerender } = rtlRender(
<TestProvider
@ -106,7 +111,6 @@ jest.mock('app/features/browse-dashboards/api/services', () => {
});
describe('browse-dashboards BrowseDashboardsPage', () => {
let props: Props;
let server: SetupServer;
const mockPermissions = {
canCreateDashboards: true,
@ -143,10 +147,6 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
});
beforeEach(() => {
props = {
...getRouteComponentProps(),
};
jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => mockPermissions);
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
});
@ -158,17 +158,17 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
describe('at the root level', () => {
it('displays "Dashboards" as the page title', async () => {
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
expect(await screen.findByRole('heading', { name: 'Dashboards' })).toBeInTheDocument();
});
it('displays a search input', async () => {
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
expect(await screen.findByPlaceholderText('Search for dashboards and folders')).toBeInTheDocument();
});
it('shows the "New" button', async () => {
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
expect(await screen.findByRole('button', { name: 'New' })).toBeInTheDocument();
});
@ -180,25 +180,25 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
canCreateFolders: false,
};
});
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
expect(await screen.findByRole('heading', { name: 'Dashboards' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'New' })).not.toBeInTheDocument();
});
it('does not show "Folder actions"', async () => {
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
expect(await screen.findByRole('heading', { name: 'Dashboards' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Folder actions' })).not.toBeInTheDocument();
});
it('does not show an "Edit title" button', async () => {
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
expect(await screen.findByRole('heading', { name: 'Dashboards' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Edit title' })).not.toBeInTheDocument();
});
it('does not show any tabs', async () => {
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
expect(await screen.findByRole('heading', { name: 'Dashboards' })).toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Dashboards' })).not.toBeInTheDocument();
@ -207,7 +207,7 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
});
it('displays the filters and hides the actions initially', async () => {
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
await screen.findByPlaceholderText('Search for dashboards and folders');
expect(await screen.findByText('Sort')).toBeInTheDocument();
@ -218,7 +218,7 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
});
it('selecting an item hides the filters and shows the actions instead', async () => {
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
const checkbox = await screen.findByTestId(selectors.pages.BrowseDashboards.table.checkbox(dashbdD.item.uid));
await userEvent.click(checkbox);
@ -233,7 +233,7 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
});
it('navigating into a child item resets the selected state', async () => {
const { rerender } = render(<BrowseDashboardsPage {...props} />);
const { rerender } = render(<BrowseDashboardsPage />);
const checkbox = await screen.findByTestId(selectors.pages.BrowseDashboards.table.checkbox(folderA.item.uid));
await userEvent.click(checkbox);
@ -242,9 +242,8 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
expect(screen.getByRole('button', { name: 'Move' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
const updatedProps = { ...props };
updatedProps.match.params = { uid: folderA.item.uid };
rerender(<BrowseDashboardsPage {...updatedProps} />);
(useParams as jest.Mock).mockReturnValue({ uid: folderA.item.uid });
rerender(<BrowseDashboardsPage />);
// Check the filters are now visible again
expect(await screen.findByText('Filter by tag')).toBeInTheDocument();
@ -258,21 +257,21 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
describe('for a child folder', () => {
beforeEach(() => {
props.match.params = { uid: folderA.item.uid };
(useParams as jest.Mock).mockReturnValue({ uid: folderA.item.uid });
});
it('shows the folder name as the page title', async () => {
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
expect(await screen.findByRole('heading', { name: folderA.item.title })).toBeInTheDocument();
});
it('displays a search input', async () => {
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
expect(await screen.findByPlaceholderText('Search for dashboards and folders')).toBeInTheDocument();
});
it('shows the "New" button', async () => {
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
expect(await screen.findByRole('button', { name: 'New' })).toBeInTheDocument();
});
@ -284,13 +283,13 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
canCreateFolders: false,
};
});
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
expect(await screen.findByRole('heading', { name: folderA.item.title })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'New' })).not.toBeInTheDocument();
});
it('shows the "Folder actions" button', async () => {
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
expect(await screen.findByRole('button', { name: 'Folder actions' })).toBeInTheDocument();
});
@ -304,13 +303,13 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
canViewPermissions: false,
};
});
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
expect(await screen.findByRole('heading', { name: folderA.item.title })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Folder actions' })).not.toBeInTheDocument();
});
it('shows an "Edit title" button', async () => {
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
expect(await screen.findByRole('button', { name: 'Edit title' })).toBeInTheDocument();
});
@ -321,13 +320,13 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
canEditFolders: false,
};
});
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
expect(await screen.findByRole('heading', { name: folderA.item.title })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Edit title' })).not.toBeInTheDocument();
});
it('displays all the folder tabs and shows the "Dashboards" tab as selected', async () => {
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
expect(await screen.findByRole('tab', { name: 'Dashboards' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Dashboards' })).toHaveAttribute('aria-selected', 'true');
@ -339,7 +338,7 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
});
it('displays the filters and hides the actions initially', async () => {
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
await screen.findByPlaceholderText('Search for dashboards and folders');
expect(await screen.findByText('Sort')).toBeInTheDocument();
@ -350,7 +349,7 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
});
it('selecting an item hides the filters and shows the actions instead', async () => {
render(<BrowseDashboardsPage {...props} />);
render(<BrowseDashboardsPage />);
const checkbox = await screen.findByTestId(
selectors.pages.BrowseDashboards.table.checkbox(folderA_folderA.item.uid)

View File

@ -1,13 +1,12 @@
import { css } from '@emotion/css';
import { memo, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom-v5-compat';
import { useLocation, useParams } from 'react-router-dom-v5-compat';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { FilterInput, useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { useDispatch } from 'app/types';
import { buildNavModel, getDashboardsTabID } from '../folders/state/navModel';
@ -24,17 +23,9 @@ import { SearchView } from './components/SearchView';
import { getFolderPermissions } from './permissions';
import { setAllSelection, useHasSelection } from './state';
export interface BrowseDashboardsPageRouteParams {
uid?: string;
slug?: string;
}
export interface Props extends GrafanaRouteComponentProps<BrowseDashboardsPageRouteParams> {}
// New Browse/Manage/Search Dashboards views for nested folders
const BrowseDashboardsPage = memo(({ match }: Props) => {
const { uid: folderUID } = match.params;
const BrowseDashboardsPage = memo(() => {
const { uid: folderUID } = useParams();
const dispatch = useDispatch();
const styles = useStyles2(getStyles);

View File

@ -1,19 +1,12 @@
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
import { screen, waitForElementToBeRemoved } from '@testing-library/react';
import { Route, Routes } from 'react-router-dom-v5-compat';
import { of } from 'rxjs';
import { TestProvider } from 'test/helpers/TestProvider';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { render } from 'test/test-utils';
import { getDefaultTimeRange, LoadingState, PanelData, PanelProps } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import {
config,
getPluginLinkExtensions,
locationService,
LocationServiceProvider,
setPluginImportUtils,
setRunRequest,
} from '@grafana/runtime';
import { config, getPluginLinkExtensions, setPluginImportUtils, setRunRequest } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { DashboardRoutes } from 'app/types/dashboard';
@ -37,27 +30,22 @@ jest.mock('@grafana/runtime', () => ({
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions);
function setup(props?: Partial<PublicDashboardSceneProps>) {
const context = getGrafanaContextMock();
function setup(token = 'an-access-token') {
const pubdashProps: PublicDashboardSceneProps = {
...getRouteComponentProps({
match: { params: { accessToken: 'an-access-token' }, isExact: true, url: '', path: '' },
route: {
routeName: DashboardRoutes.Public,
path: '/public-dashboards/:accessToken',
component: () => null,
},
}),
...props,
};
return render(
<TestProvider grafanaContext={context}>
<LocationServiceProvider service={locationService}>
<PublicDashboardScenePage {...pubdashProps} />
</LocationServiceProvider>
</TestProvider>
<Routes>
<Route path="/public-dashboards/:accessToken" element={<PublicDashboardScenePage {...pubdashProps} />} />
</Routes>,
{ historyOptions: { initialEntries: [`/public-dashboards/${token}`] } }
);
}
@ -190,9 +178,7 @@ describe('PublicDashboardScenePage', () => {
dashboard: { ...simpleDashboard, timepicker: { hidden: true } },
meta: {},
});
setup({
match: { params: { accessToken }, isExact: true, url: '', path: '' },
});
setup(accessToken);
await waitForDashboardGridToRender();
@ -210,7 +196,7 @@ describe('given unavailable public dashboard', () => {
dashboard: simpleDashboard,
meta: { publicDashboardEnabled: false, dashboardNotFound: false },
});
setup({ match: { params: { accessToken }, isExact: true, url: '', path: '' } });
setup(accessToken);
await waitForElementToBeRemoved(screen.getByTestId(publicDashboardSceneSelector.loadingPage));
@ -226,7 +212,7 @@ describe('given unavailable public dashboard', () => {
dashboard: simpleDashboard,
meta: { dashboardNotFound: true },
});
setup({ match: { params: { accessToken }, isExact: true, url: '', path: '' } });
setup(accessToken);
await waitForElementToBeRemoved(screen.getByTestId(publicDashboardSceneSelector.loadingPage));

View File

@ -1,5 +1,6 @@
import { css } from '@emotion/css';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
@ -20,23 +21,26 @@ import { DashboardScene } from '../scene/DashboardScene';
import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager';
export interface Props
extends GrafanaRouteComponentProps<PublicDashboardPageRouteParams, PublicDashboardPageRouteSearchParams> {}
const selectors = e2eSelectors.pages.PublicDashboardScene;
export function PublicDashboardScenePage({ match, route }: Props) {
export type Props = Omit<
GrafanaRouteComponentProps<PublicDashboardPageRouteParams, PublicDashboardPageRouteSearchParams>,
'match' | 'history'
>;
export function PublicDashboardScenePage({ route }: Props) {
const { accessToken = '' } = useParams();
const stateManager = getDashboardScenePageStateManager();
const styles = useStyles2(getStyles);
const { dashboard, isLoading, loadError } = stateManager.useState();
useEffect(() => {
stateManager.loadDashboard({ uid: match.params.accessToken!, route: DashboardRoutes.Public });
stateManager.loadDashboard({ uid: accessToken, route: DashboardRoutes.Public });
return () => {
stateManager.clearState();
};
}, [stateManager, match.params.accessToken, route.routeName]);
}, [stateManager, accessToken, route.routeName]);
if (!dashboard) {
return (

View File

@ -1,21 +1,17 @@
import { render, screen, waitFor } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { Route, Routes } from 'react-router-dom-v5-compat';
import { useEffectOnce } from 'react-use';
import { Props as AutoSizerProps } from 'react-virtualized-auto-sizer';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { render } from 'test/test-utils';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
import { locationService } from '@grafana/runtime';
import { Dashboard, DashboardCursorSync, FieldConfigSource, ThresholdsMode, Panel } from '@grafana/schema/src';
import config from 'app/core/config';
import { GrafanaContext } from 'app/core/context/GrafanaContext';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import * as appTypes from 'app/types';
import { DashboardInitPhase, DashboardMeta, DashboardRoutes } from 'app/types';
import { SafeDynamicImport } from '../../../core/components/DynamicImports/SafeDynamicImport';
import { configureStore } from '../../../store/configureStore';
import { Props as LazyLoaderProps } from '../dashgrid/LazyLoader';
import { DashboardModel } from '../state';
@ -55,53 +51,38 @@ jest.mock('app/types', () => ({
useDispatch: () => jest.fn(),
}));
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useParams: jest.fn().mockReturnValue({ accessToken: 'an-access-token' }),
}));
const setup = (propOverrides?: Partial<Props>, initialState?: Partial<appTypes.StoreState>) => {
const context = getGrafanaContextMock();
const store = configureStore(initialState);
const props: Props = {
...getRouteComponentProps({
route: {
routeName: DashboardRoutes.Public,
path: '/public-dashboards/:accessToken',
component: SafeDynamicImport(
() =>
import(/* webpackChunkName: "PublicDashboardPage"*/ 'app/features/dashboard/containers/PublicDashboardPage')
),
component: () => null,
},
}),
};
Object.assign(props, propOverrides);
const { unmount, rerender } = render(
<GrafanaContext.Provider value={context}>
<Provider store={store}>
<Router history={locationService.getHistory()}>
<PublicDashboardPage {...props} />
</Router>
</Provider>
</GrafanaContext.Provider>
render(
<Routes>
<Route path="/public-dashboards/:accessToken" element={<PublicDashboardPage {...props} />} />
</Routes>,
{ store, historyOptions: { initialEntries: [`/public-dashboards/an-access-token`] } }
);
const wrappedRerender = (newProps: Partial<Props>) => {
Object.assign(props, newProps);
return rerender(
<GrafanaContext.Provider value={context}>
<Provider store={store}>
<Router history={locationService.getHistory()}>
<PublicDashboardPage {...props} />
</Router>
</Provider>
</GrafanaContext.Provider>
return render(
<Routes>
<Route path="/public-dashboards/:accessToken" element={<PublicDashboardPage {...props} />} />
</Routes>,
{ store, historyOptions: { initialEntries: [`/public-dashboards/an-access-token`] } }
);
};
return { rerender: wrappedRerender, unmount };
return { rerender: wrappedRerender };
};
const selectors = e2eSelectors.components;

View File

@ -27,7 +27,10 @@ import { getTimeSrv } from '../services/TimeSrv';
import { DashboardModel } from '../state';
import { initDashboard } from '../state/initDashboard';
export type Props = GrafanaRouteComponentProps<PublicDashboardPageRouteParams, PublicDashboardPageRouteSearchParams>;
export type Props = Omit<
GrafanaRouteComponentProps<PublicDashboardPageRouteParams, PublicDashboardPageRouteSearchParams>,
'match' | 'history'
>;
const selectors = e2eSelectors.pages.PublicDashboard;

View File

@ -1,13 +1,10 @@
import { render, screen, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { screen, waitFor } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom-v5-compat';
import { render } from 'test/test-utils';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { LocationServiceProvider, config, locationService } from '@grafana/runtime';
import { GrafanaContext } from 'app/core/context/GrafanaContext';
import { config, locationService } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { configureStore } from 'app/store/configureStore';
import { DashboardRoutes } from '../../../types';
@ -31,25 +28,23 @@ jest.mock('react-router-dom-v5-compat', () => ({
}));
function setup(props: Partial<PublicDashboardPageProxyProps>) {
const context = getGrafanaContextMock();
const store = configureStore({});
return render(
<GrafanaContext.Provider value={context}>
<Provider store={store}>
<LocationServiceProvider service={locationService}>
<Router history={locationService.getHistory()}>
<PublicDashboardPageProxy
location={locationService.getLocation()}
history={locationService.getHistory()}
queryParams={{}}
route={{ routeName: DashboardRoutes.Public, component: () => null, path: '/:accessToken' }}
match={{ params: { accessToken: 'an-access-token' }, isExact: true, path: '/', url: '/' }}
{...props}
/>
</Router>
</LocationServiceProvider>
</Provider>
</GrafanaContext.Provider>
<Routes>
<Route
path="/public-dashboards/:accessToken"
element={
<PublicDashboardPageProxy
queryParams={{}}
location={locationService.getLocation()}
route={{ routeName: DashboardRoutes.Public, component: () => null, path: '/:accessToken' }}
{...props}
/>
}
/>
</Routes>,
{
historyOptions: { initialEntries: [`/public-dashboards/an-access-token`] },
}
);
}

View File

@ -1,15 +1,10 @@
import { config } from '@grafana/runtime';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { PublicDashboardScenePage } from '../../dashboard-scene/pages/PublicDashboardScenePage';
import PublicDashboardPage from './PublicDashboardPage';
import { PublicDashboardPageRouteParams, PublicDashboardPageRouteSearchParams } from './types';
import PublicDashboardPage, { type Props } from './PublicDashboardPage';
export type PublicDashboardPageProxyProps = GrafanaRouteComponentProps<
PublicDashboardPageRouteParams,
PublicDashboardPageRouteSearchParams
>;
export type PublicDashboardPageProxyProps = Props;
function PublicDashboardPageProxy(props: PublicDashboardPageProxyProps) {
if (config.featureToggles.publicDashboardsScene) {

View File

@ -2,11 +2,9 @@ import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from 'test/test-utils';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { backendSrv } from '../../core/services/backend_srv';
import { SignupInvitedPage, Props } from './SignupInvited';
import { SignupInvitedPage } from './SignupInvited';
jest.mock('app/core/core', () => ({
contextSrv: {
@ -19,6 +17,11 @@ jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => backendSrv,
}));
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useParams: jest.fn().mockReturnValue({ code: 'some code' }),
}));
const defaultGet = {
email: 'some.user@localhost',
name: 'Some User',
@ -35,18 +38,7 @@ async function setupTestContext({ get = defaultGet }: { get?: typeof defaultGet
const postSpy = jest.spyOn(backendSrv, 'post');
postSpy.mockResolvedValue([]);
const props: Props = {
...getRouteComponentProps({
match: {
params: { code: 'some code' },
isExact: false,
path: '',
url: '',
},
}),
};
render(<SignupInvitedPage {...props} />);
render(<SignupInvitedPage />);
await waitFor(() => expect(getSpy).toHaveBeenCalled());
expect(getSpy).toHaveBeenCalledTimes(1);

View File

@ -1,5 +1,6 @@
import { css, cx } from '@emotion/css';
import { useState } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { useAsync } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
@ -9,7 +10,6 @@ import { Form } from 'app/core/components/Form/Form';
import { Page } from 'app/core/components/Page/Page';
import { getConfig } from 'app/core/config';
import { contextSrv } from 'app/core/core';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { w3cStandardEmailValidator } from '../admin/utils';
@ -32,10 +32,8 @@ const navModel = {
},
};
export interface Props extends GrafanaRouteComponentProps<{ code: string }> {}
export const SignupInvitedPage = ({ match }: Props) => {
const code = match.params.code;
export const SignupInvitedPage = () => {
const { code } = useParams();
const [initFormModel, setInitFormModel] = useState<FormModel>();
const [greeting, setGreeting] = useState<string>();
const [invitedBy, setInvitedBy] = useState<string>();

View File

@ -1,10 +1,8 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { History, Location } from 'history';
import { TestProvider } from 'test/helpers/TestProvider';
import { locationService } from '@grafana/runtime';
import { RouteDescriptor } from 'app/core/navigation/types';
import { backendSrv } from 'app/core/services/backend_srv';
import { PlaylistEditPage } from './PlaylistEditPage';
@ -24,11 +22,7 @@ jest.mock('app/core/components/TagFilter/TagFilter', () => ({
async function getTestContext({ name, interval, items, uid }: Partial<Playlist> = {}) {
jest.clearAllMocks();
const playlist = { name, items, interval, uid } as unknown as Playlist;
const queryParams = {};
const route = {} as RouteDescriptor;
const match = { isExact: false, path: '', url: '', params: { uid: 'foo' } };
const location = {} as Location;
const history = {} as History;
const getMock = jest.spyOn(backendSrv, 'get');
const putMock = jest.spyOn(backendSrv, 'put').mockImplementation(() => Promise.resolve());
@ -41,7 +35,7 @@ async function getTestContext({ name, interval, items, uid }: Partial<Playlist>
const { rerender } = render(
<TestProvider>
<PlaylistEditPage queryParams={queryParams} route={route} match={match} location={location} history={history} />
<PlaylistEditPage />
</TestProvider>
);
await waitFor(() => expect(getMock).toHaveBeenCalledTimes(1));

View File

@ -1,10 +1,10 @@
import { useParams } from 'react-router-dom-v5-compat';
import { useAsync } from 'react-use';
import { NavModelItem } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { Page } from 'app/core/components/Page/Page';
import { t, Trans } from 'app/core/internationalization';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { PlaylistForm } from './PlaylistForm';
import { getPlaylistAPI } from './api';
@ -14,11 +14,10 @@ export interface RouteParams {
uid: string;
}
interface Props extends GrafanaRouteComponentProps<RouteParams> {}
export const PlaylistEditPage = ({ match }: Props) => {
export const PlaylistEditPage = () => {
const { uid = '' } = useParams();
const api = getPlaylistAPI();
const playlist = useAsync(() => api.getPlaylist(match.params.uid), [match.params]);
const playlist = useAsync(() => api.getPlaylist(uid), [uid]);
const onSubmit = async (playlist: Playlist) => {
await api.updatePlaylist(playlist);

View File

@ -1,3 +1,5 @@
import { useParams } from 'react-router-dom-v5-compat';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { playlistSrv } from './PlaylistSrv';
@ -6,6 +8,7 @@ interface Props extends GrafanaRouteComponentProps<{ uid: string }> {}
// This is a react page that just redirects to new URLs
export default function PlaylistStartPage({ match }: Props) {
playlistSrv.start(match.params.uid);
const { uid = '' } = useParams();
playlistSrv.start(uid);
return null;
}

View File

@ -3,6 +3,7 @@ import { AnyAction, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { useCallback, useEffect, useMemo, useReducer } from 'react';
import * as React from 'react';
import { useLocation, useRouteMatch } from 'react-router-dom';
import { useParams } from 'react-router-dom-v5-compat';
import {
AppEvents,
@ -38,7 +39,7 @@ import { buildPluginPageContext, PluginPageContext } from './PluginPageContext';
interface Props {
// The ID of the plugin we would like to load and display
pluginId: string;
pluginId?: string;
// The root navModelItem for the plugin (root = lives directly under 'home'). In case app does not need a nva model,
// for example it's in some way embedded or shown in a sideview this can be undefined.
pluginNavSection?: NavModelItem;
@ -55,6 +56,8 @@ interface State {
const initialState: State = { loading: true, loadingError: false, pluginNav: null, plugin: null };
export function AppRootPage({ pluginId, pluginNavSection }: Props) {
const { pluginId: pluginIdParam = '' } = useParams();
pluginId = pluginId || pluginIdParam;
const addedLinksRegistry = useAddedLinksRegistry();
const addedComponentsRegistry = useAddedComponentsRegistry();
const exposedComponentsRegistry = useExposedComponentsRegistry();

View File

@ -33,7 +33,7 @@ export function getAppPluginRoutes(): RouteDescriptor[] {
{
path: '/a/:pluginId',
exact: false, // route everything under this path to the plugin, so it can define more routes under this path
component: ({ match }) => <AppRootPage pluginId={match.params.pluginId} pluginNavSection={navIndex.home} />,
component: () => <AppRootPage pluginNavSection={navIndex.home} />,
},
];
}

View File

@ -5,7 +5,6 @@ import { NavLandingPage } from 'app/core/components/NavLandingPage/NavLandingPag
import { PageNotFound } from 'app/core/components/PageNotFound/PageNotFound';
import config from 'app/core/config';
import { contextSrv } from 'app/core/services/context_srv';
import UserAdminPage from 'app/features/admin/UserAdminPage';
import LdapPage from 'app/features/admin/ldap/LdapPage';
import { getAlertingRoutes } from 'app/features/alerting/routes';
import { isAdmin, isLocalDevEnv, isOpenSourceEdition } from 'app/features/alerting/unified/utils/misc';
@ -336,7 +335,9 @@ export function getAppRoutes(): RouteDescriptor[] {
},
{
path: '/admin/users/edit/:id',
component: UserAdminPage,
component: SafeDynamicImport(
() => import(/* webpackChunkName: "UserAdminPage" */ 'app/features/admin/UserAdminPage')
),
},
{
path: '/admin/orgs',