mirror of https://github.com/grafana/grafana
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 csspull/69230/head^2
parent
f29b058927
commit
10adebd7b3
@ -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(); |
||||
}); |
||||
}); |
||||
@ -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), |
||||
}), |
||||
}; |
||||
}; |
||||
Loading…
Reference in new issue