Handle multiple teams
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
Reference in New Issue
Block a user