diff --git a/public/app/api/clients/folder/v1beta1/hooks.ts b/public/app/api/clients/folder/v1beta1/hooks.ts index 1fea62c40d0..f8d0383f873 100644 --- a/public/app/api/clients/folder/v1beta1/hooks.ts +++ b/public/app/api/clients/folder/v1beta1/hooks.ts @@ -4,7 +4,13 @@ import { useEffect, useMemo } from 'react'; import { AppEvents } from '@grafana/data'; import { t } from '@grafana/i18n'; import { config, getAppEvents } from '@grafana/runtime'; -import { DisplayList, iamAPIv0alpha1, useLazyGetDisplayMappingQuery } from 'app/api/clients/iam/v0alpha1'; +import { + API_GROUP as IAM_API_GROUP, + API_VERSION as IAM_API_VERSION, + DisplayList, + iamAPIv0alpha1, + useLazyGetDisplayMappingQuery, +} from 'app/api/clients/iam/v0alpha1'; import { useAppNotification } from 'app/core/copy/appNotification'; import { useDeleteFolderMutation as useDeleteFolderMutationLegacy, @@ -56,6 +62,8 @@ import { ReplaceFolderApiArg, useGetAffectedItemsQuery, FolderInfo, + ObjectMeta, + OwnerReference, } from './index'; function getFolderUrl(uid: string, title: string): string { @@ -66,6 +74,10 @@ function getFolderUrl(uid: string, title: string): string { return `${config.appSubUrl}/dashboards/f/${uid}/${slug}`; } +type CombinedFolder = FolderDTO & { + ownerReferences?: OwnerReference[]; +}; + const combineFolderResponses = ( folder: Folder, legacyFolder: FolderDTO, @@ -75,7 +87,7 @@ const combineFolderResponses = ( const updatedBy = folder.metadata.annotations?.[AnnoKeyUpdatedBy]; const createdBy = folder.metadata.annotations?.[AnnoKeyCreatedBy]; - const newData: FolderDTO = { + const newData: CombinedFolder = { canAdmin: legacyFolder.canAdmin, canDelete: legacyFolder.canDelete, canEdit: legacyFolder.canEdit, @@ -84,6 +96,7 @@ const combineFolderResponses = ( createdBy: (createdBy && userDisplay?.display[userDisplay?.keys.indexOf(createdBy)]?.displayName) || 'Anonymous', updatedBy: (updatedBy && userDisplay?.display[userDisplay?.keys.indexOf(updatedBy)]?.displayName) || 'Anonymous', ...appPlatformFolderToLegacyFolder(folder), + ownerReferences: folder.metadata.ownerReferences || [], }; if (parents.length) { @@ -101,7 +114,7 @@ const combineFolderResponses = ( return newData; }; -export async function getFolderByUidFacade(uid: string): Promise { +export async function getFolderByUidFacade(uid: string) { const isVirtualFolder = uid && [GENERAL_FOLDER_UID, config.sharedWithMeFolderUID].includes(uid); const shouldUseAppPlatformAPI = Boolean(config.featureToggles.foldersAppPlatformAPI); @@ -216,7 +229,7 @@ export function useGetFolderQueryFacade(uid?: string) { // Stitch together the responses to create a single FolderDTO object so on the outside this behaves as the legacy // api client. - let newData: FolderDTO | undefined = undefined; + let newData: CombinedFolder | undefined = undefined; if ( resultFolder.data && resultParents.data && @@ -359,14 +372,36 @@ export function useCreateFolder() { return legacyHook; } - const createFolderAppPlatform = async (folder: NewFolder) => { - const payload: CreateFolderApiArg = { + const createFolderAppPlatform = async (payload: NewFolder & { createAsTeamFolder?: boolean; teamUid?: string }) => { + const { createAsTeamFolder, teamUid, ...folder } = payload; + const slugifiedTitle = kbn.slugifyForUrl(folder.title); + + const metadataName = `team-${slugifiedTitle}`; + const partialMetadata: ObjectMeta = + createAsTeamFolder && teamUid + ? { + name: metadataName, + ownerReferences: [ + { + apiVersion: `${IAM_API_GROUP}/${IAM_API_VERSION}`, + kind: 'Team', + name: folder.title, + uid: teamUid, + controller: true, + blockOwnerDeletion: false, + }, + ], + } + : { generateName: 'f' }; + + const apiPayload: CreateFolderApiArg = { folder: { spec: { title: folder.title, + description: 'Testing a description', }, metadata: { - generateName: 'f', + ...partialMetadata, annotations: { ...(folder.parentUid && { [AnnoKeyFolder]: folder.parentUid }), }, @@ -375,7 +410,7 @@ export function useCreateFolder() { }, }; - const result = await createFolder(payload); + const result = await createFolder(apiPayload); refresh({ childrenOf: folder.parentUid }); deletedDashboardsCache.clear(); diff --git a/public/app/core/components/OwnerReferences/ManageOwnerReferences.tsx b/public/app/core/components/OwnerReferences/ManageOwnerReferences.tsx new file mode 100644 index 00000000000..8e6375f7225 --- /dev/null +++ b/public/app/core/components/OwnerReferences/ManageOwnerReferences.tsx @@ -0,0 +1,86 @@ +/* eslint-disable @grafana/i18n/no-untranslated-strings */ +import { useState } from 'react'; + +import { Box, Button, Combobox, ComboboxOption, Divider, Stack, Text } from '@grafana/ui'; +import { OwnerReference } from 'app/api/clients/folder/v1beta1'; +import { useListTeamQuery, API_GROUP, API_VERSION } from 'app/api/clients/iam/v0alpha1'; +import { useDispatch } from 'app/types/store'; + +import { TeamOwnerReference } from './OwnerReference'; +import { SupportedResource, useAddOwnerReference, useGetOwnerReferences } from './hooks'; + +const TeamSelector = ({ onChange }: { onChange: (ownerRef: OwnerReference) => void }) => { + const { data: teams } = useListTeamQuery({}); + const teamsOptions = teams?.items.map((team) => ({ + label: team.spec.title, + value: team.metadata.name!, + })); + return ( + ) => { + onChange({ + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'Team', + name: team.label, + uid: team.value, + }); + }} + /> + ); +}; + +export const ManageOwnerReferences = ({ + resource, + resourceId, +}: { + resource: SupportedResource; + resourceId: string; +}) => { + const dispatch = useDispatch(); + const [addingNewReference, setAddingNewReference] = useState(false); + const [pendingReference, setPendingReference] = useState(null); + const ownerReferences = useGetOwnerReferences({ resource, resourceId }); + const [trigger, result] = useAddOwnerReference({ resource, resourceId }); + + const addOwnerReference = (ownerReference: OwnerReference) => { + trigger(ownerReference); + }; + + return ( + + Owned by: + + {ownerReferences + .filter((ownerReference) => ownerReference.kind === 'Team') + .map((ownerReference) => ( + <> + + + + ))} + + + {addingNewReference && ( + + Add new owner reference: + { + setPendingReference(ownerReference); + }} + /> + + + + )} + + + + ); +}; diff --git a/public/app/core/components/OwnerReferences/OwnerReference.tsx b/public/app/core/components/OwnerReferences/OwnerReference.tsx new file mode 100644 index 00000000000..91e961e90b3 --- /dev/null +++ b/public/app/core/components/OwnerReferences/OwnerReference.tsx @@ -0,0 +1,37 @@ +import { OwnerReference } from '@grafana/api-clients/rtkq/folder/v1beta1'; +import { useGetTeamMembersQuery } from '@grafana/api-clients/rtkq/iam/v0alpha1'; +import { Stack, Text, Avatar, Link, Tooltip } from '@grafana/ui'; + +export const getGravatarUrl = (text: string) => { + // todo + return `avatar/bd38b9ecaf6169ca02b848f60a44cb95`; +}; + +export const TeamOwnerReference = ({ ownerReference }: { ownerReference: OwnerReference }) => { + const { data: teamMembers } = useGetTeamMembersQuery({ name: ownerReference.uid }); + + const avatarURL = getGravatarUrl(ownerReference.name); + + const membersTooltip = ( + <> + + Team members: + {teamMembers?.items?.map((member) => ( +
+ {member.displayName} +
+ ))} +
+ + ); + + return ( + + + + {ownerReference.name} + + + + ); +}; diff --git a/public/app/core/components/OwnerReferences/hooks.ts b/public/app/core/components/OwnerReferences/hooks.ts new file mode 100644 index 00000000000..cdb5bb71e25 --- /dev/null +++ b/public/app/core/components/OwnerReferences/hooks.ts @@ -0,0 +1,55 @@ +import { useState, useEffect } from 'react'; + +import { useReplaceFolderMutation } from '@grafana/api-clients/rtkq/folder/v1beta1'; +import { folderAPIv1beta1, OwnerReference } from 'app/api/clients/folder/v1beta1'; +import { useDispatch } from 'app/types/store'; + +const getReferencesEndpointMap = { + Folder: (resourceId: string) => folderAPIv1beta1.endpoints.getFolder.initiate({ name: resourceId }), +} as const; + +export type SupportedResource = keyof typeof getReferencesEndpointMap; + +export const useGetOwnerReferences = ({ + resource, + resourceId, +}: { + resource: SupportedResource; + resourceId: string; +}) => { + const [ownerReferences, setOwnerReferences] = useState([]); + const dispatch = useDispatch(); + const endpointAction = getReferencesEndpointMap[resource]; + + useEffect(() => { + dispatch(endpointAction(resourceId)).then(({ data }) => { + if (data?.metadata?.ownerReferences) { + setOwnerReferences(data.metadata.ownerReferences); + } + }); + }, [dispatch, endpointAction, resourceId]); + + return ownerReferences; +}; + +export const useAddOwnerReference = ({ resource, resourceId }: { resource: SupportedResource; resourceId: string }) => { + const [replaceFolder, result] = useReplaceFolderMutation(); + return [ + (ownerReference: OwnerReference) => + replaceFolder({ + name: resourceId, + + folder: { + status: {}, + metadata: { + name: resourceId, + ownerReferences: [ownerReference], + }, + spec: { + title: resourceId, + }, + }, + }), + result, + ] as const; +}; diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index 921c67ba9cb..d8ce343d649 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -6,8 +6,9 @@ import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans } from '@grafana/i18n'; import { config, reportInteraction } from '@grafana/runtime'; -import { LinkButton, FilterInput, useStyles2, Text, Stack } from '@grafana/ui'; +import { LinkButton, FilterInput, useStyles2, Text, Stack, Box, Divider } from '@grafana/ui'; import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks'; +import { TeamOwnerReference } from 'app/core/components/OwnerReferences/OwnerReference'; import { Page } from 'app/core/components/Page/Page'; import { getConfig } from 'app/core/config'; import { useDispatch } from 'app/types/store'; @@ -146,6 +147,19 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record + {folderDTO.ownerReferences + ?.filter((ref) => ref.kind === 'Team') + .map((ref) => ( + + Owned by team: + + + ))} + + ); + return ( + + {ownerReferences} {config.featureToggles.restoreDashboards && hasAdminRights && ( )} - + } > diff --git a/public/app/features/browse-dashboards/api/services.ts b/public/app/features/browse-dashboards/api/services.ts index 98be615583b..61931329765 100644 --- a/public/app/features/browse-dashboards/api/services.ts +++ b/public/app/features/browse-dashboards/api/services.ts @@ -29,7 +29,7 @@ export async function listFolders( }); } - return folders.map((item) => ({ + const result = folders.map((item) => ({ kind: 'folder', uid: item.uid, title: item.title, @@ -40,6 +40,20 @@ export async function listFolders( // URLs from the backend come with subUrlPrefix already included, so match that behaviour here url: isSharedWithMe(item.uid) ? undefined : getFolderURL(item.uid), })); + + if (!parentUID) { + // result.unshift({ + // kind: 'folder', + // uid: 'teamfolders', + // title: 'Team folders', + // parentTitle, + // parentUID, + // managedBy: undefined, + // url: undefined, + // }); + } + + return result; } export async function listDashboards(parentUID?: string, page = 1, pageSize = PAGE_SIZE): Promise { diff --git a/public/app/features/browse-dashboards/components/CheckboxCell.tsx b/public/app/features/browse-dashboards/components/CheckboxCell.tsx index c86fd681c95..00d573bcf05 100644 --- a/public/app/features/browse-dashboards/components/CheckboxCell.tsx +++ b/public/app/features/browse-dashboards/components/CheckboxCell.tsx @@ -40,7 +40,7 @@ export default function CheckboxCell({ } } - if (isSharedWithMe(item.uid)) { + if (isSharedWithMe(item.uid) || item.uid === 'teamfolders') { return ; } diff --git a/public/app/features/browse-dashboards/components/DashboardsTree.tsx b/public/app/features/browse-dashboards/components/DashboardsTree.tsx index a7999919db9..5096f568499 100644 --- a/public/app/features/browse-dashboards/components/DashboardsTree.tsx +++ b/public/app/features/browse-dashboards/components/DashboardsTree.tsx @@ -8,7 +8,8 @@ import InfiniteLoader from 'react-window-infinite-loader'; import { GrafanaTheme2, isTruthy } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; -import { useStyles2 } from '@grafana/ui'; +import { Avatar, useStyles2 } from '@grafana/ui'; +import { TeamOwnerReference } from 'app/core/components/OwnerReferences/OwnerReference'; import { DashboardViewItem } from 'app/features/search/types'; import { @@ -102,8 +103,27 @@ export function DashboardsTree({ Header: t('browse-dashboards.dashboards-tree.tags-column', 'Tags'), Cell: (props: DashboardsTreeCellProps) => , }; + const ownerReferencesColumn: DashboardsTreeColumn = { + id: 'ownerReferences', + width: 2, + Header: 'Owner', + Cell: ({ row: { original: data } }) => { + const ownerReferences = data.item.ownerReferences; + if (!ownerReferences) { + return null; + } + + return ( +
+ {ownerReferences.map((ownerReference) => { + return ; + })} +
+ ); + }, + }; const canSelect = canSelectItems(permissions); - const columns = [canSelect && checkboxColumn, nameColumn, tagsColumns].filter(isTruthy); + const columns = [canSelect && checkboxColumn, nameColumn, ownerReferencesColumn, tagsColumns].filter(isTruthy); return columns; }, [onFolderClick, onTagClick, permissions]); diff --git a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx index 7c3070c4184..3765dfe6092 100644 --- a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx +++ b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx @@ -6,6 +6,7 @@ import { locationService, reportInteraction } from '@grafana/runtime'; import { Button, Drawer, Dropdown, Icon, Menu, MenuItem, Text } from '@grafana/ui'; import { appEvents } from 'app/core/app_events'; import { Permissions } from 'app/core/components/AccessControl/Permissions'; +import { ManageOwnerReferences } from 'app/core/components/OwnerReferences/ManageOwnerReferences'; import { RepoType } from 'app/features/provisioning/Wizard/types'; import { BulkMoveProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource'; import { DeleteProvisionedFolderForm } from 'app/features/provisioning/components/Folders/DeleteProvisionedFolderForm'; @@ -30,6 +31,7 @@ interface Props { export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props) { const [isOpen, setIsOpen] = useState(false); const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false); + const [showManageOwnersDrawer, setShowManageOwnersDrawer] = useState(false); const [showDeleteProvisionedFolderDrawer, setShowDeleteProvisionedFolderDrawer] = useState(false); const [showMoveProvisionedFolderDrawer, setShowMoveProvisionedFolderDrawer] = useState(false); const [moveFolder] = useMoveFolderMutationFacade(); @@ -126,14 +128,18 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props) }; const managePermissionsLabel = t('browse-dashboards.folder-actions-button.manage-permissions', 'Manage permissions'); + const manageOwnersLabel = t('browse-dashboards.folder-actions-button.manage-folder-owners', 'Manage folder owners'); const moveLabel = t('browse-dashboards.folder-actions-button.move', 'Move this folder'); const deleteLabel = t('browse-dashboards.folder-actions-button.delete', 'Delete this folder'); + const showManageOwners = canViewPermissions && !isProvisionedFolder; + const menu = ( {canViewPermissions && !isProvisionedFolder && ( setShowPermissionsDrawer(true)} label={managePermissionsLabel} /> )} + {showManageOwners && setShowManageOwnersDrawer(true)} label={manageOwnersLabel} />} {canMoveFolder && !isReadOnlyRepo && ( )} + {showManageOwnersDrawer && ( + setShowManageOwnersDrawer(false)} + size="md" + > + + + )} {showDeleteProvisionedFolderDrawer && ( { + return getBackendSrv().get('/apis/folder.grafana.app/v1beta1/namespaces/default/folders'); +}; + export const refetchChildren = createAsyncThunk( 'browseDashboards/refetchChildren', async ({ parentUID, pageSize }: RefetchChildrenArgs): Promise => { @@ -66,6 +71,7 @@ export const refetchChildren = createAsyncThunk( let fetchKind: DashboardViewItemKind | undefined = 'folder'; let children = await listFolders(uid, undefined, page, pageSize); + let lastPageOfKind = children.length < pageSize; // If we've loaded all folders, load the first page of dashboards. @@ -136,6 +142,16 @@ export const fetchNextChildrenPage = createAsyncThunk( ? await listFolders(uid, undefined, page, pageSize) : await listDashboards(uid, page, pageSize); + const foldersWithOwnerRefs = await hackGetOwnerRefs(); + + children.forEach((child) => { + const ownerRefs = foldersWithOwnerRefs.items.find((folder) => folder.metadata.name === child.uid)?.metadata + .ownerReferences; + if (ownerRefs) { + child.ownerReferences = ownerRefs; + } + }); + let lastPageOfKind = children.length < pageSize; // If we've loaded all folders, load the first page of dashboards. diff --git a/public/app/features/browse-dashboards/state/hooks.ts b/public/app/features/browse-dashboards/state/hooks.ts index 2c0bee62a98..cda772cdea1 100644 --- a/public/app/features/browse-dashboards/state/hooks.ts +++ b/public/app/features/browse-dashboards/state/hooks.ts @@ -183,7 +183,7 @@ export function createFlatTree( const items = [thisItem, ...mappedChildren]; - if (isSharedWithMe(thisItem.item.uid)) { + if (isSharedWithMe(thisItem.item.uid) || thisItem.item.uid === 'teamfolders') { items.push({ item: { kind: 'ui', diff --git a/public/app/features/search/page/components/ActionRow.tsx b/public/app/features/search/page/components/ActionRow.tsx index bafa0e10d84..79689050c0c 100644 --- a/public/app/features/search/page/components/ActionRow.tsx +++ b/public/app/features/search/page/components/ActionRow.tsx @@ -1,10 +1,11 @@ import { css } from '@emotion/css'; -import { FormEvent } from 'react'; +import { FormEvent, useMemo } from 'react'; +import { useListTeamQuery } from '@grafana/api-clients/rtkq/iam/v0alpha1'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; -import { Button, Checkbox, Stack, RadioButtonGroup, useStyles2 } from '@grafana/ui'; +import { Button, Checkbox, Stack, RadioButtonGroup, useStyles2, Combobox } from '@grafana/ui'; import { SortPicker } from 'app/core/components/Select/SortPicker'; import { TagFilter, TermCount } from 'app/core/components/TagFilter/TagFilter'; @@ -76,10 +77,25 @@ export const ActionRow = ({ ? [SearchLayout.Folders] : []; + const teams = useListTeamQuery({}); + + const teamOptions = useMemo(() => { + return teams.data?.items.map((team) => ({ + label: team.spec.title, + value: team.metadata.name || '', + })); + }, [teams.data?.items]); return ( + {}} + placeholder="Filter by owner" + options={teamOptions || []} + isClearable={false} + /> {config.featureToggles.panelTitleSearch && ( )} + {/*
+ +
*/} {state.datasource && ( diff --git a/public/app/features/teams/OwnedResources.tsx b/public/app/features/teams/OwnedResources.tsx new file mode 100644 index 00000000000..9d207fa6287 --- /dev/null +++ b/public/app/features/teams/OwnedResources.tsx @@ -0,0 +1,23 @@ +import { useListFolderQuery } from '@grafana/api-clients/rtkq/folder/v1beta1'; +import { Stack, Text, Link, Icon } from '@grafana/ui'; +import { Team } from 'app/types/teams'; + +export const OwnedResources = ({ team }: { team: Team }) => { + const { data } = useListFolderQuery({}); + const ownedFolders = data?.items.filter((folder) => + folder.metadata.ownerReferences?.some((ref) => ref.uid === team.uid) + ); + return ( + + Owned folders: + {ownedFolders && + ownedFolders.map((folder) => ( +
+ + {folder.spec.title} + +
+ ))} +
+ ); +}; diff --git a/public/app/features/teams/TeamPages.tsx b/public/app/features/teams/TeamPages.tsx index 3391a08c17d..6098249a457 100644 --- a/public/app/features/teams/TeamPages.tsx +++ b/public/app/features/teams/TeamPages.tsx @@ -11,6 +11,7 @@ import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types/accessControl'; import { StoreState, useSelector } from 'app/types/store'; +import { OwnedResources } from './OwnedResources'; import TeamGroupSync, { TeamSyncUpgradeContent } from './TeamGroupSync'; import TeamPermissions from './TeamPermissions'; import TeamSettings from './TeamSettings'; @@ -26,9 +27,10 @@ enum PageTypes { Members = 'members', Settings = 'settings', GroupSync = 'groupsync', + Resources = 'resources', } -const PAGES = ['members', 'settings', 'groupsync']; +const PAGES = ['members', 'settings', 'groupsync', 'resources']; const pageNavSelector = createSelector( [ @@ -59,24 +61,30 @@ const TeamPages = memo(() => { const renderPage = () => { const currentPage = PAGES.includes(pageName) ? pageName : PAGES[0]; - const canReadTeam = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsRead, team!); + if (!team) { + return null; + } + + const canReadTeam = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsRead, team); const canReadTeamPermissions = contextSrv.hasPermissionInMetadata( AccessControlAction.ActionTeamsPermissionsRead, - team! + team ); const canWriteTeamPermissions = contextSrv.hasPermissionInMetadata( AccessControlAction.ActionTeamsPermissionsWrite, - team! + team ); switch (currentPage) { case PageTypes.Members: if (canReadTeamPermissions) { - return ; + return ; } return null; case PageTypes.Settings: - return canReadTeam && ; + return canReadTeam && ; + case PageTypes.Resources: + return canReadTeam && ; case PageTypes.GroupSync: if (isSyncEnabled.current) { if (canReadTeamPermissions) { diff --git a/public/app/features/teams/TeamSettings.tsx b/public/app/features/teams/TeamSettings.tsx index 7a847d9ebf3..e5bfc1f9dca 100644 --- a/public/app/features/teams/TeamSettings.tsx +++ b/public/app/features/teams/TeamSettings.tsx @@ -1,7 +1,7 @@ import { useForm } from 'react-hook-form'; import { Trans, t } from '@grafana/i18n'; -import { Button, Field, FieldSet, Input, Stack } from '@grafana/ui'; +import { Button, Divider, Field, FieldSet, Input, Stack } from '@grafana/ui'; import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker'; import { useRoleOptions } from 'app/core/components/RolePicker/hooks'; import { SharedPreferences } from 'app/core/components/SharedPreferences/SharedPreferences'; @@ -97,6 +97,7 @@ const TeamSettings = ({ team }: Props) => { Save team details +
); diff --git a/public/app/features/teams/hooks.ts b/public/app/features/teams/hooks.ts index 2c563ef3d2f..cd3335bab15 100644 --- a/public/app/features/teams/hooks.ts +++ b/public/app/features/teams/hooks.ts @@ -1,6 +1,7 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { useEffect, useMemo } from 'react'; +import { useCreateFolder } from 'app/api/clients/folder/v1beta1/hooks'; import { useSearchTeamsQuery as useLegacySearchTeamsQuery, useCreateTeamMutation, @@ -127,14 +128,16 @@ export const useDeleteTeam = () => { export const useCreateTeam = () => { const [createTeam, response] = useCreateTeamMutation(); const [setTeamRoles] = useSetTeamRolesMutation(); + const [createFolder] = useCreateFolder(); - const trigger = async (team: CreateTeamCommand, pendingRoles?: Role[]) => { + const trigger = async (team: CreateTeamCommand, pendingRoles?: Role[], createTeamFolder?: boolean) => { const mutationResult = await createTeam({ createTeamCommand: team, }); const { data } = mutationResult; + // Add any pending roles to the team if (data && data.teamId && pendingRoles && pendingRoles.length) { await contextSrv.fetchUserPermissions(); if (contextSrv.licensedAccessControlEnabled() && canUpdateRoles()) { @@ -147,6 +150,14 @@ export const useCreateTeam = () => { } } + if (data && data.teamId && createTeamFolder) { + await createFolder({ + title: team.name, + createAsTeamFolder: true, + teamUid: data.uid, + }); + } + return mutationResult; }; diff --git a/public/app/features/teams/state/navModel.ts b/public/app/features/teams/state/navModel.ts index b6267c73d0e..22920e33723 100644 --- a/public/app/features/teams/state/navModel.ts +++ b/public/app/features/teams/state/navModel.ts @@ -59,6 +59,13 @@ export function buildNavModel(team: Team): NavModelItem { url: `org/teams/edit/${team.uid}/members`, }); } + navModel.children!.push({ + active: false, + icon: 'folder', + id: `team-resources-${team.uid}`, + text: 'Resources', + url: `org/teams/edit/${team.uid}/resources`, + }); const teamGroupSync: NavModelItem = { active: false,