mirror of https://github.com/grafana/grafana
Scenes: Row controls (#83607)
* wip row controls * wip row repeats * wip * wip * row repeat functional * refactor * refactor to reuse RepeatRowSelect2 * refactor + tests * remove comment * refactorpull/84163/head
parent
87d6bebb9e
commit
9c22a6144e
@ -0,0 +1,208 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { |
||||
SceneComponentProps, |
||||
SceneGridItem, |
||||
SceneGridLayout, |
||||
SceneGridRow, |
||||
SceneObjectBase, |
||||
SceneObjectState, |
||||
SceneQueryRunner, |
||||
VizPanel, |
||||
} from '@grafana/scenes'; |
||||
import { Icon, TextLink, useStyles2 } from '@grafana/ui'; |
||||
import appEvents from 'app/core/app_events'; |
||||
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; |
||||
import { ShowConfirmModalEvent } from 'app/types/events'; |
||||
|
||||
import { getDashboardSceneFor } from '../../utils/utils'; |
||||
import { DashboardScene } from '../DashboardScene'; |
||||
import { RowRepeaterBehavior } from '../RowRepeaterBehavior'; |
||||
|
||||
import { RowOptionsButton } from './RowOptionsButton'; |
||||
|
||||
export interface RowActionsState extends SceneObjectState {} |
||||
|
||||
export class RowActions extends SceneObjectBase<RowActionsState> { |
||||
private updateLayout(rowClone: SceneGridRow): void { |
||||
const row = this.getParent(); |
||||
|
||||
const layout = this.getDashboard().state.body; |
||||
|
||||
if (!(layout instanceof SceneGridLayout)) { |
||||
throw new Error('Layout is not a SceneGridLayout'); |
||||
} |
||||
|
||||
// remove the repeated rows
|
||||
const children = layout.state.children.filter((child) => !child.state.key?.startsWith(`${row.state.key}-clone-`)); |
||||
|
||||
// get the index to replace later
|
||||
const index = children.indexOf(row); |
||||
|
||||
if (index === -1) { |
||||
throw new Error('Parent row not found in layout children'); |
||||
} |
||||
|
||||
// replace the row with the clone
|
||||
layout.setState({ |
||||
children: [...children.slice(0, index), rowClone, ...children.slice(index + 1)], |
||||
}); |
||||
} |
||||
|
||||
public getParent(): SceneGridRow { |
||||
if (!(this.parent instanceof SceneGridRow)) { |
||||
throw new Error('RowActions must have a SceneGridRow parent'); |
||||
} |
||||
|
||||
return this.parent; |
||||
} |
||||
|
||||
public getDashboard(): DashboardScene { |
||||
return getDashboardSceneFor(this); |
||||
} |
||||
|
||||
public onUpdate = (title: string, repeat?: string | null): void => { |
||||
const row = this.getParent(); |
||||
|
||||
// return early if there is no repeat
|
||||
if (!repeat) { |
||||
const clone = row.clone(); |
||||
|
||||
// remove the row repeater behaviour, leave the rest
|
||||
clone.setState({ |
||||
title, |
||||
$behaviors: row.state.$behaviors?.filter((b) => !(b instanceof RowRepeaterBehavior)) ?? [], |
||||
}); |
||||
|
||||
this.updateLayout(clone); |
||||
|
||||
return; |
||||
} |
||||
|
||||
const children = row.state.children.map((child) => child.clone()); |
||||
|
||||
const newBehaviour = new RowRepeaterBehavior({ |
||||
variableName: repeat, |
||||
sources: children, |
||||
}); |
||||
|
||||
// get rest of behaviors except the old row repeater, if any, and push new one
|
||||
const behaviors = row.state.$behaviors?.filter((b) => !(b instanceof RowRepeaterBehavior)) ?? []; |
||||
behaviors.push(newBehaviour); |
||||
|
||||
row.setState({ |
||||
title, |
||||
$behaviors: behaviors, |
||||
}); |
||||
|
||||
newBehaviour.activate(); |
||||
}; |
||||
|
||||
public onDelete = () => { |
||||
appEvents.publish( |
||||
new ShowConfirmModalEvent({ |
||||
title: 'Delete row', |
||||
text: 'Are you sure you want to remove this row and all its panels?', |
||||
altActionText: 'Delete row only', |
||||
icon: 'trash-alt', |
||||
onConfirm: () => { |
||||
this.getDashboard().removeRow(this.getParent(), true); |
||||
}, |
||||
onAltAction: () => { |
||||
this.getDashboard().removeRow(this.getParent()); |
||||
}, |
||||
}) |
||||
); |
||||
}; |
||||
|
||||
public getWarning = () => { |
||||
const row = this.getParent(); |
||||
const gridItems = row.state.children; |
||||
|
||||
const isAnyPanelUsingDashboardDS = gridItems.some((gridItem) => { |
||||
if (!(gridItem instanceof SceneGridItem)) { |
||||
return false; |
||||
} |
||||
|
||||
if (gridItem.state.body instanceof VizPanel && gridItem.state.body.state.$data instanceof SceneQueryRunner) { |
||||
return gridItem.state.body.state.$data?.state.datasource?.uid === SHARED_DASHBOARD_QUERY; |
||||
} |
||||
|
||||
return false; |
||||
}); |
||||
|
||||
if (isAnyPanelUsingDashboardDS) { |
||||
return ( |
||||
<div> |
||||
<p> |
||||
Panels in this row use the {SHARED_DASHBOARD_QUERY} data source. These panels will reference the panel in |
||||
the original row, not the ones in the repeated rows. |
||||
</p> |
||||
<TextLink |
||||
external |
||||
href={ |
||||
'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows' |
||||
} |
||||
> |
||||
Learn more |
||||
</TextLink> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
return undefined; |
||||
}; |
||||
|
||||
static Component = ({ model }: SceneComponentProps<RowActions>) => { |
||||
const dashboard = model.getDashboard(); |
||||
const row = model.getParent(); |
||||
const { title } = row.useState(); |
||||
const { meta, isEditing } = dashboard.useState(); |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const behaviour = row.state.$behaviors?.find((b) => b instanceof RowRepeaterBehavior); |
||||
|
||||
return ( |
||||
<> |
||||
{meta.canEdit && isEditing && ( |
||||
<> |
||||
<div className={styles.rowActions}> |
||||
<RowOptionsButton |
||||
title={title} |
||||
repeat={behaviour instanceof RowRepeaterBehavior ? behaviour.state.variableName : undefined} |
||||
parent={dashboard} |
||||
onUpdate={model.onUpdate} |
||||
warning={model.getWarning()} |
||||
/> |
||||
<button type="button" onClick={model.onDelete} aria-label="Delete row"> |
||||
<Icon name="trash-alt" /> |
||||
</button> |
||||
</div> |
||||
</> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
rowActions: css({ |
||||
color: theme.colors.text.secondary, |
||||
lineHeight: '27px', |
||||
|
||||
button: { |
||||
color: theme.colors.text.secondary, |
||||
paddingLeft: theme.spacing(2), |
||||
background: 'transparent', |
||||
border: 'none', |
||||
|
||||
'&:hover': { |
||||
color: theme.colors.text.maxContrast, |
||||
}, |
||||
}, |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,50 @@ |
||||
import React from 'react'; |
||||
|
||||
import { SceneObject } from '@grafana/scenes'; |
||||
import { Icon, ModalsController } from '@grafana/ui'; |
||||
|
||||
import { OnRowOptionsUpdate } from './RowOptionsForm'; |
||||
import { RowOptionsModal } from './RowOptionsModal'; |
||||
|
||||
export interface RowOptionsButtonProps { |
||||
title: string; |
||||
repeat?: string; |
||||
parent: SceneObject; |
||||
onUpdate: OnRowOptionsUpdate; |
||||
warning?: React.ReactNode; |
||||
} |
||||
|
||||
export const RowOptionsButton = ({ repeat, title, parent, onUpdate, warning }: RowOptionsButtonProps) => { |
||||
const onUpdateChange = (hideModal: () => void) => (title: string, repeat?: string | null) => { |
||||
onUpdate(title, repeat); |
||||
hideModal(); |
||||
}; |
||||
|
||||
return ( |
||||
<ModalsController> |
||||
{({ showModal, hideModal }) => { |
||||
return ( |
||||
<button |
||||
type="button" |
||||
className="pointer" |
||||
aria-label="Row options" |
||||
onClick={() => { |
||||
showModal(RowOptionsModal, { |
||||
title, |
||||
repeat, |
||||
parent, |
||||
onDismiss: hideModal, |
||||
onUpdate: onUpdateChange(hideModal), |
||||
warning, |
||||
}); |
||||
}} |
||||
> |
||||
<Icon name="cog" /> |
||||
</button> |
||||
); |
||||
}} |
||||
</ModalsController> |
||||
); |
||||
}; |
||||
|
||||
RowOptionsButton.displayName = 'RowOptionsButton'; |
@ -0,0 +1,51 @@ |
||||
import { render, screen } from '@testing-library/react'; |
||||
import React from 'react'; |
||||
import { TestProvider } from 'test/helpers/TestProvider'; |
||||
|
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
|
||||
import { DashboardScene } from '../DashboardScene'; |
||||
|
||||
import { RowOptionsForm } from './RowOptionsForm'; |
||||
|
||||
jest.mock('app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect', () => ({ |
||||
RepeatRowSelect2: () => <div />, |
||||
})); |
||||
describe('DashboardRow', () => { |
||||
const scene = new DashboardScene({ |
||||
title: 'hello', |
||||
uid: 'dash-1', |
||||
meta: { |
||||
canEdit: true, |
||||
}, |
||||
}); |
||||
|
||||
it('Should show warning component when has warningMessage prop', () => { |
||||
render( |
||||
<TestProvider> |
||||
<RowOptionsForm |
||||
repeat={'3'} |
||||
parent={scene} |
||||
title="" |
||||
onCancel={jest.fn()} |
||||
onUpdate={jest.fn()} |
||||
warning="a warning message" |
||||
/> |
||||
</TestProvider> |
||||
); |
||||
expect( |
||||
screen.getByTestId(selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage) |
||||
).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('Should not show warning component when does not have warningMessage prop', () => { |
||||
render( |
||||
<TestProvider> |
||||
<RowOptionsForm repeat={'3'} parent={scene} title="" onCancel={jest.fn()} onUpdate={jest.fn()} /> |
||||
</TestProvider> |
||||
); |
||||
expect( |
||||
screen.queryByTestId(selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage) |
||||
).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
@ -0,0 +1,61 @@ |
||||
import React, { useCallback, useState } from 'react'; |
||||
import { useForm } from 'react-hook-form'; |
||||
|
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
import { SceneObject } from '@grafana/scenes'; |
||||
import { Button, Field, Modal, Input, Alert } from '@grafana/ui'; |
||||
import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect'; |
||||
|
||||
export type OnRowOptionsUpdate = (title: string, repeat?: string | null) => void; |
||||
|
||||
export interface Props { |
||||
title: string; |
||||
repeat?: string; |
||||
parent: SceneObject; |
||||
onUpdate: OnRowOptionsUpdate; |
||||
onCancel: () => void; |
||||
warning?: React.ReactNode; |
||||
} |
||||
|
||||
export const RowOptionsForm = ({ repeat, title, parent, warning, onUpdate, onCancel }: Props) => { |
||||
const [newRepeat, setNewRepeat] = useState<string | undefined>(repeat); |
||||
const onChangeRepeat = useCallback((name?: string) => setNewRepeat(name), [setNewRepeat]); |
||||
|
||||
const { handleSubmit, register } = useForm({ |
||||
defaultValues: { |
||||
title, |
||||
}, |
||||
}); |
||||
|
||||
const submit = (formData: { title: string }) => { |
||||
onUpdate(formData.title, newRepeat); |
||||
}; |
||||
|
||||
return ( |
||||
<form onSubmit={handleSubmit(submit)}> |
||||
<Field label="Title"> |
||||
<Input {...register('title')} type="text" /> |
||||
</Field> |
||||
<Field label="Repeat for"> |
||||
<RepeatRowSelect2 parent={parent} repeat={newRepeat} onChange={onChangeRepeat} /> |
||||
</Field> |
||||
{warning && ( |
||||
<Alert |
||||
data-testid={selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage} |
||||
severity="warning" |
||||
title="" |
||||
topSpacing={3} |
||||
bottomSpacing={0} |
||||
> |
||||
{warning} |
||||
</Alert> |
||||
)} |
||||
<Modal.ButtonRow> |
||||
<Button type="button" variant="secondary" onClick={onCancel} fill="outline"> |
||||
Cancel |
||||
</Button> |
||||
<Button type="submit">Update</Button> |
||||
</Modal.ButtonRow> |
||||
</form> |
||||
); |
||||
}; |
@ -0,0 +1,40 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { SceneObject } from '@grafana/scenes'; |
||||
import { Modal, useStyles2 } from '@grafana/ui'; |
||||
|
||||
import { OnRowOptionsUpdate, RowOptionsForm } from './RowOptionsForm'; |
||||
|
||||
export interface RowOptionsModalProps { |
||||
title: string; |
||||
repeat?: string; |
||||
parent: SceneObject; |
||||
warning?: React.ReactNode; |
||||
onDismiss: () => void; |
||||
onUpdate: OnRowOptionsUpdate; |
||||
} |
||||
|
||||
export const RowOptionsModal = ({ repeat, title, parent, onDismiss, onUpdate, warning }: RowOptionsModalProps) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
return ( |
||||
<Modal isOpen={true} title="Row options" onDismiss={onDismiss} className={styles.modal}> |
||||
<RowOptionsForm |
||||
parent={parent} |
||||
repeat={repeat} |
||||
title={title} |
||||
onCancel={onDismiss} |
||||
onUpdate={onUpdate} |
||||
warning={warning} |
||||
/> |
||||
</Modal> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = () => ({ |
||||
modal: css({ |
||||
label: 'RowOptionsModal', |
||||
width: '500px', |
||||
}), |
||||
}); |
Loading…
Reference in new issue