Page: Add inline rename functionality (#68828)

* initial attempt at inline rename

* handle version correctly

* refactor

* minor tweaks

* add unit tests

* prettier...

* add to other tabs, remove settings tab when feature toggle is enabled

* fix truncation

* allow title to span full width of page

* fix h1 styling when no renderTitle/onEditTitle is present

* better layout

* use input from grafana/ui, fix imports

* fix unit test

* better error handling

* don't use autosavefield

* undo changes to AutoSaveField

* remove timeout

* remove maxWidth now we're not using AutoSaveField

* rename isEditInProgress to isLoading

* sync localValue with value

* better responsive css
This commit is contained in:
Ashley Harrison 2023-05-31 18:03:54 +02:00 committed by GitHub
parent f29b058927
commit 10adebd7b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 364 additions and 24 deletions

View File

@ -116,6 +116,7 @@ const getStyles = (theme: GrafanaTheme2) => {
label: 'page-container',
flexGrow: 1,
minHeight: 0,
minWidth: 0,
}),
skipLink: css({
position: 'absolute',

View File

@ -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<typeof userEvent.setup>;
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(<EditableTitle value={value} onEdit={mockEdit} />);
expect(screen.getByRole('heading', { name: value })).toBeInTheDocument();
});
it('displays an edit button', () => {
render(<EditableTitle value={value} onEdit={mockEdit} />);
expect(screen.getByRole('button', { name: 'Edit title' })).toBeInTheDocument();
});
it('clicking the edit button changes the text to an input and autofocuses', async () => {
render(<EditableTitle value={value} onEdit={mockEdit} />);
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(<EditableTitle value={value} onEdit={mockEdit} />);
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(<EditableTitle value={value} onEdit={mockEdit} />);
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(<EditableTitle value={value} onEdit={mockEdit} />);
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(<EditableTitle value={value} onEdit={mockEditError} />);
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(<EditableTitle value={value} onEdit={mockEditError} />);
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();
});
});

View File

@ -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<void>;
}
export const EditableTitle = ({ value, onEdit }: Props) => {
const styles = useStyles2(getStyles);
const [localValue, setLocalValue] = useState<string>();
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string>();
// sync local value with prop value
useEffect(() => {
setLocalValue(value);
}, [value]);
const onCommitChange = useCallback(
async (event: React.FormEvent<HTMLInputElement>) => {
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 ? (
<div className={styles.textContainer}>
<div className={styles.textWrapper}>
{/*
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
*/}
<H1 truncate>{localValue}</H1>
<IconButton name="pen" size="lg" tooltip="Edit title" onClick={() => setIsEditing(true)} />
</div>
</div>
) : (
<div className={styles.inputContainer}>
<Field className={styles.field} loading={isLoading} invalid={!!errorMessage} error={errorMessage}>
<Input
className={styles.input}
defaultValue={localValue}
onKeyDown={(event) => {
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)}
/>
</Field>
</div>
);
};
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),
}),
};
};

View File

@ -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 && (
<PageHeader
actions={actions}
onEditTitle={onEditTitle}
navItem={pageHeaderNav}
renderTitle={renderTitle}
info={info}

View File

@ -6,6 +6,7 @@ import { useStyles2 } from '@grafana/ui';
import { PageInfo } from '../PageInfo/PageInfo';
import { EditableTitle } from './EditableTitle';
import { PageInfoItem } from './types';
export interface Props {
@ -14,22 +15,27 @@ export interface Props {
actions?: React.ReactNode;
info?: PageInfoItem[];
subTitle?: React.ReactNode;
onEditTitle?: (newValue: string) => Promise<void>;
}
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) : <h1>{navItem.text}</h1>;
const titleElement = onEditTitle ? (
<EditableTitle value={navItem.text} onEdit={onEditTitle} />
) : (
<div className={styles.title}>
{navItem.img && <img className={styles.img} src={navItem.img} alt={`logo for ${navItem.text}`} />}
{renderTitle ? renderTitle(navItem.text) : <h1>{navItem.text}</h1>}
</div>
);
return (
<div className={styles.pageHeader}>
<div className={styles.topRow}>
<div className={styles.titleInfoContainer}>
<div className={styles.title}>
{navItem.img && <img className={styles.img} src={navItem.img} alt={`logo for ${navItem.text}`} />}
{titleElement}
</div>
{info && <PageInfo info={info} />}
</div>
<div className={styles.actions}>{actions}</div>
@ -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',

View File

@ -13,6 +13,7 @@ export interface PageProps extends HTMLAttributes<HTMLDivElement> {
info?: PageInfoItem[];
/** Can be used to place actions inline with the heading */
actions?: React.ReactNode;
onEditTitle?: (newValue: string) => Promise<void>;
/** 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 */

View File

@ -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 (
<Page
navId="dashboards/browse"
pageNav={navModel}
onEditTitle={onEditTitle}
actions={
<>
{folderDTO && <FolderActionsButton folder={folderDTO} />}

View File

@ -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 (
<Page
navId="dashboards/browse"
pageNav={navModel}
onEditTitle={onEditTitle}
actions={<>{folderDTO && <FolderActionsButton folder={folderDTO} />}</>}
>
<Page.Contents isLoading={isLoading}>
<Page.Contents>
<AlertsFolderView folder={folder} />
</Page.Contents>
</Page>

View File

@ -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<LibraryElementDTO | undefined>(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 (
<Page
navId="dashboards/browse"
pageNav={navModel}
onEditTitle={onEditTitle}
actions={<>{folderDTO && <FolderActionsButton folder={folderDTO} />}</>}
>
<Page.Contents isLoading={isLoading}>
<Page.Contents>
<LibraryPanelsSearch
onClick={setSelected}
currentFolderUID={folderUID}

View File

@ -40,6 +40,18 @@ export const browseDashboardsAPI = createApi({
query: (folderUID) => ({ url: `/folders/${folderUID}`, params: { accesscontrol: true } }),
providesTags: (_result, _error, arg) => [{ type: 'getFolder', id: arg }],
}),
saveFolder: builder.mutation<FolderDTO, FolderDTO>({
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<DescendantCount, DashboardTreeSelection>({
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';

View File

@ -63,7 +63,6 @@ export function buildNavModel(folder: FolderDTO, parents = folder.parents): NavM
url: `${folder.url}/permissions`,
});
}
}
if (folder.canSave) {
model.children!.push({
@ -74,6 +73,7 @@ export function buildNavModel(folder: FolderDTO, parents = folder.parents): NavM
url: `${folder.url}/settings`,
});
}
}
return model;
}