Add a feature for custom panel titles (#14831) (#15004)

* Add a feature for custom panel titles

* Add tests and put back data-test-subjs

* sync with master and add padding to form

* UI/UX cleanup

- add enter on close functionality
- make reset title a link instead of a button
- Push css to visualizations instead of the panel. This means
background colors will be flush to the panel.  Override for tile maps
which apparently need it (yet region maps don’t for some reason??)

* Fix refactor miss from merge

* whoops, put block display back to make link fall to bottom

* Undo accidental delete visualization name change

* Color top pop over arrow correctly

* Use naming Options and Customize Panel

* update jest snapshot

* Use custom panels for data-title attributes
This commit is contained in:
Stacey Gammon 2017-11-16 15:56:29 -05:00 committed by GitHub
parent 3d68d3ffeb
commit 23c6106065
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 310 additions and 112 deletions

View file

@ -3,6 +3,15 @@ import { createAction } from 'redux-actions';
export const deletePanel = createAction('DELETE_PANEL');
export const updatePanel = createAction('UPDATE_PANEL');
export const resetPanelTitle = createAction('RESET_PANEl_TITLE');
export const setPanelTitle = createAction('SET_PANEl_TITLE',
/**
* @param title {string}
* @param panelIndex {string}
*/
(title, panelIndex) => ({ title, panelIndex })
);
function panelArrayToMap(panels) {
const panelsMap = {};

View file

@ -21,7 +21,7 @@ exports[`DashboardPanel matches snapshot 1`] = `
class="kuiMicroButtonGroup"
>
<div
class="kuiPopover kuiPopover--anchorRight dashboardPanelPopOver"
class="kuiPopover kuiPopover--anchorRight dashboardPanelPopOver kuiPopover--withTitle"
>
<span
aria-label="Panel options"

View file

@ -5,7 +5,10 @@ import { DashboardPanel } from './dashboard_panel';
import { DashboardViewMode } from '../dashboard_view_mode';
import { PanelError } from '../panel/panel_error';
import { store } from '../../store';
import { updateViewMode } from '../actions';
import {
updateViewMode,
setPanels,
} from '../actions';
import { Provider } from 'react-redux';
import { getEmbeddableFactoryMock } from '../__tests__/get_embeddable_factories_mock';
@ -26,6 +29,7 @@ function getProps(props = {}) {
beforeAll(() => {
store.dispatch(updateViewMode(DashboardViewMode.EDIT));
store.dispatch(setPanels([{ panelIndex: 'foo1' }]));
});
test('DashboardPanel matches snapshot', () => {

View file

@ -1,27 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
KuiContextMenuItem,
} from 'ui_framework/components';
export function DeleteMenuItem({ onDeletePanel }) {
return (
<KuiContextMenuItem
data-test-subj="dashboardPanelRemoveIcon"
onClick={onDeletePanel}
icon={(
<span
aria-hidden="true"
className="kuiButton__icon kuiIcon fa-trash"
/>
)}
>
Delete from dashboard
</KuiContextMenuItem>
);
}
DeleteMenuItem.propTypes = {
onDeletePanel: PropTypes.func.isRequired
};

View file

@ -1,27 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
KuiContextMenuItem,
} from 'ui_framework/components';
export function EditMenuItem({ onEditPanel }) {
return (
<KuiContextMenuItem
data-test-subj="dashboardPanelEditLink"
onClick={onEditPanel}
icon={(
<span
aria-hidden="true"
className="kuiButton__icon kuiIcon fa-edit"
/>
)}
>
Edit Visualization
</KuiContextMenuItem>
);
}
EditMenuItem.propTypes = {
onEditPanel: PropTypes.func.isRequired
};

View file

@ -1,28 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
KuiContextMenuItem,
} from 'ui_framework/components';
export function ExpandOrCollapseMenuItem({ onToggleExpand, isExpanded }) {
return (
<KuiContextMenuItem
data-test-subj="dashboardPanelExpandIcon"
onClick={onToggleExpand}
icon={(
<span
aria-hidden="true"
className={`kuiButton__icon kuiIcon ${isExpanded ? 'fa-compress' : 'fa-expand'}`}
/>
)}
>
{isExpanded ? 'Minimize' : 'Full screen'}
</KuiContextMenuItem>
);
}
ExpandOrCollapseMenuItem.propTypes = {
onToggleExpand: PropTypes.func.isRequired,
isExpanded: PropTypes.bool.isRequired,
};

View file

@ -1,7 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
export function PanelHeader({ title, actions }) {
export function PanelHeader({ title, actions, isViewOnlyMode }) {
if (isViewOnlyMode && !title) {
return (
<div className="panel-heading-floater">
<div className="kuiMicroButtonGroup">
{actions}
</div>
</div>
);
}
return (
<div className="panel-heading">
<span
@ -21,6 +31,7 @@ export function PanelHeader({ title, actions }) {
}
PanelHeader.propTypes = {
isViewOnlyMode: PropTypes.bool,
title: PropTypes.string,
actions: PropTypes.node,
};

View file

@ -15,7 +15,7 @@ import {
import {
getEmbeddable,
getEmbeddableTitle,
getPanel,
getMaximizedPanelId,
getFullScreenMode,
getViewMode
@ -23,8 +23,10 @@ import {
const mapStateToProps = ({ dashboard }, { panelId }) => {
const embeddable = getEmbeddable(dashboard, panelId);
const panel = getPanel(dashboard, panelId);
const embeddableTitle = embeddable ? embeddable.title : '';
return {
title: embeddable ? getEmbeddableTitle(dashboard, panelId) : '',
title: panel.title === undefined ? embeddableTitle : panel.title,
isExpanded: getMaximizedPanelId(dashboard) === panelId,
isViewOnlyMode: getFullScreenMode(dashboard) || getViewMode(dashboard) === DashboardViewMode.VIEW,
};
@ -57,6 +59,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
return {
title,
actions,
isViewOnlyMode,
};
};

View file

@ -0,0 +1,59 @@
import React from 'react';
import { Provider } from 'react-redux';
import _ from 'lodash';
import { mount } from 'enzyme';
import { PanelHeaderContainer } from './panel_header_container';
import { DashboardViewMode } from '../../dashboard_view_mode';
import { store } from '../../../store';
import {
updateViewMode,
setPanels,
setPanelTitle,
resetPanelTitle,
embeddableRenderFinished,
} from '../../actions';
import { getEmbeddableFactoryMock } from '../../__tests__/get_embeddable_factories_mock';
import {
TestSubjects,
} from 'ui_framework/src/test';
function getProps(props = {}) {
const defaultTestProps = {
panelId: 'foo1',
embeddableFactory: getEmbeddableFactoryMock(),
};
return _.defaultsDeep(props, defaultTestProps);
}
let component;
beforeAll(() => {
store.dispatch(updateViewMode(DashboardViewMode.EDIT));
store.dispatch(setPanels([{ panelIndex: 'foo1' }]));
store.dispatch(embeddableRenderFinished('foo1', { title: 'my embeddable title', editUrl: 'editme' }));
});
afterAll(() => {
component.unmount();
});
test('Panel header shows embeddable title when nothing is set on the panel', () => {
component = mount(<Provider store={store}><PanelHeaderContainer {...getProps()} /></Provider>);
expect(TestSubjects.getText(component, 'dashboardPanelTitle')).toBe('my embeddable title');
});
test('Panel header shows panel title when it is set on the panel', () => {
store.dispatch(setPanelTitle('my custom panel title', 'foo1'));
expect(TestSubjects.getText(component, 'dashboardPanelTitle')).toBe('my custom panel title');
});
test('Panel header shows no panel title when it is set to an empty string on the panel', () => {
store.dispatch(setPanelTitle('', 'foo1'));
expect(TestSubjects.getText(component, 'dashboardPanelTitle')).toBe('');
});
test('Panel header shows embeddable title when the panel title is reset', () => {
store.dispatch(resetPanelTitle('foo1'));
expect(TestSubjects.getText(component, 'dashboardPanelTitle')).toBe('my embeddable title');
});

View file

@ -2,13 +2,11 @@ import React from 'react';
import PropTypes from 'prop-types';
import {
KuiPopover,
KuiContextMenuPanel,
KuiContextMenu,
KuiKeyboardAccessible,
} from 'ui_framework/components';
import { EditMenuItem } from './edit_menu_item';
import { DeleteMenuItem } from './delete_menu_item';
import { ExpandOrCollapseMenuItem } from './expand_or_collapse_menu_item';
import { PanelOptionsMenuForm } from './panel_options_menu_form';
export class PanelOptionsMenu extends React.Component {
state = {
@ -35,23 +33,74 @@ export class PanelOptionsMenu extends React.Component {
this.props.toggleExpandedPanel();
};
renderItems() {
const items = [
<EditMenuItem
key="0"
onEditPanel={this.onEditPanel}
/>,
<ExpandOrCollapseMenuItem
key="2"
onToggleExpand={this.onToggleExpandPanel}
isExpanded={this.props.isExpanded}
/>
buildMainMenuPanel() {
const { isExpanded } = this.props;
const mainPanelMenuItems = [
{
name: 'Edit visualization',
'data-test-subj': 'dashboardPanelEditLink',
icon: <span
aria-hidden="true"
className="kuiButton__icon kuiIcon fa-edit"
/>,
onClick: this.onEditPanel,
},
{
name: 'Customize panel',
'data-test-subj': 'dashboardPanelOptionsSubMenuLink',
icon: <span
aria-hidden="true"
className="kuiButton__icon kuiIcon fa-edit"
/>,
panel: 'panelSubOptionsMenu',
},
{
name: isExpanded ? 'Minimize' : 'Full screen',
'data-test-subj': 'dashboardPanelExpandIcon',
icon: <span
aria-hidden="true"
className={`kuiButton__icon kuiIcon ${isExpanded ? 'fa-compress' : 'fa-expand'}`}
/>,
onClick: this.onToggleExpandPanel,
}
];
if (!this.props.isExpanded) {
items.push(<DeleteMenuItem key="3" onDeletePanel={this.onDeletePanel} />);
mainPanelMenuItems.push({
name: 'Delete from dashboard',
'data-test-subj': 'dashboardPanelRemoveIcon',
icon: <span
aria-hidden="true"
className="kuiButton__icon kuiIcon fa-trash"
/>,
onClick: this.onDeletePanel,
});
}
return items;
return {
title: 'Options',
id: 'mainMenu',
items: mainPanelMenuItems,
};
}
buildPanelOptionsSubMenu() {
return {
title: 'Customize panel',
id: 'panelSubOptionsMenu',
content: <PanelOptionsMenuForm
onReset={this.props.onResetPanelTitle}
onUpdatePanelTitle={this.props.onUpdatePanelTitle}
title={this.props.panelTitle}
onClose={this.closePopover}
/>,
};
}
renderPanels() {
return [
this.buildMainMenuPanel(),
this.buildPanelOptionsSubMenu(),
];
}
render() {
@ -74,10 +123,11 @@ export class PanelOptionsMenu extends React.Component {
closePopover={this.closePopover}
panelPaddingSize="none"
anchorPosition="right"
withTitle
>
<KuiContextMenuPanel
onClose={this.closePopover}
items={this.renderItems()}
<KuiContextMenu
initialPanelId="mainMenu"
panels={this.renderPanels()}
/>
</KuiPopover>
);
@ -85,6 +135,9 @@ export class PanelOptionsMenu extends React.Component {
}
PanelOptionsMenu.propTypes = {
panelTitle: PropTypes.string,
onUpdatePanelTitle: PropTypes.func.isRequired,
onResetPanelTitle: PropTypes.func.isRequired,
editUrl: PropTypes.string.isRequired,
toggleExpandedPanel: PropTypes.func.isRequired,
isExpanded: PropTypes.bool.isRequired,

View file

@ -8,17 +8,23 @@ import {
destroyEmbeddable,
maximizePanel,
minimizePanel,
resetPanelTitle,
setPanelTitle,
} from '../../actions';
import {
getEmbeddable,
getEmbeddableEditUrl,
getMaximizedPanelId,
getPanel,
} from '../../selectors';
const mapStateToProps = ({ dashboard }, { panelId }) => {
const embeddable = getEmbeddable(dashboard, panelId);
const panel = getPanel(dashboard, panelId);
const embeddableTitle = embeddable ? embeddable.title : '';
return {
panelTitle: panel.title === undefined ? embeddableTitle : panel.title,
editUrl: embeddable ? getEmbeddableEditUrl(dashboard, panelId) : '',
isExpanded: getMaximizedPanelId(dashboard) === panelId,
};
@ -36,18 +42,21 @@ const mapDispatchToProps = (dispatch, { embeddableFactory, panelId }) => ({
},
onMaximizePanel: () => dispatch(maximizePanel(panelId)),
onMinimizePanel: () => dispatch(minimizePanel()),
onResetPanelTitle: () => dispatch(resetPanelTitle(panelId)),
onUpdatePanelTitle: (newTitle) => dispatch(setPanelTitle(newTitle, panelId)),
});
const mergeProps = (stateProps, dispatchProps) => {
const { isExpanded, editUrl } = stateProps;
const { onMaximizePanel, onMinimizePanel, onDeletePanel } = dispatchProps;
const { isExpanded, editUrl, panelTitle } = stateProps;
const { onMaximizePanel, onMinimizePanel, ...dispatchers } = dispatchProps;
const toggleExpandedPanel = () => isExpanded ? onMinimizePanel() : onMaximizePanel();
return {
panelTitle,
toggleExpandedPanel,
isExpanded,
editUrl,
onDeletePanel
...dispatchers,
};
};

View file

@ -0,0 +1,53 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
KuiButton,
} from 'ui_framework/components';
import {
keyCodes,
} from 'ui_framework/services';
export function PanelOptionsMenuForm({ title, onReset, onUpdatePanelTitle, onClose }) {
function onInputChange(event) {
onUpdatePanelTitle(event.target.value);
}
function onKeyDown(event) {
if (event.keyCode === keyCodes.ENTER) {
onClose();
}
}
return (
<div
className="kuiVerticalRhythm dashboardPanelMenuOptionsForm"
data-test-subj="dashboardPanelTitleInputMenuItem"
>
<label className="kuiFormLabel" htmlFor="panelTitleInput">Panel title</label>
<input
id="panelTitleInput"
name="min"
type="text"
className="kuiTextInput"
value={title}
onChange={onInputChange}
onKeyDown={onKeyDown}
/>
<KuiButton
buttonType="hollow"
onClick={onReset}
>
Reset title
</KuiButton>
</div>
);
}
PanelOptionsMenuForm.propTypes = {
title: PropTypes.string,
onUpdatePanelTitle: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};

View file

@ -6,6 +6,8 @@ import {
updatePanel,
updatePanels,
setPanels,
resetPanelTitle,
setPanelTitle,
} from '../actions';
/**
@ -69,4 +71,31 @@ export const panels = handleActions({
...panels,
[payload.panelIndex]: mergePanelData(payload, panels),
}),
[resetPanelTitle]:
/**
* @param panels {Object.<string, PanelState>}
* @param payload {String} The id of the panel to reset it's title.
* @return {Object.<string, PanelState>}
*/
(panels, { payload }) => ({
...panels,
[payload]: {
...panels[payload],
title: undefined,
}
}),
[setPanelTitle]:
/**
* @param panels {Object.<string, PanelState>}
* @param payload {PanelState} The new panel state (is merged with existing).
* @param payload.panelIndex {String} The id of the panel to reset it's title.
* @param payload.title {String} The new title to use.
* @return {Object.<string, PanelState>}
*/
(panels, { payload }) => ({
...panels,
[payload.panelIndex]: mergePanelData(payload, panels),
}),
}, {});

View file

@ -284,6 +284,23 @@ dashboard-viewport-provider {
.visualize-show-spy {
visibility: hidden;
}
/**
* 1. Use opacity to make this element accessible to screen readers and keyboard.
* 2. Show on focus to enable keyboard accessibility.
*/
.panel-heading-floater {
opacity: 0; /* 1 */
position: absolute;
right: 1px;
top: 1px;
background-color: @dashboard-panel-bg;
z-index: 5;
&:focus {
opacity: 1; /* 2 */
}
}
/**
* 1. Use opacity to make this element accessible to screen readers and keyboard.
* 2. Show on focus to enable keyboard accessibility.
@ -296,6 +313,9 @@ dashboard-viewport-provider {
}
&:hover {
.panel-heading-floater {
opacity: 1;
}
.visualize-show-spy {
visibility: visible;
}
@ -360,3 +380,11 @@ dashboard-viewport-provider {
.dashboard-viewport-with-margins {
background-color: @dashboard-bg-with-margins;
}
.dashboardPanelMenuOptionsForm {
padding: 16px;
input {
width: 100%;
display: block;
}
}

View file

@ -31,6 +31,7 @@ export class SearchEmbeddableFactory extends EmbeddableFactory {
searchScope.editPath = this.getEditPath(panel.id);
return this.searchLoader.get(panel.id)
.then(savedObject => {
searchScope.sharedItemTitle = panel.title !== undefined ? panel.title : savedObject.title;
searchScope.savedObj = savedObject;
searchScope.panel = panel;
container.registerPanelIndexPattern(panel.panelIndex, savedObject.searchSource.get('index'));

View file

@ -3,7 +3,7 @@
sorting="sort"
columns="columns"
data-shared-item
data-title="{{savedObj.title}}"
data-title="{{sharedItemTitle}}"
data-description="{{savedObj.description}}"
render-counter
class="panel-content"

View file

@ -30,6 +30,7 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory {
visualizeScope.editUrl = this.getEditPath(panel.id);
return this.visualizeLoader.get(panel.id)
.then(savedObject => {
visualizeScope.sharedItemTitle = panel.title !== undefined ? panel.title : savedObject.title;
visualizeScope.savedObj = savedObject;
visualizeScope.panel = panel;

View file

@ -4,7 +4,7 @@
app-state="appState"
ui-state="uiState"
data-shared-item
data-title="{{savedObj.title}}"
data-title="{{sharedItemTitle}}"
data-description="{{savedObj.description}}"
render-counter
class="panel-content">

View file

@ -9,6 +9,14 @@
position: relative;
}
/**
* 1. Visualizations have some padding by default but tilemaps look nice flush against the edge to maximize viewing
* space.
*/
.tile_map {
padding: 0; /* 1. */
}
/* leaflet Dom Util div for map label */
.tilemap-legend {

View file

@ -7,6 +7,7 @@ visualization {
width: 100%;
overflow: auto;
position: relative;
padding: 8px 8px 8px 8px;
.k4tip {
white-space: pre-line;

View file

@ -1,2 +1,5 @@
export { requiredProps } from './required_props';
export { takeMountedSnapshot } from './take_mounted_snapshot';
import * as TestSubjects from './test_subjects';
export { TestSubjects };

View file

@ -0,0 +1,8 @@
export function find(element, dataTestSubject) {
return element.find(`[data-test-subj="${dataTestSubject}"]`);
}
export function getText(element, dataTestSubject) {
return find(element, dataTestSubject).text();
}