NestedFolders: Support Shared with me folder for showing items you've been granted access to (#80141)

* start shared with me frontend tweaks

* prevent linking to sharedwithme folder

* tests

* make divider take up 0 height

* Prevent sharedwithme from being selected

* test
git push

* pr feedback

* prevent setting url for sharedwithme

* split iconForItem/kind functions

* Hide sharedwithme in nested folder picker

* fix test fixture
pull/80538/head
Josh Hunt 1 year ago committed by GitHub
parent b7d6fa4423
commit 94ec6474d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      packages/grafana-runtime/src/config.ts
  2. 7
      public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx
  3. 2
      public/app/features/browse-dashboards/api/browseDashboardsAPI.ts
  4. 3
      public/app/features/browse-dashboards/api/services.ts
  5. 16
      public/app/features/browse-dashboards/components/CheckboxCell.tsx
  6. 76
      public/app/features/browse-dashboards/components/DashboardsTree.test.tsx
  7. 48
      public/app/features/browse-dashboards/components/DashboardsTree.tsx
  8. 4
      public/app/features/browse-dashboards/components/NameCell.tsx
  9. 6
      public/app/features/browse-dashboards/components/utils.ts
  10. 8
      public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts
  11. 20
      public/app/features/browse-dashboards/state/hooks.ts
  12. 61
      public/app/features/browse-dashboards/state/reducers.test.ts
  13. 48
      public/app/features/browse-dashboards/state/reducers.ts
  14. 2
      public/app/features/browse-dashboards/types.ts
  15. 1
      public/app/features/search/page/components/columns.tsx
  16. 29
      public/app/features/search/service/utils.ts

@ -166,6 +166,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
tokenExpirationDayLimit: undefined;
disableFrontendSandboxForPlugins: string[] = [];
sharedWithMeFolderUID: string | undefined;
constructor(options: GrafanaBootConfig) {
this.bootData = options.bootData;

@ -4,6 +4,7 @@ import React, { useCallback, useId, useMemo, useState } from 'react';
import { useAsync } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Alert, Icon, Input, LoadingBar, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { skipToken, useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
@ -164,6 +165,10 @@ export function NestedFolderPicker({
return createFlatTree(undefined, searchCollection, childrenCollections, {}, 0, EXCLUDED_KINDS, excludeUIDs);
}
const allExcludedUIDs = config.sharedWithMeFolderUID
? [...(excludeUIDs || []), config.sharedWithMeFolderUID]
: excludeUIDs;
let flatTree = createFlatTree(
undefined,
rootCollection,
@ -171,7 +176,7 @@ export function NestedFolderPicker({
folderOpenState,
0,
EXCLUDED_KINDS,
excludeUIDs
allExcludedUIDs
);
if (showRootFolder) {

@ -57,6 +57,7 @@ export const browseDashboardsAPI = createApi({
providesTags: (_result, _error, folderUID) => [{ type: 'getFolder', id: folderUID }],
query: (folderUID) => ({ url: `/folders/${folderUID}`, params: { accesscontrol: true } }),
}),
// create a new folder
newFolder: builder.mutation<FolderDTO, { title: string; parentUid?: string }>({
query: ({ title, parentUid }) => ({
@ -81,6 +82,7 @@ export const browseDashboardsAPI = createApi({
});
},
}),
// save an existing folder (e.g. rename)
saveFolder: builder.mutation<FolderDTO, FolderDTO>({
// because the getFolder calls contain the parents, renaming a parent/grandparent/etc needs to invalidate all child folders

@ -6,6 +6,7 @@ import { DashboardViewItem } from 'app/features/search/types';
import { contextSrv } from '../../../core/core';
import { AccessControlAction } from '../../../types';
import { isSharedWithMe } from '../components/utils';
export const PAGE_SIZE = 50;
@ -36,7 +37,7 @@ export async function listFolders(
title: item.title,
parentTitle,
parentUID,
url: `/dashboards/f/${item.uid}/`,
url: isSharedWithMe(item.uid) ? undefined : `/dashboards/f/${item.uid}/`,
}));
}

@ -8,26 +8,31 @@ import { t } from 'app/core/internationalization';
import { DashboardsTreeCellProps, SelectionState } from '../types';
import { isSharedWithMe } from './utils';
export default function CheckboxCell({
row: { original: row },
isSelected,
onItemSelectionChange,
}: DashboardsTreeCellProps) {
const styles = useStyles2(getStyles);
const item = row.item;
if (!isSelected) {
return <span className={styles.checkboxSpacer} />;
return <CheckboxSpacer />;
}
if (item.kind === 'ui') {
if (item.uiKind === 'pagination-placeholder') {
return <Checkbox disabled value={false} />;
} else {
return <span className={styles.checkboxSpacer} />;
return <CheckboxSpacer />;
}
}
if (isSharedWithMe(item.uid)) {
return <CheckboxSpacer />;
}
const state = isSelected(item);
return (
@ -41,6 +46,11 @@ export default function CheckboxCell({
);
}
function CheckboxSpacer() {
const styles = useStyles2(getStyles);
return <span className={styles.checkboxSpacer} />;
}
const getStyles = (theme: GrafanaTheme2) => ({
// Should be the same size as the <IconButton /> so Dashboard name is aligned to Folder name siblings
checkboxSpacer: css({

@ -5,8 +5,14 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { assertIsDefined } from 'test/helpers/asserts';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { wellFormedDashboard, wellFormedEmptyFolder, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture';
import {
sharedWithMeFolder,
wellFormedDashboard,
wellFormedEmptyFolder,
wellFormedFolder,
} from '../fixtures/dashboardsTreeItem.fixture';
import { SelectionState } from '../types';
import { DashboardsTree } from './DashboardsTree';
@ -27,6 +33,10 @@ describe('browse-dashboards DashboardsTree', () => {
const allItemsAreLoaded = () => true;
const requestLoadMore = () => Promise.resolve();
beforeAll(() => {
config.sharedWithMeFolderUID = 'sharedwithme';
});
it('renders a dashboard item', () => {
render(
<DashboardsTree
@ -82,9 +92,73 @@ describe('browse-dashboards DashboardsTree', () => {
requestLoadMore={requestLoadMore}
/>
);
expect(screen.queryByText(folder.item.title)).toBeInTheDocument();
});
it('renders a folder link', () => {
render(
<DashboardsTree
canSelect
items={[folder]}
isSelected={isSelected}
width={WIDTH}
height={HEIGHT}
onFolderClick={noop}
onItemSelectionChange={noop}
onAllSelectionChange={noop}
isItemLoaded={allItemsAreLoaded}
requestLoadMore={requestLoadMore}
/>
);
expect(screen.queryByText(folder.item.title)).toHaveAttribute('href', folder.item.url);
});
it("doesn't link to the sharedwithme pseudo-folder", () => {
const sharedWithMe = sharedWithMeFolder(2);
render(
<DashboardsTree
canSelect
items={[sharedWithMe, folder]}
isSelected={isSelected}
width={WIDTH}
height={HEIGHT}
onFolderClick={noop}
onItemSelectionChange={noop}
onAllSelectionChange={noop}
isItemLoaded={allItemsAreLoaded}
requestLoadMore={requestLoadMore}
/>
);
expect(screen.queryByText(sharedWithMe.item.title)).not.toHaveAttribute('href');
});
it("doesn't render a checkbox for the sharedwithme pseudo-folder", () => {
const sharedWithMe = sharedWithMeFolder(2);
render(
<DashboardsTree
canSelect
items={[sharedWithMe, folder]}
isSelected={isSelected}
width={WIDTH}
height={HEIGHT}
onFolderClick={noop}
onItemSelectionChange={noop}
onAllSelectionChange={noop}
isItemLoaded={allItemsAreLoaded}
requestLoadMore={requestLoadMore}
/>
);
expect(
screen.queryByTestId(selectors.pages.BrowseDashboards.table.checkbox(sharedWithMe.item.uid))
).not.toBeInTheDocument();
});
it('calls onFolderClick when a folder button is clicked', async () => {
const handler = jest.fn();
render(

@ -1,7 +1,7 @@
import { css, cx } from '@emotion/css';
import React, { useCallback, useEffect, useId, useMemo, useRef } from 'react';
import { TableInstance, useTable } from 'react-table';
import { FixedSizeList as List } from 'react-window';
import { VariableSizeList as List } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import { GrafanaTheme2, isTruthy } from '@grafana/data';
@ -35,6 +35,7 @@ interface DashboardsTreeProps {
const HEADER_HEIGHT = 36;
const ROW_HEIGHT = 36;
const DIVIDER_HEIGHT = 0; // Yes - make it appear as a border on the row rather than a row itself
export function DashboardsTree({
items,
@ -51,6 +52,7 @@ export function DashboardsTree({
const treeID = useId();
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
const listRef = useRef<List | null>(null);
const styles = useStyles2(getStyles);
useEffect(() => {
@ -60,6 +62,10 @@ export function DashboardsTree({
if (infiniteLoaderRef.current) {
infiniteLoaderRef.current.resetloadMoreItemsCache(true);
}
if (listRef.current) {
listRef.current.resetAfterIndex(0);
}
}, [items]);
const tableColumns = useMemo(() => {
@ -123,6 +129,18 @@ export function DashboardsTree({
[requestLoadMore, items]
);
const getRowHeight = useCallback(
(rowIndex: number) => {
const row = items[rowIndex];
if (row.item.kind === 'ui' && row.item.uiKind === 'divider') {
return DIVIDER_HEIGHT;
}
return ROW_HEIGHT;
},
[items]
);
return (
<div {...getTableProps()} role="table">
{headerGroups.map((headerGroup) => {
@ -154,12 +172,16 @@ export function DashboardsTree({
>
{({ onItemsRendered, ref }) => (
<List
ref={ref}
ref={(elem) => {
ref(elem);
listRef.current = elem;
}}
height={height - HEADER_HEIGHT}
width={width}
itemCount={items.length}
itemData={virtualData}
itemSize={ROW_HEIGHT}
estimatedItemSize={ROW_HEIGHT}
itemSize={getRowHeight}
onItemsRendered={onItemsRendered}
>
{VirtualListRow}
@ -191,13 +213,23 @@ function VirtualListRow({ index, style, data }: VirtualListRowProps) {
const row = rows[index];
prepareRow(row);
const dashboardItem = row.original.item;
if (dashboardItem.kind === 'ui' && dashboardItem.uiKind === 'divider') {
return (
<div {...row.getRowProps({ style })}>
<hr className={styles.divider} />
</div>
);
}
return (
<div
{...row.getRowProps({ style })}
className={cx(styles.row, styles.bodyRow)}
aria-labelledby={makeRowID(treeID, row.original.item)}
aria-labelledby={makeRowID(treeID, dashboardItem)}
data-testid={selectors.pages.BrowseDashboards.table.row(
'title' in row.original.item ? row.original.item.title : row.original.item.uid
'title' in dashboardItem ? dashboardItem.title : dashboardItem.uid
)}
>
{row.cells.map((cell) => {
@ -221,6 +253,12 @@ const getStyles = (theme: GrafanaTheme2) => {
gap: theme.spacing(1),
}),
divider: css({
borderTop: `1px solid ${theme.colors.border.weak}`,
width: '100%',
margin: 0,
}),
headerRow: css({
backgroundColor: theme.colors.background.secondary,
height: HEADER_HEIGHT,

@ -7,7 +7,7 @@ import { reportInteraction } from '@grafana/runtime';
import { Icon, IconButton, Link, Spinner, useStyles2, Text } from '@grafana/ui';
import { getSvgSize } from '@grafana/ui/src/components/Icon/utils';
import { t } from 'app/core/internationalization';
import { getIconForKind } from 'app/features/search/service/utils';
import { getIconForItem } from 'app/features/search/service/utils';
import { Indent } from '../../../core/components/Indent/Indent';
import { useChildrenByParentUIDState } from '../state';
@ -27,7 +27,7 @@ export function NameCell({ row: { original: data }, onFolderClick, treeID }: Nam
const { item, level, isOpen } = data;
const childrenByParentUID = useChildrenByParentUIDState();
const isLoading = isOpen && !childrenByParentUID[item.uid];
const iconName = getIconForKind(data.item.kind, isOpen);
const iconName = getIconForItem(data.item, isOpen);
if (item.kind === 'ui') {
return (

@ -1,5 +1,11 @@
import { config } from '@grafana/runtime';
import { DashboardViewItemWithUIItems } from '../types';
export function makeRowID(baseId: string, item: DashboardViewItemWithUIItems) {
return baseId + item.uid;
}
export function isSharedWithMe(uid: string) {
return uid === config.sharedWithMeFolderUID;
}

@ -65,6 +65,14 @@ export function wellFormedFolder(
};
}
export function sharedWithMeFolder(seed = 1): DashboardsTreeItem<DashboardViewItem> {
const folder = wellFormedFolder(seed, undefined, {
uid: 'sharedwithme',
url: undefined,
});
return folder;
}
export function wellFormedTree() {
let seed = 1;

@ -5,6 +5,7 @@ import { DashboardViewItem } from 'app/features/search/types';
import { useSelector, StoreState, useDispatch } from 'app/types';
import { PAGE_SIZE } from '../api/services';
import { isSharedWithMe } from '../components/utils';
import {
BrowseDashboardsState,
DashboardsTreeItem,
@ -130,7 +131,7 @@ export function useLoadNextChildrenPage(
}
/**
* Creates a list of items, with level indicating it's 'nested' in the tree structure
* Creates a list of items, with level indicating it's nesting in the tree structure
*
* @param folderUID The UID of the folder being viewed, or undefined if at root Browse Dashboards page
* @param rootItems Array of loaded items at the root level (without a parent). If viewing a folder, we expect this to be empty and unused
@ -180,7 +181,22 @@ export function createFlatTree(
isOpen,
};
return [thisItem, ...mappedChildren];
const items = [thisItem, ...mappedChildren];
if (isSharedWithMe(thisItem.item.uid)) {
items.push({
item: {
kind: 'ui',
uiKind: 'divider',
uid: 'shared-with-me-divider',
},
parentUID,
level: level + 1,
isOpen: false,
});
}
return items;
}
const isOpen = (folderUID && openFolders[folderUID]) || level === 0;

@ -1,4 +1,6 @@
import { wellFormedDashboard, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture';
import { config } from '@grafana/runtime';
import { sharedWithMeFolder, wellFormedDashboard, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture';
import { fullyLoadedViewItemCollection } from '../fixtures/state.fixtures';
import { BrowseDashboardsState } from '../types';
@ -19,6 +21,10 @@ function createInitialState(): BrowseDashboardsState {
}
describe('browse-dashboards reducers', () => {
beforeAll(() => {
config.sharedWithMeFolderUID = 'sharedwithme';
});
describe('fetchNextChildrenPageFulfilled', () => {
it('loads first page of root items', () => {
const pageSize = 50;
@ -321,11 +327,34 @@ describe('browse-dashboards reducers', () => {
expect(state.selectedItems.$all).toBeFalsy();
});
it('does not allow the sharedwithme folder to be selected', () => {
let seed = 1;
const folder = wellFormedFolder(seed++).item;
const dashboard = wellFormedDashboard(seed++).item;
const sharedWithMe = sharedWithMeFolder(seed++).item;
const sharedWithMeDashboard = wellFormedDashboard(seed++, {}, { parentUID: sharedWithMe.uid }).item;
const state = createInitialState();
state.rootItems = fullyLoadedViewItemCollection([sharedWithMe, folder, dashboard]);
state.childrenByParentUID[sharedWithMe.uid] = fullyLoadedViewItemCollection([sharedWithMeDashboard]);
setItemSelectionState(state, {
type: 'setItemSelectionState',
payload: { item: sharedWithMe, isSelected: true },
});
expect(state.selectedItems.folder[sharedWithMe.uid]).toBeFalsy();
});
});
describe('setAllSelection', () => {
let seed = 1;
const topLevelDashboard = wellFormedDashboard(seed++).item;
const sharedWithMe = sharedWithMeFolder(seed++).item;
const sharedWithMeDashboard = wellFormedDashboard(seed++, {}, { parentUID: sharedWithMe.uid }).item;
const topLevelFolder = wellFormedFolder(seed++).item;
const childDashboard = wellFormedDashboard(seed++, {}, { parentUID: topLevelFolder.uid }).item;
const childFolder = wellFormedFolder(seed++, {}, { parentUID: topLevelFolder.uid }).item;
@ -407,5 +436,35 @@ describe('browse-dashboards reducers', () => {
panel: {},
});
});
it("doesn't select the sharedwithme folder when selecting all", () => {
const state = createInitialState();
state.rootItems = fullyLoadedViewItemCollection([topLevelFolder, sharedWithMe]);
state.childrenByParentUID[sharedWithMe.uid] = fullyLoadedViewItemCollection([sharedWithMeDashboard]);
state.childrenByParentUID[topLevelFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]);
setAllSelection(state, { type: 'setAllSelection', payload: { isSelected: true, folderUID: undefined } });
expect(state.selectedItems.folder[sharedWithMe.uid]).toBeFalsy();
expect(state.selectedItems.dashboard[sharedWithMeDashboard.uid]).toBeFalsy();
});
it("doesn't select anything when on the sharedwithme folder page", () => {
const state = createInitialState();
state.rootItems = fullyLoadedViewItemCollection([topLevelFolder, topLevelDashboard]);
state.childrenByParentUID[topLevelFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]);
state.childrenByParentUID[childFolder.uid] = fullyLoadedViewItemCollection([grandchildDashboard]);
setAllSelection(state, { type: 'setAllSelection', payload: { isSelected: true, folderUID: sharedWithMe.uid } });
expect(state.selectedItems).toEqual({
$all: false,
dashboard: {},
folder: {},
panel: {},
});
});
});
});

@ -2,6 +2,7 @@ import { PayloadAction } from '@reduxjs/toolkit';
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
import { isSharedWithMe } from '../components/utils';
import { BrowseDashboardsState } from '../types';
import { fetchNextChildrenPage, refetchChildren } from './actions';
@ -86,6 +87,11 @@ export function setItemSelectionState(
) {
const { item, isSelected } = action.payload;
// UI shouldn't allow it, but also prevent sharedwithme from being selected
if (isSharedWithMe(item.uid)) {
return;
}
// Selecting a folder selects all children, and unselecting a folder deselects all children
// so propagate the new selection state to all descendants
function markChildren(kind: DashboardViewItemKind, uid: string) {
@ -103,26 +109,24 @@ export function setItemSelectionState(
markChildren(item.kind, item.uid);
// If all children of a folder are selected, then the folder is also selected.
// If *any* child of a folder is unselelected, then the folder is alo unselected.
// Reconcile all ancestors to make sure they're in the correct state.
let nextParentUID = item.parentUID;
// If we're unselecting a child, we also need to unselect all ancestors.
if (!isSelected) {
let nextParentUID = item.parentUID;
while (nextParentUID) {
const parent = findItem(state.rootItems?.items ?? [], state.childrenByParentUID, nextParentUID);
while (nextParentUID) {
const parent = findItem(state.rootItems?.items ?? [], state.childrenByParentUID, nextParentUID);
// This case should not happen, but a find can theortically return undefined, and it
// helps limit infinite loops
if (!parent) {
break;
}
// This case should not happen, but a find can theortically return undefined, and it
// helps limit infinite loops
if (!parent) {
break;
}
if (!isSelected) {
// A folder cannot be selected if any of it's children are unselected
state.selectedItems[parent.kind][parent.uid] = false;
}
nextParentUID = parent.parentUID;
nextParentUID = parent.parentUID;
}
}
// Check to see if we should mark the header checkbox selected if all root items are selected
@ -135,6 +139,12 @@ export function setAllSelection(
) {
const { isSelected, folderUID: folderUIDArg } = action.payload;
// If we're in the folder view for sharedwith me (currently not supported)
// bail and don't select anything
if (folderUIDArg && isSharedWithMe(folderUIDArg)) {
return;
}
state.selectedItems.$all = isSelected;
// Search works a bit differently so the state here does different things...
@ -146,6 +156,11 @@ export function setAllSelection(
if (isSelected) {
// Recursively select the children of the folder in view
function selectChildrenOfFolder(folderUID: string | undefined) {
// Don't descend into the sharedwithme folder
if (folderUID && isSharedWithMe(folderUID)) {
return;
}
const collection = folderUID ? state.childrenByParentUID[folderUID] : state.rootItems;
// Bail early if the collection isn't found (not loaded yet)
@ -154,6 +169,11 @@ export function setAllSelection(
}
for (const child of collection.items) {
// Don't traverse into the sharedwithme folder
if (isSharedWithMe(child.uid)) {
continue;
}
state.selectedItems[child.kind][child.uid] = isSelected;
if (child.kind !== 'folder') {

@ -29,7 +29,7 @@ export interface BrowseDashboardsState {
export interface UIDashboardViewItem {
kind: 'ui';
uiKind: 'empty-folder' | 'pagination-placeholder';
uiKind: 'empty-folder' | 'pagination-placeholder' | 'divider';
uid: string;
}

@ -178,6 +178,7 @@ export const generateColumns = (
return info ? (
<a key={p} href={info.url} className={styles.locationItem}>
<Icon name={getIconForKind(info.kind)} />
<Text variant="body" truncate>
{info.name}
</Text>

@ -1,4 +1,6 @@
import { DataFrameView, IconName } from '@grafana/data';
import { isSharedWithMe } from 'app/features/browse-dashboards/components/utils';
import { DashboardViewItemWithUIItems } from 'app/features/browse-dashboards/types';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardViewItem, DashboardViewItemKind } from '../types';
@ -50,6 +52,33 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName {
return 'question-circle';
}
export function getIconForItem(item: DashboardViewItemWithUIItems, isOpen?: boolean): IconName {
if (item && isSharedWithMe(item.uid)) {
return 'users-alt';
} else {
return getIconForKind(item.kind, isOpen);
}
}
// export function getIconForItem(itemOrKind: string | DashboardViewItemWithUIItems, isOpen?: boolean): IconName {
// const kind = typeof itemOrKind === 'string' ? itemOrKind : itemOrKind.kind;
// const item = typeof itemOrKind === 'string' ? undefined : itemOrKind;
// if (kind === 'dashboard') {
// return 'apps';
// }
// if (item && isSharedWithMe(item.uid)) {
// return 'users-alt';
// }
// if (kind === 'folder') {
// return isOpen ? 'folder-open' : 'folder';
// }
// return 'question-circle';
// }
function parseKindString(kind: string): DashboardViewItemKind {
switch (kind) {
case 'dashboard':

Loading…
Cancel
Save