feat(notebooks): introduce minimap (#18270)

* refactor: extract notebook scoped scss variables to own file

* feat: WIP introduce minimap components

* feat: attempt to add minimap state to AppContext

* refactor: update notebooks layout to handle minimap alongside pipe list

* refactor: remove border from markdown preview

* refactor: add spacing between minimap items

* chore: cleanup styles and markup

* feat: give panels an ID attribute to facilitate scrolling to their position

* feat: enable minimap to update scroll position of pipe list via notebook context

* feat: make minimap scrollable

* feat: style hidden pipes differently in minimap

* fix: remove console logs

* refactor: move scrolling related code into separate context

* refactor: use refs instead of IDs for getting a panel's scroll position

* refactor: remove minimap state from AppSettingProvider

* refactor: use persisted redux to store minimap visibility preference

Seems like the kind of thing that would have ended up here eventually so I'll save future me some trouble and do it now

* fix: add & repair unit tests
pull/18257/head^2
alexpaxton 2020-05-28 17:38:02 -07:00 committed by GitHub
parent 53794bfae5
commit 7df56f5d6c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 386 additions and 95 deletions

View File

@ -82,6 +82,7 @@ describe('Dashboards.Selector', () => {
autoRefresh: 0,
showTemplateControlBar: false,
navBarState: 'expanded',
notebookMiniMapState: 'expanded',
timeZone: 'Local' as TimeZone,
theme: 'dark',
},
@ -103,6 +104,7 @@ describe('Dashboards.Selector', () => {
autoRefresh: 0,
showTemplateControlBar: false,
navBarState: 'expanded',
notebookMiniMapState: 'expanded',
timeZone: 'UTC' as TimeZone,
theme: 'dark',
},

View File

@ -23,6 +23,7 @@ export const localState: LocalStorage = {
navBarState: 'expanded',
timeZone: 'Local' as TimeZone,
theme: 'dark',
notebookMiniMapState: 'expanded',
},
},
flags: {

View File

@ -0,0 +1,9 @@
@import '@influxdata/clockface/dist/variables.scss';
$notebook-header-height: 46px;
$notebook-panel--gutter: $cf-marg-d;
$notebook-panel--bg: mix($g1-raven, $g2-kevlar, 50%);
$notebook-divider-height: ($cf-marg-a * 2) + $cf-border;
$notebook-divider-color: $g2-kevlar;

View File

@ -2,8 +2,10 @@ import React, {FC} from 'react'
import {Page} from '@influxdata/clockface'
import {NotebookProvider} from 'src/notebooks/context/notebook'
import {ScrollProvider} from 'src/notebooks/context/scroll'
import Header from 'src/notebooks/components/header'
import PipeList from 'src/notebooks/components/PipeList'
import MiniMap from 'src/notebooks/components/minimap/MiniMap'
// NOTE: uncommon, but using this to scope the project
// within the page and not bleed it's dependancies outside
@ -13,10 +15,21 @@ import 'src/notebooks/style.scss'
const NotebookPage: FC = () => {
return (
<NotebookProvider>
<Page titleTag="Notebook">
<Header />
<PipeList />
</Page>
<ScrollProvider>
<Page titleTag="Notebook">
<Header />
<Page.Contents
fullWidth={true}
scrollable={false}
className="notebook-page"
>
<div className="notebook">
<MiniMap />
<PipeList />
</div>
</Page.Contents>
</Page>
</ScrollProvider>
</NotebookProvider>
)
}

View File

@ -3,37 +3,42 @@ import React, {FC, useContext, useCallback} from 'react'
// Contexts
import {NotebookContext} from 'src/notebooks/context/notebook'
import {ScrollContext} from 'src/notebooks/context/scroll'
// Components
import NotebookPipe from 'src/notebooks/components/NotebookPipe'
import EmptyPipeList from 'src/notebooks/components/EmptyPipeList'
import {Page} from '@influxdata/clockface'
import {DapperScrollbars} from '@influxdata/clockface'
const PipeList: FC = () => {
const {id, pipes, updatePipe} = useContext(NotebookContext)
const {scrollPosition} = useContext(ScrollContext)
const update = useCallback(updatePipe, [id])
const scrollable = !!pipes.length
let _pipes: JSX.Element | JSX.Element[] = <EmptyPipeList />
if (pipes.length) {
_pipes = pipes.map((_, index) => {
return (
<NotebookPipe
key={`pipe-${id}-${index}`}
index={index}
data={pipes[index]}
onUpdate={update}
/>
)
})
if (!pipes.length) {
return <EmptyPipeList />
}
const _pipes = pipes.map((_, index) => {
return (
<NotebookPipe
key={`pipe-${id}-${index}`}
index={index}
data={pipes[index]}
onUpdate={update}
/>
)
})
return (
<Page.Contents fullWidth={true} scrollable={scrollable}>
<DapperScrollbars
className="notebook-main"
autoHide={true}
noScrollX={true}
scrollTop={scrollPosition}
>
{_pipes}
</Page.Contents>
</DapperScrollbars>
)
}

View File

@ -3,6 +3,7 @@ import React, {FC} from 'react'
import {Page} from '@influxdata/clockface'
import AddButtons from 'src/notebooks/components/AddButtons'
import Buttons from 'src/notebooks/components/header/Buttons'
import MiniMapToggle from 'src/notebooks/components/minimap/MiniMapToggle'
const FULL_WIDTH = true
@ -17,6 +18,7 @@ const Header: FC = () => (
<AddButtons />
</Page.ControlBarLeft>
<Page.ControlBarRight>
<MiniMapToggle />
<Buttons />
</Page.ControlBarRight>
</Page.ControlBar>

View File

@ -0,0 +1,40 @@
@import '@influxdata/clockface/dist/variables.scss';
@import '~src/notebooks/NotebookVariables.scss';
.notebook-minimap {
flex: 0 0 200px;
border-right: $cf-border solid $notebook-divider-color;
}
.notebook-minimap--list {
padding-left: $cf-marg-d;
padding-right: $cf-marg-b;
}
.notebook-minimap--item {
font-size: 14px;
font-weight: $cf-font-weight--medium;
color: $g11-sidewalk;
transition: color 0.25s ease, background-color 0.25s ease;
margin-bottom: $cf-border;
height: $cf-marg-d;
line-height: $cf-marg-d;
padding: 0 $cf-marg-b;
border-radius: $cf-radius;
user-select: none;
&:hover {
cursor: pointer;
}
}
.notebook-minimap--item__hidden {
color: $g8-storm;
font-style: italic;
}
.notebook-minimap--item:hover,
.notebook-minimap--item__focus {
color: $g18-cloud;
background-color: $g3-castle;
}

View File

@ -0,0 +1,70 @@
// Libraries
import React, {FC, useContext} from 'react'
import {connect} from 'react-redux'
// Contexts
import {NotebookContext, PipeMeta} from 'src/notebooks/context/notebook'
import {ScrollContext} from 'src/notebooks/context/scroll'
// Components
import {DapperScrollbars} from '@influxdata/clockface'
import MiniMapItem from 'src/notebooks/components/minimap/MiniMapItem'
// Types
import {AppState, NotebookMiniMapState} from 'src/types'
// Styles
import 'src/notebooks/components/minimap/MiniMap.scss'
interface StateProps {
notebookMiniMapState: NotebookMiniMapState
}
const MiniMap: FC<StateProps> = ({notebookMiniMapState}) => {
const {meta, updateMeta} = useContext(NotebookContext)
const {scrollToPipe} = useContext(ScrollContext)
if (notebookMiniMapState === 'collapsed') {
return null
}
const handleClick = (idx: number): void => {
const {panelRef} = meta[idx]
scrollToPipe(panelRef)
updateMeta(idx, {focus: true} as PipeMeta)
}
const pipes = meta.map((pipe, index) => (
<MiniMapItem
key={`minimap-${pipe.title}-${index}`}
title={pipe.title}
focus={pipe.focus}
visible={pipe.visible}
index={index}
onClick={handleClick}
/>
))
return (
<DapperScrollbars className="notebook-minimap" autoHide={true}>
<div className="notebook-minimap--list">{pipes}</div>
</DapperScrollbars>
)
}
const mstp = (state: AppState): StateProps => {
const {
app: {
persisted: {notebookMiniMapState},
},
} = state
return {
notebookMiniMapState,
}
}
export default connect<StateProps, {}>(
mstp,
null
)(MiniMap)

View File

@ -0,0 +1,30 @@
// Libraries
import React, {FC} from 'react'
import classnames from 'classnames'
interface Props {
title: string
focus: boolean
visible: boolean
index: number
onClick: (index: number) => void
}
const MiniMapItem: FC<Props> = ({title, focus, onClick, index, visible}) => {
const className = classnames('notebook-minimap--item', {
'notebook-minimap--item__focus': focus,
'notebook-minimap--item__hidden': !visible,
})
const handleClick = (): void => {
onClick(index)
}
return (
<div className={className} onClick={handleClick}>
{title}
</div>
)
}
export default MiniMapItem

View File

@ -0,0 +1,65 @@
// Libraries
import React, {FC} from 'react'
import {connect} from 'react-redux'
// Components
import {SlideToggle, InputLabel} from '@influxdata/clockface'
// Actions
import {setNotebookMiniMapState} from 'src/shared/actions/app'
// Types
import {AppState, NotebookMiniMapState} from 'src/types'
interface StateProps {
notebookMiniMapState: NotebookMiniMapState
}
interface DispatchProps {
handleSetNotebookMiniMapState: typeof setNotebookMiniMapState
}
type Props = StateProps & DispatchProps
const MiniMapToggle: FC<Props> = ({
notebookMiniMapState,
handleSetNotebookMiniMapState,
}) => {
const active = notebookMiniMapState === 'expanded'
const handleChange = (): void => {
if (active) {
handleSetNotebookMiniMapState('collapsed')
} else {
handleSetNotebookMiniMapState('expanded')
}
}
return (
<>
<SlideToggle active={active} onChange={handleChange} />
<InputLabel>Minimap</InputLabel>
</>
)
}
const mstp = (state: AppState): StateProps => {
const {
app: {
persisted: {notebookMiniMapState},
},
} = state
return {
notebookMiniMapState,
}
}
const mdtp: DispatchProps = {
handleSetNotebookMiniMapState: setNotebookMiniMapState,
}
export default connect<StateProps, DispatchProps>(
mstp,
mdtp
)(MiniMapToggle)

View File

@ -1,5 +1,13 @@
// Libraries
import React, {FC, useContext, useCallback, ReactNode, MouseEvent} from 'react'
import React, {
FC,
useContext,
useCallback,
useEffect,
ReactNode,
MouseEvent,
useRef,
} from 'react'
import classnames from 'classnames'
// Components
@ -77,10 +85,15 @@ const NotebookPanelHeader: FC<HeaderProps> = ({index, controls}) => {
const NotebookPanel: FC<Props> = ({index, children, controls}) => {
const {meta, updateMeta} = useContext(NotebookContext)
const panelRef = useRef<HTMLDivElement>(null)
const isVisible = meta[index].visible
const isFocused = meta[index].focus
useEffect(() => {
updateMeta(index, {panelRef} as PipeMeta)
})
const panelClassName = classnames('notebook-panel', {
[`notebook-panel__visible`]: isVisible,
[`notebook-panel__hidden`]: !isVisible,
@ -105,7 +118,7 @@ const NotebookPanel: FC<Props> = ({index, children, controls}) => {
return (
<ClickOutside onClickOutside={handleClickOutside}>
<div className={panelClassName} onClick={handleClick}>
<div className={panelClassName} onClick={handleClick} ref={panelRef}>
<NotebookPanelHeader index={index} controls={controls} />
<div className="notebook-panel--body">{children}</div>
</div>

View File

@ -1,10 +1,11 @@
import React, {FC, useState, useCallback} from 'react'
import React, {FC, useState, useCallback, RefObject} from 'react'
import {PipeData} from 'src/notebooks'
export interface PipeMeta {
title: string
visible: boolean
focus: boolean
panelRef: RefObject<HTMLDivElement>
}
export interface NotebookContextType {

View File

@ -0,0 +1,40 @@
// Libraries
import React, {FC, useState, RefObject} from 'react'
export interface ScrollContextType {
scrollPosition: number
scrollToPipe: (panelRef: RefObject<HTMLDivElement>) => void
}
export const DEFAULT_CONTEXT: ScrollContextType = {
scrollPosition: 0,
scrollToPipe: () => {},
}
export const ScrollContext = React.createContext<ScrollContextType>(
DEFAULT_CONTEXT
)
export const ScrollProvider: FC = ({children}) => {
const [scrollPosition, setListScrollPosition] = useState(
DEFAULT_CONTEXT.scrollPosition
)
const scrollToPipe = (panelRef: RefObject<HTMLDivElement>) => {
if (panelRef && panelRef.current) {
const {offsetTop} = panelRef.current
setListScrollPosition(offsetTop)
}
}
return (
<ScrollContext.Provider
value={{
scrollPosition,
scrollToPipe,
}}
>
{children}
</ScrollContext.Provider>
)
}

View File

@ -1,31 +1,19 @@
@import "@influxdata/clockface/dist/variables.scss";
@import '@influxdata/clockface/dist/variables.scss';
$notebook-panel--bg: mix($g1-raven, $g2-kevlar, 50%);
.notebook-panel--markdown,
.notebook-panel--markdown-editor {
font-size: 14px;
line-height: 16px;
.notebook-panel--markdown {
font-size: 14px;
line-height: 16px;
padding: $cf-marg-b;
border-radius: $cf-radius - 1px;
border-width: $cf-border;
border-style: solid;
transition: border-color 0.25s ease;
}
.notebook-panel--markdown {
border-color: $g1-raven;
.notebook-panel:hover & {
border-color: $notebook-panel--bg;
}
transition: border-color 0.25s ease;
}
.notebook-panel--body .markdown-editor--monaco {
position: relative;
.react-monaco-editor-container {
min-height: 100px;
min-height: 100px;
}
}

View File

@ -1,19 +1,18 @@
@import '@influxdata/clockface/dist/variables.scss';
$notebook-header-height: 46px;
$notebook-size-toggle: 16px;
$notebook-left-bar: 22px;
$notebook-panel--bg: mix($g1-raven, $g2-kevlar, 50%);
$notebook-divider-height: ($cf-marg-a * 2) + $cf-border;
@import '~src/notebooks/NotebookVariables.scss';
.notebook {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
flex-direction: row;
align-items: stretch;
}
.notebook-page .cf-page-contents__fluid {
padding: 0;
}
.notebook--add-cell-label {
user-select: none;
margin: 0 $cf-marg-c 0 0;
@ -21,16 +20,26 @@ $notebook-divider-height: ($cf-marg-a * 2) + $cf-border;
font-weight: $cf-font-weight--medium;
}
.notebook-main,
.notebook-empty {
flex: 1 0 0;
}
.notebook-empty {
padding: 0 $notebook-panel--gutter;
}
.notebook-panel {
display: flex;
flex-direction: column;
align-items: stretch;
border-radius: $cf-radius;
margin: 0 $notebook-panel--gutter;
&:after {
content: '';
height: $cf-border;
background-color: $g2-kevlar;
background-color: $notebook-divider-color;
border-radius: $cf-border / 2;
margin: $cf-marg-a 0;
}
@ -121,37 +130,6 @@ $notebook-divider-height: ($cf-marg-a * 2) + $cf-border;
top: -2px;
}
.notebook-panel--toggle {
top: 0;
left: 0;
width: $notebook-left-bar;
background-color: $g2-kevlar;
border-radius: $cf-radius;
height: 100%;
position: absolute;
&:after {
box-sizing: border-box;
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: $notebook-size-toggle;
transition: height 0.25s cubic-bezier(0.25, 1, 0.5, 1),
border-color 0.25s ease;
border: $cf-border solid $g8-storm;
border-radius: $cf-radius / 2;
}
&:hover {
cursor: pointer;
&:after {
border-color: $g13-mist;
}
}
}
.notebook-panel--body {
border-radius: 0 0 $cf-radius $cf-radius;
padding: $cf-marg-b;
@ -198,15 +176,6 @@ $notebook-divider-height: ($cf-marg-a * 2) + $cf-border;
min-height: $notebook-header-height;
}
/*
Notebook Action Bar aka Footer
------------------------------------------------------------------------------
*/
.notebook--actions {
margin-top: $cf-marg-c;
}
/*
Visualization Panel
------------------------------------------------------------------------------
@ -242,6 +211,6 @@ $notebook-divider-height: ($cf-marg-a * 2) + $cf-border;
flex-wrap: wrap;
> * {
margin-left: 4px;
margin-left: $cf-marg-a;
}
}

View File

@ -4,12 +4,13 @@ import {notify} from 'src/shared/actions/notifications'
import {presentationMode} from 'src/shared/copy/notifications'
import {Dispatch} from 'redux'
import {TimeZone, Theme, NavBarState} from 'src/types'
import {TimeZone, Theme, NavBarState, NotebookMiniMapState} from 'src/types'
export enum ActionTypes {
EnablePresentationMode = 'ENABLE_PRESENTATION_MODE',
DisablePresentationMode = 'DISABLE_PRESENTATION_MODE',
SetNavBarState = 'SET_NAV_BAR_STATE',
SetNotebookMiniMapState = 'SET_NOTEBOOK_MINI_MAP_STATE',
SetAutoRefresh = 'SET_AUTOREFRESH',
SetTimeZone = 'SET_APP_TIME_ZONE',
TemplateControlBarVisibilityToggled = 'TemplateControlBarVisibilityToggledAction',
@ -20,6 +21,7 @@ export type Action =
| ReturnType<typeof enablePresentationMode>
| ReturnType<typeof disablePresentationMode>
| ReturnType<typeof setNavBarState>
| ReturnType<typeof setNotebookMiniMapState>
| ReturnType<typeof setAutoRefresh>
| ReturnType<typeof setTimeZone>
| ReturnType<typeof setTheme>
@ -54,6 +56,14 @@ export const setNavBarState = (navBarState: NavBarState) =>
navBarState,
} as const)
export const setNotebookMiniMapState = (
notebookMiniMapState: NotebookMiniMapState
) =>
({
type: ActionTypes.SetNotebookMiniMapState,
notebookMiniMapState,
} as const)
export const setAutoRefresh = (milliseconds: number) =>
({
type: ActionTypes.SetAutoRefresh,

View File

@ -4,6 +4,7 @@ import {
disablePresentationMode,
setTheme,
setNavBarState,
setNotebookMiniMapState,
setAutoRefresh,
} from 'src/shared/actions/app'
import {TimeZone} from 'src/types'
@ -18,6 +19,7 @@ describe('Shared.Reducers.appReducer', () => {
autoRefresh: 0,
showTemplateControlBar: false,
navBarState: 'expanded',
notebookMiniMapState: 'expanded',
timeZone: 'Local' as TimeZone,
theme: 'dark',
},
@ -65,6 +67,28 @@ describe('Shared.Reducers.appReducer', () => {
expect(reducedState.persisted.navBarState).toBe('expanded')
})
it('should handle SET_NOTEBOOK_MINI_MAP_STATE to collapsed', () => {
const reducedState = appReducer(
initialState,
setNotebookMiniMapState('collapsed')
)
expect(reducedState.persisted.notebookMiniMapState).toBe('collapsed')
})
it('should handle SET_NOTEBOOK_MINI_MAP_STATE to expanded', () => {
Object.assign(initialState, {
persisted: {notebookMiniMapState: 'collapsed'},
})
const reducedState = appReducer(
initialState,
setNotebookMiniMapState('expanded')
)
expect(reducedState.persisted.notebookMiniMapState).toBe('expanded')
})
it('should handle SET_AUTOREFRESH', () => {
const expectedMs = 15000

View File

@ -3,7 +3,7 @@ import {combineReducers} from 'redux'
// Types
import {ActionTypes, Action} from 'src/shared/actions/app'
import {AUTOREFRESH_DEFAULT_INTERVAL} from 'src/shared/constants'
import {TimeZone, NavBarState, Theme} from 'src/types'
import {TimeZone, NavBarState, Theme, NotebookMiniMapState} from 'src/types'
export interface AppState {
ephemeral: {
@ -15,6 +15,7 @@ export interface AppState {
timeZone: TimeZone
navBarState: NavBarState
theme: Theme
notebookMiniMapState: NotebookMiniMapState
}
}
@ -28,6 +29,7 @@ const initialState: AppState = {
showTemplateControlBar: false,
timeZone: 'Local',
navBarState: 'collapsed',
notebookMiniMapState: 'expanded',
},
}
@ -82,6 +84,12 @@ const appPersistedReducer = (
return {...state, timeZone}
}
case ActionTypes.SetNotebookMiniMapState: {
const notebookMiniMapState = action.notebookMiniMapState
return {...state, notebookMiniMapState}
}
case 'SET_NAV_BAR_STATE': {
const navBarState = action.navBarState
return {

View File

@ -1,3 +1,4 @@
export type CurrentPage = 'dashboard' | 'not set'
export type Theme = 'light' | 'dark'
export type NavBarState = 'expanded' | 'collapsed'
export type NotebookMiniMapState = 'expanded' | 'collapsed'