Handle multiple teams

This commit is contained in:
Andrej Ocenas
2026-01-05 17:17:52 +01:00
parent 2d83ad6e2a
commit 9f6f944a46
4 changed files with 148 additions and 50 deletions
@@ -2,4 +2,4 @@ import { generatedAPI } from '@grafana/api-clients/rtkq/dashboard/v0alpha1';
export const dashboardAPIv0alpha1 = generatedAPI.enhanceEndpoints({});
export const { useGetSearchQuery } = dashboardAPIv0alpha1;
export const { useGetSearchQuery, useLazyGetSearchQuery } = dashboardAPIv0alpha1;
@@ -20,7 +20,7 @@ import { FolderRepo } from './FolderRepo';
import { getDOMId, NestedFolderList } from './NestedFolderList';
import Trigger from './Trigger';
import { useFoldersQuery } from './useFoldersQuery';
import { useTeamOwnedFolder } from './useTeamOwnedFolder';
import { useGetTeamFolders } from './useTeamOwnedFolder';
import { useTreeInteractions } from './useTreeInteractions';
import { getRootFolderItem } from './utils';
@@ -83,22 +83,30 @@ export function NestedFolderPicker({
id,
}: NestedFolderPickerProps) {
const styles = useStyles2(getStyles);
const { folder: teamFolder } = useTeamOwnedFolder();
const effectiveValue = value || teamFolder?.name;
const selectedFolder = useGetFolderQueryFacade(effectiveValue);
const { foldersByTeam } = useGetTeamFolders();
const firstTeamFolder = foldersByTeam[0]?.folder;
const getSelectedFolderResult = useGetFolderQueryFacade(value);
useEffect(() => {
if (!value && firstTeamFolder && onChange) {
onChange(firstTeamFolder.name, firstTeamFolder.title);
}
}, [value, firstTeamFolder, onChange]);
// user might not have access to the folder, but they have access to the dashboard
// in this case we disable the folder picker - this is an edge case when user has edit access to a dashboard
// but doesn't have access to the folder
const isForbidden = getStatusFromError(selectedFolder.error) === 403;
const isForbidden = getStatusFromError(getSelectedFolderResult.error) === 403;
const [search, setSearch] = useState('');
const [searchResults, setSearchResults] = useState<(QueryResponse & { items: DashboardViewItem[] }) | null>(null);
const [isFetchingSearchResults, setIsFetchingSearchResults] = useState(false);
const [autoFocusButton, setAutoFocusButton] = useState(false);
const [overlayOpen, setOverlayOpen] = useState(false);
const [foldersOpenState, setFoldersOpenState] = useState<Record<string, boolean>>({});
const overlayId = useId();
const [error] = useState<Error | undefined>(undefined); // TODO: error not populated anymore
const lastSearchTimestamp = useRef<number>(0);
@@ -116,12 +124,6 @@ export function NestedFolderPicker({
rootFolderItem,
});
useEffect(() => {
if (value === undefined && teamFolder && onChange) {
onChange(teamFolder.name, teamFolder.title);
}
}, [onChange, teamFolder, value]);
useEffect(() => {
if (!search) {
setSearchResults(null);
@@ -219,22 +221,18 @@ export function NestedFolderPicker({
[search, fetchFolderPage]
);
const teamFolderTreeItem = useMemo(() => {
if (!teamFolder) {
return undefined;
}
return {
const teamFolderTreeItems = useMemo(() => {
return foldersByTeam.map(({ folder }) => ({
isOpen: false,
level: 0,
item: {
kind: 'folder' as const,
title: teamFolder.title,
uid: teamFolder.name,
parentUID: teamFolder.folder,
title: folder.title,
uid: folder.name,
parentUID: folder.folder,
},
};
}, [teamFolder]);
}));
}, [foldersByTeam]);
const flatTree = useMemo(() => {
let flatTree: Array<DashboardsTreeItem<DashboardViewItemWithUIItems>> = [];
@@ -256,8 +254,8 @@ export function NestedFolderPicker({
})) ?? [];
}
if (teamFolderTreeItem) {
flatTree = [teamFolderTreeItem, ...flatTree];
if (teamFolderTreeItems.length) {
flatTree = [...teamFolderTreeItems, ...flatTree];
}
// It's not super optimal to filter these in an additional iteration, but
@@ -277,7 +275,7 @@ export function NestedFolderPicker({
}
return flatTree;
}, [browseFlatTree, excludeUIDs, isBrowsing, searchResults?.items, showRootFolder, teamFolderTreeItem]);
}, [browseFlatTree, excludeUIDs, isBrowsing, searchResults?.items, showRootFolder, teamFolderTreeItems]);
const isItemLoaded = useCallback(
(itemIndex: number) => {
@@ -306,7 +304,7 @@ export function NestedFolderPicker({
visible: overlayOpen,
});
let label = selectedFolder.data?.title;
let label = getSelectedFolderResult.data?.title;
if (!label) {
label = t('browse-dashboards.folder-picker.root-title', 'Dashboards');
}
@@ -315,7 +313,7 @@ export function NestedFolderPicker({
const labelComponent = label ? (
<Stack alignItems={'center'}>
<Text truncate>{label}</Text>
<FolderRepo folder={selectedFolder.data} />
<FolderRepo folder={getSelectedFolderResult.data} />
</Stack>
) : (
''
@@ -328,7 +326,7 @@ export function NestedFolderPicker({
label={labelComponent}
handleClearSelection={clearable && value !== undefined ? handleClearSelection : undefined}
invalid={invalid}
isLoading={selectedFolder.isLoading}
isLoading={getSelectedFolderResult.isLoading}
autoFocus={autoFocusButton}
ref={refs.setReference}
aria-label={
@@ -393,7 +391,7 @@ export function NestedFolderPicker({
<NestedFolderList
items={flatTree}
selectedFolder={effectiveValue}
selectedFolder={value}
focusedItemIndex={focusedItemIndex}
onFolderExpand={handleFolderExpand}
onFolderSelect={handleFolderSelect}
@@ -1,37 +1,82 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useEffect, useMemo, useState } from 'react';
import type { DashboardHit } from '@grafana/api-clients/rtkq/dashboard/v0alpha1';
import { TeamDto } from '@grafana/api-clients/rtkq/legacy';
import { useGetSearchQuery } from 'app/api/clients/dashboard/v0alpha1';
import { isFetchError } from '@grafana/runtime';
import { useGetSearchQuery, useLazyGetSearchQuery } from 'app/api/clients/dashboard/v0alpha1';
import { api as profileApi } from 'app/features/profile/api';
type FolderByTeam = {
team: TeamDto;
folder: DashboardHit;
};
/**
* Returns the first folder owned by any team the current user belongs to.
* Uses the dashboard v0alpha1 search API with the `owner` filter.
* Returns folders owned by teams the current user belongs to.
*/
export function useTeamOwnedFolder() {
const [teams, setTeams] = useState<TeamDto[] | null>(null);
const [teamError, setTeamError] = useState<Error | null>(null);
export function useGetTeamFolders() {
const { teams, error: teamError } = useTeams();
const [triggerSearch] = useLazyGetSearchQuery();
const [foldersByTeam, setFoldersByTeam] = useState<FolderByTeam[]>([]);
const [foldersLoading, setFoldersLoading] = useState(false);
const [foldersError, setFoldersError] = useState<unknown>(undefined);
useEffect(() => {
let cancelled = false;
profileApi
.loadTeams()
.then((result) => {
if (!cancelled) {
setTeams(result);
if (!teams || teamError) {
return;
}
setFoldersLoading(true);
setFoldersError(undefined);
const requests = teams.map((team) => {
const owner = team.uid ?? team.name;
const request = triggerSearch({ owner, type: 'folder' }, true);
return { team, request };
});
Promise.allSettled(requests.map((r) => r.request.unwrap()))
.then((results) => {
const next = requests.reduce<FolderByTeam[]>((acc, r, idx) => {
const result = results[idx];
const folder = result.status === 'fulfilled' ? result.value.hits?.[0] : undefined;
if (folder) {
acc.push({ team: r.team, folder });
}
return acc;
}, []);
setFoldersByTeam(next);
})
.catch((err: unknown) => {
if (!isAbortError(err)) {
setFoldersError(err);
}
})
.catch((err) => {
if (!cancelled) {
setTeamError(err);
}
.finally(() => {
setFoldersLoading(false);
});
return () => {
cancelled = true;
for (const r of requests) {
r.request.abort();
}
};
}, []);
}, [teams, teamError, triggerSearch]);
return {
foldersByTeam,
isLoading: (teams === null && !teamError) || foldersLoading,
error: teamError ?? foldersError,
};
}
/**
* Returns the first folder owned by any team the current user belongs to.
*/
export function useTeamOwnedFolder() {
const { teams, error: teamError } = useTeams();
const owner = useMemo(() => {
if (!teams || teams.length === 0) {
@@ -63,3 +108,54 @@ export function useTeamOwnedFolder() {
error: teamError ?? searchError,
};
}
function useTeams() {
const [teams, setTeams] = useState<TeamDto[] | null>(null);
const [teamError, setTeamError] = useState<Error | null>(null);
useEffect(() => {
const abortController = new AbortController();
profileApi
.loadTeams({ abortSignal: abortController.signal })
.then((result) => {
setTeams(result);
})
.catch((err: unknown) => {
if (!isAbortError(err)) {
if (err instanceof Error) {
setTeamError(err);
return;
}
if (isFetchError(err)) {
setTeamError(new Error(err.data?.message ?? err.statusText ?? 'Failed to load teams'));
return;
}
setTeamError(new Error('Failed to load teams'));
}
});
return () => {
abortController.abort();
};
}, []);
return {
teams,
error: teamError,
};
}
// TODO maybe there should be a common utility like this
function isAbortError(err: unknown) {
if (err instanceof DOMException && err.name === 'AbortError') {
return true;
}
if (isFetchError(err) && (err.cancelled || err.statusText === 'Request was aborted')) {
return true;
}
return false;
}
+6 -2
View File
@@ -4,6 +4,10 @@ import { UserDTO, UserOrg, UserSession } from 'app/types/user';
import { ChangePasswordFields, ProfileUpdateFields } from './types';
type LoadTeamsOptions = {
abortSignal?: AbortSignal;
};
async function changePassword(payload: ChangePasswordFields): Promise<void> {
try {
await getBackendSrv().put('/api/user/password', payload);
@@ -16,8 +20,8 @@ function loadUser(): Promise<UserDTO> {
return getBackendSrv().get('/api/user');
}
function loadTeams(): Promise<Team[]> {
return getBackendSrv().get('/api/user/teams');
function loadTeams(options?: LoadTeamsOptions): Promise<Team[]> {
return getBackendSrv().get('/api/user/teams', undefined, undefined, { abortSignal: options?.abortSignal });
}
function loadOrgs(): Promise<UserOrg[]> {