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
This commit is contained in:
linoman 2024-10-22 16:21:10 +02:00 committed by GitHub
parent 15340a27ef
commit 06a0890c2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 429 additions and 54 deletions

View File

@ -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 <Trans />", "0"]
],
"public/app/features/serviceaccounts/ServiceAccountPage.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],

View File

@ -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(<RolePickerBadges {...props} />);
expect(screen.getByText(/\+1/i)).toBeInTheDocument();
expect(screen.getByText(/Admin/i)).toBeInTheDocument();
await userEvent.click(screen.getByText('Admin'));
});
});

View File

@ -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 (
<>
<Stack gap={1}>
<Badge className={badgeStyle} color="blue" onClick={drawerControl} text={watch('role')} />
{user.roles && user.roles.length > 0 && (
<Badge className={badgeStyle} color="blue" onClick={drawerControl} text={`+${user.roles.length}`} />
)}
</Stack>
{isDrawerOpen && (
<FormProvider {...methods}>
<RolePickerDrawer onClose={() => setIsDrawerOpen(false)} />
</FormProvider>
)}
</>
);
};
function getStyles(theme: GrafanaTheme2) {
return {
badge: css({
cursor: 'pointer',
}),
badgeDisabled: css({
cursor: 'not-allowed',
}),
};
}

View File

@ -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 <FormProvider {...formMethods}>{props.children}</FormProvider>;
};
it('should render', () => {
render(
<Wrapper>
<RolePickerDrawer {...props} />
</Wrapper>
);
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();
});
});

View File

@ -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 = (
<Trans i18nKey="role-picker.title.description">
Assign roles to users to ensure granular control over access to Grafana&lsquo;s features and resources. Find out
more in our{' '}
<TextLink
external
href="https://grafana.com/docs/grafana/latest/administration/roles-and-permissions/#organization-roles"
>
documentation
</TextLink>
.
</Trans>
);
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 (
<Drawer title={name} subtitle={drawerSubtitle} onClose={onClose}>
<Field label={t('role-picker-drawer.basic-roles.label', 'Basic Roles')}>
<Controller
name="role"
control={control}
render={({ field: { onChange, ref, ...fields } }) => (
<RadioButtonGroup
{...fields}
options={roleOptions}
onChange={(v) => {
setValue('roleCollection', [v, ...roles]);
onChange(v);
}}
/>
)}
/>
</Field>
</Drawer>
);
};

View File

@ -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 <FormProvider {...formMethods}>{props.children}</FormProvider>;
};
it('should render', async () => {
const props = {};
render(
<Wrapper>
<RolePickerSelect {...props} />
</Wrapper>
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});

View File

@ -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 (
<>
<Controller
name="role-collection"
control={control}
render={({ field: { ref, value, ...field } }) => (
<MultiSelect
{...field}
onOpenMenu={toggleDrawer}
onChange={() => {
// TODO cannnot remove basic roles
// TODO open drawer instead
}}
value={value?.map(toOption)}
/>
)}
/>
{isDrawerOpen && <RolePickerDrawer onClose={toggleDrawer} />}
</>
);
};

View File

@ -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 <RolePickerBadges disabled={basicRoleDisabled} user={original} />;
}
return contextSrv.licensedAccessControlEnabled() ? (
<UserRolePicker
userId={original.userId}

View File

@ -1,11 +1,14 @@
import { useCallback, useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { getBackendSrv, locationService } from '@grafana/runtime';
import { config, getBackendSrv, locationService } from '@grafana/runtime';
import { Button, Input, Field, FieldSet } from '@grafana/ui';
import { t, Trans } from '@grafana/ui/src/utils/i18n';
import { Form } from 'app/core/components/Form/Form';
import { Page } from 'app/core/components/Page/Page';
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
import { fetchRoleOptions, updateUserRoles } from 'app/core/components/RolePicker/api';
import { RolePickerSelect } from 'app/core/components/RolePickerDrawer/RolePickerSelect';
import { contextSrv } from 'app/core/core';
import { AccessControlAction, OrgRole, Role, ServiceAccountCreateApiResponse, ServiceAccountDTO } from 'app/types';
@ -22,22 +25,37 @@ const createServiceAccount = async (sa: ServiceAccountDTO) => {
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<Role[]>([]);
const [pendingRoles, setPendingRoles] = useState<Role[]>([]);
const currentOrgId = contextSrv.user.orgId;
const [serviceAccount, setServiceAccount] = useState<ServiceAccountDTO>({
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<ServiceAccountDTO>(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 (
<Page navId="serviceaccounts" pageNav={{ text: 'Create service account' }}>
<Page
navId="serviceaccounts"
pageNav={{ text: t('service-account-create-page.page-nav.label', 'Create service account') }}
>
<Page.Contents>
<Form onSubmit={onSubmit} validateOn="onSubmit">
{({ register, errors }) => {
return (
<>
<FieldSet>
<Field
label="Display name"
required
invalid={!!errors.name}
error={errors.name ? 'Display name is required' : undefined}
>
<Input id="display-name-input" {...register('name', { required: true })} autoFocus />
</Field>
<Field label="Role">
{contextSrv.licensedAccessControlEnabled() ? (
<UserRolePicker
apply
userId={serviceAccount.id || 0}
orgId={serviceAccount.orgId}
basicRole={serviceAccount.role}
onBasicRoleChange={onRoleChange}
roleOptions={roleOptions}
onApplyRoles={onPendingRolesUpdate}
pendingRoles={pendingRoles}
maxWidth="100%"
/>
) : (
<OrgRolePicker aria-label="Role" value={serviceAccount.role} onChange={onRoleChange} />
)}
</Field>
</FieldSet>
<Button type="submit">Create</Button>
</>
);
}}
</Form>
{config.featureToggles.rolePickerDrawer && (
<FormProvider {...methods}>
<form>
<FieldSet>
<Field
label={t('service-account-create-page.name.label', 'Display name')}
required
invalid={!!errors.name}
error={
errors.name
? t('service-account-create-page.name.required-error', 'Display name is required')
: undefined
}
>
<Input id="name" {...register('name', { required: true })} autoFocus />
</Field>
<Field label={t('service-account-create-page.role.label', 'Role')}>
<RolePickerSelect />
</Field>
</FieldSet>
<Button type="submit">
<Trans i18nKey="service-account-create-page.create.button">Create</Trans>
</Button>
</form>
</FormProvider>
)}
{!config.featureToggles.rolePickerDrawer && (
<Form onSubmit={onSubmit} validateOn="onSubmit">
{({ register, errors }) => {
return (
<>
<FieldSet>
<Field
label={t('service-account-create-page.name.label', 'Display name')}
required
invalid={!!errors.name}
error={
errors.name
? t('service-account-create-page.name.required-error', 'Display name is required')
: undefined
}
>
<Input id="display-name-input" {...register('name', { required: true })} autoFocus />
</Field>
<Field label={t('service-account-create-page.role.label', 'Role')}>
{contextSrv.licensedAccessControlEnabled() ? (
<UserRolePicker
apply
userId={serviceAccount.id || 0}
orgId={serviceAccount.orgId}
basicRole={serviceAccount.role}
onBasicRoleChange={onRoleChange}
roleOptions={roleOptions}
onApplyRoles={onPendingRolesUpdate}
pendingRoles={pendingRoles}
maxWidth="100%"
/>
) : (
<OrgRolePicker
aria-label={t('service-account-create-page.role.label', 'Role')}
value={serviceAccount.role}
onChange={onRoleChange}
/>
)}
</Field>
</FieldSet>
<Button type="submit">
<Trans i18nKey="service-account-create-page.create.button">Create</Trans>
</Button>
</>
);
}}
</Form>
)}
</Page.Contents>
</Page>
);

View File

@ -2455,6 +2455,16 @@
},
"dismissable-button": "Close"
},
"role-picker": {
"title": {
"description": "Assign roles to users to ensure granular control over access to Grafana&lsquo;s features and resources. Find out more in our <2>documentation</2>."
}
},
"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",

View File

@ -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ŧäŧįőʼn</2>."
}
},
"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ŧ",