mirror of
https://github.com/grafana/grafana.git
synced 2025-12-22 04:34:27 +08:00
Compare commits
1 Commits
docs/add-a
...
folders/cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
688d6746c9 |
@@ -4,7 +4,13 @@ import { useEffect, useMemo } from 'react';
|
|||||||
import { AppEvents } from '@grafana/data';
|
import { AppEvents } from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { config, getAppEvents } from '@grafana/runtime';
|
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 { useAppNotification } from 'app/core/copy/appNotification';
|
||||||
import {
|
import {
|
||||||
useDeleteFolderMutation as useDeleteFolderMutationLegacy,
|
useDeleteFolderMutation as useDeleteFolderMutationLegacy,
|
||||||
@@ -56,6 +62,8 @@ import {
|
|||||||
ReplaceFolderApiArg,
|
ReplaceFolderApiArg,
|
||||||
useGetAffectedItemsQuery,
|
useGetAffectedItemsQuery,
|
||||||
FolderInfo,
|
FolderInfo,
|
||||||
|
ObjectMeta,
|
||||||
|
OwnerReference,
|
||||||
} from './index';
|
} from './index';
|
||||||
|
|
||||||
function getFolderUrl(uid: string, title: string): string {
|
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}`;
|
return `${config.appSubUrl}/dashboards/f/${uid}/${slug}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CombinedFolder = FolderDTO & {
|
||||||
|
ownerReferences?: OwnerReference[];
|
||||||
|
};
|
||||||
|
|
||||||
const combineFolderResponses = (
|
const combineFolderResponses = (
|
||||||
folder: Folder,
|
folder: Folder,
|
||||||
legacyFolder: FolderDTO,
|
legacyFolder: FolderDTO,
|
||||||
@@ -75,7 +87,7 @@ const combineFolderResponses = (
|
|||||||
const updatedBy = folder.metadata.annotations?.[AnnoKeyUpdatedBy];
|
const updatedBy = folder.metadata.annotations?.[AnnoKeyUpdatedBy];
|
||||||
const createdBy = folder.metadata.annotations?.[AnnoKeyCreatedBy];
|
const createdBy = folder.metadata.annotations?.[AnnoKeyCreatedBy];
|
||||||
|
|
||||||
const newData: FolderDTO = {
|
const newData: CombinedFolder = {
|
||||||
canAdmin: legacyFolder.canAdmin,
|
canAdmin: legacyFolder.canAdmin,
|
||||||
canDelete: legacyFolder.canDelete,
|
canDelete: legacyFolder.canDelete,
|
||||||
canEdit: legacyFolder.canEdit,
|
canEdit: legacyFolder.canEdit,
|
||||||
@@ -84,6 +96,7 @@ const combineFolderResponses = (
|
|||||||
createdBy: (createdBy && userDisplay?.display[userDisplay?.keys.indexOf(createdBy)]?.displayName) || 'Anonymous',
|
createdBy: (createdBy && userDisplay?.display[userDisplay?.keys.indexOf(createdBy)]?.displayName) || 'Anonymous',
|
||||||
updatedBy: (updatedBy && userDisplay?.display[userDisplay?.keys.indexOf(updatedBy)]?.displayName) || 'Anonymous',
|
updatedBy: (updatedBy && userDisplay?.display[userDisplay?.keys.indexOf(updatedBy)]?.displayName) || 'Anonymous',
|
||||||
...appPlatformFolderToLegacyFolder(folder),
|
...appPlatformFolderToLegacyFolder(folder),
|
||||||
|
ownerReferences: folder.metadata.ownerReferences || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (parents.length) {
|
if (parents.length) {
|
||||||
@@ -101,7 +114,7 @@ const combineFolderResponses = (
|
|||||||
return newData;
|
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 isVirtualFolder = uid && [GENERAL_FOLDER_UID, config.sharedWithMeFolderUID].includes(uid);
|
||||||
const shouldUseAppPlatformAPI = Boolean(config.featureToggles.foldersAppPlatformAPI);
|
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
|
// Stitch together the responses to create a single FolderDTO object so on the outside this behaves as the legacy
|
||||||
// api client.
|
// api client.
|
||||||
let newData: FolderDTO | undefined = undefined;
|
let newData: CombinedFolder | undefined = undefined;
|
||||||
if (
|
if (
|
||||||
resultFolder.data &&
|
resultFolder.data &&
|
||||||
resultParents.data &&
|
resultParents.data &&
|
||||||
@@ -359,14 +372,36 @@ export function useCreateFolder() {
|
|||||||
return legacyHook;
|
return legacyHook;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createFolderAppPlatform = async (folder: NewFolder) => {
|
const createFolderAppPlatform = async (payload: NewFolder & { createAsTeamFolder?: boolean; teamUid?: string }) => {
|
||||||
const payload: CreateFolderApiArg = {
|
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: {
|
folder: {
|
||||||
spec: {
|
spec: {
|
||||||
title: folder.title,
|
title: folder.title,
|
||||||
|
description: 'Testing a description',
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
generateName: 'f',
|
...partialMetadata,
|
||||||
annotations: {
|
annotations: {
|
||||||
...(folder.parentUid && { [AnnoKeyFolder]: folder.parentUid }),
|
...(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 });
|
refresh({ childrenOf: folder.parentUid });
|
||||||
deletedDashboardsCache.clear();
|
deletedDashboardsCache.clear();
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
55
public/app/core/components/OwnerReferences/hooks.ts
Normal file
55
public/app/core/components/OwnerReferences/hooks.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -6,8 +6,9 @@ import AutoSizer from 'react-virtualized-auto-sizer';
|
|||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Trans } from '@grafana/i18n';
|
import { Trans } from '@grafana/i18n';
|
||||||
import { config, reportInteraction } from '@grafana/runtime';
|
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 { 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 { Page } from 'app/core/components/Page/Page';
|
||||||
import { getConfig } from 'app/core/config';
|
import { getConfig } from 'app/core/config';
|
||||||
import { useDispatch } from 'app/types/store';
|
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 (
|
return (
|
||||||
<Page
|
<Page
|
||||||
navId="dashboards/browse"
|
navId="dashboards/browse"
|
||||||
@@ -153,7 +167,8 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
|
|||||||
onEditTitle={showEditTitle ? onEditTitle : undefined}
|
onEditTitle={showEditTitle ? onEditTitle : undefined}
|
||||||
renderTitle={renderTitle}
|
renderTitle={renderTitle}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<Stack alignItems="center">
|
||||||
|
{ownerReferences}
|
||||||
{config.featureToggles.restoreDashboards && hasAdminRights && (
|
{config.featureToggles.restoreDashboards && hasAdminRights && (
|
||||||
<LinkButton
|
<LinkButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -173,7 +188,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
|
|||||||
isReadOnlyRepo={isReadOnlyRepo}
|
isReadOnlyRepo={isReadOnlyRepo}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</Stack>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Page.Contents className={styles.pageContents}>
|
<Page.Contents className={styles.pageContents}>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export async function listFolders(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return folders.map((item) => ({
|
const result = folders.map((item) => ({
|
||||||
kind: 'folder',
|
kind: 'folder',
|
||||||
uid: item.uid,
|
uid: item.uid,
|
||||||
title: item.title,
|
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
|
// URLs from the backend come with subUrlPrefix already included, so match that behaviour here
|
||||||
url: isSharedWithMe(item.uid) ? undefined : getFolderURL(item.uid),
|
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[]> {
|
export async function listDashboards(parentUID?: string, page = 1, pageSize = PAGE_SIZE): Promise<DashboardViewItem[]> {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function CheckboxCell({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSharedWithMe(item.uid)) {
|
if (isSharedWithMe(item.uid) || item.uid === 'teamfolders') {
|
||||||
return <CheckboxSpacer />;
|
return <CheckboxSpacer />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import InfiniteLoader from 'react-window-infinite-loader';
|
|||||||
import { GrafanaTheme2, isTruthy } from '@grafana/data';
|
import { GrafanaTheme2, isTruthy } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
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 { DashboardViewItem } from 'app/features/search/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -102,8 +103,27 @@ export function DashboardsTree({
|
|||||||
Header: t('browse-dashboards.dashboards-tree.tags-column', 'Tags'),
|
Header: t('browse-dashboards.dashboards-tree.tags-column', 'Tags'),
|
||||||
Cell: (props: DashboardsTreeCellProps) => <TagsCell {...props} onTagClick={onTagClick} />,
|
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 canSelect = canSelectItems(permissions);
|
||||||
const columns = [canSelect && checkboxColumn, nameColumn, tagsColumns].filter(isTruthy);
|
const columns = [canSelect && checkboxColumn, nameColumn, ownerReferencesColumn, tagsColumns].filter(isTruthy);
|
||||||
|
|
||||||
return columns;
|
return columns;
|
||||||
}, [onFolderClick, onTagClick, permissions]);
|
}, [onFolderClick, onTagClick, permissions]);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { locationService, reportInteraction } from '@grafana/runtime';
|
|||||||
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem, Text } from '@grafana/ui';
|
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem, Text } from '@grafana/ui';
|
||||||
import { appEvents } from 'app/core/app_events';
|
import { appEvents } from 'app/core/app_events';
|
||||||
import { Permissions } from 'app/core/components/AccessControl/Permissions';
|
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 { RepoType } from 'app/features/provisioning/Wizard/types';
|
||||||
import { BulkMoveProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource';
|
import { BulkMoveProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource';
|
||||||
import { DeleteProvisionedFolderForm } from 'app/features/provisioning/components/Folders/DeleteProvisionedFolderForm';
|
import { DeleteProvisionedFolderForm } from 'app/features/provisioning/components/Folders/DeleteProvisionedFolderForm';
|
||||||
@@ -30,6 +31,7 @@ interface Props {
|
|||||||
export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props) {
|
export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false);
|
const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false);
|
||||||
|
const [showManageOwnersDrawer, setShowManageOwnersDrawer] = useState(false);
|
||||||
const [showDeleteProvisionedFolderDrawer, setShowDeleteProvisionedFolderDrawer] = useState(false);
|
const [showDeleteProvisionedFolderDrawer, setShowDeleteProvisionedFolderDrawer] = useState(false);
|
||||||
const [showMoveProvisionedFolderDrawer, setShowMoveProvisionedFolderDrawer] = useState(false);
|
const [showMoveProvisionedFolderDrawer, setShowMoveProvisionedFolderDrawer] = useState(false);
|
||||||
const [moveFolder] = useMoveFolderMutationFacade();
|
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 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 moveLabel = t('browse-dashboards.folder-actions-button.move', 'Move this folder');
|
||||||
const deleteLabel = t('browse-dashboards.folder-actions-button.delete', 'Delete this folder');
|
const deleteLabel = t('browse-dashboards.folder-actions-button.delete', 'Delete this folder');
|
||||||
|
|
||||||
|
const showManageOwners = canViewPermissions && !isProvisionedFolder;
|
||||||
|
|
||||||
const menu = (
|
const menu = (
|
||||||
<Menu>
|
<Menu>
|
||||||
{canViewPermissions && !isProvisionedFolder && (
|
{canViewPermissions && !isProvisionedFolder && (
|
||||||
<MenuItem onClick={() => setShowPermissionsDrawer(true)} label={managePermissionsLabel} />
|
<MenuItem onClick={() => setShowPermissionsDrawer(true)} label={managePermissionsLabel} />
|
||||||
)}
|
)}
|
||||||
|
{showManageOwners && <MenuItem onClick={() => setShowManageOwnersDrawer(true)} label={manageOwnersLabel} />}
|
||||||
{canMoveFolder && !isReadOnlyRepo && (
|
{canMoveFolder && !isReadOnlyRepo && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={isProvisionedFolder ? handleShowMoveProvisionedFolderDrawer : showMoveModal}
|
onClick={isProvisionedFolder ? handleShowMoveProvisionedFolderDrawer : showMoveModal}
|
||||||
@@ -180,6 +186,16 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
|
|||||||
<Permissions resource="folders" resourceId={folder.uid} canSetPermissions={canSetPermissions} />
|
<Permissions resource="folders" resourceId={folder.uid} canSetPermissions={canSetPermissions} />
|
||||||
</Drawer>
|
</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 && (
|
{showDeleteProvisionedFolderDrawer && (
|
||||||
<Drawer
|
<Drawer
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getBackendSrv } from '@grafana/runtime';
|
||||||
import { GENERAL_FOLDER_UID } from 'app/features/search/constants';
|
import { GENERAL_FOLDER_UID } from 'app/features/search/constants';
|
||||||
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
||||||
import { createAsyncThunk } from 'app/types/store';
|
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(
|
export const refetchChildren = createAsyncThunk(
|
||||||
'browseDashboards/refetchChildren',
|
'browseDashboards/refetchChildren',
|
||||||
async ({ parentUID, pageSize }: RefetchChildrenArgs): Promise<RefetchChildrenResult> => {
|
async ({ parentUID, pageSize }: RefetchChildrenArgs): Promise<RefetchChildrenResult> => {
|
||||||
@@ -66,6 +71,7 @@ export const refetchChildren = createAsyncThunk(
|
|||||||
let fetchKind: DashboardViewItemKind | undefined = 'folder';
|
let fetchKind: DashboardViewItemKind | undefined = 'folder';
|
||||||
|
|
||||||
let children = await listFolders(uid, undefined, page, pageSize);
|
let children = await listFolders(uid, undefined, page, pageSize);
|
||||||
|
|
||||||
let lastPageOfKind = children.length < pageSize;
|
let lastPageOfKind = children.length < pageSize;
|
||||||
|
|
||||||
// If we've loaded all folders, load the first page of dashboards.
|
// 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 listFolders(uid, undefined, page, pageSize)
|
||||||
: await listDashboards(uid, 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;
|
let lastPageOfKind = children.length < pageSize;
|
||||||
|
|
||||||
// If we've loaded all folders, load the first page of dashboards.
|
// If we've loaded all folders, load the first page of dashboards.
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export function createFlatTree(
|
|||||||
|
|
||||||
const items = [thisItem, ...mappedChildren];
|
const items = [thisItem, ...mappedChildren];
|
||||||
|
|
||||||
if (isSharedWithMe(thisItem.item.uid)) {
|
if (isSharedWithMe(thisItem.item.uid) || thisItem.item.uid === 'teamfolders') {
|
||||||
items.push({
|
items.push({
|
||||||
item: {
|
item: {
|
||||||
kind: 'ui',
|
kind: 'ui',
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { css } from '@emotion/css';
|
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 { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { config } from '@grafana/runtime';
|
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 { SortPicker } from 'app/core/components/Select/SortPicker';
|
||||||
import { TagFilter, TermCount } from 'app/core/components/TagFilter/TagFilter';
|
import { TagFilter, TermCount } from 'app/core/components/TagFilter/TagFilter';
|
||||||
|
|
||||||
@@ -76,10 +77,25 @@ export const ActionRow = ({
|
|||||||
? [SearchLayout.Folders]
|
? [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 (
|
return (
|
||||||
<Stack justifyContent="space-between" alignItems="center">
|
<Stack justifyContent="space-between" alignItems="center">
|
||||||
<Stack gap={2} alignItems="center">
|
<Stack gap={2} alignItems="center">
|
||||||
<TagFilter isClearable={false} tags={state.tag} tagOptions={getTagOptions} onChange={onTagFilterChange} />
|
<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 && (
|
{config.featureToggles.panelTitleSearch && (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
data-testid="include-panels"
|
data-testid="include-panels"
|
||||||
@@ -99,6 +115,13 @@ export const ActionRow = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* <div className={styles.checkboxWrapper}>
|
||||||
|
<Checkbox
|
||||||
|
label={t('search.actions.owned-by-me', 'My team folders')}
|
||||||
|
onChange={onStarredFilterChange}
|
||||||
|
value={state.teamFolders}
|
||||||
|
/>
|
||||||
|
</div> */}
|
||||||
{state.datasource && (
|
{state.datasource && (
|
||||||
<Button icon="times" variant="secondary" onClick={() => onDatasourceChange(undefined)}>
|
<Button icon="times" variant="secondary" onClick={() => onDatasourceChange(undefined)}>
|
||||||
<Trans i18nKey="search.actions.remove-datasource-filter">
|
<Trans i18nKey="search.actions.remove-datasource-filter">
|
||||||
|
|||||||
@@ -416,7 +416,7 @@ export function toDashboardResults(rsp: SearchAPIResponse, sort: string): DataFr
|
|||||||
|
|
||||||
async function loadLocationInfo(): Promise<Record<string, LocationInfo>> {
|
async function loadLocationInfo(): Promise<Record<string, LocationInfo>> {
|
||||||
// TODO: use proper pagination for search.
|
// TODO: use proper pagination for search.
|
||||||
const uri = `${searchURI}?type=folders&limit=100000`;
|
const uri = `${searchURI}?type=folder&limit=100000`;
|
||||||
const rsp = getBackendSrv()
|
const rsp = getBackendSrv()
|
||||||
.get<SearchAPIResponse>(uri)
|
.get<SearchAPIResponse>(uri)
|
||||||
.then((rsp) => {
|
.then((rsp) => {
|
||||||
|
|||||||
@@ -60,8 +60,11 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getIconForItem(item: DashboardViewItemWithUIItems, isOpen?: boolean): IconName {
|
export function getIconForItem(item: DashboardViewItemWithUIItems, isOpen?: boolean): IconName {
|
||||||
if (item && isSharedWithMe(item.uid)) {
|
if (item.uid === 'teamfolders') {
|
||||||
return 'users-alt';
|
return 'users-alt';
|
||||||
|
}
|
||||||
|
if (item && isSharedWithMe(item.uid)) {
|
||||||
|
return 'share-alt';
|
||||||
} else {
|
} else {
|
||||||
return getIconForKind(item.kind, isOpen);
|
return getIconForKind(item.kind, isOpen);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Action } from 'redux';
|
import { Action } from 'redux';
|
||||||
|
|
||||||
|
import { OwnerReference } from '@grafana/api-clients/rtkq/folder/v1beta1';
|
||||||
import { WithAccessControlMetadata } from '@grafana/data';
|
import { WithAccessControlMetadata } from '@grafana/data';
|
||||||
|
|
||||||
import { ManagerKind } from '../apiserver/types';
|
import { ManagerKind } from '../apiserver/types';
|
||||||
@@ -83,6 +84,7 @@ export interface DashboardViewItem {
|
|||||||
sortMeta?: number | string; // value sorted by
|
sortMeta?: number | string; // value sorted by
|
||||||
sortMetaName?: string; // name of the value being sorted e.g. 'Views'
|
sortMetaName?: string; // name of the value being sorted e.g. 'Views'
|
||||||
managedBy?: ManagerKind;
|
managedBy?: ManagerKind;
|
||||||
|
ownerReferences?: OwnerReference[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchAction extends Action {
|
export interface SearchAction extends Action {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { UserEvent } from '@testing-library/user-event';
|
|||||||
import { Route, Routes } from 'react-router-dom-v5-compat';
|
import { Route, Routes } from 'react-router-dom-v5-compat';
|
||||||
import { render, screen, waitFor } from 'test/test-utils';
|
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 { setupMockServer } from '@grafana/test-utils/server';
|
||||||
import { MOCK_TEAMS } from '@grafana/test-utils/unstable';
|
import { MOCK_TEAMS } from '@grafana/test-utils/unstable';
|
||||||
import { backendSrv } from 'app/core/services/backend_srv';
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
@@ -27,9 +27,15 @@ const setup = async () => {
|
|||||||
return view;
|
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));
|
teamName && (await user.type(screen.getByRole('textbox', { name: /name/i }), teamName));
|
||||||
teamEmail && (await user.type(screen.getByLabelText(/email/i), teamEmail));
|
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 }));
|
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();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { useForm } from 'react-hook-form';
|
|||||||
|
|
||||||
import { NavModelItem } from '@grafana/data';
|
import { NavModelItem } from '@grafana/data';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { config, locationService } from '@grafana/runtime';
|
||||||
import { Button, Field, Input, FieldSet, Stack } from '@grafana/ui';
|
import { Button, Field, Input, FieldSet, Stack, Checkbox, Alert } from '@grafana/ui';
|
||||||
import { extractErrorMessage } from 'app/api/utils';
|
import { extractErrorMessage } from 'app/api/utils';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
|
import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
|
||||||
@@ -16,34 +16,42 @@ import { TeamDTO } from 'app/types/teams';
|
|||||||
|
|
||||||
import { useCreateTeam } from './hooks';
|
import { useCreateTeam } from './hooks';
|
||||||
|
|
||||||
const pageNav: NavModelItem = {
|
type NewTeamForm = TeamDTO & { createTeamFolder?: boolean };
|
||||||
|
|
||||||
|
export const CreateTeam = (): JSX.Element => {
|
||||||
|
const pageNav: NavModelItem = {
|
||||||
icon: 'users-alt',
|
icon: 'users-alt',
|
||||||
id: 'team-new',
|
id: 'team-new',
|
||||||
text: 'New team',
|
text: t('teams.create-team.page-title', 'New team'),
|
||||||
subTitle: 'Create a new team. Teams let you grant permissions to a group of users.',
|
subTitle: t(
|
||||||
};
|
'teams.create-team.page-subtitle',
|
||||||
|
'Create a new team. Teams let you grant permissions to a group of users.'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
const CreateTeam = (): JSX.Element => {
|
const teamFoldersEnabled = config.featureToggles.teamFolders;
|
||||||
|
const showRolesPicker = contextSrv.licensedAccessControlEnabled();
|
||||||
const currentOrgId = contextSrv.user.orgId;
|
const currentOrgId = contextSrv.user.orgId;
|
||||||
|
|
||||||
const notifyApp = useAppNotification();
|
const notifyApp = useAppNotification();
|
||||||
const [createTeamTrigger] = useCreateTeam();
|
const [createTeamTrigger, createResponse] = useCreateTeam();
|
||||||
const [pendingRoles, setPendingRoles] = useState<Role[]>([]);
|
const [pendingRoles, setPendingRoles] = useState<Role[]>([]);
|
||||||
const [{ roleOptions }] = useRoleOptions(currentOrgId);
|
const [{ roleOptions }] = useRoleOptions(currentOrgId);
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<TeamDTO>();
|
} = useForm<NewTeamForm>();
|
||||||
|
|
||||||
const createTeam = async (formModel: TeamDTO) => {
|
const createTeam = async (formModel: NewTeamForm) => {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await createTeamTrigger(
|
const { data, error } = await createTeamTrigger(
|
||||||
{
|
{
|
||||||
email: formModel.email || '',
|
email: formModel.email || '',
|
||||||
name: formModel.name,
|
name: formModel.name,
|
||||||
},
|
},
|
||||||
pendingRoles
|
pendingRoles,
|
||||||
|
formModel.createTeamFolder
|
||||||
);
|
);
|
||||||
|
|
||||||
const errorMessage = error ? extractErrorMessage(error) : undefined;
|
const errorMessage = error ? extractErrorMessage(error) : undefined;
|
||||||
@@ -73,11 +81,11 @@ const CreateTeam = (): JSX.Element => {
|
|||||||
label={t('teams.create-team.label-name', 'Name')}
|
label={t('teams.create-team.label-name', 'Name')}
|
||||||
required
|
required
|
||||||
invalid={!!errors.name}
|
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" />
|
<Input {...register('name', { required: true })} id="team-name" />
|
||||||
</Field>
|
</Field>
|
||||||
{contextSrv.licensedAccessControlEnabled() && (
|
{showRolesPicker && (
|
||||||
<Field noMargin label={t('teams.create-team.label-role', 'Role')}>
|
<Field noMargin label={t('teams.create-team.label-role', 'Role')}>
|
||||||
<TeamRolePicker
|
<TeamRolePicker
|
||||||
teamId={0}
|
teamId={0}
|
||||||
@@ -106,8 +114,37 @@ const CreateTeam = (): JSX.Element => {
|
|||||||
placeholder="email@test.com"
|
placeholder="email@test.com"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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>
|
</Stack>
|
||||||
</FieldSet>
|
</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">
|
<Button type="submit" variant="primary">
|
||||||
<Trans i18nKey="teams.create-team.create">Create</Trans>
|
<Trans i18nKey="teams.create-team.create">Create</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
23
public/app/features/teams/OwnedResources.tsx
Normal file
23
public/app/features/teams/OwnedResources.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ import { contextSrv } from 'app/core/services/context_srv';
|
|||||||
import { AccessControlAction } from 'app/types/accessControl';
|
import { AccessControlAction } from 'app/types/accessControl';
|
||||||
import { StoreState, useSelector } from 'app/types/store';
|
import { StoreState, useSelector } from 'app/types/store';
|
||||||
|
|
||||||
|
import { OwnedResources } from './OwnedResources';
|
||||||
import TeamGroupSync, { TeamSyncUpgradeContent } from './TeamGroupSync';
|
import TeamGroupSync, { TeamSyncUpgradeContent } from './TeamGroupSync';
|
||||||
import TeamPermissions from './TeamPermissions';
|
import TeamPermissions from './TeamPermissions';
|
||||||
import TeamSettings from './TeamSettings';
|
import TeamSettings from './TeamSettings';
|
||||||
@@ -26,9 +27,10 @@ enum PageTypes {
|
|||||||
Members = 'members',
|
Members = 'members',
|
||||||
Settings = 'settings',
|
Settings = 'settings',
|
||||||
GroupSync = 'groupsync',
|
GroupSync = 'groupsync',
|
||||||
|
Resources = 'resources',
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGES = ['members', 'settings', 'groupsync'];
|
const PAGES = ['members', 'settings', 'groupsync', 'resources'];
|
||||||
|
|
||||||
const pageNavSelector = createSelector(
|
const pageNavSelector = createSelector(
|
||||||
[
|
[
|
||||||
@@ -59,24 +61,30 @@ const TeamPages = memo(() => {
|
|||||||
const renderPage = () => {
|
const renderPage = () => {
|
||||||
const currentPage = PAGES.includes(pageName) ? pageName : PAGES[0];
|
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(
|
const canReadTeamPermissions = contextSrv.hasPermissionInMetadata(
|
||||||
AccessControlAction.ActionTeamsPermissionsRead,
|
AccessControlAction.ActionTeamsPermissionsRead,
|
||||||
team!
|
team
|
||||||
);
|
);
|
||||||
const canWriteTeamPermissions = contextSrv.hasPermissionInMetadata(
|
const canWriteTeamPermissions = contextSrv.hasPermissionInMetadata(
|
||||||
AccessControlAction.ActionTeamsPermissionsWrite,
|
AccessControlAction.ActionTeamsPermissionsWrite,
|
||||||
team!
|
team
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (currentPage) {
|
switch (currentPage) {
|
||||||
case PageTypes.Members:
|
case PageTypes.Members:
|
||||||
if (canReadTeamPermissions) {
|
if (canReadTeamPermissions) {
|
||||||
return <TeamPermissions team={team!} />;
|
return <TeamPermissions team={team} />;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
case PageTypes.Settings:
|
case PageTypes.Settings:
|
||||||
return canReadTeam && <TeamSettings team={team!} />;
|
return canReadTeam && <TeamSettings team={team} />;
|
||||||
|
case PageTypes.Resources:
|
||||||
|
return canReadTeam && <OwnedResources team={team} />;
|
||||||
case PageTypes.GroupSync:
|
case PageTypes.GroupSync:
|
||||||
if (isSyncEnabled.current) {
|
if (isSyncEnabled.current) {
|
||||||
if (canReadTeamPermissions) {
|
if (canReadTeamPermissions) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { Trans, t } from '@grafana/i18n';
|
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 { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
|
||||||
import { useRoleOptions } from 'app/core/components/RolePicker/hooks';
|
import { useRoleOptions } from 'app/core/components/RolePicker/hooks';
|
||||||
import { SharedPreferences } from 'app/core/components/SharedPreferences/SharedPreferences';
|
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>
|
<Trans i18nKey="teams.team-settings.save">Save team details</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
<Divider />
|
||||||
<SharedPreferences resourceUri={`teams/${team.id}`} disabled={!canWriteTeamSettings} preferenceType="team" />
|
<SharedPreferences resourceUri={`teams/${team.id}`} disabled={!canWriteTeamSettings} preferenceType="team" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { skipToken } from '@reduxjs/toolkit/query';
|
import { skipToken } from '@reduxjs/toolkit/query';
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useCreateFolder } from 'app/api/clients/folder/v1beta1/hooks';
|
||||||
import {
|
import {
|
||||||
useSearchTeamsQuery as useLegacySearchTeamsQuery,
|
useSearchTeamsQuery as useLegacySearchTeamsQuery,
|
||||||
useCreateTeamMutation,
|
useCreateTeamMutation,
|
||||||
@@ -127,14 +128,16 @@ export const useDeleteTeam = () => {
|
|||||||
export const useCreateTeam = () => {
|
export const useCreateTeam = () => {
|
||||||
const [createTeam, response] = useCreateTeamMutation();
|
const [createTeam, response] = useCreateTeamMutation();
|
||||||
const [setTeamRoles] = useSetTeamRolesMutation();
|
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({
|
const mutationResult = await createTeam({
|
||||||
createTeamCommand: team,
|
createTeamCommand: team,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data } = mutationResult;
|
const { data } = mutationResult;
|
||||||
|
|
||||||
|
// Add any pending roles to the team
|
||||||
if (data && data.teamId && pendingRoles && pendingRoles.length) {
|
if (data && data.teamId && pendingRoles && pendingRoles.length) {
|
||||||
await contextSrv.fetchUserPermissions();
|
await contextSrv.fetchUserPermissions();
|
||||||
if (contextSrv.licensedAccessControlEnabled() && canUpdateRoles()) {
|
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;
|
return mutationResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ export function buildNavModel(team: Team): NavModelItem {
|
|||||||
url: `org/teams/edit/${team.uid}/members`,
|
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 = {
|
const teamGroupSync: NavModelItem = {
|
||||||
active: false,
|
active: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user