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
   }
 }
 `,