Nested folders: move permissions to a drawer (#68476)

* move permissions to a drawer when nested folders is enabled

* only show count when resource is folder

* Extract descendant count out into its own component

* remove label
This commit is contained in:
Ashley Harrison 2023-05-17 16:15:36 +01:00 committed by GitHub
parent 3ffff632be
commit e27e71ee59
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 208 additions and 133 deletions

View File

@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Button, Form, HorizontalGroup, Select } from '@grafana/ui'; import { Stack } from '@grafana/experimental';
import { Button, Form, Select } from '@grafana/ui';
import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
import { TeamPicker } from 'app/core/components/Select/TeamPicker'; import { TeamPicker } from 'app/core/components/Select/TeamPicker';
import { UserPicker } from 'app/core/components/Select/UserPicker'; import { UserPicker } from 'app/core/components/Select/UserPicker';
@ -16,7 +17,7 @@ export interface Props {
onAdd: (state: SetPermission) => void; onAdd: (state: SetPermission) => void;
} }
export const AddPermission = ({ title = 'Add Permission For', permissions, assignments, onAdd, onCancel }: Props) => { export const AddPermission = ({ title = 'Add permission for', permissions, assignments, onAdd, onCancel }: Props) => {
const [target, setPermissionTarget] = useState<PermissionTarget>(PermissionTarget.None); const [target, setPermissionTarget] = useState<PermissionTarget>(PermissionTarget.None);
const [teamId, setTeamId] = useState(0); const [teamId, setTeamId] = useState(0);
const [userId, setUserId] = useState(0); const [userId, setUserId] = useState(0);
@ -59,35 +60,32 @@ export const AddPermission = ({ title = 'Add Permission For', permissions, assig
onSubmit={() => onAdd({ userId, teamId, builtInRole, permission, target })} onSubmit={() => onAdd({ userId, teamId, builtInRole, permission, target })}
> >
{() => ( {() => (
<HorizontalGroup> <Stack gap={1} direction="row">
<Select <Select
aria-label="Role to add new permission to" aria-label="Role to add new permission to"
value={target} value={target}
options={targetOptions} options={targetOptions}
onChange={(v) => setPermissionTarget(v.value!)} onChange={(v) => setPermissionTarget(v.value!)}
disabled={targetOptions.length === 0} disabled={targetOptions.length === 0}
width="auto"
/> />
{target === PermissionTarget.User && ( {target === PermissionTarget.User && <UserPicker onSelected={(u) => setUserId(u?.value || 0)} />}
<UserPicker onSelected={(u) => setUserId(u.value || 0)} className={'width-20'} />
)}
{target === PermissionTarget.Team && ( {target === PermissionTarget.Team && <TeamPicker onSelected={(t) => setTeamId(t.value?.id || 0)} />}
<TeamPicker onSelected={(t) => setTeamId(t.value?.id || 0)} className={'width-20'} />
)}
{target === PermissionTarget.BuiltInRole && ( {target === PermissionTarget.BuiltInRole && (
<Select <Select
aria-label={'Built-in role picker'} aria-label={'Built-in role picker'}
options={Object.values(OrgRole).map((r) => ({ value: r, label: r }))} options={Object.values(OrgRole).map((r) => ({ value: r, label: r }))}
onChange={(r) => setBuiltinRole(r.value || '')} onChange={(r) => setBuiltinRole(r.value || '')}
width={40} width="auto"
/> />
)} )}
<Select <Select
aria-label="Permission Level" aria-label="Permission Level"
width={25} width="auto"
value={permissions.find((p) => p === permission)} value={permissions.find((p) => p === permission)}
options={permissions.map((p) => ({ label: p, value: p }))} options={permissions.map((p) => ({ label: p, value: p }))}
onChange={(v) => setPermission(v.value || '')} onChange={(v) => setPermission(v.value || '')}
@ -95,7 +93,7 @@ export const AddPermission = ({ title = 'Add Permission For', permissions, assig
<Button type="submit" disabled={!isValid()}> <Button type="submit" disabled={!isValid()}>
Save Save
</Button> </Button>
</HorizontalGroup> </Stack>
)} )}
</Form> </Form>
</div> </div>

View File

@ -14,19 +14,16 @@ interface Props {
export const PermissionListItem = ({ item, permissionLevels, canSet, onRemove, onChange }: Props) => ( export const PermissionListItem = ({ item, permissionLevels, canSet, onRemove, onChange }: Props) => (
<tr> <tr>
<td style={{ width: '1%' }}>{getAvatar(item)}</td> <td>{getAvatar(item)}</td>
<td style={{ width: '90%' }}>{getDescription(item)}</td> <td>{getDescription(item)}</td>
<td>{item.isInherited && <em className="muted no-wrap">Inherited from folder</em>}</td> <td>{item.isInherited && <em className="muted no-wrap">Inherited from folder</em>}</td>
<td> <td>
<div className="gf-form"> <Select
<Select disabled={!canSet || !item.isManaged}
className="width-20" onChange={(p) => onChange(item, p.value!)}
disabled={!canSet || !item.isManaged} value={permissionLevels.find((p) => p === item.permission)}
onChange={(p) => onChange(item, p.value!)} options={permissionLevels.map((p) => ({ value: p, label: p }))}
value={permissionLevels.find((p) => p === item.permission)} />
options={permissionLevels.map((p) => ({ value: p, label: p }))}
/>
</div>
</td> </td>
<td> <td>
<Tooltip content={getPermissionInfo(item)}> <Tooltip content={getPermissionInfo(item)}>

View File

@ -1,9 +1,13 @@
import { css } from '@emotion/css';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from '@grafana/ui'; import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Button, useStyles2 } from '@grafana/ui';
import { SlideDown } from 'app/core/components/Animations/SlideDown'; import { SlideDown } from 'app/core/components/Animations/SlideDown';
import { getBackendSrv } from 'app/core/services/backend_srv'; import { getBackendSrv } from 'app/core/services/backend_srv';
import { DescendantCount } from 'app/features/browse-dashboards/components/BrowseActions/DescendantCount';
import { AddPermission } from './AddPermission'; import { AddPermission } from './AddPermission';
import { PermissionList } from './PermissionList'; import { PermissionList } from './PermissionList';
@ -42,6 +46,7 @@ export const Permissions = ({
canSetPermissions, canSetPermissions,
addPermissionTitle, addPermissionTitle,
}: Props) => { }: Props) => {
const styles = useStyles2(getStyles);
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [items, setItems] = useState<ResourcePermission[]>([]); const [items, setItems] = useState<ResourcePermission[]>([]);
const [desc, setDesc] = useState(INITIAL_DESCRIPTION); const [desc, setDesc] = useState(INITIAL_DESCRIPTION);
@ -127,63 +132,74 @@ export const Permissions = ({
return ( return (
<div> <div>
<div className="page-action-bar"> {config.featureToggles.nestedFolders && resource === 'folders' && (
<h3 className="page-sub-heading">{title}</h3> <>
<div className="page-action-bar__spacer" /> This will change permissions for this folder and all its descendants. In total, this will affect:
{canSetPermissions && ( <DescendantCount
<Button variant={'primary'} key="add-permission" onClick={() => setIsAdding(true)}> selectedItems={{
{buttonLabel} folder: { [resourceId]: true },
</Button> dashboard: {},
)} panel: {},
</div> $all: false,
}}
<div>
<SlideDown in={isAdding}>
<AddPermission
title={addPermissionTitle}
onAdd={onAdd}
permissions={desc.permissions}
assignments={desc.assignments}
onCancel={() => setIsAdding(false)}
/> />
</SlideDown> </>
{items.length === 0 && ( )}
<table className="filter-table gf-form-group"> {canSetPermissions && (
<tbody> <Button
<tr> className={styles.addPermissionButton}
<th>{emptyLabel}</th> variant={'primary'}
</tr> key="add-permission"
</tbody> onClick={() => setIsAdding(true)}
</table> >
)} {buttonLabel}
<PermissionList </Button>
title="Role" )}
items={builtInRoles} <SlideDown in={isAdding}>
compareKey={'builtInRole'} <AddPermission
permissionLevels={desc.permissions} title={addPermissionTitle}
onChange={onChange} onAdd={onAdd}
onRemove={onRemove} permissions={desc.permissions}
canSet={canSetPermissions} assignments={desc.assignments}
onCancel={() => setIsAdding(false)}
/> />
<PermissionList </SlideDown>
title="User" {items.length === 0 && (
items={users} <table className="filter-table gf-form-group">
compareKey={'userLogin'} <tbody>
permissionLevels={desc.permissions} <tr>
onChange={onChange} <th>{emptyLabel}</th>
onRemove={onRemove} </tr>
canSet={canSetPermissions} </tbody>
/> </table>
<PermissionList )}
title="Team" <PermissionList
items={teams} title="Role"
compareKey={'team'} items={builtInRoles}
permissionLevels={desc.permissions} compareKey={'builtInRole'}
onChange={onChange} permissionLevels={desc.permissions}
onRemove={onRemove} onChange={onChange}
canSet={canSetPermissions} onRemove={onRemove}
/> canSet={canSetPermissions}
</div> />
<PermissionList
title="User"
items={users}
compareKey={'userLogin'}
permissionLevels={desc.permissions}
onChange={onChange}
onRemove={onRemove}
canSet={canSetPermissions}
/>
<PermissionList
title="Team"
items={teams}
compareKey={'team'}
permissionLevels={desc.permissions}
onChange={onChange}
onRemove={onRemove}
canSet={canSetPermissions}
/>
</div> </div>
); );
}; };
@ -217,3 +233,14 @@ const setPermission = (
permission: string permission: string
): Promise<void> => ): Promise<void> =>
getBackendSrv().post(`/api/access-control/${resource}/${resourceId}/${type}/${typeId}`, { permission }); getBackendSrv().post(`/api/access-control/${resource}/${resourceId}/${type}/${typeId}`, { permission });
const getStyles = (theme: GrafanaTheme2) => ({
breakdown: css({
...theme.typography.bodySmall,
color: theme.colors.text.secondary,
marginBottom: theme.spacing(2),
}),
addPermissionButton: css({
marginBottom: theme.spacing(2),
}),
});

View File

@ -17,6 +17,7 @@ import { BrowseActions } from './components/BrowseActions/BrowseActions';
import { BrowseFilters } from './components/BrowseFilters'; import { BrowseFilters } from './components/BrowseFilters';
import { BrowseView } from './components/BrowseView'; import { BrowseView } from './components/BrowseView';
import { CreateNewButton } from './components/CreateNewButton'; import { CreateNewButton } from './components/CreateNewButton';
import { FolderActionsButton } from './components/FolderActionsButton';
import { SearchView } from './components/SearchView'; import { SearchView } from './components/SearchView';
import { getFolderPermissions } from './permissions'; import { getFolderPermissions } from './permissions';
import { setAllSelection, useHasSelection } from './state'; import { setAllSelection, useHasSelection } from './state';
@ -82,13 +83,16 @@ const BrowseDashboardsPage = memo(({ match }: Props) => {
navId="dashboards/browse" navId="dashboards/browse"
pageNav={navModel} pageNav={navModel}
actions={ actions={
(canCreateDashboards || canCreateFolder) && ( <>
<CreateNewButton {folderDTO && <FolderActionsButton folder={folderDTO} />}
inFolder={folderUID} {(canCreateDashboards || canCreateFolder) && (
canCreateDashboard={canCreateDashboards} <CreateNewButton
canCreateFolder={canCreateFolder} inFolder={folderUID}
/> canCreateDashboard={canCreateDashboards}
) canCreateFolder={canCreateFolder}
/>
)}
</>
} }
> >
<Page.Contents className={styles.pageContents}> <Page.Contents className={styles.pageContents}>

View File

@ -2,12 +2,11 @@ import { css } from '@emotion/css';
import React from 'react'; import React from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Alert, ConfirmModal, Spinner, useStyles2 } from '@grafana/ui'; import { ConfirmModal, useStyles2 } from '@grafana/ui';
import { useGetAffectedItemsQuery } from '../../api/browseDashboardsAPI';
import { DashboardTreeSelection } from '../../types'; import { DashboardTreeSelection } from '../../types';
import { buildBreakdownString } from './utils'; import { DescendantCount } from './DescendantCount';
export interface Props { export interface Props {
isOpen: boolean; isOpen: boolean;
@ -18,7 +17,6 @@ export interface Props {
export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => { export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { data, isFetching, isLoading, error } = useGetAffectedItemsQuery(selectedItems);
const onDelete = () => { const onDelete = () => {
onConfirm(); onConfirm();
@ -30,13 +28,7 @@ export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: P
body={ body={
<div className={styles.modalBody}> <div className={styles.modalBody}>
This action will delete the following content: This action will delete the following content:
<div className={styles.breakdown}> <DescendantCount selectedItems={selectedItems} />
<>
{data && buildBreakdownString(data.folder, data.dashboard, data.libraryPanel, data.alertRule)}
{(isFetching || isLoading) && <Spinner size={12} />}
{error && <Alert severity="error" title="Unable to retrieve descendant information" />}
</>
</div>
</div> </div>
} }
confirmationText="Delete" confirmationText="Delete"
@ -50,11 +42,6 @@ export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: P
}; };
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
breakdown: css({
...theme.typography.bodySmall,
color: theme.colors.text.secondary,
marginBottom: theme.spacing(2),
}),
modalBody: css({ modalBody: css({
...theme.typography.body, ...theme.typography.body,
}), }),

View File

@ -0,0 +1,37 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Spinner, useStyles2 } from '@grafana/ui';
import { useGetAffectedItemsQuery } from '../../api/browseDashboardsAPI';
import { DashboardTreeSelection } from '../../types';
import { buildBreakdownString } from './utils';
export interface Props {
selectedItems: DashboardTreeSelection;
}
export const DescendantCount = ({ selectedItems }: Props) => {
const styles = useStyles2(getStyles);
const { data, isFetching, isLoading, error } = useGetAffectedItemsQuery(selectedItems);
return (
<div className={styles.breakdown}>
<>
{data && buildBreakdownString(data.folder, data.dashboard, data.libraryPanel, data.alertRule)}
{(isFetching || isLoading) && <Spinner size={12} />}
{error && <Alert severity="error" title="Unable to retrieve descendant information" />}
</>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
breakdown: css({
...theme.typography.bodySmall,
color: theme.colors.text.secondary,
marginBottom: theme.spacing(2),
}),
});

View File

@ -1,14 +1,11 @@
import { css } from '@emotion/css';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { Alert, Button, Field, Modal } from '@grafana/ui';
import { Alert, Button, Field, Modal, Spinner, useStyles2 } from '@grafana/ui';
import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { useGetAffectedItemsQuery } from '../../api/browseDashboardsAPI';
import { DashboardTreeSelection } from '../../types'; import { DashboardTreeSelection } from '../../types';
import { buildBreakdownString } from './utils'; import { DescendantCount } from './DescendantCount';
export interface Props { export interface Props {
isOpen: boolean; isOpen: boolean;
@ -19,9 +16,7 @@ export interface Props {
export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => { export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
const [moveTarget, setMoveTarget] = useState<string>(); const [moveTarget, setMoveTarget] = useState<string>();
const styles = useStyles2(getStyles);
const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]); const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]);
const { data, isFetching, isLoading, error } = useGetAffectedItemsQuery(selectedItems);
const onMove = () => { const onMove = () => {
if (moveTarget !== undefined) { if (moveTarget !== undefined) {
@ -34,13 +29,7 @@ export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Pro
<Modal title="Move" onDismiss={onDismiss} {...props}> <Modal title="Move" onDismiss={onDismiss} {...props}>
{selectedFolders.length > 0 && <Alert severity="warning" title="Moving this item may change its permissions." />} {selectedFolders.length > 0 && <Alert severity="warning" title="Moving this item may change its permissions." />}
This action will move the following content: This action will move the following content:
<div className={styles.breakdown}> <DescendantCount selectedItems={selectedItems} />
<>
{data && buildBreakdownString(data.folder, data.dashboard, data.libraryPanel, data.alertRule)}
{(isFetching || isLoading) && <Spinner size={12} />}
{error && <Alert severity="error" title="Unable to retrieve descendant information" />}
</>
</div>
<Field label="Folder name"> <Field label="Folder name">
<FolderPicker allowEmpty onChange={({ uid }) => setMoveTarget(uid)} /> <FolderPicker allowEmpty onChange={({ uid }) => setMoveTarget(uid)} />
</Field> </Field>
@ -55,11 +44,3 @@ export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Pro
</Modal> </Modal>
); );
}; };
const getStyles = (theme: GrafanaTheme2) => ({
breakdown: css({
...theme.typography.bodySmall,
color: theme.colors.text.secondary,
marginBottom: theme.spacing(2),
}),
});

View File

@ -0,0 +1,42 @@
import React, { useState } from 'react';
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui';
import { Permissions } from 'app/core/components/AccessControl';
import { contextSrv } from 'app/core/core';
import { AccessControlAction, FolderDTO } from 'app/types';
interface Props {
folder: FolderDTO;
}
export function FolderActionsButton({ folder }: Props) {
const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false);
const canSetPermissions = contextSrv.hasPermission(AccessControlAction.FoldersPermissionsWrite);
const menu = (
<Menu>
<MenuItem onClick={() => setShowPermissionsDrawer(true)} label="Set permissions" />
</Menu>
);
return (
<>
<Dropdown overlay={menu}>
<Button variant="secondary">
Folder actions
<Icon name="angle-down" />
</Button>
</Dropdown>
{showPermissionsDrawer && (
<Drawer
title="Permissions"
subtitle={folder.title}
scrollableContent
onClose={() => setShowPermissionsDrawer(false)}
size="md"
>
<Permissions resource="folders" resourceId={folder.uid} canSetPermissions={canSetPermissions} />
</Drawer>
)}
</>
);
}

View File

@ -51,14 +51,16 @@ export function buildNavModel(folder: FolderDTO, parents = folder.parents): NavM
}); });
} }
if (folder.canAdmin) { if (!config.featureToggles.nestedFolders) {
model.children!.push({ if (folder.canAdmin) {
active: false, model.children!.push({
icon: 'lock', active: false,
id: getPermissionsTabID(folder.uid), icon: 'lock',
text: 'Permissions', id: getPermissionsTabID(folder.uid),
url: `${folder.url}/permissions`, text: 'Permissions',
}); url: `${folder.url}/permissions`,
});
}
} }
if (folder.canSave) { if (folder.canSave) {

View File

@ -153,7 +153,7 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "NewDashboardsFolder"*/ 'app/features/folders/components/NewDashboardsFolder') () => import(/* webpackChunkName: "NewDashboardsFolder"*/ 'app/features/folders/components/NewDashboardsFolder')
), ),
}, },
{ !config.featureToggles.nestedFolders && {
path: '/dashboards/f/:uid/:slug/permissions', path: '/dashboards/f/:uid/:slug/permissions',
component: config.rbacEnabled component: config.rbacEnabled
? SafeDynamicImport( ? SafeDynamicImport(