Compare commits

...

1 Commits

Author SHA1 Message Date
Tom Ratcliffe
688d6746c9 --wip-- 2025-12-09 14:17:06 +00:00
22 changed files with 478 additions and 45 deletions

View File

@@ -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<FolderDTO> {
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();

View File

@@ -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 (
<Combobox
options={teamsOptions}
onChange={(team: ComboboxOption<string>) => {
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<OwnerReference | null>(null);
const ownerReferences = useGetOwnerReferences({ resource, resourceId });
const [trigger, result] = useAddOwnerReference({ resource, resourceId });
const addOwnerReference = (ownerReference: OwnerReference) => {
trigger(ownerReference);
};
return (
<Stack direction="column">
<Text variant="h3">Owned by:</Text>
<Box>
{ownerReferences
.filter((ownerReference) => ownerReference.kind === 'Team')
.map((ownerReference) => (
<>
<TeamOwnerReference key={ownerReference.uid} ownerReference={ownerReference} />
<Divider />
</>
))}
</Box>
<Box>
{addingNewReference && (
<Box paddingBottom={2}>
<Text variant="h3">Add new owner reference:</Text>
<TeamSelector
onChange={(ownerReference) => {
setPendingReference(ownerReference);
}}
/>
<Button
onClick={() => {
addOwnerReference(pendingReference);
}}
>
Save
</Button>
<Divider />
</Box>
)}
<Button onClick={() => setAddingNewReference(true)}>Add new owner reference</Button>
</Box>
</Stack>
);
};

View File

@@ -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 = (
<>
<Stack gap={1} direction="column">
<Text>Team members:</Text>
{teamMembers?.items?.map((member) => (
<div key={member.identity.name}>
<Avatar src={member.avatarURL} /> {member.displayName}
</div>
))}
</Stack>
</>
);
return (
<Link href={`/org/teams/edit/${ownerReference.uid}/members`} key={ownerReference.uid}>
<Tooltip content={membersTooltip}>
<Stack gap={1} alignItems="center">
<Avatar src={avatarURL} alt={ownerReference.name} /> {ownerReference.name}
</Stack>
</Tooltip>
</Link>
);
};

View File

@@ -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<OwnerReference[]>([]);
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;
};

View File

@@ -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<string
);
};
const ownerReferences = folderDTO && 'ownerReferences' in folderDTO && (
<Box>
{folderDTO.ownerReferences
?.filter((ref) => ref.kind === 'Team')
.map((ref) => (
<Stack key={ref.uid} direction="row">
<Text>Owned by team:</Text>
<TeamOwnerReference ownerReference={ref} />
</Stack>
))}
</Box>
);
return (
<Page
navId="dashboards/browse"
@@ -153,7 +167,8 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
onEditTitle={showEditTitle ? onEditTitle : undefined}
renderTitle={renderTitle}
actions={
<>
<Stack alignItems="center">
{ownerReferences}
{config.featureToggles.restoreDashboards && hasAdminRights && (
<LinkButton
variant="secondary"
@@ -173,7 +188,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
isReadOnlyRepo={isReadOnlyRepo}
/>
)}
</>
</Stack>
}
>
<Page.Contents className={styles.pageContents}>

View File

@@ -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<DashboardViewItem[]> {

View File

@@ -40,7 +40,7 @@ export default function CheckboxCell({
}
}
if (isSharedWithMe(item.uid)) {
if (isSharedWithMe(item.uid) || item.uid === 'teamfolders') {
return <CheckboxSpacer />;
}

View File

@@ -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) => <TagsCell {...props} onTagClick={onTagClick} />,
};
const ownerReferencesColumn: DashboardsTreeColumn = {
id: 'ownerReferences',
width: 2,
Header: 'Owner',
Cell: ({ row: { original: data } }) => {
const ownerReferences = data.item.ownerReferences;
if (!ownerReferences) {
return null;
}
return (
<div>
{ownerReferences.map((ownerReference) => {
return <TeamOwnerReference ownerReference={ownerReference} key={ownerReference.uid} />;
})}
</div>
);
},
};
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]);

View File

@@ -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 = (
<Menu>
{canViewPermissions && !isProvisionedFolder && (
<MenuItem onClick={() => setShowPermissionsDrawer(true)} label={managePermissionsLabel} />
)}
{showManageOwners && <MenuItem onClick={() => setShowManageOwnersDrawer(true)} label={manageOwnersLabel} />}
{canMoveFolder && !isReadOnlyRepo && (
<MenuItem
onClick={isProvisionedFolder ? handleShowMoveProvisionedFolderDrawer : showMoveModal}
@@ -180,6 +186,16 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
<Permissions resource="folders" resourceId={folder.uid} canSetPermissions={canSetPermissions} />
</Drawer>
)}
{showManageOwnersDrawer && (
<Drawer
title={t('browse-dashboards.action.manage-permissions-button', 'Manage owners')}
subtitle={folder.title}
onClose={() => setShowManageOwnersDrawer(false)}
size="md"
>
<ManageOwnerReferences resource="Folder" resourceId={folder.uid} />
</Drawer>
)}
{showDeleteProvisionedFolderDrawer && (
<Drawer
title={

View File

@@ -1,3 +1,4 @@
import { getBackendSrv } from '@grafana/runtime';
import { GENERAL_FOLDER_UID } from 'app/features/search/constants';
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
import { createAsyncThunk } from 'app/types/store';
@@ -53,6 +54,10 @@ export const refreshParents = createAsyncThunk(
}
);
const hackGetOwnerRefs = async () => {
return getBackendSrv().get('/apis/folder.grafana.app/v1beta1/namespaces/default/folders');
};
export const refetchChildren = createAsyncThunk(
'browseDashboards/refetchChildren',
async ({ parentUID, pageSize }: RefetchChildrenArgs): Promise<RefetchChildrenResult> => {
@@ -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.

View File

@@ -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',

View File

@@ -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 (
<Stack justifyContent="space-between" alignItems="center">
<Stack gap={2} alignItems="center">
<TagFilter isClearable={false} tags={state.tag} tagOptions={getTagOptions} onChange={onTagFilterChange} />
<Combobox
prefixIcon="user-arrows"
onChange={() => {}}
placeholder="Filter by owner"
options={teamOptions || []}
isClearable={false}
/>
{config.featureToggles.panelTitleSearch && (
<Checkbox
data-testid="include-panels"
@@ -99,6 +115,13 @@ export const ActionRow = ({
/>
</div>
)}
{/* <div className={styles.checkboxWrapper}>
<Checkbox
label={t('search.actions.owned-by-me', 'My team folders')}
onChange={onStarredFilterChange}
value={state.teamFolders}
/>
</div> */}
{state.datasource && (
<Button icon="times" variant="secondary" onClick={() => onDatasourceChange(undefined)}>
<Trans i18nKey="search.actions.remove-datasource-filter">

View File

@@ -416,7 +416,7 @@ export function toDashboardResults(rsp: SearchAPIResponse, sort: string): DataFr
async function loadLocationInfo(): Promise<Record<string, LocationInfo>> {
// TODO: use proper pagination for search.
const uri = `${searchURI}?type=folders&limit=100000`;
const uri = `${searchURI}?type=folder&limit=100000`;
const rsp = getBackendSrv()
.get<SearchAPIResponse>(uri)
.then((rsp) => {

View File

@@ -60,8 +60,11 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName {
}
export function getIconForItem(item: DashboardViewItemWithUIItems, isOpen?: boolean): IconName {
if (item && isSharedWithMe(item.uid)) {
if (item.uid === 'teamfolders') {
return 'users-alt';
}
if (item && isSharedWithMe(item.uid)) {
return 'share-alt';
} else {
return getIconForKind(item.kind, isOpen);
}

View File

@@ -1,5 +1,6 @@
import { Action } from 'redux';
import { OwnerReference } from '@grafana/api-clients/rtkq/folder/v1beta1';
import { WithAccessControlMetadata } from '@grafana/data';
import { ManagerKind } from '../apiserver/types';
@@ -83,6 +84,7 @@ export interface DashboardViewItem {
sortMeta?: number | string; // value sorted by
sortMetaName?: string; // name of the value being sorted e.g. 'Views'
managedBy?: ManagerKind;
ownerReferences?: OwnerReference[];
}
export interface SearchAction extends Action {

View File

@@ -2,7 +2,7 @@ import { UserEvent } from '@testing-library/user-event';
import { Route, Routes } from 'react-router-dom-v5-compat';
import { render, screen, waitFor } from 'test/test-utils';
import { setBackendSrv } from '@grafana/runtime';
import { config, setBackendSrv } from '@grafana/runtime';
import { setupMockServer } from '@grafana/test-utils/server';
import { MOCK_TEAMS } from '@grafana/test-utils/unstable';
import { backendSrv } from 'app/core/services/backend_srv';
@@ -27,9 +27,15 @@ const setup = async () => {
return view;
};
const attemptCreateTeam = async (user: UserEvent, teamName?: string, teamEmail?: string) => {
const attemptCreateTeam = async (
user: UserEvent,
teamName?: string,
teamEmail?: string,
createTeamFolder?: boolean
) => {
teamName && (await user.type(screen.getByRole('textbox', { name: /name/i }), teamName));
teamEmail && (await user.type(screen.getByLabelText(/email/i), teamEmail));
createTeamFolder && (await user.click(screen.getByLabelText(/auto-create a team folder/i)));
await user.click(screen.getByRole('button', { name: /create/i }));
};
@@ -72,4 +78,22 @@ describe('Create team', () => {
expect(screen.queryByText(/edit team page/i)).not.toBeInTheDocument();
});
describe('team folders enabled', () => {
const originalFeatureToggles = config.featureToggles;
beforeEach(() => {
config.featureToggles = { ...originalFeatureToggles, teamFolders: true };
});
afterEach(() => {
config.featureToggles = originalFeatureToggles;
});
it('renders team folder checkbox', async () => {
const { user } = await setup();
await attemptCreateTeam(user, MOCK_TEAMS[0].spec.title, undefined, true);
expect(screen.queryByText(/edit team page/i)).not.toBeInTheDocument();
});
});
});

View File

@@ -3,8 +3,8 @@ import { useForm } from 'react-hook-form';
import { NavModelItem } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { locationService } from '@grafana/runtime';
import { Button, Field, Input, FieldSet, Stack } from '@grafana/ui';
import { config, locationService } from '@grafana/runtime';
import { Button, Field, Input, FieldSet, Stack, Checkbox, Alert } from '@grafana/ui';
import { extractErrorMessage } from 'app/api/utils';
import { Page } from 'app/core/components/Page/Page';
import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
@@ -16,34 +16,42 @@ import { TeamDTO } from 'app/types/teams';
import { useCreateTeam } from './hooks';
const pageNav: NavModelItem = {
icon: 'users-alt',
id: 'team-new',
text: 'New team',
subTitle: 'Create a new team. Teams let you grant permissions to a group of users.',
};
type NewTeamForm = TeamDTO & { createTeamFolder?: boolean };
const CreateTeam = (): JSX.Element => {
export const CreateTeam = (): JSX.Element => {
const pageNav: NavModelItem = {
icon: 'users-alt',
id: 'team-new',
text: t('teams.create-team.page-title', 'New team'),
subTitle: t(
'teams.create-team.page-subtitle',
'Create a new team. Teams let you grant permissions to a group of users.'
),
};
const teamFoldersEnabled = config.featureToggles.teamFolders;
const showRolesPicker = contextSrv.licensedAccessControlEnabled();
const currentOrgId = contextSrv.user.orgId;
const notifyApp = useAppNotification();
const [createTeamTrigger] = useCreateTeam();
const [createTeamTrigger, createResponse] = useCreateTeam();
const [pendingRoles, setPendingRoles] = useState<Role[]>([]);
const [{ roleOptions }] = useRoleOptions(currentOrgId);
const {
handleSubmit,
register,
formState: { errors },
} = useForm<TeamDTO>();
} = useForm<NewTeamForm>();
const createTeam = async (formModel: TeamDTO) => {
const createTeam = async (formModel: NewTeamForm) => {
try {
const { data, error } = await createTeamTrigger(
{
email: formModel.email || '',
name: formModel.name,
},
pendingRoles
pendingRoles,
formModel.createTeamFolder
);
const errorMessage = error ? extractErrorMessage(error) : undefined;
@@ -73,11 +81,11 @@ const CreateTeam = (): JSX.Element => {
label={t('teams.create-team.label-name', 'Name')}
required
invalid={!!errors.name}
error="Team name is required"
error={t('teams.create-team.error-name-required', 'Team name is required')}
>
<Input {...register('name', { required: true })} id="team-name" />
</Field>
{contextSrv.licensedAccessControlEnabled() && (
{showRolesPicker && (
<Field noMargin label={t('teams.create-team.label-role', 'Role')}>
<TeamRolePicker
teamId={0}
@@ -106,8 +114,37 @@ const CreateTeam = (): JSX.Element => {
placeholder="email@test.com"
/>
</Field>
{teamFoldersEnabled && (
<Field
noMargin
label={t('teams.create-team.team-folder', 'Team folder')}
description={t(
'teams.create-team.description-team-folder',
'This creates a folder associated with the team, where users can add resources like dashboards and schedules with the right permissions.'
)}
>
<Checkbox
{...register('createTeamFolder')}
id="team-folder"
label={t(
'teams.create-team.team-folder-label-autocreate-a-team-folder',
'Auto-create a team folder'
)}
/>
</Field>
)}
</Stack>
</FieldSet>
{Boolean(createResponse.error) && (
<Alert title={t('teams.create-team.error-title', 'Error creating team')} severity="error">
<Trans i18nKey="teams.create-team.error-message">
We were unable to create your new team. Please try again later or contact support.
</Trans>
<br />
<br />
<div>{extractErrorMessage(createResponse.error)}</div>
</Alert>
)}
<Button type="submit" variant="primary">
<Trans i18nKey="teams.create-team.create">Create</Trans>
</Button>

View File

@@ -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 (
<Stack gap={1} direction="column">
<Text variant="h3">Owned folders:</Text>
{ownedFolders &&
ownedFolders.map((folder) => (
<div key={folder.metadata.uid}>
<Link href={`/dashboards/f/${folder.metadata.name}`}>
<Icon name="folder" /> <Text>{folder.spec.title}</Text>
</Link>
</div>
))}
</Stack>
);
};

View File

@@ -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 <TeamPermissions team={team!} />;
return <TeamPermissions team={team} />;
}
return null;
case PageTypes.Settings:
return canReadTeam && <TeamSettings team={team!} />;
return canReadTeam && <TeamSettings team={team} />;
case PageTypes.Resources:
return canReadTeam && <OwnedResources team={team} />;
case PageTypes.GroupSync:
if (isSyncEnabled.current) {
if (canReadTeamPermissions) {

View File

@@ -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) => {
<Trans i18nKey="teams.team-settings.save">Save team details</Trans>
</Button>
</form>
<Divider />
<SharedPreferences resourceUri={`teams/${team.id}`} disabled={!canWriteTeamSettings} preferenceType="team" />
</Stack>
);

View File

@@ -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;
};

View File

@@ -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,