Add team info to the folders in the picker

This commit is contained in:
Andrej Ocenas
2026-01-07 15:58:54 +01:00
parent 9f6f944a46
commit 750b0a6ef0
2 changed files with 100 additions and 8 deletions
@@ -7,7 +7,7 @@ import InfiniteLoader from 'react-window-infinite-loader';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { IconButton, useStyles2, Text } from '@grafana/ui';
import { Avatar, IconButton, Text, useStyles2 } from '@grafana/ui';
import { Indent } from 'app/core/components/Indent/Indent';
import { childrenByParentUIDSelector, rootItemsSelector } from 'app/features/browse-dashboards/state/hooks';
import { DashboardsTreeItem } from 'app/features/browse-dashboards/types';
@@ -19,10 +19,11 @@ import { FolderRepo } from './FolderRepo';
const ROW_HEIGHT = 40;
const CHEVRON_SIZE = 'md';
const TEAM_FOLDERS_UID = 'teamfolders';
export const getDOMId = (idPrefix: string, id: string) => `${idPrefix}-${id || 'root'}`;
interface NestedFolderListProps {
export interface NestedFolderListProps {
items: DashboardsTreeItem[];
focusedItemIndex: number;
foldersAreOpenable: boolean;
@@ -33,6 +34,7 @@ interface NestedFolderListProps {
isItemLoaded: (itemIndex: number) => boolean;
requestLoadMore: (folderUid: string | undefined) => void;
emptyFolders: Set<string>;
teamFolderOwnersByUid?: Record<string, { name: string; avatarUrl?: string }>;
}
export function NestedFolderList({
@@ -46,6 +48,7 @@ export function NestedFolderList({
isItemLoaded,
requestLoadMore,
emptyFolders,
teamFolderOwnersByUid,
}: NestedFolderListProps) {
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
const styles = useStyles2(getStyles);
@@ -60,6 +63,7 @@ export function NestedFolderList({
onFolderSelect,
idPrefix,
emptyFolders,
teamFolderOwnersByUid,
}),
[
items,
@@ -70,6 +74,7 @@ export function NestedFolderList({
onFolderSelect,
idPrefix,
emptyFolders,
teamFolderOwnersByUid,
]
);
@@ -140,13 +145,14 @@ function Row({ index, style: virtualStyles, data }: RowProps) {
onFolderSelect,
idPrefix,
emptyFolders,
teamFolderOwnersByUid,
} = data;
const { item, isOpen, level, parentUID } = items[index];
const rowRef = useRef<HTMLDivElement>(null);
const labelId = useId();
const rootCollection = useSelector(rootItemsSelector);
const childrenCollections = useSelector(childrenByParentUIDSelector);
const children = (item.uid ? childrenCollections[item.uid] : rootCollection)?.items ?? [];
let children = (item.uid ? childrenCollections[item.uid] : rootCollection)?.items ?? [];
let siblings: DashboardViewItem[] = [];
// only look for siblings if we're not at the root
if (item.uid) {
@@ -168,6 +174,10 @@ function Row({ index, style: virtualStyles, data }: RowProps) {
const handleSelect = useCallback(() => {
if (item.kind === 'folder') {
// Virtual container folder
if (item.uid === TEAM_FOLDERS_UID) {
return;
}
onFolderSelect(item);
}
}, [item, onFolderSelect]);
@@ -197,6 +207,30 @@ function Row({ index, style: virtualStyles, data }: RowProps) {
// approximation as when searching all items will be at top level, while things that are actually in the top level
// when just looking at a folders tree should not have parent.
const isSearchItem = level === 0 && item.parentUID !== undefined;
const teamOwner = teamFolderOwnersByUid?.[item.uid];
// Fix up aria and sibling info for the virtual "Team folders" group, as it's not part of browse-dashboards redux state.
const teamFoldersParentIndex = items.findIndex((it) => it.item.kind === 'folder' && it.item.uid === TEAM_FOLDERS_UID);
if (teamFoldersParentIndex >= 0) {
const parentLevel = items[teamFoldersParentIndex].level;
const teamFoldersChildren: DashboardViewItem[] = [];
for (let i = teamFoldersParentIndex + 1; i < items.length; i++) {
const candidate = items[i];
if (candidate.level <= parentLevel) {
break;
}
if (candidate.level === parentLevel + 1 && candidate.item.kind === 'folder') {
teamFoldersChildren.push(candidate.item);
}
}
if (item.kind === 'folder' && item.uid === TEAM_FOLDERS_UID) {
children = teamFoldersChildren;
}
if (parentUID === TEAM_FOLDERS_UID) {
siblings = teamFoldersChildren;
}
}
return (
// don't need a key handler here, it's handled at the input level in NestedFolderPicker
@@ -247,6 +281,14 @@ function Row({ index, style: virtualStyles, data }: RowProps) {
<Text truncate>{item.title}</Text>
<FolderRepo folder={item} />
</label>
{teamOwner && (
<div className={styles.teamOwner}>
<Avatar src={teamOwner.avatarUrl ?? ''} alt={teamOwner.name} />
<Text truncate color="secondary" variant="bodySmall">
{teamOwner.name}
</Text>
</div>
)}
{isSearchItem && <FolderParent item={items[index]} />}
</div>
</div>
@@ -255,6 +297,7 @@ function Row({ index, style: virtualStyles, data }: RowProps) {
const getStyles = (theme: GrafanaTheme2) => {
const rowBody = css({
label: 'rowBody',
height: ROW_HEIGHT,
display: 'flex',
position: 'relative',
@@ -310,6 +353,7 @@ const getStyles = (theme: GrafanaTheme2) => {
rowBody,
label: css({
label: 'label',
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
@@ -323,5 +367,17 @@ const getStyles = (theme: GrafanaTheme2) => {
cursor: 'pointer',
},
}),
teamOwner: css({
label: 'teamOwner',
display: 'flex',
marginLeft: theme.spacing(1),
alignItems: 'center',
gap: theme.spacing(0.5),
minWidth: 0,
overflow: 'hidden',
whiteSpace: 'nowrap',
flex: '0 1 auto',
pointerEvents: 'none', // avoid interfering with folder selection
}),
};
};
@@ -24,6 +24,8 @@ import { useGetTeamFolders } from './useTeamOwnedFolder';
import { useTreeInteractions } from './useTreeInteractions';
import { getRootFolderItem } from './utils';
const TEAM_FOLDERS_UID = 'teamfolders';
export interface NestedFolderPickerProps {
/* Folder UID to show as selected */
value?: string;
@@ -87,6 +89,13 @@ export function NestedFolderPicker({
const firstTeamFolder = foldersByTeam[0]?.folder;
const getSelectedFolderResult = useGetFolderQueryFacade(value);
const teamFolderOwnersByUid = useMemo(() => {
return foldersByTeam.reduce<Record<string, { name: string; avatarUrl?: string }>>((acc, { team, folder }) => {
acc[folder.name] = { name: team.name, avatarUrl: team.avatarUrl };
return acc;
}, {});
}, [foldersByTeam]);
useEffect(() => {
if (!value && firstTeamFolder && onChange) {
onChange(firstTeamFolder.name, firstTeamFolder.title);
@@ -104,7 +113,8 @@ export function NestedFolderPicker({
const [autoFocusButton, setAutoFocusButton] = useState(false);
const [overlayOpen, setOverlayOpen] = useState(false);
const [foldersOpenState, setFoldersOpenState] = useState<Record<string, boolean>>({});
// keep Team folders expanded by default so the UX matches when team folders were previously listed at top level
const [foldersOpenState, setFoldersOpenState] = useState<Record<string, boolean>>({ [TEAM_FOLDERS_UID]: true });
const overlayId = useId();
const [error] = useState<Error | undefined>(undefined); // TODO: error not populated anymore
@@ -180,6 +190,11 @@ export function NestedFolderPicker({
async (uid: string, newOpenState: boolean) => {
setFoldersOpenState((old) => ({ ...old, [uid]: newOpenState }));
// Team folders is a virtual folder, so don't trigger browse pagination for it
if (uid === TEAM_FOLDERS_UID) {
return;
}
if (newOpenState && !foldersOpenState[uid]) {
fetchFolderPage(uid);
}
@@ -222,17 +237,36 @@ export function NestedFolderPicker({
);
const teamFolderTreeItems = useMemo(() => {
return foldersByTeam.map(({ folder }) => ({
// "Team folders" is a virtual root, sibling to the "Dashboards" virtual root.
const baseLevel = 0;
const childLevel = 1;
const teamFoldersIsOpen = foldersOpenState[TEAM_FOLDERS_UID] ?? true;
const parentItem: DashboardsTreeItem<DashboardViewItemWithUIItems> = {
isOpen: teamFoldersIsOpen,
level: baseLevel,
item: {
kind: 'folder' as const,
title: t('browse-dashboards.folder-picker.team-folders', 'Team folders'),
uid: TEAM_FOLDERS_UID,
parentUID: undefined,
},
};
const children = foldersByTeam.map(({ folder }) => ({
isOpen: false,
level: 0,
level: childLevel,
parentUID: TEAM_FOLDERS_UID,
item: {
kind: 'folder' as const,
title: folder.title,
uid: folder.name,
parentUID: folder.folder,
parentUID: TEAM_FOLDERS_UID,
},
}));
}, [foldersByTeam]);
return teamFoldersIsOpen ? [parentItem, ...children] : [parentItem];
}, [foldersByTeam, foldersOpenState]);
const flatTree = useMemo(() => {
let flatTree: Array<DashboardsTreeItem<DashboardViewItemWithUIItems>> = [];
@@ -255,6 +289,7 @@ export function NestedFolderPicker({
}
if (teamFolderTreeItems.length) {
// Make "Team folders" a sibling root to the "Dashboards" root.
flatTree = [...teamFolderTreeItems, ...flatTree];
}
@@ -400,6 +435,7 @@ export function NestedFolderPicker({
isItemLoaded={isItemLoaded}
requestLoadMore={handleLoadMore}
emptyFolders={emptyFolders}
teamFolderOwnersByUid={teamFolderOwnersByUid}
/>
</div>
)}