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 { 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 { TeamPicker } from 'app/core/components/Select/TeamPicker';
import { UserPicker } from 'app/core/components/Select/UserPicker';
@ -16,7 +17,7 @@ export interface Props {
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 [teamId, setTeamId] = 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 })}
>
{() => (
<HorizontalGroup>
<Stack gap={1} direction="row">
<Select
aria-label="Role to add new permission to"
value={target}
options={targetOptions}
onChange={(v) => setPermissionTarget(v.value!)}
disabled={targetOptions.length === 0}
width="auto"
/>
{target === PermissionTarget.User && (
<UserPicker onSelected={(u) => setUserId(u.value || 0)} className={'width-20'} />
)}
{target === PermissionTarget.User && <UserPicker onSelected={(u) => setUserId(u?.value || 0)} />}
{target === PermissionTarget.Team && (
<TeamPicker onSelected={(t) => setTeamId(t.value?.id || 0)} className={'width-20'} />
)}
{target === PermissionTarget.Team && <TeamPicker onSelected={(t) => setTeamId(t.value?.id || 0)} />}
{target === PermissionTarget.BuiltInRole && (
<Select
aria-label={'Built-in role picker'}
options={Object.values(OrgRole).map((r) => ({ value: r, label: r }))}
onChange={(r) => setBuiltinRole(r.value || '')}
width={40}
width="auto"
/>
)}
<Select
aria-label="Permission Level"
width={25}
width="auto"
value={permissions.find((p) => p === permission)}
options={permissions.map((p) => ({ label: p, value: p }))}
onChange={(v) => setPermission(v.value || '')}
@ -95,7 +93,7 @@ export const AddPermission = ({ title = 'Add Permission For', permissions, assig
<Button type="submit" disabled={!isValid()}>
Save
</Button>
</HorizontalGroup>
</Stack>
)}
</Form>
</div>

View File

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

View File

@ -1,9 +1,13 @@
import { css } from '@emotion/css';
import { sortBy } from 'lodash';
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 { getBackendSrv } from 'app/core/services/backend_srv';
import { DescendantCount } from 'app/features/browse-dashboards/components/BrowseActions/DescendantCount';
import { AddPermission } from './AddPermission';
import { PermissionList } from './PermissionList';
@ -42,6 +46,7 @@ export const Permissions = ({
canSetPermissions,
addPermissionTitle,
}: Props) => {
const styles = useStyles2(getStyles);
const [isAdding, setIsAdding] = useState(false);
const [items, setItems] = useState<ResourcePermission[]>([]);
const [desc, setDesc] = useState(INITIAL_DESCRIPTION);
@ -127,17 +132,29 @@ export const Permissions = ({
return (
<div>
<div className="page-action-bar">
<h3 className="page-sub-heading">{title}</h3>
<div className="page-action-bar__spacer" />
{config.featureToggles.nestedFolders && resource === 'folders' && (
<>
This will change permissions for this folder and all its descendants. In total, this will affect:
<DescendantCount
selectedItems={{
folder: { [resourceId]: true },
dashboard: {},
panel: {},
$all: false,
}}
/>
</>
)}
{canSetPermissions && (
<Button variant={'primary'} key="add-permission" onClick={() => setIsAdding(true)}>
<Button
className={styles.addPermissionButton}
variant={'primary'}
key="add-permission"
onClick={() => setIsAdding(true)}
>
{buttonLabel}
</Button>
)}
</div>
<div>
<SlideDown in={isAdding}>
<AddPermission
title={addPermissionTitle}
@ -184,7 +201,6 @@ export const Permissions = ({
canSet={canSetPermissions}
/>
</div>
</div>
);
};
@ -217,3 +233,14 @@ const setPermission = (
permission: string
): Promise<void> =>
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 { BrowseView } from './components/BrowseView';
import { CreateNewButton } from './components/CreateNewButton';
import { FolderActionsButton } from './components/FolderActionsButton';
import { SearchView } from './components/SearchView';
import { getFolderPermissions } from './permissions';
import { setAllSelection, useHasSelection } from './state';
@ -82,13 +83,16 @@ const BrowseDashboardsPage = memo(({ match }: Props) => {
navId="dashboards/browse"
pageNav={navModel}
actions={
(canCreateDashboards || canCreateFolder) && (
<>
{folderDTO && <FolderActionsButton folder={folderDTO} />}
{(canCreateDashboards || canCreateFolder) && (
<CreateNewButton
inFolder={folderUID}
canCreateDashboard={canCreateDashboards}
canCreateFolder={canCreateFolder}
/>
)
)}
</>
}
>
<Page.Contents className={styles.pageContents}>

View File

@ -2,12 +2,11 @@ import { css } from '@emotion/css';
import React from 'react';
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 { buildBreakdownString } from './utils';
import { DescendantCount } from './DescendantCount';
export interface Props {
isOpen: boolean;
@ -18,7 +17,6 @@ export interface Props {
export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
const styles = useStyles2(getStyles);
const { data, isFetching, isLoading, error } = useGetAffectedItemsQuery(selectedItems);
const onDelete = () => {
onConfirm();
@ -30,13 +28,7 @@ export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: P
body={
<div className={styles.modalBody}>
This action will delete the following content:
<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>
<DescendantCount selectedItems={selectedItems} />
</div>
}
confirmationText="Delete"
@ -50,11 +42,6 @@ export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: P
};
const getStyles = (theme: GrafanaTheme2) => ({
breakdown: css({
...theme.typography.bodySmall,
color: theme.colors.text.secondary,
marginBottom: theme.spacing(2),
}),
modalBody: css({
...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 { GrafanaTheme2 } from '@grafana/data';
import { Alert, Button, Field, Modal, Spinner, useStyles2 } from '@grafana/ui';
import { Alert, Button, Field, Modal } from '@grafana/ui';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { useGetAffectedItemsQuery } from '../../api/browseDashboardsAPI';
import { DashboardTreeSelection } from '../../types';
import { buildBreakdownString } from './utils';
import { DescendantCount } from './DescendantCount';
export interface Props {
isOpen: boolean;
@ -19,9 +16,7 @@ export interface Props {
export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
const [moveTarget, setMoveTarget] = useState<string>();
const styles = useStyles2(getStyles);
const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]);
const { data, isFetching, isLoading, error } = useGetAffectedItemsQuery(selectedItems);
const onMove = () => {
if (moveTarget !== undefined) {
@ -34,13 +29,7 @@ export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Pro
<Modal title="Move" onDismiss={onDismiss} {...props}>
{selectedFolders.length > 0 && <Alert severity="warning" title="Moving this item may change its permissions." />}
This action will move the following content:
<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>
<DescendantCount selectedItems={selectedItems} />
<Field label="Folder name">
<FolderPicker allowEmpty onChange={({ uid }) => setMoveTarget(uid)} />
</Field>
@ -55,11 +44,3 @@ export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Pro
</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,6 +51,7 @@ export function buildNavModel(folder: FolderDTO, parents = folder.parents): NavM
});
}
if (!config.featureToggles.nestedFolders) {
if (folder.canAdmin) {
model.children!.push({
active: false,
@ -60,6 +61,7 @@ export function buildNavModel(folder: FolderDTO, parents = folder.parents): NavM
url: `${folder.url}/permissions`,
});
}
}
if (folder.canSave) {
model.children!.push({

View File

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