From 06a0890c2eabe146c89eafc019f5a8f2606f0840 Mon Sep 17 00:00:00 2001
From: linoman <2051016+linoman@users.noreply.github.com>
Date: Tue, 22 Oct 2024 16:21:10 +0200
Subject: [PATCH] RolePickerDrawer: Replace RolePicker modal for drawer
(#94801)
* Add RolePickerDrawer
* Add RoiePickerBadges
* Add RolePickerSelect
* Replace RolePicker in OrgUsersTable
* Replace RolePicker in ServiceAccountCreatePage
* Add RolePickerDrawer test
* Add tests
* Add i18n texts
* Update RolePickerBadges
---
.betterer.results | 3 -
.../RolePickerBadges.test.tsx | 46 +++++
.../RolePickerDrawer/RolePickerBadges.tsx | 63 +++++++
.../RolePickerDrawer.test.tsx | 40 +++++
.../RolePickerDrawer/RolePickerDrawer.tsx | 52 ++++++
.../RolePickerSelect.test.tsx | 25 +++
.../RolePickerDrawer/RolePickerSelect.tsx | 37 ++++
.../features/admin/Users/OrgUsersTable.tsx | 6 +
.../ServiceAccountCreatePage.tsx | 161 ++++++++++++------
public/locales/en-US/grafana.json | 25 +++
public/locales/pseudo-LOCALE/grafana.json | 25 +++
11 files changed, 429 insertions(+), 54 deletions(-)
create mode 100644 public/app/core/components/RolePickerDrawer/RolePickerBadges.test.tsx
create mode 100644 public/app/core/components/RolePickerDrawer/RolePickerBadges.tsx
create mode 100644 public/app/core/components/RolePickerDrawer/RolePickerDrawer.test.tsx
create mode 100644 public/app/core/components/RolePickerDrawer/RolePickerDrawer.tsx
create mode 100644 public/app/core/components/RolePickerDrawer/RolePickerSelect.test.tsx
create mode 100644 public/app/core/components/RolePickerDrawer/RolePickerSelect.tsx
diff --git a/.betterer.results b/.betterer.results
index dc17bc5297c..92718adbf97 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -4688,9 +4688,6 @@ exports[`better eslint`] = {
"public/app/features/search/utils.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
- "public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx:5381": [
- [0, 0, 0, "No untranslated strings. Wrap text with ", "0"]
- ],
"public/app/features/serviceaccounts/ServiceAccountPage.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with ", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with ", "1"],
diff --git a/public/app/core/components/RolePickerDrawer/RolePickerBadges.test.tsx b/public/app/core/components/RolePickerDrawer/RolePickerBadges.test.tsx
new file mode 100644
index 00000000000..076ad470c36
--- /dev/null
+++ b/public/app/core/components/RolePickerDrawer/RolePickerBadges.test.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { OrgRole } from '@grafana/data';
+
+import { RolePickerBadges } from './RolePickerBadges';
+
+const props = {
+ disabled: false,
+ user: {
+ login: 'admin',
+ email: 'email@example.com',
+ avatarUrl: 'avatarURL',
+ lastSeenAt: 'lastSeenAt',
+ lastSeenAtAge: 'lastSeenAtAge',
+ name: 'administrator',
+ orgId: 1,
+ role: OrgRole.Admin,
+ roles: [
+ {
+ uid: 'uid',
+ name: 'admin',
+ displayName: 'Admin',
+ description: 'description',
+ group: 'group',
+ global: true,
+ version: 1,
+ created: 'created',
+ updated: 'updated',
+ },
+ ],
+ userId: 1,
+ isDisabled: false,
+ },
+};
+
+describe('RolePickerBadges', () => {
+ it('should render', async () => {
+ render();
+
+ expect(screen.getByText(/\+1/i)).toBeInTheDocument();
+
+ expect(screen.getByText(/Admin/i)).toBeInTheDocument();
+ await userEvent.click(screen.getByText('Admin'));
+ });
+});
diff --git a/public/app/core/components/RolePickerDrawer/RolePickerBadges.tsx b/public/app/core/components/RolePickerDrawer/RolePickerBadges.tsx
new file mode 100644
index 00000000000..7c5eeec4444
--- /dev/null
+++ b/public/app/core/components/RolePickerDrawer/RolePickerBadges.tsx
@@ -0,0 +1,63 @@
+import { css } from '@emotion/css';
+import { useState } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { useStyles2, Badge, Stack } from '@grafana/ui';
+import { OrgUser } from 'app/types';
+
+import { RolePickerDrawer } from './RolePickerDrawer';
+
+export interface Props {
+ disabled?: boolean;
+ user: OrgUser;
+}
+
+export const RolePickerBadges = ({ disabled, user }: Props) => {
+ const [isDrawerOpen, setIsDrawerOpen] = useState(false);
+
+ const { badge, badgeDisabled } = useStyles2(getStyles);
+ const badgeStyle = disabled ? badgeDisabled : badge;
+
+ const methods = useForm({
+ defaultValues: {
+ name: user.name,
+ role: user.role,
+ roles: user.roles,
+ },
+ });
+ const { watch } = methods;
+
+ const drawerControl = () => {
+ if (!disabled) {
+ setIsDrawerOpen(true);
+ }
+ };
+
+ return (
+ <>
+
+
+ {user.roles && user.roles.length > 0 && (
+
+ )}
+
+ {isDrawerOpen && (
+
+ setIsDrawerOpen(false)} />
+
+ )}
+ >
+ );
+};
+
+function getStyles(theme: GrafanaTheme2) {
+ return {
+ badge: css({
+ cursor: 'pointer',
+ }),
+ badgeDisabled: css({
+ cursor: 'not-allowed',
+ }),
+ };
+}
diff --git a/public/app/core/components/RolePickerDrawer/RolePickerDrawer.test.tsx b/public/app/core/components/RolePickerDrawer/RolePickerDrawer.test.tsx
new file mode 100644
index 00000000000..20a26a159c6
--- /dev/null
+++ b/public/app/core/components/RolePickerDrawer/RolePickerDrawer.test.tsx
@@ -0,0 +1,40 @@
+import { render, screen } from '@testing-library/react';
+import { FormProvider, useForm } from 'react-hook-form';
+
+import { RolePickerDrawer } from './RolePickerDrawer';
+
+const props = {
+ onClose: () => {},
+};
+
+describe('RolePickerDrawer', () => {
+ interface WrapperProps {
+ children: React.ReactNode;
+ }
+
+ const Wrapper = (props: WrapperProps) => {
+ const formMethods = useForm({
+ defaultValues: {
+ name: 'service-account-name',
+ },
+ });
+ return {props.children};
+ };
+
+ it('should render', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByRole('heading', { name: 'service-account-name' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'documentation' })).toBeInTheDocument();
+
+ expect(screen.getByRole('radio', { name: 'None' })).toBeInTheDocument();
+ expect(screen.getByRole('radio', { name: 'Viewer' })).toBeInTheDocument();
+ expect(screen.getByRole('radio', { name: 'Editor' })).toBeInTheDocument();
+ expect(screen.getByRole('radio', { name: 'Admin' })).toBeInTheDocument();
+ });
+});
diff --git a/public/app/core/components/RolePickerDrawer/RolePickerDrawer.tsx b/public/app/core/components/RolePickerDrawer/RolePickerDrawer.tsx
new file mode 100644
index 00000000000..5192291b787
--- /dev/null
+++ b/public/app/core/components/RolePickerDrawer/RolePickerDrawer.tsx
@@ -0,0 +1,52 @@
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { toOption } from '@grafana/data';
+import { Drawer, Field, RadioButtonGroup, TextLink } from '@grafana/ui';
+import { t, Trans } from 'app/core/internationalization';
+import { OrgRole } from 'app/types';
+
+const roleOptions = Object.keys(OrgRole).map(toOption);
+
+const drawerSubtitle = (
+
+ Assign roles to users to ensure granular control over access to Grafana‘s features and resources. Find out
+ more in our{' '}
+
+ documentation
+
+ .
+
+);
+
+export interface Props {
+ onClose: () => void;
+}
+
+export const RolePickerDrawer = ({ onClose }: Props) => {
+ const methods = useFormContext();
+ const { control, getValues, setValue } = methods;
+ const [name, roles] = getValues(['name', 'roles']);
+ return (
+
+
+ (
+ {
+ setValue('roleCollection', [v, ...roles]);
+ onChange(v);
+ }}
+ />
+ )}
+ />
+
+
+ );
+};
diff --git a/public/app/core/components/RolePickerDrawer/RolePickerSelect.test.tsx b/public/app/core/components/RolePickerDrawer/RolePickerSelect.test.tsx
new file mode 100644
index 00000000000..1fd1278e0d8
--- /dev/null
+++ b/public/app/core/components/RolePickerDrawer/RolePickerSelect.test.tsx
@@ -0,0 +1,25 @@
+import { render, screen } from '@testing-library/react';
+import { FormProvider, useForm } from 'react-hook-form';
+
+import { RolePickerSelect } from './RolePickerSelect';
+
+describe('RolePickerSelect', () => {
+ interface WrapperProps {
+ children: React.ReactNode;
+ }
+
+ const Wrapper = (props: WrapperProps) => {
+ const formMethods = useForm({});
+ return {props.children};
+ };
+
+ it('should render', async () => {
+ const props = {};
+ render(
+
+
+
+ );
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
+ });
+});
diff --git a/public/app/core/components/RolePickerDrawer/RolePickerSelect.tsx b/public/app/core/components/RolePickerDrawer/RolePickerSelect.tsx
new file mode 100644
index 00000000000..6f8a91cbec1
--- /dev/null
+++ b/public/app/core/components/RolePickerDrawer/RolePickerSelect.tsx
@@ -0,0 +1,37 @@
+import { useState } from 'react';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { toOption } from '@grafana/data';
+import { MultiSelect } from '@grafana/ui';
+
+import { RolePickerDrawer } from './RolePickerDrawer';
+
+export interface Props {}
+
+export const RolePickerSelect = ({}: Props) => {
+ const [isDrawerOpen, setIsDrawerOpen] = useState(false);
+ const { control } = useFormContext();
+
+ const toggleDrawer = () => setIsDrawerOpen(!isDrawerOpen);
+
+ return (
+ <>
+ (
+ {
+ // TODO cannnot remove basic roles
+ // TODO open drawer instead
+ }}
+ value={value?.map(toOption)}
+ />
+ )}
+ />
+ {isDrawerOpen && }
+ >
+ );
+};
diff --git a/public/app/features/admin/Users/OrgUsersTable.tsx b/public/app/features/admin/Users/OrgUsersTable.tsx
index 98fc52462ac..607d732bb12 100644
--- a/public/app/features/admin/Users/OrgUsersTable.tsx
+++ b/public/app/features/admin/Users/OrgUsersTable.tsx
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { OrgRole } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
+import { config } from '@grafana/runtime';
import {
Avatar,
Box,
@@ -20,6 +21,7 @@ import {
} from '@grafana/ui';
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
import { fetchRoleOptions, updateUserRoles } from 'app/core/components/RolePicker/api';
+import { RolePickerBadges } from 'app/core/components/RolePickerDrawer/RolePickerBadges';
import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
import { contextSrv } from 'app/core/core';
import { AccessControlAction, OrgUser, Role } from 'app/types';
@@ -127,6 +129,10 @@ export const OrgUsersTable = ({
}
};
+ if (config.featureToggles.rolePickerDrawer) {
+ return ;
+ }
+
return contextSrv.licensedAccessControlEnabled() ? (
{
const updateServiceAccount = async (id: number, sa: ServiceAccountDTO) =>
getBackendSrv().patch(`/api/serviceaccounts/${id}`, sa);
+const defaultServiceAccount = {
+ id: 0,
+ orgId: contextSrv.user.orgId,
+ role: contextSrv.licensedAccessControlEnabled() ? OrgRole.None : OrgRole.Viewer,
+ tokens: 0,
+ name: '',
+ login: '',
+ isDisabled: false,
+ createdAt: '',
+ teams: [],
+};
+
export const ServiceAccountCreatePage = ({}: Props): JSX.Element => {
const [roleOptions, setRoleOptions] = useState([]);
const [pendingRoles, setPendingRoles] = useState([]);
- const currentOrgId = contextSrv.user.orgId;
- const [serviceAccount, setServiceAccount] = useState({
- id: 0,
- orgId: contextSrv.user.orgId,
- role: contextSrv.licensedAccessControlEnabled() ? OrgRole.None : OrgRole.Viewer,
- tokens: 0,
- name: '',
- login: '',
- isDisabled: false,
- createdAt: '',
- teams: [],
+ const methods = useForm({
+ defaultValues: {
+ name: '',
+ role: defaultServiceAccount.role,
+ roleCollection: [defaultServiceAccount.role],
+ roles: [],
+ },
});
+ const {
+ formState: { errors },
+ register,
+ } = methods;
+
+ const currentOrgId = contextSrv.user.orgId;
+ const [serviceAccount, setServiceAccount] = useState(defaultServiceAccount);
useEffect(() => {
async function fetchOptions() {
@@ -47,7 +65,7 @@ export const ServiceAccountCreatePage = ({}: Props): JSX.Element => {
setRoleOptions(options);
}
} catch (e) {
- console.error('Error loading options', e);
+ console.error('Error loading options', e); // TODO: handle error
}
}
if (contextSrv.licensedAccessControlEnabled()) {
@@ -79,7 +97,7 @@ export const ServiceAccountCreatePage = ({}: Props): JSX.Element => {
await updateUserRoles(pendingRoles, newAccount.id, newAccount.orgId);
}
} catch (e) {
- console.error(e);
+ console.error(e); // TODO: handle error
}
locationService.push(`/org/serviceaccounts/${response.id}`);
},
@@ -99,44 +117,85 @@ export const ServiceAccountCreatePage = ({}: Props): JSX.Element => {
};
return (
-
+
-
+ {config.featureToggles.rolePickerDrawer && (
+
+
+
+ )}
+ {!config.featureToggles.rolePickerDrawer && (
+
+ )}
);
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index a8433824dec..858f7357e31 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -2455,6 +2455,16 @@
},
"dismissable-button": "Close"
},
+ "role-picker": {
+ "title": {
+ "description": "Assign roles to users to ensure granular control over access to Grafana‘s features and resources. Find out more in our <2>documentation2>."
+ }
+ },
+ "role-picker-drawer": {
+ "basic-roles": {
+ "label": "Basic Roles"
+ }
+ },
"save-dashboards": {
"name-exists": {
"message-info": "A dashboard with the same name in the selected folder already exists, including recently deleted dashboards.",
@@ -2534,6 +2544,21 @@
"selected-count": "Selected "
}
},
+ "service-account-create-page": {
+ "create": {
+ "button": "Create"
+ },
+ "name": {
+ "label": "Display name",
+ "required-error": "Display name is required"
+ },
+ "page-nav": {
+ "label": "Create service account"
+ },
+ "role": {
+ "label": "Role"
+ }
+ },
"service-accounts": {
"empty-state": {
"button-title": "Add service account",
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index 573b334e572..31f557cdd18 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -2455,6 +2455,16 @@
},
"dismissable-button": "Cľőşę"
},
+ "role-picker": {
+ "title": {
+ "description": "Åşşįģʼn řőľęş ŧő ūşęřş ŧő ęʼnşūřę ģřäʼnūľäř čőʼnŧřőľ ővęř äččęşş ŧő Ğřäƒäʼnä&ľşqūő;ş ƒęäŧūřęş äʼnđ řęşőūřčęş. Fįʼnđ őūŧ mőřę įʼn őūř <2>đőčūmęʼnŧäŧįőʼn2>."
+ }
+ },
+ "role-picker-drawer": {
+ "basic-roles": {
+ "label": "ßäşįč Ŗőľęş"
+ }
+ },
"save-dashboards": {
"name-exists": {
"message-info": "Å đäşĥþőäřđ ŵįŧĥ ŧĥę şämę ʼnämę įʼn ŧĥę şęľęčŧęđ ƒőľđęř äľřęäđy ęχįşŧş, įʼnčľūđįʼnģ řęčęʼnŧľy đęľęŧęđ đäşĥþőäřđş.",
@@ -2534,6 +2544,21 @@
"selected-count": "Ŝęľęčŧęđ "
}
},
+ "service-account-create-page": {
+ "create": {
+ "button": "Cřęäŧę"
+ },
+ "name": {
+ "label": "Đįşpľäy ʼnämę",
+ "required-error": "Đįşpľäy ʼnämę įş řęqūįřęđ"
+ },
+ "page-nav": {
+ "label": "Cřęäŧę şęřvįčę äččőūʼnŧ"
+ },
+ "role": {
+ "label": "Ŗőľę"
+ }
+ },
"service-accounts": {
"empty-state": {
"button-title": "Åđđ şęřvįčę äččőūʼnŧ",