From 9f6f944a460a092d4d63522a7fe8dba0e5489fe9 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Mon, 5 Jan 2026 17:17:52 +0100 Subject: [PATCH] Handle multiple teams --- .../api/clients/dashboard/v0alpha1/index.ts | 2 +- .../NestedFolderPicker/NestedFolderPicker.tsx | 56 ++++---- .../NestedFolderPicker/useTeamOwnedFolder.ts | 132 +++++++++++++++--- public/app/features/profile/api.ts | 8 +- 4 files changed, 148 insertions(+), 50 deletions(-) diff --git a/public/app/api/clients/dashboard/v0alpha1/index.ts b/public/app/api/clients/dashboard/v0alpha1/index.ts index 02cbd74b8ed..29479d6705c 100644 --- a/public/app/api/clients/dashboard/v0alpha1/index.ts +++ b/public/app/api/clients/dashboard/v0alpha1/index.ts @@ -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; diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx index 9ef4cb8a9d2..2985b87ecda 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx @@ -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>({}); const overlayId = useId(); + const [error] = useState(undefined); // TODO: error not populated anymore const lastSearchTimestamp = useRef(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> = []; @@ -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 ? ( {label} - + ) : ( '' @@ -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({ (null); - const [teamError, setTeamError] = useState(null); +export function useGetTeamFolders() { + const { teams, error: teamError } = useTeams(); + + const [triggerSearch] = useLazyGetSearchQuery(); + const [foldersByTeam, setFoldersByTeam] = useState([]); + const [foldersLoading, setFoldersLoading] = useState(false); + const [foldersError, setFoldersError] = useState(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((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(null); + const [teamError, setTeamError] = useState(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; +} diff --git a/public/app/features/profile/api.ts b/public/app/features/profile/api.ts index a5ab3247c9e..c45491a6e54 100644 --- a/public/app/features/profile/api.ts +++ b/public/app/features/profile/api.ts @@ -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 { try { await getBackendSrv().put('/api/user/password', payload); @@ -16,8 +20,8 @@ function loadUser(): Promise { return getBackendSrv().get('/api/user'); } -function loadTeams(): Promise { - return getBackendSrv().get('/api/user/teams'); +function loadTeams(options?: LoadTeamsOptions): Promise { + return getBackendSrv().get('/api/user/teams', undefined, undefined, { abortSignal: options?.abortSignal }); } function loadOrgs(): Promise {