mirror of
https://github.com/grafana/grafana.git
synced 2025-12-20 19:44:55 +08:00
Compare commits
1 Commits
dynamicall
...
folders/cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
688d6746c9 |
@@ -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();
|
||||
|
||||
|
||||
@@ -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 { 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}>
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function CheckboxCell({
|
||||
}
|
||||
}
|
||||
|
||||
if (isSharedWithMe(item.uid)) {
|
||||
if (isSharedWithMe(item.uid) || item.uid === 'teamfolders') {
|
||||
return <CheckboxSpacer />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
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 { 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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user