From 759891e37fbc9bb0ef185235efca269e4026287d Mon Sep 17 00:00:00 2001 From: Christopher Henn <chris@chrishenn.net> Date: Tue, 27 Nov 2018 17:03:13 -0800 Subject: [PATCH] Add ability to create notes on a dashboard --- http/view_test.go | 12 +- ui/package.json | 2 +- ui/src/dashboards/actions/v2/notes.ts | 124 ++++++++++++++ ui/src/dashboards/components/Dashboard.tsx | 2 + .../dashboards/components/DashboardHeader.tsx | 29 +++- .../dashboards/components/DashboardPage.tsx | 2 + ui/src/dashboards/components/NoteEditor.scss | 42 +++++ ui/src/dashboards/components/NoteEditor.tsx | 112 +++++++++++++ .../components/NoteEditorContainer.scss | 10 ++ .../components/NoteEditorContainer.tsx | 157 ++++++++++++++++++ .../components/NoteEditorPreview.tsx | 20 +++ .../dashboards/components/NoteEditorText.scss | 9 + .../dashboards/components/NoteEditorText.tsx | 53 ++++++ ui/src/dashboards/reducers/v2/notes.ts | 66 ++++++++ ui/src/external/codemirror.ts | 2 + ui/src/shared/components/EmptyQueryView.tsx | 19 ++- ui/src/shared/components/GaugeChart.test.tsx | 2 + ui/src/shared/components/RefreshingView.tsx | 7 + ui/src/shared/components/cells/Cell.scss | 17 ++ ui/src/shared/components/cells/Cell.tsx | 70 ++++---- .../shared/components/cells/CellContext.tsx | 113 +++++++++---- ui/src/shared/components/cells/CellHeader.tsx | 45 ++--- .../components/cells/CellHeaderNote.tsx | 79 +++++++++ .../cells/CellHeaderNoteTooltip.scss | 15 ++ .../cells/CellHeaderNoteTooltip.tsx | 41 +++++ ui/src/shared/components/cells/Cells.tsx | 1 - ui/src/shared/components/cells/View.tsx | 5 +- .../code_mirror/CodeMirrorTheme.scss | 1 + .../code_mirror/_ThemeMarkdown.scss | 59 +++++++ ui/src/shared/components/views/Markdown.scss | 6 +- ui/src/shared/components/views/Markdown.tsx | 4 +- ui/src/shared/constants/codeMirrorModes.ts | 48 ++++++ ui/src/shared/copy/v2/notifications.ts | 6 + ui/src/shared/utils/view.ts | 8 +- ui/src/store/configureStore.ts | 2 + ui/src/types/v2/dashboards.ts | 22 ++- ui/src/types/v2/index.ts | 2 + ui/yarn.lock | 92 +++++++++- view.go | 96 +++++++---- view_test.go | 4 +- 40 files changed, 1246 insertions(+), 160 deletions(-) create mode 100644 ui/src/dashboards/actions/v2/notes.ts create mode 100644 ui/src/dashboards/components/NoteEditor.scss create mode 100644 ui/src/dashboards/components/NoteEditor.tsx create mode 100644 ui/src/dashboards/components/NoteEditorContainer.scss create mode 100644 ui/src/dashboards/components/NoteEditorContainer.tsx create mode 100644 ui/src/dashboards/components/NoteEditorPreview.tsx create mode 100644 ui/src/dashboards/components/NoteEditorText.scss create mode 100644 ui/src/dashboards/components/NoteEditorText.tsx create mode 100644 ui/src/dashboards/reducers/v2/notes.ts create mode 100644 ui/src/shared/components/cells/CellHeaderNote.tsx create mode 100644 ui/src/shared/components/cells/CellHeaderNoteTooltip.scss create mode 100644 ui/src/shared/components/cells/CellHeaderNoteTooltip.tsx create mode 100644 ui/src/shared/components/code_mirror/_ThemeMarkdown.scss diff --git a/http/view_test.go b/http/view_test.go index 657a3d9878..7c3071f31d 100644 --- a/http/view_test.go +++ b/http/view_test.go @@ -84,7 +84,9 @@ func TestService_handleGetViews(t *testing.T) { "type": "xy", "colors": null, "legend": {}, - "geom": "" + "geom": "", + "note": "", + "showNoteWhenEmpty": false } }, { @@ -330,7 +332,9 @@ func TestService_handlePostViews(t *testing.T) { "type": "xy", "colors": null, "legend": {}, - "geom": "" + "geom": "", + "note": "", + "showNoteWhenEmpty": false } } `, @@ -530,7 +534,9 @@ func TestService_handlePatchView(t *testing.T) { "type": "xy", "colors": null, "legend": {}, - "geom": "" + "geom": "", + "note": "", + "showNoteWhenEmpty": false } } `, diff --git a/ui/package.json b/ui/package.json index f8394ea5de..bfac3be0df 100644 --- a/ui/package.json +++ b/ui/package.json @@ -140,7 +140,7 @@ "react-dnd-html5-backend": "^2.6.0", "react-dom": "^16.3.1", "react-grid-layout": "^0.16.6", - "react-markdown": "^3.6.0", + "react-markdown": "^4.0.3", "react-redux": "^5.0.7", "react-resize-detector": "^2.3.0", "react-router": "^3.0.2", diff --git a/ui/src/dashboards/actions/v2/notes.ts b/ui/src/dashboards/actions/v2/notes.ts new file mode 100644 index 0000000000..59097b5d1e --- /dev/null +++ b/ui/src/dashboards/actions/v2/notes.ts @@ -0,0 +1,124 @@ +// Libraries +import {get, isUndefined} from 'lodash' + +// Actions +import {createCellWithView} from 'src/dashboards/actions/v2' +import {updateView} from 'src/dashboards/actions/v2/views' + +// Utils +import {createView} from 'src/shared/utils/view' + +// Types +import {GetState} from 'src/types/v2' +import {NoteEditorMode, MarkdownView, ViewType} from 'src/types/v2/dashboards' +import {NoteEditorState} from 'src/dashboards/reducers/v2/notes' + +export type Action = + | OpenNoteEditorAction + | CloseNoteEditorAction + | SetIsPreviewingAction + | ToggleShowNoteWhenEmptyAction + | SetNoteAction + +interface OpenNoteEditorAction { + type: 'OPEN_NOTE_EDITOR' + payload: {initialState: Partial<NoteEditorState>} +} + +export const openNoteEditor = ( + initialState: Partial<NoteEditorState> +): OpenNoteEditorAction => ({ + type: 'OPEN_NOTE_EDITOR', + payload: {initialState}, +}) + +export const addNote = (): OpenNoteEditorAction => ({ + type: 'OPEN_NOTE_EDITOR', + payload: { + initialState: { + mode: NoteEditorMode.Adding, + viewID: null, + toggleVisible: false, + note: '', + }, + }, +}) + +interface CloseNoteEditorAction { + type: 'CLOSE_NOTE_EDITOR' +} + +export const closeNoteEditor = (): CloseNoteEditorAction => ({ + type: 'CLOSE_NOTE_EDITOR', +}) + +interface SetIsPreviewingAction { + type: 'SET_IS_PREVIEWING' + payload: {isPreviewing: boolean} +} + +export const setIsPreviewing = ( + isPreviewing: boolean +): SetIsPreviewingAction => ({ + type: 'SET_IS_PREVIEWING', + payload: {isPreviewing}, +}) + +interface ToggleShowNoteWhenEmptyAction { + type: 'TOGGLE_SHOW_NOTE_WHEN_EMPTY' +} + +export const toggleShowNoteWhenEmpty = (): ToggleShowNoteWhenEmptyAction => ({ + type: 'TOGGLE_SHOW_NOTE_WHEN_EMPTY', +}) + +interface SetNoteAction { + type: 'SET_NOTE' + payload: {note: string} +} + +export const setNote = (note: string): SetNoteAction => ({ + type: 'SET_NOTE', + payload: {note}, +}) + +export const createNoteCell = (dashboardID: string) => async ( + dispatch, + getState: GetState +) => { + const dashboard = getState().dashboards.find(d => d.id === dashboardID) + + if (!dashboard) { + throw new Error(`could not find dashboard with id "${dashboardID}"`) + } + + const {note} = getState().noteEditor + const view = createView<MarkdownView>(ViewType.Markdown) + + view.properties.note = note + + return dispatch(createCellWithView(dashboard, view)) +} + +export const updateViewNote = () => async (dispatch, getState: GetState) => { + const state = getState() + const {note, showNoteWhenEmpty, viewID} = state.noteEditor + const view: any = get(state, `views.${viewID}.view`) + + if (!view) { + throw new Error(`could not find view with id "${viewID}"`) + } + + if (isUndefined(view.properties.note)) { + throw new Error( + `view type "${view.properties.type}" does not support notes` + ) + } + + const updatedView = { + ...view, + properties: {...view.properties, note, showNoteWhenEmpty}, + } + + return dispatch(updateView(view.links.self, updatedView)) +} diff --git a/ui/src/dashboards/components/Dashboard.tsx b/ui/src/dashboards/components/Dashboard.tsx index f37ea3fffe..5e998f7f9e 100644 --- a/ui/src/dashboards/components/Dashboard.tsx +++ b/ui/src/dashboards/components/Dashboard.tsx @@ -65,6 +65,8 @@ class DashboardComponent extends PureComponent<Props> { ) : ( <DashboardEmpty dashboard={dashboard} /> )} + {/* This element is used as a portal container for note tooltips in cell headers */} + <div className="cell-header-note-tooltip-container" /> </div> </FancyScrollbar> ) diff --git a/ui/src/dashboards/components/DashboardHeader.tsx b/ui/src/dashboards/components/DashboardHeader.tsx index 17b03c0c8f..647f32594e 100644 --- a/ui/src/dashboards/components/DashboardHeader.tsx +++ b/ui/src/dashboards/components/DashboardHeader.tsx @@ -1,5 +1,6 @@ // Libraries import React, {Component} from 'react' +import {connect} from 'react-redux' // Components import {Page} from 'src/pageLayout' @@ -9,13 +10,16 @@ import GraphTips from 'src/shared/components/graph_tips/GraphTips' import RenameDashboard from 'src/dashboards/components/rename_dashboard/RenameDashboard' import {Button, ButtonShape, ComponentColor, IconFont} from 'src/clockface' +// Actions +import {addNote} from 'src/dashboards/actions/v2/notes' + // Types import * as AppActions from 'src/types/actions/app' import * as QueriesModels from 'src/types/queries' import {Dashboard} from 'src/api' import {DashboardSwitcherLinks} from 'src/types/v2/dashboards' -interface Props { +interface OwnProps { activeDashboard: string dashboard: Dashboard timeRange: QueriesModels.TimeRange @@ -32,6 +36,12 @@ interface Props { isHidden: boolean } +interface DispatchProps { + onAddNote: typeof addNote +} + +type Props = OwnProps & DispatchProps + class DashboardHeader extends Component<Props> { public static defaultProps: Partial<Props> = { zoomedTimeRange: { @@ -49,6 +59,7 @@ class DashboardHeader extends Component<Props> { timeRange: {upper, lower}, zoomedTimeRange: {upper: zoomedUpper, lower: zoomedLower}, isHidden, + onAddNote, } = this.props return ( @@ -57,6 +68,11 @@ class DashboardHeader extends Component<Props> { <Page.Header.Right> <GraphTips /> {this.addCellButton} + <Button + icon={IconFont.TextBlock} + text="Add Note" + onClick={onAddNote} + /> <AutoRefreshDropdown onChoose={handleChooseAutoRefresh} onManualRefresh={onManualRefresh} @@ -90,10 +106,10 @@ class DashboardHeader extends Component<Props> { if (dashboard) { return ( <Button - shape={ButtonShape.Square} icon={IconFont.AddCell} color={ComponentColor.Primary} onClick={onAddCell} + text="Add Cell" titleText="Add cell to dashboard" /> ) @@ -113,4 +129,11 @@ class DashboardHeader extends Component<Props> { } } -export default DashboardHeader +const mdtp = { + onAddNote: addNote, +} + +export default connect<{}, DispatchProps, OwnProps>( + null, + mdtp +)(DashboardHeader) diff --git a/ui/src/dashboards/components/DashboardPage.tsx b/ui/src/dashboards/components/DashboardPage.tsx index cf19a6f201..0d671bb0a3 100644 --- a/ui/src/dashboards/components/DashboardPage.tsx +++ b/ui/src/dashboards/components/DashboardPage.tsx @@ -13,6 +13,7 @@ import ManualRefresh from 'src/shared/components/ManualRefresh' import VEO from 'src/dashboards/components/VEO' import {OverlayTechnology} from 'src/clockface' import {HoverTimeProvider} from 'src/dashboards/utils/hoverTime' +import NoteEditorContainer from 'src/dashboards/components/NoteEditorContainer' // Actions import * as dashboardActions from 'src/dashboards/actions/v2' @@ -222,6 +223,7 @@ class DashboardPage extends Component<Props, State> { /> </OverlayTechnology> </HoverTimeProvider> + <NoteEditorContainer /> </Page> ) } diff --git a/ui/src/dashboards/components/NoteEditor.scss b/ui/src/dashboards/components/NoteEditor.scss new file mode 100644 index 0000000000..95e3e96df5 --- /dev/null +++ b/ui/src/dashboards/components/NoteEditor.scss @@ -0,0 +1,42 @@ +@import "src/style/modules"; + +.note-editor { + height: 100%; + display: flex; + flex-direction: column; + + .note-editor-text, .note-editor-preview { + flex: 1 1 0; + border: 2px solid $g6-smoke; + border-radius: 4px; + } + + .note-editor-preview { + padding: 15px; + } +} + +.note-editor--controls { + margin-bottom: 20px; + display: flex; + justify-content: space-between; + align-items: center; + + &.centered { + justify-content: center; + } +} + +.note-editor--toggle { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + font-weight: 600; + color: $g13-mist; + + .slide-toggle { + margin-left: 10px; + margin-top: 2px; + } +} diff --git a/ui/src/dashboards/components/NoteEditor.tsx b/ui/src/dashboards/components/NoteEditor.tsx new file mode 100644 index 0000000000..0b9b2196a5 --- /dev/null +++ b/ui/src/dashboards/components/NoteEditor.tsx @@ -0,0 +1,112 @@ +// Libraries +import React, {SFC} from 'react' +import {connect} from 'react-redux' + +// Components +import {Radio, SlideToggle, ComponentSize} from 'src/clockface' +import NoteEditorText from 'src/dashboards/components/NoteEditorText' +import NoteEditorPreview from 'src/dashboards/components/NoteEditorPreview' + +// Actions +import { + setIsPreviewing, + toggleShowNoteWhenEmpty, + setNote, +} from 'src/dashboards/actions/v2/notes' + +// Styles +import 'src/dashboards/components/NoteEditor.scss' + +// Types +import {AppState} from 'src/types/v2' + +interface StateProps { + note: string + isPreviewing: boolean + toggleVisible: boolean + showNoteWhenEmpty: boolean +} + +interface DispatchProps { + onSetIsPreviewing: typeof setIsPreviewing + onToggleShowNoteWhenEmpty: typeof toggleShowNoteWhenEmpty + onSetNote: typeof setNote +} + +interface OwnProps {} + +type Props = StateProps & DispatchProps & OwnProps + +const NoteEditor: SFC<Props> = props => { + const { + note, + isPreviewing, + toggleVisible, + showNoteWhenEmpty, + onSetIsPreviewing, + onToggleShowNoteWhenEmpty, + onSetNote, + } = props + + return ( + <div className="note-editor"> + <div + className={`note-editor--controls ${toggleVisible ? '' : 'centered'}`} + > + <Radio> + <Radio.Button + value={false} + active={!isPreviewing} + onClick={onSetIsPreviewing} + > + Compose + </Radio.Button> + <Radio.Button + value={true} + active={isPreviewing} + onClick={onSetIsPreviewing} + > + Preview + </Radio.Button> + </Radio> + {toggleVisible && ( + <label className="note-editor--toggle"> + Show note when query returns no data + <SlideToggle + active={showNoteWhenEmpty} + size={ComponentSize.ExtraSmall} + onChange={onToggleShowNoteWhenEmpty} + /> + </label> + )} + </div> + {isPreviewing ? ( + <NoteEditorPreview note={note} /> + ) : ( + <NoteEditorText note={note} onChangeNote={onSetNote} /> + )} + </div> + ) +} + +const mstp = (state: AppState) => { + const { + note, + isPreviewing, + toggleVisible, + showNoteWhenEmpty, + } = state.noteEditor + + return {note, isPreviewing, toggleVisible, showNoteWhenEmpty} +} + +const mdtp = { + onSetIsPreviewing: setIsPreviewing, + onToggleShowNoteWhenEmpty: toggleShowNoteWhenEmpty, + onSetNote: setNote, +} + +export default connect<StateProps, DispatchProps, OwnProps>( + mstp, + mdtp +)(NoteEditor) diff --git a/ui/src/dashboards/components/NoteEditorContainer.scss b/ui/src/dashboards/components/NoteEditorContainer.scss new file mode 100644 index 0000000000..ac8d03a947 --- /dev/null +++ b/ui/src/dashboards/components/NoteEditorContainer.scss @@ -0,0 +1,10 @@ +.note-editor-container .overlay--container { + height: 80%; + max-height: 600px; + display: flex; + flex-direction: column; +} + +.note-editor-container .overlay--body { + height: 100%; +} diff --git a/ui/src/dashboards/components/NoteEditorContainer.tsx b/ui/src/dashboards/components/NoteEditorContainer.tsx new file mode 100644 index 0000000000..ad0393c036 --- /dev/null +++ b/ui/src/dashboards/components/NoteEditorContainer.tsx @@ -0,0 +1,157 @@ +// Libraries +import React, {PureComponent} from 'react' +import {connect} from 'react-redux' +import {withRouter, WithRouterProps} from 'react-router' + +// Components +import NoteEditor from 'src/dashboards/components/NoteEditor' +import { + OverlayBody, + OverlayHeading, + OverlayTechnology, + OverlayContainer, + Button, + ComponentColor, + ComponentStatus, +} from 'src/clockface' + +// Actions +import { + closeNoteEditor, + createNoteCell, + updateViewNote, +} from 'src/dashboards/actions/v2/notes' +import {notify} from 'src/shared/actions/notifications' + +// Utils +import {savingNoteFailed} from 'src/shared/copy/v2/notifications' + +// Styles +import 'src/dashboards/components/NoteEditorContainer.scss' + +// Types +import {RemoteDataState} from 'src/types' +import {AppState} from 'src/types/v2' +import {NoteEditorMode} from 'src/types/v2/dashboards' + +interface StateProps { + mode: NoteEditorMode + overlayVisible: boolean + viewID: string +} + +interface DispatchProps { + onHide: typeof closeNoteEditor + onCreateNoteCell: (dashboardID: string) => Promise<void> + onUpdateViewNote: () => Promise<void> + onNotify: typeof notify +} + +interface OwnProps {} + +type Props = StateProps & DispatchProps & OwnProps & WithRouterProps + +interface State { + savingStatus: RemoteDataState +} + +class NoteEditorContainer extends PureComponent<Props, State> { + public state: State = {savingStatus: RemoteDataState.NotStarted} + + public render() { + const {onHide, overlayVisible} = this.props + + return ( + <div className="note-editor-container"> + <OverlayTechnology visible={overlayVisible}> + <OverlayContainer> + <OverlayHeading title={this.overlayTitle}> + <div className="create-source-overlay--heading-buttons"> + <Button text="Cancel" onClick={onHide} /> + <Button + text="Save" + color={ComponentColor.Success} + status={this.saveButtonStatus} + onClick={this.handleSave} + /> + </div> + </OverlayHeading> + <OverlayBody> + <NoteEditor /> + </OverlayBody> + </OverlayContainer> + </OverlayTechnology> + </div> + ) + } + + private get overlayTitle(): string { + const {mode} = this.props + + let overlayTitle: string + + if (mode === NoteEditorMode.Editing) { + overlayTitle = 'Edit Note' + } else { + overlayTitle = 'Add Note' + } + + return overlayTitle + } + + private get saveButtonStatus(): ComponentStatus { + const {savingStatus} = this.state + + if (savingStatus === RemoteDataState.Loading) { + return ComponentStatus.Loading + } + + return ComponentStatus.Default + } + + private handleSave = async () => { + const { + viewID, + onCreateNoteCell, + onUpdateViewNote, + onHide, + onNotify, + } = this.props + + const dashboardID = this.props.params.dashboardID + + this.setState({savingStatus: RemoteDataState.Loading}) + + try { + if (viewID) { + await onUpdateViewNote() + } else { + await onCreateNoteCell(dashboardID) + } + + this.setState({savingStatus: RemoteDataState.NotStarted}, onHide) + } catch (error) { + onNotify(savingNoteFailed(error.message)) + console.error(error) + this.setState({savingStatus: RemoteDataState.Error}) + } + } +} + +const mstp = (state: AppState) => { + const {mode, overlayVisible, viewID} = state.noteEditor + + return {mode, overlayVisible, viewID} +} + +const mdtp = { + onHide: closeNoteEditor, + onNotify: notify, + onCreateNoteCell: createNoteCell as any, + onUpdateViewNote: updateViewNote as any, +} + +export default connect<StateProps, DispatchProps, OwnProps>( + mstp, + mdtp +)(withRouter<StateProps & DispatchProps & OwnProps>(NoteEditorContainer)) diff --git a/ui/src/dashboards/components/NoteEditorPreview.tsx b/ui/src/dashboards/components/NoteEditorPreview.tsx new file mode 100644 index 0000000000..91c571bd91 --- /dev/null +++ b/ui/src/dashboards/components/NoteEditorPreview.tsx @@ -0,0 +1,20 @@ +import React, {SFC} from 'react' +import ReactMarkdown from 'react-markdown' + +import FancyScrollbar from 'src/shared/components/fancy_scrollbar/FancyScrollbar' + +interface Props { + note: string +} + +const NoteEditorPreview: SFC<Props> = props => { + return ( + <div className="note-editor-preview markdown-format"> + <FancyScrollbar> + <ReactMarkdown source={props.note} escapeHtml={true} /> + </FancyScrollbar> + </div> + ) +} + +export default NoteEditorPreview diff --git a/ui/src/dashboards/components/NoteEditorText.scss b/ui/src/dashboards/components/NoteEditorText.scss new file mode 100644 index 0000000000..9f7f127f42 --- /dev/null +++ b/ui/src/dashboards/components/NoteEditorText.scss @@ -0,0 +1,9 @@ +@import "src/style/modules"; + +.note-editor-text { + overflow: hidden; + + .react-codemirror2 { + padding: 10px; + } +} diff --git a/ui/src/dashboards/components/NoteEditorText.tsx b/ui/src/dashboards/components/NoteEditorText.tsx new file mode 100644 index 0000000000..f2830e4e96 --- /dev/null +++ b/ui/src/dashboards/components/NoteEditorText.tsx @@ -0,0 +1,53 @@ +// Libraries +import React, {PureComponent} from 'react' +import {Controlled as ReactCodeMirror} from 'react-codemirror2' + +// Utils +import {humanizeNote} from 'src/dashboards/utils/notes' + +// Styles +import 'src/dashboards/components/NoteEditorText.scss' + +const OPTIONS = { + mode: 'markdown', + theme: 'markdown', + tabIndex: 1, + readonly: false, + lineNumbers: false, + autoRefresh: true, + completeSingle: false, + lineWrapping: true, +} + +const noOp = () => {} + +interface Props { + note: string + onChangeNote: (value: string) => void +} + +class NoteEditorText extends PureComponent<Props, {}> { + public render() { + const {note} = this.props + + return ( + <div className="note-editor-text"> + <ReactCodeMirror + autoCursor={true} + value={humanizeNote(note)} + options={OPTIONS} + onBeforeChange={this.handleChange} + onTouchStart={noOp} + /> + </div> + ) + } + + private handleChange = (_, __, note: string) => { + const {onChangeNote} = this.props + + onChangeNote(note) + } +} + +export default NoteEditorText diff --git a/ui/src/dashboards/reducers/v2/notes.ts b/ui/src/dashboards/reducers/v2/notes.ts new file mode 100644 index 0000000000..1bf2860686 --- /dev/null +++ b/ui/src/dashboards/reducers/v2/notes.ts @@ -0,0 +1,66 @@ +import {Action} from 'src/dashboards/actions/v2/notes' +import {NoteEditorMode} from 'src/types/v2/dashboards' + +export interface NoteEditorState { + overlayVisible: boolean + mode: NoteEditorMode + viewID: string + toggleVisible: boolean + note: string + showNoteWhenEmpty: boolean + isPreviewing: boolean +} + +const initialState = (): NoteEditorState => ({ + overlayVisible: false, + mode: NoteEditorMode.Adding, + viewID: null, + toggleVisible: false, + note: '', + showNoteWhenEmpty: false, + isPreviewing: false, +}) + +const noteEditorReducer = ( + state: NoteEditorState = initialState(), + action: Action +) => { + switch (action.type) { + case 'OPEN_NOTE_EDITOR': { + const {initialState} = action.payload + + return { + ...state, + ...initialState, + overlayVisible: true, + isPreviewing: false, + } + } + + case 'CLOSE_NOTE_EDITOR': { + return {...state, overlayVisible: false} + } + + case 'SET_IS_PREVIEWING': { + const {isPreviewing} = action.payload + + return {...state, isPreviewing} + } + + case 'TOGGLE_SHOW_NOTE_WHEN_EMPTY': { + const {showNoteWhenEmpty} = state + + return {...state, showNoteWhenEmpty: !showNoteWhenEmpty} + } + + case 'SET_NOTE': { + const {note} = action.payload + + return {...state, note} + } + } + + return state +} + +export default noteEditorReducer diff --git a/ui/src/external/codemirror.ts b/ui/src/external/codemirror.ts index f1e2f1c74c..4846580caf 100644 --- a/ui/src/external/codemirror.ts +++ b/ui/src/external/codemirror.ts @@ -2,6 +2,7 @@ import { modeFlux, modeTickscript, modeInfluxQL, + modeMarkdown, } from 'src/shared/constants/codeMirrorModes' import 'codemirror/addon/hint/show-hint' @@ -326,3 +327,4 @@ function indentFunction(states, meta) { CodeMirror.defineSimpleMode('flux', modeFlux) CodeMirror.defineSimpleMode('tickscript', modeTickscript) CodeMirror.defineSimpleMode('influxQL', modeInfluxQL) +CodeMirror.defineSimpleMode('markdown', modeMarkdown) diff --git a/ui/src/shared/components/EmptyQueryView.tsx b/ui/src/shared/components/EmptyQueryView.tsx index ffa2c6832e..d7b0b6bc5f 100644 --- a/ui/src/shared/components/EmptyQueryView.tsx +++ b/ui/src/shared/components/EmptyQueryView.tsx @@ -3,6 +3,7 @@ import React, {PureComponent} from 'react' // Components import EmptyGraphMessage from 'src/shared/components/EmptyGraphMessage' +import Markdown from 'src/shared/components/views/Markdown' // Constants import {emptyGraphCopy} from 'src/shared/copy/cell' @@ -17,11 +18,19 @@ interface Props { loading: RemoteDataState tables: FluxTable[] queries: DashboardQuery[] + fallbackNote?: string } export default class EmptyQueryView extends PureComponent<Props> { public render() { - const {error, isInitialFetch, loading, tables, queries} = this.props + const { + error, + isInitialFetch, + loading, + tables, + queries, + fallbackNote, + } = this.props if (!queries.length) { return <EmptyGraphMessage message={emptyGraphCopy} /> @@ -35,7 +44,13 @@ export default class EmptyQueryView extends PureComponent<Props> { return <EmptyGraphMessage message="Loading..." /> } - if (!tables.some(d => !!d.data.length)) { + const hasNoResults = !tables.some(d => !!d.data.length) + + if (hasNoResults && fallbackNote) { + return <Markdown text={fallbackNote} /> + } + + if (hasNoResults) { return <EmptyGraphMessage message="No Results" /> } diff --git a/ui/src/shared/components/GaugeChart.test.tsx b/ui/src/shared/components/GaugeChart.test.tsx index c39ebe7f48..cd3063f402 100644 --- a/ui/src/shared/components/GaugeChart.test.tsx +++ b/ui/src/shared/components/GaugeChart.test.tsx @@ -33,6 +33,8 @@ const properties: GaugeView = { type: ViewType.Gauge, prefix: '', suffix: '', + note: '', + showNoteWhenEmpty: false, decimalPlaces: { digits: 10, isEnforced: false, diff --git a/ui/src/shared/components/RefreshingView.tsx b/ui/src/shared/components/RefreshingView.tsx index 1a347ce5f9..9c2c08c5b7 100644 --- a/ui/src/shared/components/RefreshingView.tsx +++ b/ui/src/shared/components/RefreshingView.tsx @@ -55,6 +55,7 @@ class RefreshingView extends PureComponent<Props> { loading={loading} isInitialFetch={isInitialFetch} queries={this.queries} + fallbackNote={this.fallbackNote} > <QueryViewSwitcher tables={tables} @@ -85,6 +86,12 @@ class RefreshingView extends PureComponent<Props> { return queries } + + private get fallbackNote(): string { + const {note, showNoteWhenEmpty} = this.props.properties + + return showNoteWhenEmpty ? note : null + } } export default RefreshingView diff --git a/ui/src/shared/components/cells/Cell.scss b/ui/src/shared/components/cells/Cell.scss index c2c3fe629f..9b6ed3b0a5 100644 --- a/ui/src/shared/components/cells/Cell.scss +++ b/ui/src/shared/components/cells/Cell.scss @@ -71,6 +71,23 @@ $cell--header-size: 36px; transform: translateY(-50%); width: 100%; pointer-events: none; + + .cell--header-note & { + margin-left: 25px; + } +} + +.cell-header-note { + position: absolute; + top: 7px; + left: 10px; + z-index: 10; + cursor: default; + color: $g14-chromium; + + &:hover { + color: $g20-white; + } } .cell--header-bar { diff --git a/ui/src/shared/components/cells/Cell.tsx b/ui/src/shared/components/cells/Cell.tsx index f4e3216a85..a141163555 100644 --- a/ui/src/shared/components/cells/Cell.tsx +++ b/ui/src/shared/components/cells/Cell.tsx @@ -1,7 +1,7 @@ // Libraries import React, {Component, ComponentClass} from 'react' import {connect} from 'react-redux' -import _ from 'lodash' +import {get} from 'lodash' // Components import CellHeader from 'src/shared/components/cells/CellHeader' @@ -14,13 +14,13 @@ import {readView} from 'src/dashboards/actions/v2/views' // Types import {RemoteDataState, TimeRange} from 'src/types' -import {Cell, View, AppState} from 'src/types/v2' +import {Cell, View, AppState, ViewType} from 'src/types/v2' // Styles import './Cell.scss' interface StateProps { - view: View + view: View | null viewStatus: RemoteDataState } @@ -37,7 +37,6 @@ interface PassedProps { onCloneCell: (cell: Cell) => void onEditCell: () => void onZoom: (range: TimeRange) => void - isEditable: boolean } type Props = StateProps & DispatchProps & PassedProps @@ -53,35 +52,51 @@ class CellComponent extends Component<Props> { } public render() { - const {isEditable, onEditCell, onDeleteCell, onCloneCell, cell} = this.props + const {onEditCell, onDeleteCell, onCloneCell, cell, view} = this.props return ( <> - <CellHeader name={this.viewName} isEditable={isEditable} /> - <CellContext - visible={isEditable} - cell={cell} - onDeleteCell={onDeleteCell} - onCloneCell={onCloneCell} - onEditCell={onEditCell} - onCSVDownload={this.handleCSVDownload} - /> + <CellHeader name={this.viewName} note={this.viewNote} /> + {view && ( + <CellContext + cell={cell} + view={view} + onDeleteCell={onDeleteCell} + onCloneCell={onCloneCell} + onEditCell={onEditCell} + onCSVDownload={this.handleCSVDownload} + /> + )} <div className="cell--view">{this.view}</div> </> ) } - // private get queries(): DashboardQuery[] { - // const {view} = this.props - - // return _.get(view, ['properties.queries'], []) - // } - private get viewName(): string { const {view} = this.props - const viewName = view ? view.name : '' - return viewName + if (view && view.properties.type !== ViewType.Markdown) { + return view.name + } + + return '' + } + + private get viewNote(): string { + const {view} = this.props + + if (!view) { + return '' + } + + const isMarkdownView = view.properties.type === ViewType.Markdown + const showNoteWhenEmpty = get(view, 'properties.showNoteWhenEmpty') + + if (isMarkdownView || showNoteWhenEmpty) { + return '' + } + + return get(view, 'properties.note', '') } private get view(): JSX.Element { @@ -112,16 +127,7 @@ class CellComponent extends Component<Props> { } private handleCSVDownload = (): void => { - // TODO: get data from link - // const {cellData, cell} = this.props - // const joinedName = cell.name.split(' ').join('_') - // const {data} = timeSeriesToTableGraph(cellData) - // try { - // download(dataToCSV(data), `${joinedName}.csv`, 'text/plain') - // } catch (error) { - // notify(csvDownloadFailed()) - // console.error(error) - // } + throw new Error('csv download not implemented') } } diff --git a/ui/src/shared/components/cells/CellContext.tsx b/ui/src/shared/components/cells/CellContext.tsx index 4843aa6b9f..9cf9efdb53 100644 --- a/ui/src/shared/components/cells/CellContext.tsx +++ b/ui/src/shared/components/cells/CellContext.tsx @@ -1,53 +1,76 @@ // Libraries import React, {PureComponent} from 'react' +import {connect} from 'react-redux' +import {get} from 'lodash' // Components import {Context, IconFont, ComponentColor} from 'src/clockface' - -// Types -import {Cell} from 'src/types/v2' - import {ErrorHandling} from 'src/shared/decorators/errors' -interface Props { - visible: boolean +// Actions +import {openNoteEditor} from 'src/dashboards/actions/v2/notes' + +// Types +import {Cell, View, ViewType} from 'src/types/v2' +import {NoteEditorMode} from 'src/types/v2/dashboards' + +interface OwnProps { cell: Cell + view: View onDeleteCell: (cell: Cell) => void onCloneCell: (cell: Cell) => void onCSVDownload: () => void onEditCell: () => void } +interface DispatchProps { + onOpenNoteEditor: typeof openNoteEditor +} + +type Props = DispatchProps & OwnProps + @ErrorHandling class CellContext extends PureComponent<Props> { public render() { - const {onEditCell, onCSVDownload, visible} = this.props + return ( + <Context className="cell--context"> + <Context.Menu icon={IconFont.Pencil}>{this.editMenuItems}</Context.Menu> + <Context.Menu + icon={IconFont.Duplicate} + color={ComponentColor.Secondary} + > + <Context.Item label="Clone" action={this.handleCloneCell} /> + </Context.Menu> + <Context.Menu icon={IconFont.Trash} color={ComponentColor.Danger}> + <Context.Item label="Delete" action={this.handleDeleteCell} /> + </Context.Menu> + </Context> + ) + } - if (visible) { - return ( - <Context className="cell--context"> - <Context.Menu icon={IconFont.Pencil}> - <Context.Item label="Configure" action={onEditCell} /> - <Context.Item - label="Download CSV" - action={onCSVDownload} - disabled={true} - /> - </Context.Menu> - <Context.Menu - icon={IconFont.Duplicate} - color={ComponentColor.Secondary} - > - <Context.Item label="Clone" action={this.handleCloneCell} /> - </Context.Menu> - <Context.Menu icon={IconFont.Trash} color={ComponentColor.Danger}> - <Context.Item label="Delete" action={this.handleDeleteCell} /> - </Context.Menu> - </Context> - ) + private get editMenuItems(): JSX.Element[] | JSX.Element { + const {view, onEditCell, onCSVDownload} = this.props + + if (view.properties.type === ViewType.Markdown) { + return <Context.Item label="Edit Note" action={this.handleEditNote} /> } - return null + const hasNote = !!get(view, 'properties.note') + + return [ + <Context.Item key="configure" label="Configure" action={onEditCell} />, + <Context.Item + key="note" + label={hasNote ? 'Edit Note' : 'Add Note'} + action={this.handleEditNote} + />, + <Context.Item + key="download" + label="Download CSV" + action={onCSVDownload} + disabled={true} + />, + ] } private handleDeleteCell = () => { @@ -61,6 +84,34 @@ class CellContext extends PureComponent<Props> { onCloneCell(cell) } + + private handleEditNote = () => { + const {onOpenNoteEditor, view} = this.props + + const note: string = get(view, 'properties.note', '') + const showNoteWhenEmpty: boolean = get( + view, + 'properties.showNoteWhenEmpty', + false + ) + + const initialState = { + viewID: view.id, + toggleVisible: view.properties.type !== ViewType.Markdown, + note, + showNoteWhenEmpty, + mode: note === '' ? NoteEditorMode.Adding : NoteEditorMode.Editing, + } + + onOpenNoteEditor(initialState) + } } -export default CellContext +const mdtp = { + onOpenNoteEditor: openNoteEditor, +} + +export default connect<{}, DispatchProps, OwnProps>( + null, + mdtp +)(CellContext) diff --git a/ui/src/shared/components/cells/CellHeader.tsx b/ui/src/shared/components/cells/CellHeader.tsx index f8e6b7ae32..99773158a3 100644 --- a/ui/src/shared/components/cells/CellHeader.tsx +++ b/ui/src/shared/components/cells/CellHeader.tsx @@ -1,42 +1,27 @@ // Libraries -import React, {PureComponent} from 'react' +import React, {SFC} from 'react' import classnames from 'classnames' -import {ErrorHandling} from 'src/shared/decorators/errors' +// Components +import CellHeaderNote from 'src/shared/components/cells/CellHeaderNote' interface Props { name: string - isEditable: boolean + note: string } -@ErrorHandling -class CellHeader extends PureComponent<Props> { - public render() { - const {isEditable, name} = this.props +const CellHeader: SFC<Props> = ({name, note}) => { + const className = classnames('cell--header cell--draggable', { + 'cell--header-note': !!note, + }) - if (isEditable) { - return ( - <div className="cell--header cell--draggable"> - <label className={this.cellNameClass}>{name}</label> - <div className="cell--header-bar" /> - </div> - ) - } - - return ( - <div className="cell--header"> - <label className="cell--name">{name}</label> - </div> - ) - } - - private get cellNameClass(): string { - const {name} = this.props - - const isNameBlank = !!name.trim() - - return classnames('cell--name', {'cell--name__blank': isNameBlank}) - } + return ( + <div className={className}> + <label className="cell--name">{name}</label> + <div className="cell--header-bar" /> + {note && <CellHeaderNote note={note} />} + </div> + ) } export default CellHeader diff --git a/ui/src/shared/components/cells/CellHeaderNote.tsx b/ui/src/shared/components/cells/CellHeaderNote.tsx new file mode 100644 index 0000000000..d03ac3e3ca --- /dev/null +++ b/ui/src/shared/components/cells/CellHeaderNote.tsx @@ -0,0 +1,79 @@ +// Libraries +import React, {PureComponent, CSSProperties} from 'react' + +// Components +import CellHeaderNoteTooltip from 'src/shared/components/cells/CellHeaderNoteTooltip' + +const MAX_TOOLTIP_WIDTH = 400 +const MAX_TOOLTIP_HEIGHT = 200 + +interface Props { + note: string +} + +interface State { + isShowingTooltip: boolean + domRect?: DOMRect +} + +class CellHeaderNote extends PureComponent<Props, State> { + public state: State = {isShowingTooltip: false} + + public render() { + const {note} = this.props + const {isShowingTooltip} = this.state + + return ( + <div + className="cell-header-note" + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + > + <span className="icon chat" /> + {isShowingTooltip && ( + <CellHeaderNoteTooltip + note={note} + containerStyle={this.tooltipStyle} + maxWidth={MAX_TOOLTIP_WIDTH} + maxHeight={MAX_TOOLTIP_HEIGHT} + /> + )} + </div> + ) + } + + private get tooltipStyle(): CSSProperties { + const {x, y, width, height} = this.state.domRect + const overflowsBottom = y + MAX_TOOLTIP_HEIGHT > window.innerHeight + const overflowsRight = x + MAX_TOOLTIP_WIDTH > window.innerWidth + + const style: CSSProperties = {} + + if (overflowsBottom) { + style.bottom = `${window.innerHeight - y - height}px` + } else { + style.top = `${y}px` + } + + if (overflowsRight) { + style.right = `${window.innerWidth - x}px` + } else { + style.left = `${x + width}px` + } + + return style + } + + private handleMouseEnter = e => { + this.setState({ + isShowingTooltip: true, + domRect: e.target.getBoundingClientRect(), + }) + } + + private handleMouseLeave = () => { + this.setState({isShowingTooltip: false}) + } +} + +export default CellHeaderNote diff --git a/ui/src/shared/components/cells/CellHeaderNoteTooltip.scss b/ui/src/shared/components/cells/CellHeaderNoteTooltip.scss new file mode 100644 index 0000000000..5177f04fa5 --- /dev/null +++ b/ui/src/shared/components/cells/CellHeaderNoteTooltip.scss @@ -0,0 +1,15 @@ +@import "src/style/modules"; + +.cell-header-note-tooltip { + position: fixed; + z-index: 3; + padding: 0 5px; +} + +.cell-header-note-tooltip--content { + padding: 10px 15px; + border-radius: 4px; + @include gradient-v($g0-obsidian, $g1-raven); + font-size: 13px; + overflow: scroll; +} diff --git a/ui/src/shared/components/cells/CellHeaderNoteTooltip.tsx b/ui/src/shared/components/cells/CellHeaderNoteTooltip.tsx new file mode 100644 index 0000000000..7b1b8e5c01 --- /dev/null +++ b/ui/src/shared/components/cells/CellHeaderNoteTooltip.tsx @@ -0,0 +1,41 @@ +// Libraries +import React, {SFC, CSSProperties} from 'react' +import {createPortal} from 'react-dom' +import ReactMarkdown from 'react-markdown' + +// Styles +import 'src/shared/components/cells/CellHeaderNoteTooltip.scss' + +interface Props { + note: string + containerStyle: CSSProperties + maxWidth: number + maxHeight: number +} + +const CellHeaderNoteTooltip: SFC<Props> = props => { + const {note, containerStyle, maxWidth, maxHeight} = props + + const style = { + maxWidth: `${maxWidth}px`, + maxHeight: `${maxHeight}px`, + } + + const content = ( + <div className="cell-header-note-tooltip" style={containerStyle}> + <div + className="cell-header-note-tooltip--content markdown-format" + style={style} + > + <ReactMarkdown source={note} /> + </div> + </div> + ) + + return createPortal( + content, + document.querySelector('.cell-header-note-tooltip-container') + ) +} + +export default CellHeaderNoteTooltip diff --git a/ui/src/shared/components/cells/Cells.tsx b/ui/src/shared/components/cells/Cells.tsx index 228b23a829..4dc774df04 100644 --- a/ui/src/shared/components/cells/Cells.tsx +++ b/ui/src/shared/components/cells/Cells.tsx @@ -85,7 +85,6 @@ class Cells extends Component<Props & WithRouterProps, State> { <CellComponent cell={cell} onZoom={onZoom} - isEditable={true} autoRefresh={autoRefresh} manualRefresh={manualRefresh} timeRange={timeRange} diff --git a/ui/src/shared/components/cells/View.tsx b/ui/src/shared/components/cells/View.tsx index 71d1759ae0..697e94f7c5 100644 --- a/ui/src/shared/components/cells/View.tsx +++ b/ui/src/shared/components/cells/View.tsx @@ -5,9 +5,6 @@ import React, {Component} from 'react' import Markdown from 'src/shared/components/views/Markdown' import RefreshingView from 'src/shared/components/RefreshingView' -// Constants -import {text} from 'src/shared/components/views/gettingsStarted' - // Types import {TimeRange} from 'src/types' import {View, ViewType, ViewShape} from 'src/types/v2' @@ -37,7 +34,7 @@ class ViewComponent extends Component<Props> { case ViewType.LogViewer: return this.emptyGraph case ViewType.Markdown: - return <Markdown text={text} /> + return <Markdown text={view.properties.note} /> default: return ( <RefreshingView diff --git a/ui/src/shared/components/code_mirror/CodeMirrorTheme.scss b/ui/src/shared/components/code_mirror/CodeMirrorTheme.scss index 81133505c7..27e5befd27 100644 --- a/ui/src/shared/components/code_mirror/CodeMirrorTheme.scss +++ b/ui/src/shared/components/code_mirror/CodeMirrorTheme.scss @@ -92,3 +92,4 @@ div.CodeMirror-selected, @import 'ThemeTickscript'; @import 'ThemeInfluxQL'; @import 'ThemeHints'; +@import 'ThemeMarkdown'; diff --git a/ui/src/shared/components/code_mirror/_ThemeMarkdown.scss b/ui/src/shared/components/code_mirror/_ThemeMarkdown.scss new file mode 100644 index 0000000000..d0d2a7aa3f --- /dev/null +++ b/ui/src/shared/components/code_mirror/_ThemeMarkdown.scss @@ -0,0 +1,59 @@ +/* + CodeMirror "Markdown" Theme + ------------------------------------------------------------------------------ + Intended for use with the Markdown CodeMirror Mode +*/ + +.cm-s-markdown { + color: $g15-platinum; + + &:hover { + cursor: text; + } + + .cm-italic { + font-style: italic; + } + + .cm-bold { + font-weight: 900; + } + + .cm-strikethrough { + text-decoration: none; + opacity: 0.5; + } + + .cm-heading { + color: $c-honeydew; + } + + .cm-image { + color: $c-comet; + } + + .cm-link { + color: $c-pool; + text-decoration: none; + } + + .cm-blockquote { + color: $g18-cloud; + background-color: $g3-castle; + } + + .CodeMirror-scrollbar-filler { + background-color: transparent; + } + + .CodeMirror-vscrollbar, + .CodeMirror-hscrollbar { + @include custom-scrollbar-round($g2-kevlar, $c-pool); + } +} + +.cm-s-markdown.CodeMirror-empty { + font-style: italic; + color: $g9-mountain; +} + diff --git a/ui/src/shared/components/views/Markdown.scss b/ui/src/shared/components/views/Markdown.scss index 834795206d..93548167cc 100644 --- a/ui/src/shared/components/views/Markdown.scss +++ b/ui/src/shared/components/views/Markdown.scss @@ -4,10 +4,7 @@ */ .markdown-cell { - position: absolute !important; - height: calc( - 100% - 20px - ) !important; // Prevent it from covering the cell resizer + height: calc(100% - 20px) !important; } .markdown-cell--contents { @@ -26,7 +23,6 @@ position: absolute; bottom: 0; left: 0; - height: 20px; width: 100%; @include gradient-v(rgba($g3-castle, 0), $g3-castle); z-index: 1; diff --git a/ui/src/shared/components/views/Markdown.tsx b/ui/src/shared/components/views/Markdown.tsx index 938e072692..d61bdca77e 100644 --- a/ui/src/shared/components/views/Markdown.tsx +++ b/ui/src/shared/components/views/Markdown.tsx @@ -1,5 +1,5 @@ // Libraries -import React, {Component} from 'react' +import React, {PureComponent} from 'react' import ReactMarkdown from 'react-markdown' // Components @@ -16,7 +16,7 @@ interface Props { } @ErrorHandling -class Markdown extends Component<Props> { +class Markdown extends PureComponent<Props> { public render() { const {text} = this.props diff --git a/ui/src/shared/constants/codeMirrorModes.ts b/ui/src/shared/constants/codeMirrorModes.ts index 0c2bd40977..36ff88f0a5 100644 --- a/ui/src/shared/constants/codeMirrorModes.ts +++ b/ui/src/shared/constants/codeMirrorModes.ts @@ -251,3 +251,51 @@ export const modeInfluxQL = { lineComment: '//', }, } + +export const modeMarkdown = { + start: [ + { + regex: /[*](\s|\w)+[*]/, + token: 'italic', + }, + { + regex: /[*][*](\s|\w)+[*][*]/, + token: 'bold', + }, + { + regex: /[~][~](\s|\w)+[~][~]/, + token: 'strikethrough', + }, + { + regex: /\#+\s.+(?=$)/gm, + token: 'heading', + }, + { + regex: /\>.+(?=$)/gm, + token: 'blockquote', + }, + { + regex: /\[.+\]\(.+\)/, + token: 'link', + }, + { + regex: /[!]\[.+\]\(.+\)/, + token: 'image', + }, + ], + comment: [ + { + regex: /.*?\*\//, + token: 'comment', + next: 'start', + }, + { + regex: /.*/, + token: 'comment', + }, + ], + meta: { + dontIndentStates: ['comment'], + lineComment: '//', + }, +} diff --git a/ui/src/shared/copy/v2/notifications.ts b/ui/src/shared/copy/v2/notifications.ts index ae8b55006b..00b4bf531e 100644 --- a/ui/src/shared/copy/v2/notifications.ts +++ b/ui/src/shared/copy/v2/notifications.ts @@ -56,3 +56,9 @@ export const getTelegrafConfigFailed = (): Notification => ({ ...defaultErrorNotification, message: 'Failed to get telegraf config', }) + +export const savingNoteFailed = (error): Notification => ({ + ...defaultErrorNotification, + duration: FIVE_SECONDS, + message: `Failed to save note: ${error}`, +}) diff --git a/ui/src/shared/utils/view.ts b/ui/src/shared/utils/view.ts index 4bde5e435f..abd25805fb 100644 --- a/ui/src/shared/utils/view.ts +++ b/ui/src/shared/utils/view.ts @@ -37,6 +37,8 @@ function defaultLineViewProperties() { queries: defaultViewQueries(), colors: [], legend: {}, + note: '', + showNoteWhenEmpty: false, axes: { x: { bounds: ['', ''] as [string, string], @@ -72,6 +74,8 @@ function defaultGaugeViewProperties() { colors: DEFAULT_GAUGE_COLORS, prefix: '', suffix: '', + note: '', + showNoteWhenEmpty: false, decimalPlaces: { isEnforced: true, digits: 2, @@ -137,6 +141,8 @@ const NEW_VIEW_CREATORS = { digits: 2, }, timeFormat: 'YYYY-MM-DD HH:mm:ss', + note: '', + showNoteWhenEmpty: false, }, }), [ViewType.Markdown]: (): NewView<MarkdownView> => ({ @@ -144,7 +150,7 @@ const NEW_VIEW_CREATORS = { properties: { type: ViewType.Markdown, shape: ViewShape.ChronografV2, - text: '', + note: '', }, }), } diff --git a/ui/src/store/configureStore.ts b/ui/src/store/configureStore.ts index 12fa19cb5c..4a330d4fa8 100644 --- a/ui/src/store/configureStore.ts +++ b/ui/src/store/configureStore.ts @@ -20,6 +20,7 @@ import logsReducer from 'src/logs/reducers' import timeMachinesReducer from 'src/shared/reducers/v2/timeMachines' import orgsReducer from 'src/organizations/reducers/orgs' import onboardingReducer from 'src/onboarding/reducers/' +import noteEditorReducer from 'src/dashboards/reducers/v2/notes' // Types import {LocalStorage} from 'src/types/localStorage' @@ -43,6 +44,7 @@ const rootReducer = combineReducers<ReducerState>({ orgs: orgsReducer, me: meReducer, onboarding: onboardingReducer, + noteEditor: noteEditorReducer, }) const composeEnhancers = diff --git a/ui/src/types/v2/dashboards.ts b/ui/src/types/v2/dashboards.ts index 5a6c2bb854..9d1b870278 100644 --- a/ui/src/types/v2/dashboards.ts +++ b/ui/src/types/v2/dashboards.ts @@ -63,11 +63,6 @@ export interface DecimalPlaces { digits: number } -export interface MarkDownProperties { - type: ViewType.Markdown - text: string -} - export interface View<T extends ViewProperties = ViewProperties> { id: string name: string @@ -121,6 +116,8 @@ export interface XYView { axes: Axes colors: Color[] legend: Legend + note: string + showNoteWhenEmpty: boolean } export interface LinePlusSingleStatView { @@ -133,6 +130,8 @@ export interface LinePlusSingleStatView { prefix: string suffix: string decimalPlaces: DecimalPlaces + note: string + showNoteWhenEmpty: boolean } export interface SingleStatView { @@ -143,6 +142,8 @@ export interface SingleStatView { prefix: string suffix: string decimalPlaces: DecimalPlaces + note: string + showNoteWhenEmpty: boolean } export interface GaugeView { @@ -153,6 +154,8 @@ export interface GaugeView { prefix: string suffix: string decimalPlaces: DecimalPlaces + note: string + showNoteWhenEmpty: boolean } export interface TableView { @@ -164,12 +167,14 @@ export interface TableView { fieldOptions: FieldOption[] decimalPlaces: DecimalPlaces timeFormat: string + note: string + showNoteWhenEmpty: boolean } export interface MarkdownView { type: ViewType.Markdown shape: ViewShape.ChronografV2 - text: string + note: string } export interface LogViewerView { @@ -250,3 +255,8 @@ export interface DashboardSwitcherLinks { export interface ViewParams { type: ViewType } + +export enum NoteEditorMode { + Adding = 'adding', + Editing = 'editing', +} diff --git a/ui/src/types/v2/index.ts b/ui/src/types/v2/index.ts index a26b8c828d..7e9247d2bc 100644 --- a/ui/src/types/v2/index.ts +++ b/ui/src/types/v2/index.ts @@ -31,6 +31,7 @@ import {MeState} from 'src/shared/reducers/v2/me' import {OverlayState} from 'src/types/v2/overlay' import {SourcesState} from 'src/sources/reducers' import {OnboardingState} from 'src/onboarding/reducers' +import {NoteEditorState} from 'src/dashboards/reducers/v2/notes' export interface AppState { VERSION: string @@ -49,6 +50,7 @@ export interface AppState { orgs: Organization[] me: MeState onboarding: OnboardingState + noteEditor: NoteEditorState } export type GetState = () => AppState diff --git a/ui/yarn.lock b/ui/yarn.lock index 31a12ac52b..345609e951 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -1019,6 +1019,11 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +arity-n@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/arity-n/-/arity-n-1.0.4.tgz#d9e76b11733e08569c0847ae7b39b2860b30b745" + integrity sha1-2edrEXM+CFacCEeuezmyhgswt0U= + arr-diff@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf" @@ -1802,6 +1807,11 @@ cheerio@^1.0.0-rc.2: lodash "^4.15.0" parse5 "^3.0.1" +chickencurry@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/chickencurry/-/chickencurry-1.1.1.tgz#02655f2b26b3bc2ee1ae1e5316886de38eb79738" + integrity sha1-AmVfKyazvC7hrh5TFoht4463lzg= + chokidar@^2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.0.4.tgz#356ff4e2b0e8e43e322d18a372460bbcf3accd26" @@ -2056,6 +2066,13 @@ component-emitter@^1.2.1: resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY= +compose-function@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/compose-function/-/compose-function-2.0.0.tgz#e642fa7e1da21529720031476776fc24691ac0b0" + integrity sha1-5kL6fh2iFSlyADFHZ3b8JGkawLA= + dependencies: + arity-n "^1.0.4" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -3859,6 +3876,17 @@ html-encoding-sniffer@^1.0.1, html-encoding-sniffer@^1.0.2: dependencies: whatwg-encoding "^1.0.1" +html-to-react@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/html-to-react/-/html-to-react-1.3.3.tgz#e41666a735f9997ed2372dcd21d8b0e42b334467" + integrity sha512-4Qi5/t8oBr6c1t1kBJKyxEeJu0lb7ctvq29oFZioiUHH0Wz88VWGwoXuH26HDt9v64bDHA4NMPNTH8bVrcaJWA== + dependencies: + domhandler "^2.3.0" + escape-string-regexp "^1.0.5" + htmlparser2 "^3.8.3" + ramda "^0.25.0" + underscore.string.fp "^1.0.4" + htmlnano@^0.1.9: version "0.1.10" resolved "https://registry.yarnpkg.com/htmlnano/-/htmlnano-0.1.10.tgz#a0a548eb4c76ae2cf2423ec7a25c881734d3dea6" @@ -3871,6 +3899,18 @@ htmlnano@^0.1.9: svgo "^1.0.5" terser "^3.8.1" +htmlparser2@^3.8.3: + version "3.10.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.0.tgz#5f5e422dcf6119c0d983ed36260ce9ded0bee464" + integrity sha512-J1nEUGv+MkXS0weHNWVKJJ+UrLfePxRWpN3C9bEi9fLxL2+ggW94DQvgYVXsaT30PGwYRIZKNZXuyMhp3Di4bQ== + dependencies: + domelementtype "^1.3.0" + domhandler "^2.3.0" + domutils "^1.5.1" + entities "^1.1.1" + inherits "^2.0.1" + readable-stream "^3.0.6" + htmlparser2@^3.9.0, htmlparser2@^3.9.1, htmlparser2@^3.9.2: version "3.9.2" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.9.2.tgz#1bdf87acca0f3f9e53fa4fcceb0f4b4cbb00b338" @@ -7065,6 +7105,11 @@ railroad-diagrams@^1.0.0: resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e" integrity sha1-635iZ1SN3t+4mcG5Dlc3RVnN234= +ramda@^0.25.0: + version "0.25.0" + resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.25.0.tgz#8fdf68231cffa90bc2f9460390a0cb74a29b29a9" + integrity sha512-GXpfrYVPwx3K7RQ6aYT8KPS8XViSXUVJT1ONhoKPE9VAleW42YE+U+8VEyGWt41EnEQW7gwecYJriTI0pKoecQ== + randexp@0.4.6: version "0.4.6" resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.4.6.tgz#e986ad5e5e31dae13ddd6f7b3019aa7c87f60ca3" @@ -7221,11 +7266,12 @@ react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4: resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== -react-markdown@^3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-3.6.0.tgz#29f6aaab5270c8ef0a5e234093a873ec3e01722b" - integrity sha512-TV0wQDHHPCEeKJHWXFfEAKJ8uSEsJ9LgrMERkXx05WV/3q6Ig+59KDNaTmjcoqlCpE/sH5PqqLMh4t0QWKrJ8Q== +react-markdown@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-4.0.3.tgz#8851f9265d0322bb5d60ab2766b3ab48cdbeb890" + integrity sha512-CIc3eLVpW5XocM1MCid2rS0vs9skhvdL/slAkY/a3Cr9y72b0J/25GiD70fGmStjuxsd5ROdm4ZYfiYYxPPyGA== dependencies: + html-to-react "^1.3.3" mdast-add-list-metadata "1.0.1" prop-types "^15.6.1" remark-parse "^5.0.0" @@ -7352,6 +7398,15 @@ readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable string_decoder "~1.1.1" util-deprecate "~1.0.1" +readable-stream@^3.0.6: + version "3.0.6" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.0.6.tgz#351302e4c68b5abd6a2ed55376a7f9a25be3057a" + integrity sha512-9E1oLoOWfhSXHGv6QlwXJim7uNzd9EVlWK+21tCU9Ju/kR0/p2AZYPz4qSchgO8PlLIH4FpZYfzwS+rEksZjIg== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readdirp@^2.0.0: version "2.2.1" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" @@ -7705,6 +7760,11 @@ ret@~0.1.10: resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== +reverse-arguments@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/reverse-arguments/-/reverse-arguments-1.0.0.tgz#c28095a3a921ac715d61834ddece9027992667cd" + integrity sha1-woCVo6khrHFdYYNN3s6QJ5kmZ80= + rgb-regex@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/rgb-regex/-/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1" @@ -8272,6 +8332,13 @@ string_decoder@^1.0.0, string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" +string_decoder@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.2.0.tgz#fe86e738b19544afe70469243b2a1ee9240eae8d" + integrity sha512-6YqyX6ZWEYguAxgZzHGL7SsCeGx3V2TtOTqZz1xSTSWnqsbWwbptafNyvf/ACquZUXV3DANr5BDIwNYe1mN42w== + dependencies: + safe-buffer "~5.1.0" + strip-ansi@^3.0.0, strip-ansi@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" @@ -8727,6 +8794,21 @@ uglify-js@^3.1.4: commander "~2.17.1" source-map "~0.6.1" +underscore.string.fp@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/underscore.string.fp/-/underscore.string.fp-1.0.4.tgz#054b3f1843bcae561286c87de5e8879b4fc98364" + integrity sha1-BUs/GEO8rlYShsh95eiHm0/Jg2Q= + dependencies: + chickencurry "1.1.1" + compose-function "^2.0.0" + reverse-arguments "1.0.0" + underscore.string "3.0.3" + +underscore.string@3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/underscore.string/-/underscore.string-3.0.3.tgz#4617b8c1a250cf6e5064fbbb363d0fa96cf14552" + integrity sha1-Rhe4waJQz25QZPu7Nj0PqWzxRVI= + underscore@~1.4.4: version "1.4.4" resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.4.4.tgz#61a6a32010622afa07963bf325203cf12239d604" @@ -8880,7 +8962,7 @@ use@^3.1.0: resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== -util-deprecate@~1.0.1: +util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= diff --git a/view.go b/view.go index 867192ec11..0060b6e6f1 100644 --- a/view.go +++ b/view.go @@ -129,6 +129,12 @@ func UnmarshalViewPropertiesJSON(b []byte) (ViewProperties, error) { return nil, err } vis = tv + case "markdown": + var mv MarkdownViewProperties + if err := json.Unmarshal(v.B, &mv); err != nil { + return nil, err + } + vis = mv case "log-viewer": // happens in log viewer stays in log viewer. var lv LogViewProperties if err := json.Unmarshal(v.B, &lv); err != nil { @@ -199,6 +205,14 @@ func MarshalViewPropertiesJSON(v ViewProperties) ([]byte, error) { Shape: "chronograf-v2", LinePlusSingleStatProperties: vis, } + case MarkdownViewProperties: + s = struct { + Shape string `json:"shape"` + MarkdownViewProperties + }{ + Shape: "chronograf-v2", + MarkdownViewProperties: vis, + } case LogViewProperties: s = struct { Shape string `json:"shape"` @@ -281,55 +295,70 @@ func (u ViewUpdate) MarshalJSON() ([]byte, error) { // LinePlusSingleStatProperties represents options for line plus single stat view in Chronograf type LinePlusSingleStatProperties struct { - Queries []DashboardQuery `json:"queries"` - Axes map[string]Axis `json:"axes"` - Type string `json:"type"` - Legend Legend `json:"legend"` - ViewColors []ViewColor `json:"colors"` - Prefix string `json:"prefix"` - Suffix string `json:"suffix"` - DecimalPlaces DecimalPlaces `json:"decimalPlaces"` + Queries []DashboardQuery `json:"queries"` + Axes map[string]Axis `json:"axes"` + Type string `json:"type"` + Legend Legend `json:"legend"` + ViewColors []ViewColor `json:"colors"` + Prefix string `json:"prefix"` + Suffix string `json:"suffix"` + DecimalPlaces DecimalPlaces `json:"decimalPlaces"` + Note string `json:"note"` + ShowNoteWhenEmpty bool `json:"showNoteWhenEmpty"` } // XYViewProperties represents options for line, bar, step, or stacked view in Chronograf type XYViewProperties struct { - Queries []DashboardQuery `json:"queries"` - Axes map[string]Axis `json:"axes"` - Type string `json:"type"` - Legend Legend `json:"legend"` - Geom string `json:"geom"` // Either "line", "step", "stacked", or "bar" - ViewColors []ViewColor `json:"colors"` + Queries []DashboardQuery `json:"queries"` + Axes map[string]Axis `json:"axes"` + Type string `json:"type"` + Legend Legend `json:"legend"` + Geom string `json:"geom"` // Either "line", "step", "stacked", or "bar" + ViewColors []ViewColor `json:"colors"` + Note string `json:"note"` + ShowNoteWhenEmpty bool `json:"showNoteWhenEmpty"` } // SingleStatViewProperties represents options for single stat view in Chronograf type SingleStatViewProperties struct { - Type string `json:"type"` - Queries []DashboardQuery `json:"queries"` - Prefix string `json:"prefix"` - Suffix string `json:"suffix"` - ViewColors []ViewColor `json:"colors"` - DecimalPlaces DecimalPlaces `json:"decimalPlaces"` + Type string `json:"type"` + Queries []DashboardQuery `json:"queries"` + Prefix string `json:"prefix"` + Suffix string `json:"suffix"` + ViewColors []ViewColor `json:"colors"` + DecimalPlaces DecimalPlaces `json:"decimalPlaces"` + Note string `json:"note"` + ShowNoteWhenEmpty bool `json:"showNoteWhenEmpty"` } // GaugeViewProperties represents options for gauge view in Chronograf type GaugeViewProperties struct { - Type string `json:"type"` - Queries []DashboardQuery `json:"queries"` - Prefix string `json:"prefix"` - Suffix string `json:"suffix"` - ViewColors []ViewColor `json:"colors"` - DecimalPlaces DecimalPlaces `json:"decimalPlaces"` + Type string `json:"type"` + Queries []DashboardQuery `json:"queries"` + Prefix string `json:"prefix"` + Suffix string `json:"suffix"` + ViewColors []ViewColor `json:"colors"` + DecimalPlaces DecimalPlaces `json:"decimalPlaces"` + Note string `json:"note"` + ShowNoteWhenEmpty bool `json:"showNoteWhenEmpty"` } // TableViewProperties represents options for table view in Chronograf type TableViewProperties struct { - Type string `json:"type"` - Queries []DashboardQuery `json:"queries"` - ViewColors []ViewColor `json:"colors"` - TableOptions TableOptions `json:"tableOptions"` - FieldOptions []RenamableField `json:"fieldOptions"` - TimeFormat string `json:"timeFormat"` - DecimalPlaces DecimalPlaces `json:"decimalPlaces"` + Type string `json:"type"` + Queries []DashboardQuery `json:"queries"` + ViewColors []ViewColor `json:"colors"` + TableOptions TableOptions `json:"tableOptions"` + FieldOptions []RenamableField `json:"fieldOptions"` + TimeFormat string `json:"timeFormat"` + DecimalPlaces DecimalPlaces `json:"decimalPlaces"` + Note string `json:"note"` + ShowNoteWhenEmpty bool `json:"showNoteWhenEmpty"` +} + +type MarkdownViewProperties struct { + Type string `json:"type"` + Note string `json:"note"` } // LogViewProperties represents options for log viewer in Chronograf. @@ -357,6 +386,7 @@ func (LinePlusSingleStatProperties) viewProperties() {} func (SingleStatViewProperties) viewProperties() {} func (GaugeViewProperties) viewProperties() {} func (TableViewProperties) viewProperties() {} +func (MarkdownViewProperties) viewProperties() {} func (LogViewProperties) viewProperties() {} ///////////////////////////// diff --git a/view_test.go b/view_test.go index 7c48d60a01..5c2060056d 100644 --- a/view_test.go +++ b/view_test.go @@ -45,7 +45,9 @@ func TestView_MarshalJSON(t *testing.T) { "type": "xy", "colors": null, "legend": {}, - "geom": "" + "geom": "", + "note": "", + "showNoteWhenEmpty": false } } `,