feat(ui/labels): Add the ability to edit labels on a dashboard

Co-authored-by: Alex Paxton <thealexpaxton@gmail.com>
pull/10616/head
Alex P 2019-01-08 16:15:32 -08:00 committed by Iris Scholten
parent 7cf643d1d5
commit d12e94bb01
23 changed files with 969 additions and 134 deletions

View File

@ -1397,13 +1397,49 @@ paths:
responses:
'200':
description: a list of all labels for a dashboard
content:
application/json:
schema:
type: object
properties:
label:
$ref: "#/components/schemas/Label"
links:
$ref: "#/components/schemas/Links"
default:
description: unexpected error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
'/dashboards/{dashboardID}/labels/{name}':
delete:
tags:
- Dashboards
parameters:
- $ref: '#/components/parameters/TraceSpan'
- in: path
name: dashboardID
schema:
type: string
required: true
description: ID of the dashboard
- in: path
name: name
schema:
type: string
required: true
description: name of the label
responses:
'200':
description: the removed label for a dashboard
content:
application/json:
schema:
type: object
properties:
labels:
$ref: "#/components/schemas/Labels"
$ref: "#/components/schemas/Label"
links:
$ref: "#/components/schemas/Links"
default:

View File

@ -1110,10 +1110,10 @@ export interface InlineResponse200 {
export interface InlineResponse2001 {
/**
*
* @type {Array<Task>}
* @type {Label}
* @memberof InlineResponse2001
*/
tasks?: Array<Task>;
label?: Label;
/**
*
* @type {Links}
@ -1130,14 +1130,54 @@ export interface InlineResponse2001 {
export interface InlineResponse2002 {
/**
*
* @type {Array<Run>}
* @type {Label}
* @memberof InlineResponse2002
*/
labels?: Label;
/**
*
* @type {Links}
* @memberof InlineResponse2002
*/
links?: Links;
}
/**
*
* @export
* @interface InlineResponse2003
*/
export interface InlineResponse2003 {
/**
*
* @type {Array<Task>}
* @memberof InlineResponse2003
*/
tasks?: Array<Task>;
/**
*
* @type {Links}
* @memberof InlineResponse2003
*/
links?: Links;
}
/**
*
* @export
* @interface InlineResponse2004
*/
export interface InlineResponse2004 {
/**
*
* @type {Array<Run>}
* @memberof InlineResponse2004
*/
runs?: Array<Run>;
/**
*
* @type {Links}
* @memberof InlineResponse2002
* @memberof InlineResponse2004
*/
links?: Links;
}
@ -7003,6 +7043,49 @@ export const DashboardsApiAxiosParamCreator = function (configuration?: Configur
options: localVarRequestOptions,
};
},
/**
*
* @param {string} dashboardID ID of the dashboard
* @param {string} name name of the label
* @param {string} [zapTraceSpan] OpenTracing span context
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
dashboardsDashboardIDLabelsNameDelete(dashboardID: string, name: string, zapTraceSpan?: string, options: any = {}): RequestArgs {
// verify required parameter 'dashboardID' is not null or undefined
if (dashboardID === null || dashboardID === undefined) {
throw new RequiredError('dashboardID','Required parameter dashboardID was null or undefined when calling dashboardsDashboardIDLabelsNameDelete.');
}
// verify required parameter 'name' is not null or undefined
if (name === null || name === undefined) {
throw new RequiredError('name','Required parameter name was null or undefined when calling dashboardsDashboardIDLabelsNameDelete.');
}
const localVarPath = `/dashboards/{dashboardID}/labels/{name}`
.replace(`{${"dashboardID"}}`, encodeURIComponent(String(dashboardID)))
.replace(`{${"name"}}`, encodeURIComponent(String(name)));
const localVarUrlObj = url.parse(localVarPath, true);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = Object.assign({ method: 'DELETE' }, baseOptions, options);
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
if (zapTraceSpan !== undefined && zapTraceSpan !== null) {
localVarHeaderParameter['Zap-Trace-Span'] = String(zapTraceSpan);
}
localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query);
// fix override query string Detail: https://stackoverflow.com/a/7517673/1077943
delete localVarUrlObj.search;
localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers);
return {
url: url.format(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary add a label to a dashboard
@ -7525,6 +7608,21 @@ export const DashboardsApiFp = function(configuration?: Configuration) {
return axios.request(axiosRequestArgs);
};
},
/**
*
* @param {string} dashboardID ID of the dashboard
* @param {string} name name of the label
* @param {string} [zapTraceSpan] OpenTracing span context
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
dashboardsDashboardIDLabelsNameDelete(dashboardID: string, name: string, zapTraceSpan?: string, options?: any): (axios?: AxiosInstance, basePath?: string) => AxiosPromise<InlineResponse2002> {
const localVarAxiosArgs = DashboardsApiAxiosParamCreator(configuration).dashboardsDashboardIDLabelsNameDelete(dashboardID, name, zapTraceSpan, options);
return (axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
const axiosRequestArgs = Object.assign(localVarAxiosArgs.options, {url: basePath + localVarAxiosArgs.url})
return axios.request(axiosRequestArgs);
};
},
/**
*
* @summary add a label to a dashboard
@ -7534,7 +7632,7 @@ export const DashboardsApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
dashboardsDashboardIDLabelsPost(dashboardID: string, label: Label, zapTraceSpan?: string, options?: any): (axios?: AxiosInstance, basePath?: string) => AxiosPromise<InlineResponse200> {
dashboardsDashboardIDLabelsPost(dashboardID: string, label: Label, zapTraceSpan?: string, options?: any): (axios?: AxiosInstance, basePath?: string) => AxiosPromise<InlineResponse2001> {
const localVarAxiosArgs = DashboardsApiAxiosParamCreator(configuration).dashboardsDashboardIDLabelsPost(dashboardID, label, zapTraceSpan, options);
return (axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
const axiosRequestArgs = Object.assign(localVarAxiosArgs.options, {url: basePath + localVarAxiosArgs.url})
@ -7761,6 +7859,17 @@ export const DashboardsApiFactory = function (configuration?: Configuration, bas
dashboardsDashboardIDLabelsGet(dashboardID: string, zapTraceSpan?: string, options?: any) {
return DashboardsApiFp(configuration).dashboardsDashboardIDLabelsGet(dashboardID, zapTraceSpan, options)(axios, basePath);
},
/**
*
* @param {string} dashboardID ID of the dashboard
* @param {string} name name of the label
* @param {string} [zapTraceSpan] OpenTracing span context
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
dashboardsDashboardIDLabelsNameDelete(dashboardID: string, name: string, zapTraceSpan?: string, options?: any) {
return DashboardsApiFp(configuration).dashboardsDashboardIDLabelsNameDelete(dashboardID, name, zapTraceSpan, options)(axios, basePath);
},
/**
*
* @summary add a label to a dashboard
@ -7972,6 +8081,19 @@ export class DashboardsApi extends BaseAPI {
return DashboardsApiFp(this.configuration).dashboardsDashboardIDLabelsGet(dashboardID, zapTraceSpan, options)(this.axios, this.basePath);
}
/**
*
* @param {string} dashboardID ID of the dashboard
* @param {string} name name of the label
* @param {string} [zapTraceSpan] OpenTracing span context
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof DashboardsApi
*/
public dashboardsDashboardIDLabelsNameDelete(dashboardID: string, name: string, zapTraceSpan?: string, options?: any) {
return DashboardsApiFp(this.configuration).dashboardsDashboardIDLabelsNameDelete(dashboardID, name, zapTraceSpan, options)(this.axios, this.basePath);
}
/**
*
* @summary add a label to a dashboard
@ -11613,7 +11735,7 @@ export const TasksApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
tasksGet(after?: string, user?: string, org?: string, options?: any): (axios?: AxiosInstance, basePath?: string) => AxiosPromise<InlineResponse2001> {
tasksGet(after?: string, user?: string, org?: string, options?: any): (axios?: AxiosInstance, basePath?: string) => AxiosPromise<InlineResponse2003> {
const localVarAxiosArgs = TasksApiAxiosParamCreator(configuration).tasksGet(after, user, org, options);
return (axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
const axiosRequestArgs = Object.assign(localVarAxiosArgs.options, {url: basePath + localVarAxiosArgs.url})
@ -11790,7 +11912,7 @@ export const TasksApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
tasksTaskIDRunsGet(taskID: string, after?: string, limit?: number, afterTime?: Date, beforeTime?: Date, options?: any): (axios?: AxiosInstance, basePath?: string) => AxiosPromise<InlineResponse2002> {
tasksTaskIDRunsGet(taskID: string, after?: string, limit?: number, afterTime?: Date, beforeTime?: Date, options?: any): (axios?: AxiosInstance, basePath?: string) => AxiosPromise<InlineResponse2004> {
const localVarAxiosArgs = TasksApiAxiosParamCreator(configuration).tasksTaskIDRunsGet(taskID, after, limit, afterTime, beforeTime, options);
return (axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
const axiosRequestArgs = Object.assign(localVarAxiosArgs.options, {url: basePath + localVarAxiosArgs.url})

View File

@ -203,3 +203,13 @@
.index-list--labels {
margin-left: $ix-marg-b;
}
.index-list--row-cell .index-list--cell a {
white-space: nowrap;
}
.index-list--resource-name {
margin-left: 6px;
margin-right: $ix-marg-a + $ix-marg-b;
font-size: 17px;
}

View File

@ -21,23 +21,6 @@ $label-margin: 1px;
}
}
.label--container {
width: 100%;
}
.label--container-margin {
width: calc(100% + #{$label-margin * 2});
position: relative;
left: $label-margin * -2;
display: flex;
flex-wrap: wrap;
padding: $label-margin;
> .label {
margin: $label-margin;
}
}
.label--clickable {
&, &:hover {
cursor: pointer;
@ -118,68 +101,3 @@ $label-margin: 1px;
.label--lg {
@include labelSizeModifier($form-lg-font, $form-lg-padding, $form-lg-height);
}
/*
Additional Labels Indicator
------------------------------------------------------------------------------
*/
.additional-labels {
position: relative;
user-select: none;
font-weight: 700;
margin: $label-margin;
transition: background-color 0.25s ease, color 0.25s ease;
background-color: rgba($g6-smoke, 0.5);
color: $g11-sidewalk;
&:hover {
cursor: pointer;
background-color: $g6-smoke;
color: $g15-platinum;
}
}
.label--tooltip {
z-index: 9999;
visibility: hidden;
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
transition-property: all;
}
.label--tooltip-container {
@extend %drop-shadow;
opacity: 0;
background-color: $g0-obsidian;
border-radius: $radius;
padding: $ix-marg-c;
margin-top: $ix-marg-b + $ix-border;
transition: opacity 0.25s ease;
position: relative;
&:after {
content: '';
position: absolute;
top: 0;
left: 50%;
border: $ix-marg-b solid transparent;
border-bottom-color: $g0-obsidian;
transform: translate(-50%, -100%);
}
> .label {
margin: $label-margin;
}
}
.additional-labels:hover .label--tooltip {
visibility: visible;
}
.additional-labels:hover .label--tooltip-container {
opacity: 1;
}

View File

@ -0,0 +1,167 @@
/*
Label Container Styles
------------------------------------------------------------------------------
*/
@import 'src/style/modules';
$label-margin: 1px;
$label-edit-button-diameter: 18px;
.label--container {
width: 100%;
}
.label--container-margin {
width: calc(100% + #{$label-margin * 2});
position: relative;
left: $label-margin * -2;
display: flex;
flex-wrap: wrap;
padding: $label-margin;
> .label {
margin: $label-margin;
}
}
/*
Additional Labels Indicator
------------------------------------------------------------------------------
*/
.additional-labels {
position: relative;
user-select: none;
font-weight: 700;
margin: $label-margin;
transition: background-color 0.25s ease, color 0.25s ease;
background-color: rgba($g6-smoke, 0.5);
color: $g11-sidewalk;
&:hover {
cursor: pointer;
background-color: $g6-smoke;
color: $g15-platinum;
}
}
.label--tooltip {
z-index: 9999;
visibility: hidden;
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
transition-property: all;
}
.label--tooltip-container {
@extend %drop-shadow;
opacity: 0;
background-color: $g0-obsidian;
border-radius: $radius;
padding: $ix-marg-c;
margin-top: $ix-marg-b + $ix-border;
transition: opacity 0.25s ease;
position: relative;
&:after {
content: '';
position: absolute;
top: 0;
left: 50%;
border: $ix-marg-b solid transparent;
border-bottom-color: $g0-obsidian;
transform: translate(-50%, -100%);
}
> .label {
margin: $label-margin;
}
}
.additional-labels:hover .label--tooltip {
visibility: visible;
}
.additional-labels:hover .label--tooltip-container {
opacity: 1;
}
/*
Edit Labels on Resource
------------------------------------------------------------------------------
*/
.label--edit-button {
width: $label-edit-button-diameter;
height: $label-edit-button-diameter;
margin: $label-margin;
position: relative;
transition: opacity 0.25s ease;
&:before,
&:after {
content: '';
pointer-events: none;
background-color: $g20-white;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%,-50%);
z-index: 3;
transition: width 0.25s ease;
height: $ix-border;
width: $label-edit-button-diameter - $ix-marg-b;
border-radius: $ix-border / 2;
}
&:after {
transform: translate(-50%,-50%) rotate(90deg);
}
> .button.button-sm {
position: absolute;
z-index: 2;
top: 50%;
left: 50%;
width: $label-edit-button-diameter;
height: $label-edit-button-diameter;
transform: translate(-50%, -50%);
border-radius: 50%;
color: transparent;
transition:
background-color 0.25s ease,
border-color 0.25s ease,
box-shadow 0.25s ease,
height 0.25s ease,
width 0.25s ease;
> .button-icon {
font-size: $form-sm-font;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%,-50%);
}
}
&:hover > .button.button-sm {
width: $form-sm-height - $ix-marg-a;
height: $form-sm-height - $ix-marg-a;
}
&:hover:before,
&:hover:after {
width: $form-sm-height - $ix-marg-c;
}
}
/* When used inside an index list, hide until row hover */
.index-list--row-cell .label--edit-button {
opacity: 0;
}
.index-list--row-cell:hover .label--edit-button {
opacity: 1;
}

View File

@ -1,19 +1,28 @@
// Libraries
import React, {Component} from 'react'
import classnames from 'classnames'
import _ from 'lodash'
// Components
import LabelTooltip from 'src/clockface/components/label/LabelTooltip'
import Button from 'src/clockface/components/Button/index'
import {ButtonShape, IconFont, ComponentColor} from 'src/clockface/types'
// Styles
import 'src/clockface/components/label/LabelContainer.scss'
interface Props {
children: JSX.Element | JSX.Element[]
children?: JSX.Element[]
className?: string
limitChildCount?: number
resourceName?: string
onEdit?: () => void
}
class LabelContainer extends Component<Props> {
public static defaultProps: Partial<Props> = {
limitChildCount: 999,
resourceName: 'this resource',
}
public render() {
@ -28,19 +37,39 @@ class LabelContainer extends Component<Props> {
<div className="label--container-margin">
{this.children}
{this.additionalChildrenIndicator}
{this.editButton}
</div>
</div>
)
}
private get children(): JSX.Element[] {
private get sortedChildren(): JSX.Element[] {
const {children} = this.props
if (children && React.Children.count(children) > 1) {
return children.sort((a: JSX.Element, b: JSX.Element) => {
const textA = a.props.name.toUpperCase()
const textB = b.props.name.toUpperCase()
return textA < textB ? -1 : textA > textB ? 1 : 0
})
}
return children
}
private get children(): JSX.Element[] | JSX.Element {
const {children, limitChildCount} = this.props
return React.Children.map(children, (child: JSX.Element, i: number) => {
if (i < limitChildCount) {
return child
}
})
if (children) {
return React.Children.map(
this.sortedChildren,
(child: JSX.Element, i: number) => {
if (i < limitChildCount) {
return child
}
}
)
}
}
private get additionalChildrenIndicator(): JSX.Element {
@ -53,7 +82,29 @@ class LabelContainer extends Component<Props> {
return (
<div className="additional-labels">
+{additionalCount} more
<LabelTooltip labels={children} />
<LabelTooltip labels={this.sortedChildren.slice(limitChildCount)} />
</div>
)
}
}
private get editButton(): JSX.Element {
const {onEdit, children, resourceName} = this.props
const titleText = React.Children.count(children)
? `Edit Labels for ${resourceName}`
: `Add Labels to ${resourceName}`
if (onEdit) {
return (
<div className="label--edit-button">
<Button
color={ComponentColor.Primary}
titleText={titleText}
onClick={onEdit}
shape={ButtonShape.Square}
icon={IconFont.Plus}
/>
</div>
)
}

View File

@ -5,13 +5,14 @@ import _ from 'lodash'
// Components
import Input from 'src/clockface/components/inputs/Input'
import Button from 'src/clockface/components/Button'
import Label, {LabelType} from 'src/clockface/components/label/Label'
import Label from 'src/clockface/components/label/Label'
import LabelContainer from 'src/clockface/components/label/LabelContainer'
import LabelSelectorMenu from 'src/clockface/components/label/LabelSelectorMenu'
import {ClickOutside} from 'src/shared/components/ClickOutside'
// Types
import {ComponentSize} from 'src/clockface/types'
import {Label as LabelType} from 'src/api'
// Styles
import './LabelSelector.scss'
@ -64,7 +65,7 @@ class LabelSelector extends Component<Props, State> {
}
public render() {
const {resourceType} = this.props
const {resourceType, inputSize} = this.props
const {filterValue} = this.state
return (
@ -77,6 +78,7 @@ class LabelSelector extends Component<Props, State> {
onFocus={this.handleStartSuggesting}
onKeyDown={this.handleKeyDown}
onChange={this.handleInputChange}
size={inputSize}
/>
{this.suggestionMenu}
</div>
@ -97,12 +99,12 @@ class LabelSelector extends Component<Props, State> {
<LabelContainer className="label-selector--selected">
{selectedLabels.map(label => (
<Label
key={label.id}
key={label.name}
name={label.name}
id={label.id}
colorHex={label.colorHex}
id={label.name}
colorHex={label.properties.color}
onDelete={this.handleDelete}
description={label.description}
description={label.properties.description}
/>
))}
</LabelContainer>
@ -138,7 +140,7 @@ class LabelSelector extends Component<Props, State> {
private handleAddLabel = (labelID: string): void => {
const {onAddLabel, allLabels} = this.props
const label = allLabels.find(label => label.id === labelID)
const label = allLabels.find(label => label.name === labelID)
onAddLabel(label)
this.handleStopSuggesting()
@ -159,7 +161,7 @@ class LabelSelector extends Component<Props, State> {
})
}
const highlightedID = this.availableLabels[0].id
const highlightedID = this.availableLabels[0].name
this.setState({isSuggesting: true, highlightedID, filterValue: ''})
}
@ -198,11 +200,11 @@ class LabelSelector extends Component<Props, State> {
})
const highlightedIDAvailable = filteredLabels.find(
al => al.id === highlightedID
al => al.name === highlightedID
)
if (!highlightedIDAvailable && filteredLabels.length) {
highlightedID = filteredLabels[0].id
highlightedID = filteredLabels[0].name
}
this.setState({filterValue, filteredLabels, highlightedID})
@ -218,7 +220,7 @@ class LabelSelector extends Component<Props, State> {
private handleDelete = (labelID: string): void => {
const {onRemoveLabel, selectedLabels} = this.props
const label = selectedLabels.find(l => l.id === labelID)
const label = selectedLabels.find(l => l.name === labelID)
onRemoveLabel(label)
}
@ -233,7 +235,7 @@ class LabelSelector extends Component<Props, State> {
const highlightedIndex = _.findIndex(
availableLabels,
label => label.id === highlightedID
label => label.name === highlightedID
)
const adjacentIndex = Math.min(
@ -241,7 +243,7 @@ class LabelSelector extends Component<Props, State> {
availableLabels.length - 1
)
const adjacentID = availableLabels[adjacentIndex].id
const adjacentID = availableLabels[adjacentIndex].name
this.setState({highlightedID: adjacentID})
}

View File

@ -4,14 +4,17 @@ import _ from 'lodash'
// Components
import FancyScrollbar from 'src/shared/components/fancy_scrollbar/FancyScrollbar'
import {LabelType} from 'src/clockface/components/label/Label'
import LabelSelectorMenuItem from 'src/clockface/components/label/LabelSelectorMenuItem'
// Decorators
import {ErrorHandling} from 'src/shared/decorators/errors'
// Types
import {Label} from 'src/api'
interface Props {
highlightItemID: string
filteredLabels: LabelType[]
filteredLabels: Label[]
onItemClick: (labelID: string) => void
onItemHighlight: (labelID: string) => void
allLabelsUsed: boolean
@ -40,12 +43,12 @@ class LabelSelectorMenu extends Component<Props> {
if (filteredLabels.length) {
return filteredLabels.map(label => (
<LabelSelectorMenuItem
highlighted={highlightItemID === label.id}
key={label.id}
highlighted={highlightItemID === label.name}
key={label.name}
name={label.name}
id={label.id}
description={label.description}
colorHex={label.colorHex}
id={label.name}
description={label.properties.description}
colorHex={label.properties.color}
onClick={onItemClick}
onHighlight={onItemHighlight}
/>

View File

@ -12,6 +12,8 @@ import {
updateCells as updateCellsAJAX,
addCell as addCellAJAX,
deleteCell as deleteCellAJAX,
addDashboardLabels as addDashboardLabelsAJAX,
removeDashboardLabels as removeDashboardLabelsAJAX,
} from 'src/dashboards/apis/v2'
import {createView as createViewAJAX} from 'src/dashboards/apis/v2/view'
@ -36,7 +38,7 @@ import * as copy from 'src/shared/copy/notifications'
// Types
import {RemoteDataState} from 'src/types'
import {PublishNotificationAction} from 'src/types/actions/notifications'
import {Dashboard, Cell} from 'src/api'
import {Dashboard, Cell, Label} from 'src/api'
import {NewView} from 'src/types/v2/dashboards'
export enum ActionTypes {
@ -46,6 +48,8 @@ export enum ActionTypes {
DeleteDashboardFailed = 'DELETE_DASHBOARD_FAILED',
UpdateDashboard = 'UPDATE_DASHBOARD',
DeleteCell = 'DELETE_CELL',
AddDashboardLabels = 'ADD_DASHBOARD_LABELS',
RemoveDashboardLabels = 'REMOVE_DASHBOARD_LABELS',
}
export type Action =
@ -58,6 +62,8 @@ export type Action =
| SetViewAction
| DeleteTimeRangeAction
| DeleteDashboardFailedAction
| AddDashboardLabelsAction
| RemoveDashboardLabelsAction
interface DeleteCellAction {
type: ActionTypes.DeleteCell
@ -102,6 +108,22 @@ interface LoadDashboardAction {
}
}
interface AddDashboardLabelsAction {
type: ActionTypes.AddDashboardLabels
payload: {
dashboardID: string
labels: Label[]
}
}
interface RemoveDashboardLabelsAction {
type: ActionTypes.RemoveDashboardLabels
payload: {
dashboardID: string
labels: Label[]
}
}
// Action Creators
export const updateDashboard = (
@ -147,6 +169,22 @@ export const deleteCell = (
payload: {dashboard, cell},
})
export const addDashboardLabels = (
dashboardID: string,
labels: Label[]
): AddDashboardLabelsAction => ({
type: ActionTypes.AddDashboardLabels,
payload: {dashboardID, labels},
})
export const removeDashboardLabels = (
dashboardID: string,
labels: Label[]
): RemoveDashboardLabelsAction => ({
type: ActionTypes.RemoveDashboardLabels,
payload: {dashboardID, labels},
})
// Thunks
export const getDashboardsAsync = () => async (
@ -317,3 +355,30 @@ export const copyDashboardCellAsync = (
console.error(error)
}
}
export const addDashboardLabelsAsync = (
dashboardID: string,
labels: Label[]
) => async (dispatch: Dispatch<Action>) => {
try {
const newLabels = await addDashboardLabelsAJAX(dashboardID, labels)
dispatch(addDashboardLabels(dashboardID, newLabels))
} catch (error) {
console.error(error)
dispatch(notify(copy.addDashboardLabelFailed()))
}
}
export const removeDashboardLabelsAsync = (
dashboardID: string,
labels: Label[]
) => async (dispatch: Dispatch<Action>) => {
try {
await removeDashboardLabelsAJAX(dashboardID, labels)
dispatch(removeDashboardLabels(dashboardID, labels))
} catch (error) {
console.error(error)
dispatch(notify(copy.removedDashboardLabelFailed()))
}
}

View File

@ -2,7 +2,7 @@
import {dashboardsAPI, cellsAPI} from 'src/utils/api'
// Types
import {Dashboard, Cell, CreateCell} from 'src/api'
import {Dashboard, Cell, CreateCell, Label} from 'src/api'
import {DashboardSwitcherLinks} from 'src/types/v2/dashboards'
// Utils
@ -83,3 +83,35 @@ export const deleteCell = async (
): Promise<void> => {
await cellsAPI.dashboardsDashboardIDCellsCellIDDelete(dashboardID, cell.id)
}
export const addDashboardLabels = async (
dashboardID: string,
labels: Label[]
): Promise<Label[]> => {
const addedLabels = await Promise.all(
labels.map(async label => {
const {data} = await dashboardsAPI.dashboardsDashboardIDLabelsPost(
dashboardID,
label
)
return data.label
})
)
return addedLabels
}
export const removeDashboardLabels = async (
dashboardID: string,
labels: Label[]
): Promise<void> => {
await Promise.all(
labels.map(async label => {
const {data} = await dashboardsAPI.dashboardsDashboardIDLabelsNameDelete(
dashboardID,
label.name
)
return data
})
)
}

View File

@ -0,0 +1,183 @@
// Libraries
import React, {PureComponent} from 'react'
import _ from 'lodash'
// Components
import {
OverlayContainer,
OverlayHeading,
OverlayBody,
LabelSelector,
ComponentSize,
ComponentColor,
Button,
Grid,
Form,
ComponentStatus,
} from 'src/clockface'
import FetchLabels from 'src/shared/components/FetchLabels'
// Actions
import {
addDashboardLabelsAsync,
removeDashboardLabelsAsync,
} from 'src/dashboards/actions/v2'
// Decorators
import {ErrorHandling} from 'src/shared/decorators/errors'
// Types
import {Dashboard} from 'src/types/v2'
import {Label} from 'src/api'
import {RemoteDataState} from 'src/types'
interface Props {
dashboard: Dashboard
onDismissOverlay: () => void
onAddDashboardLabels: typeof addDashboardLabelsAsync
onRemoveDashboardLabels: typeof removeDashboardLabelsAsync
}
interface State {
selectedLabels: Label[]
loading: RemoteDataState
}
@ErrorHandling
class EditDashboardLabelsOverlay extends PureComponent<Props, State> {
constructor(props: Props) {
super(props)
this.state = {
selectedLabels: props.dashboard.labels,
loading: RemoteDataState.NotStarted,
}
}
public render() {
const {onDismissOverlay, dashboard} = this.props
const {selectedLabels} = this.state
return (
<OverlayContainer maxWidth={720}>
<OverlayHeading title="Manage Labels" onDismiss={onDismissOverlay} />
<OverlayBody>
<Form>
<Grid>
<Grid.Row>
<Grid.Column>
<Form.Element label="">
<Form.Box>
<FetchLabels>
{labels => (
<LabelSelector
inputSize={ComponentSize.Medium}
allLabels={labels}
resourceType={dashboard.name}
selectedLabels={selectedLabels}
onAddLabel={this.handleAddLabel}
onRemoveLabel={this.handleRemoveLabel}
onRemoveAllLabels={this.handleRemoveAllLabels}
/>
)}
</FetchLabels>
</Form.Box>
</Form.Element>
<Form.Footer>
<Button text="Cancel" onClick={onDismissOverlay} />
<Button
color={ComponentColor.Success}
text="Save Changes"
onClick={this.handleSaveLabels}
status={this.buttonStatus}
/>
</Form.Footer>
</Grid.Column>
</Grid.Row>
</Grid>
</Form>
</OverlayBody>
</OverlayContainer>
)
}
private get buttonStatus(): ComponentStatus {
if (this.changes.isChanged) {
if (this.state.loading === RemoteDataState.Loading) {
return ComponentStatus.Loading
}
return ComponentStatus.Default
}
return ComponentStatus.Disabled
}
private get changes(): {
isChanged: boolean
removedLabels: Label[]
addedLabels: Label[]
} {
const {dashboard} = this.props
const {selectedLabels} = this.state
const intersection = _.intersectionBy(
dashboard.labels,
selectedLabels,
'name'
)
const removedLabels = _.differenceBy(dashboard.labels, intersection, 'name')
const addedLabels = _.differenceBy(selectedLabels, intersection, 'name')
return {
isChanged: !!removedLabels.length || !!addedLabels.length,
addedLabels,
removedLabels,
}
}
private handleRemoveAllLabels = (): void => {
this.setState({selectedLabels: []})
}
private handleRemoveLabel = (label: Label): void => {
const selectedLabels = this.state.selectedLabels.filter(
l => l.name !== label.name
)
this.setState({selectedLabels})
}
private handleAddLabel = (label: Label): void => {
const selectedLabels = [...this.state.selectedLabels, label]
this.setState({selectedLabels})
}
private handleSaveLabels = async (): Promise<void> => {
const {
onAddDashboardLabels,
onRemoveDashboardLabels,
dashboard,
onDismissOverlay,
} = this.props
const {addedLabels, removedLabels} = this.changes
this.setState({loading: RemoteDataState.Loading})
if (addedLabels.length) {
await onAddDashboardLabels(dashboard.id, addedLabels)
}
if (removedLabels.length) {
await onRemoveDashboardLabels(dashboard.id, removedLabels)
}
this.setState({loading: RemoteDataState.Done})
onDismissOverlay()
}
}
export default EditDashboardLabelsOverlay

View File

@ -21,6 +21,7 @@ interface Props {
onExportDashboard: (dashboard: Dashboard) => void
onDeleteDashboard: (dashboard: Dashboard) => void
onUpdateDashboard: (dashboard: Dashboard) => void
onEditLabels: (dashboard: Dashboard) => void
notify: (message: Notification) => void
searchTerm: string
}
@ -36,6 +37,7 @@ export default class DashboardsIndexContents extends Component<Props> {
defaultDashboardLink,
onSetDefaultDashboard,
onUpdateDashboard,
onEditLabels,
searchTerm,
} = this.props
@ -51,6 +53,7 @@ export default class DashboardsIndexContents extends Component<Props> {
defaultDashboardLink={defaultDashboardLink}
onSetDefaultDashboard={onSetDefaultDashboard}
onUpdateDashboard={onUpdateDashboard}
onEditLabels={onEditLabels}
/>
</div>
)

View File

@ -16,6 +16,7 @@ import {
IconFont,
} from 'src/clockface'
import ImportDashboardOverlay from 'src/dashboards/components/ImportDashboardOverlay'
import EditDashboardLabelsOverlay from 'src/dashboards/components/EditDashboardLabelsOverlay'
// Utils
import {getDeep} from 'src/utils/wrappers'
@ -29,6 +30,8 @@ import {
importDashboardAsync,
deleteDashboardAsync,
updateDashboardAsync,
addDashboardLabelsAsync,
removeDashboardLabelsAsync,
} from 'src/dashboards/actions/v2'
import {setDefaultDashboard} from 'src/shared/actions/links'
import {retainRangesDashTimeV1 as retainRangesDashTimeV1Action} from 'src/dashboards/actions/v2/ranges'
@ -48,14 +51,12 @@ import {DEFAULT_DASHBOARD_NAME} from 'src/dashboards/constants/index'
import {Notification} from 'src/types/notifications'
import {DashboardFile} from 'src/types/v2/dashboards'
import {Cell, Dashboard} from 'src/api'
import {Links} from 'src/types/v2'
import {Links, AppState} from 'src/types/v2'
// Decorators
import {ErrorHandling} from 'src/shared/decorators/errors'
interface Props {
router: InjectedRouter
links: Links
interface DispatchProps {
handleSetDefaultDashboard: typeof setDefaultDashboard
handleGetDashboards: typeof getDashboardsAsync
handleDeleteDashboard: typeof deleteDashboardAsync
@ -63,12 +64,26 @@ interface Props {
handleUpdateDashboard: typeof updateDashboardAsync
notify: (message: Notification) => void
retainRangesDashTimeV1: (dashboardIDs: string[]) => void
onAddDashboardLabels: typeof addDashboardLabelsAsync
onRemoveDashboardLabels: typeof removeDashboardLabelsAsync
}
interface StateProps {
links: Links
dashboards: Dashboard[]
}
interface OwnProps {
router: InjectedRouter
}
type Props = DispatchProps & StateProps & OwnProps
interface State {
searchTerm: string
isImportingDashboard: boolean
isEditingDashboardLabels: boolean
dashboardLabelsEdit: Dashboard
}
@ErrorHandling
@ -79,6 +94,8 @@ class DashboardIndex extends PureComponent<Props, State> {
this.state = {
searchTerm: '',
isImportingDashboard: false,
isEditingDashboardLabels: false,
dashboardLabelsEdit: null,
}
}
@ -124,12 +141,14 @@ class DashboardIndex extends PureComponent<Props, State> {
onCloneDashboard={this.handleCloneDashboard}
onExportDashboard={this.handleExportDashboard}
onUpdateDashboard={handleUpdateDashboard}
onEditLabels={this.handleStartEditingLabels}
notify={notify}
searchTerm={searchTerm}
/>
</Page.Contents>
</Page>
{this.renderImportOverlay}
{this.renderLabelEditorOverlay}
</>
)
}
@ -251,9 +270,33 @@ class DashboardIndex extends PureComponent<Props, State> {
</OverlayTechnology>
)
}
private handleStartEditingLabels = (dashboardLabelsEdit: Dashboard): void => {
this.setState({dashboardLabelsEdit, isEditingDashboardLabels: true})
}
private handleStopEditingLabels = (): void => {
this.setState({isEditingDashboardLabels: false})
}
private get renderLabelEditorOverlay(): JSX.Element {
const {onAddDashboardLabels, onRemoveDashboardLabels} = this.props
const {isEditingDashboardLabels, dashboardLabelsEdit} = this.state
return (
<OverlayTechnology visible={isEditingDashboardLabels}>
<EditDashboardLabelsOverlay
dashboard={dashboardLabelsEdit}
onDismissOverlay={this.handleStopEditingLabels}
onAddDashboardLabels={onAddDashboardLabels}
onRemoveDashboardLabels={onRemoveDashboardLabels}
/>
</OverlayTechnology>
)
}
}
const mstp = state => {
const mstp = (state: AppState): StateProps => {
const {dashboards, links} = state
return {
@ -262,7 +305,7 @@ const mstp = state => {
}
}
const mdtp = {
const mdtp: DispatchProps = {
notify: notifyAction,
handleSetDefaultDashboard: setDefaultDashboard,
handleGetDashboards: getDashboardsAsync,
@ -270,9 +313,11 @@ const mdtp = {
handleImportDashboard: importDashboardAsync,
handleUpdateDashboard: updateDashboardAsync,
retainRangesDashTimeV1: retainRangesDashTimeV1Action,
onAddDashboardLabels: addDashboardLabelsAsync,
onRemoveDashboardLabels: removeDashboardLabelsAsync,
}
export default connect(
export default connect<StateProps, DispatchProps, OwnProps>(
mstp,
mdtp
)(DashboardIndex)

View File

@ -30,6 +30,7 @@ interface Props {
onExportDashboard: (dashboard: Dashboard) => void
onUpdateDashboard: (dashboard: Dashboard) => void
onSetDefaultDashboard: (dashboardLink: string) => void
onEditLabels: (dashboard: Dashboard) => void
}
interface DatedDashboard extends Dashboard {
@ -104,6 +105,7 @@ class DashboardsTable extends PureComponent<Props & WithRouterProps, State> {
onCloneDashboard,
onDeleteDashboard,
onUpdateDashboard,
onEditLabels,
} = this.props
const {sortKey, sortDirection} = this.state
@ -122,6 +124,7 @@ class DashboardsTable extends PureComponent<Props & WithRouterProps, State> {
onExportDashboard={onExportDashboard}
onDeleteDashboard={onDeleteDashboard}
onUpdateDashboard={onUpdateDashboard}
onEditLabels={onEditLabels}
/>
)}
</SortingHat>

View File

@ -16,6 +16,7 @@ const setup = (override = {}) => {
onCloneDashboard: jest.fn(),
onExportDashboard: jest.fn(),
onUpdateDashboard: jest.fn(),
onEditLabels: jest.fn(),
...override,
}

View File

@ -1,6 +1,7 @@
// Libraries
import React, {PureComponent} from 'react'
import {Link} from 'react-router'
import classnames from 'classnames'
// Components
import {
@ -32,6 +33,7 @@ interface Props {
onCloneDashboard: (dashboard: Dashboard) => void
onExportDashboard: (dashboard: Dashboard) => void
onUpdateDashboard: (dashboard: Dashboard) => void
onEditLabels: (dashboard: Dashboard) => void
}
export default class DashboardsIndexTableRow extends PureComponent<Props> {
@ -90,14 +92,24 @@ export default class DashboardsIndexTableRow extends PureComponent<Props> {
const {dashboard} = this.props
if (!dashboard.labels.length) {
return
return (
<Label.Container
limitChildCount={4}
className="index-list--labels"
onEdit={this.handleEditLabels}
/>
)
}
return (
<Label.Container limitChildCount={4} className="index-list--labels">
<Label.Container
limitChildCount={4}
className="index-list--labels"
onEdit={this.handleEditLabels}
>
{dashboard.labels.map(label => (
<Label
key={label.resourceID}
key={`${label.resourceID}-${label.name}`}
id={label.resourceID}
colorHex={label.properties.color}
name={label.name}
@ -123,6 +135,11 @@ export default class DashboardsIndexTableRow extends PureComponent<Props> {
)
}
private handleEditLabels = () => {
const {dashboard, onEditLabels} = this.props
onEditLabels(dashboard)
}
private get name(): string {
const {dashboard} = this.props
@ -132,9 +149,12 @@ export default class DashboardsIndexTableRow extends PureComponent<Props> {
private get nameClassName(): string {
const {dashboard} = this.props
if (dashboard.name === '' || dashboard.name === DEFAULT_DASHBOARD_NAME) {
return 'untitled-name'
}
const dashboardIsUntitled =
dashboard.name === '' || dashboard.name === DEFAULT_DASHBOARD_NAME
return classnames('index-list--resource-name', {
'untitled-name': dashboardIsUntitled,
})
}
private handleUpdateDescription = (description: string): void => {

View File

@ -13,6 +13,7 @@ interface Props {
onCloneDashboard: (dashboard: Dashboard) => void
onExportDashboard: (dashboard: Dashboard) => void
onUpdateDashboard: (dashboard: Dashboard) => void
onEditLabels: (dashboard: Dashboard) => void
}
export default class DashboardsIndexTableRows extends PureComponent<Props> {
@ -23,6 +24,7 @@ export default class DashboardsIndexTableRows extends PureComponent<Props> {
onCloneDashboard,
onDeleteDashboard,
onUpdateDashboard,
onEditLabels,
} = this.props
return dashboards.map(d => (
@ -33,6 +35,7 @@ export default class DashboardsIndexTableRows extends PureComponent<Props> {
onCloneDashboard={onCloneDashboard}
onDeleteDashboard={onDeleteDashboard}
onUpdateDashboard={onUpdateDashboard}
onEditLabels={onEditLabels}
/>
))
}

View File

@ -8,10 +8,13 @@ import {
deleteDashboard,
updateDashboard,
deleteCell,
addDashboardLabels,
removeDashboardLabels,
} from 'src/dashboards/actions/v2/'
// Resources
import {dashboard} from 'src/dashboards/resources'
import {labels} from 'mocks/dummyData'
describe('dashboards reducer', () => {
it('can load the dashboards', () => {
@ -58,4 +61,26 @@ describe('dashboards reducer', () => {
expect(actual).toEqual(expected)
})
it('can add labels to a dashboard', () => {
const dashboardWithoutLabels = {...dashboard, labels: []}
const expected = [{...dashboard, labels}]
const actual = reducer(
[dashboardWithoutLabels],
addDashboardLabels(dashboardWithoutLabels.id, labels)
)
expect(actual).toEqual(expected)
})
it('can delete labels from a dashboard', () => {
const dashboardWithLabels = {...dashboard, labels}
const expected = [{...dashboard, labels: []}]
const actual = reducer(
[dashboardWithLabels],
removeDashboardLabels(dashboardWithLabels.id, labels)
)
expect(actual).toEqual(expected)
})
})

View File

@ -48,6 +48,36 @@ export default (state: State = [], action: Action): State => {
return [...newState]
}
case ActionTypes.AddDashboardLabels: {
const {dashboardID, labels} = action.payload
const newState = state.map(d => {
if (d.id === dashboardID) {
return {...d, labels: [...d.labels, ...labels]}
}
return d
})
return [...newState]
}
case ActionTypes.RemoveDashboardLabels: {
const {dashboardID, labels} = action.payload
const newState = state.map(d => {
if (d.id === dashboardID) {
const updatedLabels = d.labels.filter(l => {
return !labels.includes(l)
})
return {...d, labels: updatedLabels}
}
return d
})
return [...newState]
}
}
return state
}

View File

@ -0,0 +1,47 @@
import {Label} from 'src/api'
// import {labelsAPI} from 'src/utils/api'
// TODO: use actual API when ready
const mockLabels = [
{
resourceID: '0336b93e5b791000',
name: 'Oomph',
properties: {color: '#334455', description: 'this is a description'},
},
{
resourceID: '0336b93e5b791000',
name: 'TROGDOOOOORRRRRRRRR',
properties: {color: '#44ffcc', description: 'this is a description'},
},
{
resourceID: '0336b93e5b791000',
name: 'ZZYXX',
properties: {color: '#ff33ff', description: 'this is a description'},
},
{
resourceID: '0336b93e5b791000',
name: 'labeldawg',
properties: {color: '#ffb3b3', description: 'this is a description'},
},
{
resourceID: '0336b93e5b791000',
name: 'porphyria',
properties: {color: '#ff0054', description: 'this is a description'},
},
{
resourceID: '0336b93e5b791000',
name: 'snakes!',
properties: {color: '#44ff44', description: 'this is a description'},
},
]
export const getLabels = async (): Promise<Label[]> => {
try {
// const {data} = await labelsAPI.labelsGet()
// return data.labels
return mockLabels
} catch (error) {
console.error(error)
throw error
}
}

View File

@ -0,0 +1,59 @@
// Libraries
import React, {PureComponent} from 'react'
// Components
import {EmptyState} from 'src/clockface'
// APIs
import {getLabels} from 'src/shared/apis/v2/labels'
// Types
import {RemoteDataState} from 'src/types'
import {Label} from 'src/api'
// Decorators
import {ErrorHandling} from 'src/shared/decorators/errors'
interface Props {
children: (labels: Label[]) => JSX.Element
}
interface State {
labels: Label[]
ready: RemoteDataState
}
@ErrorHandling
class FetchLabels extends PureComponent<Props, State> {
constructor(props: Props) {
super(props)
this.state = {
labels: [],
ready: RemoteDataState.NotStarted,
}
}
public async componentDidMount() {
const labels = await getLabels()
this.setState({ready: RemoteDataState.Done, labels})
}
public render() {
if (this.state.ready === RemoteDataState.Error) {
return (
<EmptyState>
<EmptyState.Text text="Could not load labels" />
</EmptyState>
)
}
if (this.state.ready !== RemoteDataState.Done) {
return <div className="page-spinner" />
}
return this.props.children(this.state.labels)
}
}
export default FetchLabels

View File

@ -546,6 +546,16 @@ export const cellDeleted = (): Notification => ({
message: `Cell deleted from dashboard.`,
})
export const addDashboardLabelFailed = (): Notification => ({
...defaultErrorNotification,
message: 'Failed to add label to dashboard',
})
export const removedDashboardLabelFailed = (): Notification => ({
...defaultErrorNotification,
message: 'Failed to remove label from dashboard',
})
export const builderDisabled = (): Notification => ({
type: 'info',
icon: 'graphline',

View File

@ -88,7 +88,7 @@ $ix-text-base-5: (ceil($ix-text-base * $ix-text-scale * $ix-text-scale * $ix-tex
$form-xs-height: 22px;
$form-xs-padding: 6px;
$form-xs-font: 13px;
$form-xs-font: 12px;
$form-sm-height: 30px;
$form-sm-padding: 9px;