diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx index 58bd7a6d7cc..aa0616d4d44 100644 --- a/public/app/core/components/AppChrome/AppChrome.tsx +++ b/public/app/core/components/AppChrome/AppChrome.tsx @@ -116,6 +116,7 @@ const getStyles = (theme: GrafanaTheme2) => { label: 'page-container', flexGrow: 1, minHeight: 0, + minWidth: 0, }), skipLink: css({ position: 'absolute', diff --git a/public/app/core/components/Page/EditableTitle.test.tsx b/public/app/core/components/Page/EditableTitle.test.tsx new file mode 100644 index 00000000000..fec1b44915b --- /dev/null +++ b/public/app/core/components/Page/EditableTitle.test.tsx @@ -0,0 +1,147 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { FetchError } from '@grafana/runtime'; + +import { EditableTitle } from './EditableTitle'; + +describe('EditableTitle', () => { + let user: ReturnType; + const value = 'Test'; + + beforeEach(() => { + jest.useFakeTimers(); + user = userEvent.setup({ delay: null }); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const mockEdit = jest.fn().mockImplementation((newValue: string) => Promise.resolve(newValue)); + + it('displays the provided text correctly', () => { + render(); + expect(screen.getByRole('heading', { name: value })).toBeInTheDocument(); + }); + + it('displays an edit button', () => { + render(); + expect(screen.getByRole('button', { name: 'Edit title' })).toBeInTheDocument(); + }); + + it('clicking the edit button changes the text to an input and autofocuses', async () => { + render(); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + + const editButton = screen.getByRole('button', { name: 'Edit title' }); + await user.click(editButton); + + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Edit title' })).not.toBeInTheDocument(); + expect(document.activeElement).toBe(screen.getByRole('textbox')); + }); + + it('blurring the input calls the onEdit callback and reverts back to text', async () => { + render(); + + const editButton = screen.getByRole('button', { name: 'Edit title' }); + await user.click(editButton); + + const input = screen.getByRole('textbox'); + await user.clear(input); + await user.type(input, 'New value'); + + await user.click(document.body); + expect(mockEdit).toHaveBeenCalledWith('New value'); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.getByRole('heading')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Edit title' })).toBeInTheDocument(); + }); + }); + + it('pressing enter calls the onEdit callback and reverts back to text', async () => { + render(); + + const editButton = screen.getByRole('button', { name: 'Edit title' }); + await user.click(editButton); + + const input = screen.getByRole('textbox'); + await user.clear(input); + await user.type(input, 'New value'); + + await user.keyboard('{enter}'); + expect(mockEdit).toHaveBeenCalledWith('New value'); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.getByRole('heading')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Edit title' })).toBeInTheDocument(); + }); + }); + + it('displays an error message when attempting to save an empty value', async () => { + render(); + + const editButton = screen.getByRole('button', { name: 'Edit title' }); + await user.click(editButton); + + const input = screen.getByRole('textbox'); + await user.clear(input); + await user.keyboard('{enter}'); + + expect(screen.getByText('Please enter a title')).toBeInTheDocument(); + }); + + it('displays a regular error message', async () => { + const mockEditError = jest.fn().mockImplementation(() => { + throw new Error('Uh oh spaghettios'); + }); + render(); + + const editButton = screen.getByRole('button', { name: 'Edit title' }); + await user.click(editButton); + + const input = screen.getByRole('textbox'); + await user.clear(input); + await user.type(input, 'New value'); + + await user.keyboard('{enter}'); + expect(screen.getByText('Uh oh spaghettios')).toBeInTheDocument(); + }); + + it('displays a detailed fetch error message', async () => { + const mockEditError = jest.fn().mockImplementation(() => { + const fetchError: FetchError = { + status: 500, + config: { + url: '', + }, + data: { + message: 'Uh oh spaghettios a fetch error', + }, + }; + throw fetchError; + }); + render(); + + const editButton = screen.getByRole('button', { name: 'Edit title' }); + await user.click(editButton); + + const input = screen.getByRole('textbox'); + await user.clear(input); + await user.type(input, 'New value'); + + await user.keyboard('{enter}'); + expect(screen.getByText('Uh oh spaghettios a fetch error')).toBeInTheDocument(); + }); +}); diff --git a/public/app/core/components/Page/EditableTitle.tsx b/public/app/core/components/Page/EditableTitle.tsx new file mode 100644 index 00000000000..ffe380a4e3b --- /dev/null +++ b/public/app/core/components/Page/EditableTitle.tsx @@ -0,0 +1,121 @@ +import { css } from '@emotion/css'; +import React, { useCallback, useEffect, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { isFetchError } from '@grafana/runtime'; +import { Field, IconButton, Input, useStyles2 } from '@grafana/ui'; +import { H1 } from '@grafana/ui/src/unstable'; + +export interface Props { + value: string; + onEdit: (newValue: string) => Promise; +} + +export const EditableTitle = ({ value, onEdit }: Props) => { + const styles = useStyles2(getStyles); + const [localValue, setLocalValue] = useState(); + const [isEditing, setIsEditing] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + + // sync local value with prop value + useEffect(() => { + setLocalValue(value); + }, [value]); + + const onCommitChange = useCallback( + async (event: React.FormEvent) => { + const newValue = event.currentTarget.value; + + if (!newValue) { + setErrorMessage('Please enter a title'); + } else if (newValue === value) { + // no need to bother saving if the value hasn't changed + // just clear any previous error messages and exit edit mode + setErrorMessage(undefined); + setIsEditing(false); + } else { + setIsLoading(true); + try { + await onEdit(newValue); + setErrorMessage(undefined); + setIsEditing(false); + } catch (error) { + if (isFetchError(error)) { + setErrorMessage(error.data.message); + } else if (error instanceof Error) { + setErrorMessage(error.message); + } + } + setIsLoading(false); + } + }, + [onEdit, value] + ); + + return !isEditing ? ( +
+
+ {/* + use localValue instead of value + this is to prevent the title from flickering back to the old value after the user has edited + caused by the delay between the save completing and the new value being refetched + */} +

{localValue}

+ setIsEditing(true)} /> +
+
+ ) : ( +
+ + { + if (event.key === 'Enter') { + onCommitChange(event); + } + }} + // perfectly reasonable to autofocus here since we've made a conscious choice by clicking the edit button + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus + onBlur={onCommitChange} + onChange={(event) => setLocalValue(event.currentTarget.value)} + onFocus={() => setIsEditing(true)} + /> + +
+ ); +}; + +EditableTitle.displayName = 'EditableTitle'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + textContainer: css({ + minWidth: 0, + }), + field: css({ + flex: 1, + // magic number here to ensure the input text lines up exactly with the h1 text + // input has a 1px border + theme.spacing(1) padding so we need to offset that + left: `calc(-${theme.spacing(1)} - 1px)`, + position: 'relative', + marginBottom: 0, + }), + input: css({ + input: { + ...theme.typography.h1, + }, + }), + inputContainer: css({ + display: 'flex', + flex: 1, + }), + textWrapper: css({ + alignItems: 'center', + display: 'flex', + gap: theme.spacing(1), + }), + }; +}; diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx index 3f080d90bef..88a11b3fce2 100644 --- a/public/app/core/components/Page/Page.tsx +++ b/public/app/core/components/Page/Page.tsx @@ -18,6 +18,7 @@ export const Page: PageType = ({ navModel: oldNavProp, pageNav, renderTitle, + onEditTitle, actions, subTitle, children, @@ -56,6 +57,7 @@ export const Page: PageType = ({ {pageHeaderNav && ( Promise; } -export function PageHeader({ navItem, renderTitle, actions, info, subTitle }: Props) { +export function PageHeader({ navItem, renderTitle, actions, info, subTitle, onEditTitle }: Props) { const styles = useStyles2(getStyles); const sub = subTitle ?? navItem.subTitle; - const titleElement = renderTitle ? renderTitle(navItem.text) :

{navItem.text}

; + const titleElement = onEditTitle ? ( + + ) : ( +
+ {navItem.img && {`logo} + {renderTitle ? renderTitle(navItem.text) :

{navItem.text}

} +
+ ); return (
-
- {navItem.img && {`logo} - {titleElement} -
+ {titleElement} {info && }
{actions}
@@ -42,7 +48,7 @@ export function PageHeader({ navItem, renderTitle, actions, info, subTitle }: Pr const getStyles = (theme: GrafanaTheme2) => { return { topRow: css({ - alignItems: 'center', + alignItems: 'flex-start', display: 'flex', flexDirection: 'row', flexWrap: 'wrap', @@ -69,6 +75,7 @@ const getStyles = (theme: GrafanaTheme2) => { gap: theme.spacing(1, 4), justifyContent: 'space-between', maxWidth: '100%', + minWidth: '300px', }), pageHeader: css({ label: 'page-header', diff --git a/public/app/core/components/Page/types.ts b/public/app/core/components/Page/types.ts index 6b9264324e6..aa76f10af73 100644 --- a/public/app/core/components/Page/types.ts +++ b/public/app/core/components/Page/types.ts @@ -13,6 +13,7 @@ export interface PageProps extends HTMLAttributes { info?: PageInfoItem[]; /** Can be used to place actions inline with the heading */ actions?: React.ReactNode; + onEditTitle?: (newValue: string) => Promise; /** Can be used to customize rendering of title */ renderTitle?: (title: string) => React.ReactNode; /** Can be used to customize or customize and set a page sub title */ diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index f4ff8a9d222..76169d3d8ab 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -12,7 +12,7 @@ import { buildNavModel, getDashboardsTabID } from '../folders/state/navModel'; import { useSearchStateManager } from '../search/state/SearchStateManager'; import { getSearchPlaceholder } from '../search/tempI18nPhrases'; -import { skipToken, useGetFolderQuery } from './api/browseDashboardsAPI'; +import { skipToken, useGetFolderQuery, useSaveFolderMutation } from './api/browseDashboardsAPI'; import { BrowseActions } from './components/BrowseActions/BrowseActions'; import { BrowseFilters } from './components/BrowseFilters'; import { BrowseView } from './components/BrowseView'; @@ -59,6 +59,7 @@ const BrowseDashboardsPage = memo(({ match }: Props) => { }, [isSearching, searchState.result, stateManager]); const { data: folderDTO } = useGetFolderQuery(folderUID ?? skipToken); + const [saveFolder] = useSaveFolderMutation(); const navModel = useMemo(() => { if (!folderDTO) { return undefined; @@ -78,10 +79,25 @@ const BrowseDashboardsPage = memo(({ match }: Props) => { const { canEditInFolder, canCreateDashboards, canCreateFolder } = getFolderPermissions(folderDTO); + const onEditTitle = folderUID + ? async (newValue: string) => { + if (folderDTO) { + const result = await saveFolder({ + ...folderDTO, + title: newValue, + }); + if ('error' in result) { + throw result.error; + } + } + } + : undefined; + return ( {folderDTO && } diff --git a/public/app/features/browse-dashboards/BrowseFolderAlertingPage.tsx b/public/app/features/browse-dashboards/BrowseFolderAlertingPage.tsx index b4124d61f85..54908ba4d15 100644 --- a/public/app/features/browse-dashboards/BrowseFolderAlertingPage.tsx +++ b/public/app/features/browse-dashboards/BrowseFolderAlertingPage.tsx @@ -7,15 +7,16 @@ import { useSelector } from 'app/types'; import { AlertsFolderView } from '../alerting/unified/AlertsFolderView'; -import { useGetFolderQuery } from './api/browseDashboardsAPI'; +import { useGetFolderQuery, useSaveFolderMutation } from './api/browseDashboardsAPI'; import { FolderActionsButton } from './components/FolderActionsButton'; export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {} export function BrowseFolderAlertingPage({ match }: OwnProps) { const { uid: folderUID } = match.params; - const { data: folderDTO, isLoading } = useGetFolderQuery(folderUID); + const { data: folderDTO } = useGetFolderQuery(folderUID); const folder = useSelector((state) => state.folder); + const [saveFolder] = useSaveFolderMutation(); const navModel = useMemo(() => { if (!folderDTO) { @@ -32,13 +33,28 @@ export function BrowseFolderAlertingPage({ match }: OwnProps) { return model; }, [folderDTO]); + const onEditTitle = folderUID + ? async (newValue: string) => { + if (folderDTO) { + const result = await saveFolder({ + ...folderDTO, + title: newValue, + }); + if ('error' in result) { + throw result.error; + } + } + } + : undefined; + return ( {folderDTO && }} > - + diff --git a/public/app/features/browse-dashboards/BrowseFolderLibraryPanelsPage.tsx b/public/app/features/browse-dashboards/BrowseFolderLibraryPanelsPage.tsx index 45e0e269cf4..4c52890a54f 100644 --- a/public/app/features/browse-dashboards/BrowseFolderLibraryPanelsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseFolderLibraryPanelsPage.tsx @@ -9,14 +9,15 @@ import { LibraryPanelsSearch } from '../library-panels/components/LibraryPanelsS import { OpenLibraryPanelModal } from '../library-panels/components/OpenLibraryPanelModal/OpenLibraryPanelModal'; import { LibraryElementDTO } from '../library-panels/types'; -import { useGetFolderQuery } from './api/browseDashboardsAPI'; +import { useGetFolderQuery, useSaveFolderMutation } from './api/browseDashboardsAPI'; export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {} export function BrowseFolderLibraryPanelsPage({ match }: OwnProps) { const { uid: folderUID } = match.params; - const { data: folderDTO, isLoading } = useGetFolderQuery(folderUID); + const { data: folderDTO } = useGetFolderQuery(folderUID); const [selected, setSelected] = useState(undefined); + const [saveFolder] = useSaveFolderMutation(); const navModel = useMemo(() => { if (!folderDTO) { @@ -33,13 +34,28 @@ export function BrowseFolderLibraryPanelsPage({ match }: OwnProps) { return model; }, [folderDTO]); + const onEditTitle = folderUID + ? async (newValue: string) => { + if (folderDTO) { + const result = await saveFolder({ + ...folderDTO, + title: newValue, + }); + if ('error' in result) { + throw result.error; + } + } + } + : undefined; + return ( {folderDTO && }} > - + ({ url: `/folders/${folderUID}`, params: { accesscontrol: true } }), providesTags: (_result, _error, arg) => [{ type: 'getFolder', id: arg }], }), + saveFolder: builder.mutation({ + invalidatesTags: (_result, _error, args) => [{ type: 'getFolder', id: args.uid }], + query: (folder) => ({ + method: 'PUT', + showErrorAlert: false, + url: `/folders/${folder.uid}`, + data: { + title: folder.title, + version: folder.version, + }, + }), + }), getAffectedItems: builder.query({ queryFn: async (selectedItems) => { const folderUIDs = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]); @@ -80,5 +92,6 @@ export const browseDashboardsAPI = createApi({ }), }); -export const { endpoints, useGetAffectedItemsQuery, useGetFolderQuery, useMoveFolderMutation } = browseDashboardsAPI; +export const { endpoints, useGetAffectedItemsQuery, useGetFolderQuery, useMoveFolderMutation, useSaveFolderMutation } = + browseDashboardsAPI; export { skipToken } from '@reduxjs/toolkit/query/react'; diff --git a/public/app/features/folders/state/navModel.ts b/public/app/features/folders/state/navModel.ts index f33f5f4adb7..cdbbb7fdecc 100644 --- a/public/app/features/folders/state/navModel.ts +++ b/public/app/features/folders/state/navModel.ts @@ -63,16 +63,16 @@ export function buildNavModel(folder: FolderDTO, parents = folder.parents): NavM url: `${folder.url}/permissions`, }); } - } - if (folder.canSave) { - model.children!.push({ - active: false, - icon: 'cog', - id: getSettingsTabID(folder.uid), - text: 'Settings', - url: `${folder.url}/settings`, - }); + if (folder.canSave) { + model.children!.push({ + active: false, + icon: 'cog', + id: getSettingsTabID(folder.uid), + text: 'Settings', + url: `${folder.url}/settings`, + }); + } } return model;