FolderPicker: Add permission filter to nested folder picker (#84644)

* FolderPicker: Add permission filter to nested folder picker

* Add permission filtering to Searcher

* make default edit only

* dashlist view folders

* update tests
This commit is contained in:
Josh Hunt 2024-03-19 11:44:52 +00:00 committed by GitHub
parent 4a50897f39
commit 365fd2cb72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 92 additions and 36 deletions

View File

@ -8,33 +8,23 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { config } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { PermissionLevelString } from 'app/types';
import { wellFormedTree } from '../../../features/browse-dashboards/fixtures/dashboardsTreeItem.fixture';
import {
treeViewersCanEdit,
wellFormedTree,
} from '../../../features/browse-dashboards/fixtures/dashboardsTreeItem.fixture';
import { NestedFolderPicker } from './NestedFolderPicker';
const [mockTree, { folderA, folderB, folderC, folderA_folderA, folderA_folderB }] = wellFormedTree();
const [mockTreeThatViewersCanEdit /* shares folders with wellFormedTree */] = treeViewersCanEdit();
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => backendSrv,
}));
jest.mock('app/features/browse-dashboards/api/services', () => {
const orig = jest.requireActual('app/features/browse-dashboards/api/services');
return {
...orig,
listFolders(parentUID?: string) {
const childrenForUID = mockTree
.filter((v) => v.item.kind === 'folder' && v.item.parentUID === parentUID)
.map((v) => v.item);
return Promise.resolve(childrenForUID);
},
};
});
function render(...[ui, options]: Parameters<typeof rtlRender>) {
rtlRender(<TestProvider>{ui}</TestProvider>, options);
}
@ -58,12 +48,15 @@ describe('NestedFolderPicker', () => {
http.get('/api/folders', ({ request }) => {
const url = new URL(request.url);
const parentUid = url.searchParams.get('parentUid') ?? undefined;
const permission = url.searchParams.get('permission');
const limit = parseInt(url.searchParams.get('limit') ?? '1000', 10);
const page = parseInt(url.searchParams.get('page') ?? '1', 10);
const tree = permission === 'Edit' ? mockTreeThatViewersCanEdit : mockTree;
// reconstruct a folder API response from the flat tree fixture
const folders = mockTree
const folders = tree
.filter((v) => v.item.kind === 'folder' && v.item.parentUID === parentUid)
.map((folder) => {
return {
@ -117,7 +110,7 @@ describe('NestedFolderPicker', () => {
expect(screen.getByPlaceholderText('Search folders')).toBeInTheDocument();
expect(screen.getByLabelText('Dashboards')).toBeInTheDocument();
expect(screen.getByLabelText(folderA.item.title)).toBeInTheDocument();
expect(screen.getByLabelText(folderB.item.title)).toBeInTheDocument();
// expect(screen.getByLabelText(folderB.item.title)).toBeInTheDocument();
expect(screen.getByLabelText(folderC.item.title)).toBeInTheDocument();
});
@ -174,14 +167,36 @@ describe('NestedFolderPicker', () => {
});
it('hides folders specififed by UID', async () => {
render(<NestedFolderPicker excludeUIDs={[folderA.item.uid]} onChange={mockOnChange} />);
render(<NestedFolderPicker excludeUIDs={[folderC.item.uid]} onChange={mockOnChange} />);
// Open the picker and wait for children to load
const button = await screen.findByRole('button', { name: 'Select folder' });
await userEvent.click(button);
await screen.findByLabelText(folderB.item.title);
await screen.findByLabelText(folderA.item.title);
expect(screen.queryByLabelText(folderA.item.title)).not.toBeInTheDocument();
expect(screen.queryByLabelText(folderC.item.title)).not.toBeInTheDocument();
});
it('by default only shows items the user can edit', async () => {
render(<NestedFolderPicker onChange={mockOnChange} />);
const button = await screen.findByRole('button', { name: 'Select folder' });
await userEvent.click(button);
await screen.findByLabelText(folderA.item.title);
expect(screen.queryByLabelText(folderB.item.title)).not.toBeInTheDocument(); // folderB is not editable
expect(screen.getByLabelText(folderC.item.title)).toBeInTheDocument(); // but folderC is
});
it('shows items the user can view, with the prop', async () => {
render(<NestedFolderPicker permission={PermissionLevelString.View} onChange={mockOnChange} />);
const button = await screen.findByRole('button', { name: 'Select folder' });
await userEvent.click(button);
await screen.findByLabelText(folderA.item.title);
expect(screen.getByLabelText(folderB.item.title)).toBeInTheDocument();
expect(screen.getByLabelText(folderC.item.title)).toBeInTheDocument();
});
describe('when nestedFolders is enabled', () => {
@ -196,7 +211,7 @@ describe('NestedFolderPicker', () => {
});
it('can expand and collapse a folder to show its children', async () => {
render(<NestedFolderPicker onChange={mockOnChange} />);
render(<NestedFolderPicker permission={PermissionLevelString.View} onChange={mockOnChange} />);
// Open the picker and wait for children to load
const button = await screen.findByRole('button', { name: 'Select folder' });
@ -227,7 +242,7 @@ describe('NestedFolderPicker', () => {
});
it('can expand and collapse a folder to show its children with the keyboard', async () => {
render(<NestedFolderPicker onChange={mockOnChange} />);
render(<NestedFolderPicker permission={PermissionLevelString.View} onChange={mockOnChange} />);
const button = await screen.findByRole('button', { name: 'Select folder' });
await userEvent.click(button);

View File

@ -12,6 +12,7 @@ import { DashboardViewItemWithUIItems, DashboardsTreeItem } from 'app/features/b
import { QueryResponse, getGrafanaSearcher } from 'app/features/search/service';
import { queryResultToViewItem } from 'app/features/search/service/utils';
import { DashboardViewItem } from 'app/features/search/types';
import { PermissionLevelString } from 'app/types';
import { getDOMId, NestedFolderList } from './NestedFolderList';
import Trigger from './Trigger';
@ -31,6 +32,9 @@ export interface NestedFolderPickerProps {
/* Folder UIDs to exclude from the picker, to prevent invalid operations */
excludeUIDs?: string[];
/* Show folders matching this permission, mainly used to also show folders user can view. Defaults to showing only folders user has Edit */
permission?: PermissionLevelString.View | PermissionLevelString.Edit;
/* Callback for when the user selects a folder */
onChange?: (folderUID: string | undefined, folderName: string | undefined) => void;
@ -40,11 +44,12 @@ export interface NestedFolderPickerProps {
const debouncedSearch = debounce(getSearchResults, 300);
async function getSearchResults(searchQuery: string) {
async function getSearchResults(searchQuery: string, permission?: PermissionLevelString) {
const queryResponse = await getGrafanaSearcher().search({
query: searchQuery,
kind: ['folder'],
limit: 100,
permission: permission,
});
const items = queryResponse.view.map((v) => queryResultToViewItem(v, queryResponse.view));
@ -57,6 +62,7 @@ export function NestedFolderPicker({
showRootFolder = true,
clearable = false,
excludeUIDs,
permission = PermissionLevelString.Edit,
onChange,
}: NestedFolderPickerProps) {
const styles = useStyles2(getStyles);
@ -79,7 +85,7 @@ export function NestedFolderPicker({
items: browseFlatTree,
isLoading: isBrowseLoading,
requestNextPage: fetchFolderPage,
} = useFoldersQuery(isBrowsing, foldersOpenState);
} = useFoldersQuery(isBrowsing, foldersOpenState, permission);
useEffect(() => {
if (!search) {
@ -90,7 +96,7 @@ export function NestedFolderPicker({
const timestamp = Date.now();
setIsFetchingSearchResults(true);
debouncedSearch(search).then((queryResponse) => {
debouncedSearch(search, permission).then((queryResponse) => {
// Only keep the results if it's was issued after the most recently resolved search.
// This prevents results showing out of order if first request is slower than later ones.
// We don't need to worry about clearing the isFetching state either - if there's a later
@ -102,7 +108,7 @@ export function NestedFolderPicker({
lastSearchTimestamp.current = timestamp;
}
});
}, [search]);
}, [search, permission]);
// the order of middleware is important!
const middleware = [

View File

@ -9,7 +9,7 @@ import { PAGE_SIZE } from 'app/features/browse-dashboards/api/services';
import { getPaginationPlaceholders } from 'app/features/browse-dashboards/state/utils';
import { DashboardViewItemWithUIItems, DashboardsTreeItem } from 'app/features/browse-dashboards/types';
import { RootState } from 'app/store/configureStore';
import { FolderListItemDTO } from 'app/types';
import { FolderListItemDTO, PermissionLevelString } from 'app/types';
import { useDispatch, useSelector } from 'app/types/store';
type ListFoldersQuery = ReturnType<ReturnType<typeof browseDashboardsAPI.endpoints.listFolders.select>>;
@ -29,8 +29,9 @@ const listFoldersSelector = createSelector(
state: RootState,
parentUid: ListFolderQueryArgs['parentUid'],
page: ListFolderQueryArgs['page'],
limit: ListFolderQueryArgs['limit']
) => browseDashboardsAPI.endpoints.listFolders.select({ parentUid, page, limit }),
limit: ListFolderQueryArgs['limit'],
permission: ListFolderQueryArgs['permission']
) => browseDashboardsAPI.endpoints.listFolders.select({ parentUid, page, limit, permission }),
(state, selectFolderList) => selectFolderList(state)
);
@ -48,7 +49,7 @@ const listAllFoldersSelector = createSelector(
continue;
}
const page = listFoldersSelector(state, req.arg.parentUid, req.arg.page, req.arg.limit);
const page = listFoldersSelector(state, req.arg.parentUid, req.arg.page, req.arg.limit, req.arg.permission);
if (page.status === 'pending') {
isLoading = true;
}
@ -91,7 +92,11 @@ function getPagesLoadStatus(pages: ListFoldersQuery[]): [boolean, number | undef
/**
* Returns a loaded folder hierarchy as a flat list and a function to load more pages.
*/
export function useFoldersQuery(isBrowsing: boolean, openFolders: Record<string, boolean>) {
export function useFoldersQuery(
isBrowsing: boolean,
openFolders: Record<string, boolean>,
permission?: PermissionLevelString
) {
const dispatch = useDispatch();
// Keep a list of all requests so we can
@ -113,13 +118,13 @@ export function useFoldersQuery(isBrowsing: boolean, openFolders: Record<string,
return;
}
const args = { parentUid, page: (pageNumber ?? 0) + 1, limit: PAGE_SIZE };
const args = { parentUid, page: (pageNumber ?? 0) + 1, limit: PAGE_SIZE, permission };
const promise = dispatch(browseDashboardsAPI.endpoints.listFolders.initiate(args));
// It's important that we create a new array so we can correctly memoize with it
requestsRef.current = requestsRef.current.concat([promise]);
},
[state, dispatch]
[state, dispatch, permission]
);
// Unsubscribe from all requests when the component is unmounted

View File

@ -16,6 +16,7 @@ import {
FolderDTO,
FolderListItemDTO,
ImportDashboardResponseDTO,
PermissionLevelString,
SaveDashboardResponseDTO,
} from 'app/types';
@ -74,6 +75,7 @@ export interface ListFolderQueryArgs {
page: number;
parentUid: string | undefined;
limit: number;
permission?: PermissionLevelString;
}
export const browseDashboardsAPI = createApi({
@ -83,7 +85,10 @@ export const browseDashboardsAPI = createApi({
endpoints: (builder) => ({
listFolders: builder.query<FolderListItemDTO[], ListFolderQueryArgs>({
providesTags: (result) => result?.map((folder) => ({ type: 'getFolder', id: folder.uid })) ?? [],
query: ({ page, parentUid, limit }) => ({ url: '/folders', params: { page, parentUid, limit } }),
query: ({ parentUid, limit, page, permission }) => ({
url: '/folders',
params: { parentUid, limit, page, permission },
}),
}),
// get folder info (e.g. title, parents) but *not* children

View File

@ -73,6 +73,18 @@ export function sharedWithMeFolder(seed = 1): DashboardsTreeItem<DashboardViewIt
return folder;
}
export function treeViewersCanEdit() {
const [, { folderA, folderC }] = wellFormedTree();
return [
[folderA, folderC],
{
folderA,
folderC,
},
] as const;
}
export function wellFormedTree() {
let seed = 1;

View File

@ -2,6 +2,7 @@ import { DataFrame, DataFrameView, FieldType, getDisplayProcessor, SelectableVal
import { config } from '@grafana/runtime';
import { TermCount } from 'app/core/components/TagFilter/TagFilter';
import { backendSrv } from 'app/core/services/backend_srv';
import { PermissionLevelString } from 'app/types';
import { DEFAULT_MAX_VALUES, TYPE_KIND_MAP } from '../constants';
import { DashboardSearchHit, DashboardSearchItemType } from '../types';
@ -21,6 +22,7 @@ interface APIQuery {
folderUIDs?: string[];
sort?: string;
starred?: boolean;
permission?: PermissionLevelString;
}
// Internal object to hold folderId
@ -85,6 +87,7 @@ export class SQLSearcher implements GrafanaSearcher {
limit: limit,
tag: query.tags,
sort: query.sort,
permission: query.permission,
page,
},
query

View File

@ -1,5 +1,6 @@
import { DataFrameView, SelectableValue } from '@grafana/data';
import { TermCount } from 'app/core/components/TagFilter/TagFilter';
import { PermissionLevelString } from 'app/types';
export interface FacetField {
field: string;
@ -25,6 +26,7 @@ export interface SearchQuery {
limit?: number;
from?: number;
starred?: boolean;
permission?: PermissionLevelString;
}
export interface DashboardQueryResult {

View File

@ -3,6 +3,7 @@ import React from 'react';
import { PanelPlugin } from '@grafana/data';
import { TagsInput } from '@grafana/ui';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { PermissionLevelString } from 'app/types';
import { DashList } from './DashList';
import { dashlistMigrationHandler } from './migrations';
@ -57,7 +58,14 @@ export const plugin = new PanelPlugin<Options>(DashList)
id: 'folderUID',
defaultValue: undefined,
editor: function RenderFolderPicker({ value, onChange }) {
return <FolderPicker clearable value={value} onChange={(folderUID) => onChange(folderUID)} />;
return (
<FolderPicker
clearable
permission={PermissionLevelString.View}
value={value}
onChange={(folderUID) => onChange(folderUID)}
/>
);
},
})
.addCustomEditor({