mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
f29b058927
commit
10adebd7b3
@ -116,6 +116,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
label: 'page-container',
|
label: 'page-container',
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
}),
|
}),
|
||||||
skipLink: css({
|
skipLink: css({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
147
public/app/core/components/Page/EditableTitle.test.tsx
Normal file
147
public/app/core/components/Page/EditableTitle.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
121
public/app/core/components/Page/EditableTitle.tsx
Normal file
121
public/app/core/components/Page/EditableTitle.tsx
Normal 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),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
@ -18,6 +18,7 @@ export const Page: PageType = ({
|
|||||||
navModel: oldNavProp,
|
navModel: oldNavProp,
|
||||||
pageNav,
|
pageNav,
|
||||||
renderTitle,
|
renderTitle,
|
||||||
|
onEditTitle,
|
||||||
actions,
|
actions,
|
||||||
subTitle,
|
subTitle,
|
||||||
children,
|
children,
|
||||||
@ -56,6 +57,7 @@ export const Page: PageType = ({
|
|||||||
{pageHeaderNav && (
|
{pageHeaderNav && (
|
||||||
<PageHeader
|
<PageHeader
|
||||||
actions={actions}
|
actions={actions}
|
||||||
|
onEditTitle={onEditTitle}
|
||||||
navItem={pageHeaderNav}
|
navItem={pageHeaderNav}
|
||||||
renderTitle={renderTitle}
|
renderTitle={renderTitle}
|
||||||
info={info}
|
info={info}
|
||||||
|
@ -6,6 +6,7 @@ import { useStyles2 } from '@grafana/ui';
|
|||||||
|
|
||||||
import { PageInfo } from '../PageInfo/PageInfo';
|
import { PageInfo } from '../PageInfo/PageInfo';
|
||||||
|
|
||||||
|
import { EditableTitle } from './EditableTitle';
|
||||||
import { PageInfoItem } from './types';
|
import { PageInfoItem } from './types';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
@ -14,22 +15,27 @@ export interface Props {
|
|||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
info?: PageInfoItem[];
|
info?: PageInfoItem[];
|
||||||
subTitle?: React.ReactNode;
|
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 styles = useStyles2(getStyles);
|
||||||
const sub = subTitle ?? navItem.subTitle;
|
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 (
|
return (
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div className={styles.topRow}>
|
<div className={styles.topRow}>
|
||||||
<div className={styles.titleInfoContainer}>
|
<div className={styles.titleInfoContainer}>
|
||||||
<div className={styles.title}>
|
{titleElement}
|
||||||
{navItem.img && <img className={styles.img} src={navItem.img} alt={`logo for ${navItem.text}`} />}
|
|
||||||
{titleElement}
|
|
||||||
</div>
|
|
||||||
{info && <PageInfo info={info} />}
|
{info && <PageInfo info={info} />}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.actions}>{actions}</div>
|
<div className={styles.actions}>{actions}</div>
|
||||||
@ -42,7 +48,7 @@ export function PageHeader({ navItem, renderTitle, actions, info, subTitle }: Pr
|
|||||||
const getStyles = (theme: GrafanaTheme2) => {
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
return {
|
return {
|
||||||
topRow: css({
|
topRow: css({
|
||||||
alignItems: 'center',
|
alignItems: 'flex-start',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
@ -69,6 +75,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
gap: theme.spacing(1, 4),
|
gap: theme.spacing(1, 4),
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
|
minWidth: '300px',
|
||||||
}),
|
}),
|
||||||
pageHeader: css({
|
pageHeader: css({
|
||||||
label: 'page-header',
|
label: 'page-header',
|
||||||
|
@ -13,6 +13,7 @@ export interface PageProps extends HTMLAttributes<HTMLDivElement> {
|
|||||||
info?: PageInfoItem[];
|
info?: PageInfoItem[];
|
||||||
/** Can be used to place actions inline with the heading */
|
/** Can be used to place actions inline with the heading */
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
|
onEditTitle?: (newValue: string) => Promise<void>;
|
||||||
/** Can be used to customize rendering of title */
|
/** Can be used to customize rendering of title */
|
||||||
renderTitle?: (title: string) => React.ReactNode;
|
renderTitle?: (title: string) => React.ReactNode;
|
||||||
/** Can be used to customize or customize and set a page sub title */
|
/** Can be used to customize or customize and set a page sub title */
|
||||||
|
@ -12,7 +12,7 @@ import { buildNavModel, getDashboardsTabID } from '../folders/state/navModel';
|
|||||||
import { useSearchStateManager } from '../search/state/SearchStateManager';
|
import { useSearchStateManager } from '../search/state/SearchStateManager';
|
||||||
import { getSearchPlaceholder } from '../search/tempI18nPhrases';
|
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 { BrowseActions } from './components/BrowseActions/BrowseActions';
|
||||||
import { BrowseFilters } from './components/BrowseFilters';
|
import { BrowseFilters } from './components/BrowseFilters';
|
||||||
import { BrowseView } from './components/BrowseView';
|
import { BrowseView } from './components/BrowseView';
|
||||||
@ -59,6 +59,7 @@ const BrowseDashboardsPage = memo(({ match }: Props) => {
|
|||||||
}, [isSearching, searchState.result, stateManager]);
|
}, [isSearching, searchState.result, stateManager]);
|
||||||
|
|
||||||
const { data: folderDTO } = useGetFolderQuery(folderUID ?? skipToken);
|
const { data: folderDTO } = useGetFolderQuery(folderUID ?? skipToken);
|
||||||
|
const [saveFolder] = useSaveFolderMutation();
|
||||||
const navModel = useMemo(() => {
|
const navModel = useMemo(() => {
|
||||||
if (!folderDTO) {
|
if (!folderDTO) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -78,10 +79,25 @@ const BrowseDashboardsPage = memo(({ match }: Props) => {
|
|||||||
|
|
||||||
const { canEditInFolder, canCreateDashboards, canCreateFolder } = getFolderPermissions(folderDTO);
|
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 (
|
return (
|
||||||
<Page
|
<Page
|
||||||
navId="dashboards/browse"
|
navId="dashboards/browse"
|
||||||
pageNav={navModel}
|
pageNav={navModel}
|
||||||
|
onEditTitle={onEditTitle}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{folderDTO && <FolderActionsButton folder={folderDTO} />}
|
{folderDTO && <FolderActionsButton folder={folderDTO} />}
|
||||||
|
@ -7,15 +7,16 @@ import { useSelector } from 'app/types';
|
|||||||
|
|
||||||
import { AlertsFolderView } from '../alerting/unified/AlertsFolderView';
|
import { AlertsFolderView } from '../alerting/unified/AlertsFolderView';
|
||||||
|
|
||||||
import { useGetFolderQuery } from './api/browseDashboardsAPI';
|
import { useGetFolderQuery, useSaveFolderMutation } from './api/browseDashboardsAPI';
|
||||||
import { FolderActionsButton } from './components/FolderActionsButton';
|
import { FolderActionsButton } from './components/FolderActionsButton';
|
||||||
|
|
||||||
export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {}
|
export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {}
|
||||||
|
|
||||||
export function BrowseFolderAlertingPage({ match }: OwnProps) {
|
export function BrowseFolderAlertingPage({ match }: OwnProps) {
|
||||||
const { uid: folderUID } = match.params;
|
const { uid: folderUID } = match.params;
|
||||||
const { data: folderDTO, isLoading } = useGetFolderQuery(folderUID);
|
const { data: folderDTO } = useGetFolderQuery(folderUID);
|
||||||
const folder = useSelector((state) => state.folder);
|
const folder = useSelector((state) => state.folder);
|
||||||
|
const [saveFolder] = useSaveFolderMutation();
|
||||||
|
|
||||||
const navModel = useMemo(() => {
|
const navModel = useMemo(() => {
|
||||||
if (!folderDTO) {
|
if (!folderDTO) {
|
||||||
@ -32,13 +33,28 @@ export function BrowseFolderAlertingPage({ match }: OwnProps) {
|
|||||||
return model;
|
return model;
|
||||||
}, [folderDTO]);
|
}, [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 (
|
return (
|
||||||
<Page
|
<Page
|
||||||
navId="dashboards/browse"
|
navId="dashboards/browse"
|
||||||
pageNav={navModel}
|
pageNav={navModel}
|
||||||
|
onEditTitle={onEditTitle}
|
||||||
actions={<>{folderDTO && <FolderActionsButton folder={folderDTO} />}</>}
|
actions={<>{folderDTO && <FolderActionsButton folder={folderDTO} />}</>}
|
||||||
>
|
>
|
||||||
<Page.Contents isLoading={isLoading}>
|
<Page.Contents>
|
||||||
<AlertsFolderView folder={folder} />
|
<AlertsFolderView folder={folder} />
|
||||||
</Page.Contents>
|
</Page.Contents>
|
||||||
</Page>
|
</Page>
|
||||||
|
@ -9,14 +9,15 @@ import { LibraryPanelsSearch } from '../library-panels/components/LibraryPanelsS
|
|||||||
import { OpenLibraryPanelModal } from '../library-panels/components/OpenLibraryPanelModal/OpenLibraryPanelModal';
|
import { OpenLibraryPanelModal } from '../library-panels/components/OpenLibraryPanelModal/OpenLibraryPanelModal';
|
||||||
import { LibraryElementDTO } from '../library-panels/types';
|
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 interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {}
|
||||||
|
|
||||||
export function BrowseFolderLibraryPanelsPage({ match }: OwnProps) {
|
export function BrowseFolderLibraryPanelsPage({ match }: OwnProps) {
|
||||||
const { uid: folderUID } = match.params;
|
const { uid: folderUID } = match.params;
|
||||||
const { data: folderDTO, isLoading } = useGetFolderQuery(folderUID);
|
const { data: folderDTO } = useGetFolderQuery(folderUID);
|
||||||
const [selected, setSelected] = useState<LibraryElementDTO | undefined>(undefined);
|
const [selected, setSelected] = useState<LibraryElementDTO | undefined>(undefined);
|
||||||
|
const [saveFolder] = useSaveFolderMutation();
|
||||||
|
|
||||||
const navModel = useMemo(() => {
|
const navModel = useMemo(() => {
|
||||||
if (!folderDTO) {
|
if (!folderDTO) {
|
||||||
@ -33,13 +34,28 @@ export function BrowseFolderLibraryPanelsPage({ match }: OwnProps) {
|
|||||||
return model;
|
return model;
|
||||||
}, [folderDTO]);
|
}, [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 (
|
return (
|
||||||
<Page
|
<Page
|
||||||
navId="dashboards/browse"
|
navId="dashboards/browse"
|
||||||
pageNav={navModel}
|
pageNav={navModel}
|
||||||
|
onEditTitle={onEditTitle}
|
||||||
actions={<>{folderDTO && <FolderActionsButton folder={folderDTO} />}</>}
|
actions={<>{folderDTO && <FolderActionsButton folder={folderDTO} />}</>}
|
||||||
>
|
>
|
||||||
<Page.Contents isLoading={isLoading}>
|
<Page.Contents>
|
||||||
<LibraryPanelsSearch
|
<LibraryPanelsSearch
|
||||||
onClick={setSelected}
|
onClick={setSelected}
|
||||||
currentFolderUID={folderUID}
|
currentFolderUID={folderUID}
|
||||||
|
@ -40,6 +40,18 @@ export const browseDashboardsAPI = createApi({
|
|||||||
query: (folderUID) => ({ url: `/folders/${folderUID}`, params: { accesscontrol: true } }),
|
query: (folderUID) => ({ url: `/folders/${folderUID}`, params: { accesscontrol: true } }),
|
||||||
providesTags: (_result, _error, arg) => [{ type: 'getFolder', id: arg }],
|
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>({
|
getAffectedItems: builder.query<DescendantCount, DashboardTreeSelection>({
|
||||||
queryFn: async (selectedItems) => {
|
queryFn: async (selectedItems) => {
|
||||||
const folderUIDs = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]);
|
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';
|
export { skipToken } from '@reduxjs/toolkit/query/react';
|
||||||
|
@ -63,16 +63,16 @@ export function buildNavModel(folder: FolderDTO, parents = folder.parents): NavM
|
|||||||
url: `${folder.url}/permissions`,
|
url: `${folder.url}/permissions`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (folder.canSave) {
|
if (folder.canSave) {
|
||||||
model.children!.push({
|
model.children!.push({
|
||||||
active: false,
|
active: false,
|
||||||
icon: 'cog',
|
icon: 'cog',
|
||||||
id: getSettingsTabID(folder.uid),
|
id: getSettingsTabID(folder.uid),
|
||||||
text: 'Settings',
|
text: 'Settings',
|
||||||
url: `${folder.url}/settings`,
|
url: `${folder.url}/settings`,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return model;
|
return model;
|
||||||
|
Loading…
Reference in New Issue
Block a user