feat: flows index (#19448)

* feat(flows): empty state for index page

* feat(wip): link to flows from index page

* feat(wip): rename flows

* feat: name flows

* fix: set feature flags

* test: skip flagged test
pull/19455/head
Andrew Watkins 2020-08-26 13:25:08 -07:00 committed by GitHub
parent 684b1149f3
commit 6151c389f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 261 additions and 36 deletions

View File

@ -0,0 +1,26 @@
describe('Flows', () => {
beforeEach(() => {
cy.flush()
cy.signin().then(({body}) => {
const {
org: {id},
bucket,
} = body
cy.wrap(body.org).as('org')
cy.wrap(bucket).as('bucket')
cy.fixture('routes').then(({orgs, flows}) => {
cy.visit(`${orgs}/${id}${flows}`)
})
})
})
// TODO: unskip when no longer blocked by feature flag
it.skip('CRUD a flow from the index page', () => {
cy.getByTestID('create-flow--button')
.first()
.click()
cy.getByTestID('page-title').click()
cy.getByTestID('renamable-page-title--input').type('My Flow {enter}')
})
})

View File

@ -7,5 +7,6 @@
"endpoints": "/endpoints", "endpoints": "/endpoints",
"rules": "/rules", "rules": "/rules",
"buckets": "/load-data/buckets", "buckets": "/load-data/buckets",
"telegrafs": "/load-data/telegrafs" "telegrafs": "/load-data/telegrafs",
"flows": "/flows"
} }

View File

@ -0,0 +1,30 @@
import React, {FC} from 'react'
import {useParams, useHistory} from 'react-router-dom'
// Components
import {ResourceCard} from '@influxdata/clockface'
interface Props {
id: string
name: string
}
const FlowCard: FC<Props> = ({id, name}) => {
const {orgID} = useParams()
const history = useHistory()
const handleClick = () => {
history.push(`/orgs/${orgID}/notebooks/${id}`)
}
return (
<ResourceCard key={`flow-card--${id}`}>
<ResourceCard.Name
name={name || 'Name this flow'}
onClick={handleClick}
/>
</ResourceCard>
)
}
export default FlowCard

View File

@ -0,0 +1,32 @@
import React, {useContext} from 'react'
import {ResourceList, Grid, Columns} from '@influxdata/clockface'
import {NotebookListContext} from 'src/notebooks/context/notebook.list'
import FlowsIndexEmpty from 'src/notebooks/components/FlowsIndexEmpty'
import FlowCard from 'src/notebooks/components/FlowCard'
const FlowCards = () => {
const {notebooks} = useContext(NotebookListContext)
return (
<Grid>
<Grid.Row>
<Grid.Column
widthXS={Columns.Twelve}
widthSM={Columns.Eight}
widthMD={Columns.Ten}
>
<ResourceList>
<ResourceList.Body emptyState={<FlowsIndexEmpty />}>
{Object.entries(notebooks).map(([id, {name}]) => {
return <FlowCard key={id} id={id} name={name} />
})}
</ResourceList.Body>
</ResourceList>
</Grid.Column>
</Grid.Row>
</Grid>
)
}
export default FlowCards

View File

@ -0,0 +1,31 @@
// Libraries
import React, {useContext} from 'react'
import {useHistory, useParams} from 'react-router-dom'
// Components
import {Button, IconFont, ComponentColor} from '@influxdata/clockface'
import {NotebookListContext} from 'src/notebooks/context/notebook.list'
const FlowCreateButton = () => {
const history = useHistory()
const {orgID} = useParams()
const {add} = useContext(NotebookListContext)
const handleCreate = async () => {
const id = await add()
history.push(`/orgs/${orgID}/notebooks/${id}`)
}
return (
<Button
icon={IconFont.Plus}
color={ComponentColor.Primary}
text="Create Flow"
titleText="Click to create a Flow"
onClick={handleCreate}
testID="create-flow--button"
/>
)
}
export default FlowCreateButton

View File

@ -0,0 +1,27 @@
import React from 'react'
// Components
import {Page} from '@influxdata/clockface'
import FlowHeader from 'src/notebooks/components/header'
import PipeList from 'src/notebooks/components/PipeList'
import MiniMap from 'src/notebooks/components/minimap/MiniMap'
const FlowPage = () => {
return (
<Page titleTag="Flows">
<FlowHeader />
<Page.Contents
fullWidth={true}
scrollable={false}
className="notebook-page"
>
<div className="notebook">
<MiniMap />
<PipeList />
</div>
</Page.Contents>
</Page>
)
}
export default FlowPage

View File

@ -0,0 +1,32 @@
// Libraries
import React from 'react'
// Components
import {Page, PageHeader} from '@influxdata/clockface'
import FlowCreateButton from 'src/notebooks/components/FlowCreateButton'
import NotebookListProvider from 'src/notebooks/context/notebook.list'
import FlowCards from 'src/notebooks/components/FlowCards'
// Utils
import {pageTitleSuffixer} from 'src/shared/utils/pageTitles'
const FlowsIndex = () => {
return (
<NotebookListProvider>
<Page titleTag={pageTitleSuffixer(['Flows'])} testID="flows-index">
<PageHeader fullWidth={false}>
<Page.Title title="Flows" />
</PageHeader>
<Page.ControlBar fullWidth={false}>
<Page.ControlBarLeft></Page.ControlBarLeft>
<Page.ControlBarRight>
<FlowCreateButton />
</Page.ControlBarRight>
</Page.ControlBar>
<FlowCards />
</Page>
</NotebookListProvider>
)
}
export default FlowsIndex

View File

@ -0,0 +1,16 @@
import React from 'react'
import {ComponentSize, EmptyState} from '@influxdata/clockface'
import FlowCreateButton from './FlowCreateButton'
const FlowsIndexEmpty = () => {
return (
<EmptyState size={ComponentSize.Large}>
<EmptyState.Text>
Looks like there aren't any <b>Flows</b>, why not create one?
</EmptyState.Text>
<FlowCreateButton />
</EmptyState>
)
}
export default FlowsIndexEmpty

View File

@ -1,18 +1,26 @@
// Libraries // Libraries
import React, {FC} from 'react' import React, {FC, useContext, useEffect} from 'react'
import {useParams} from 'react-router-dom'
// Components // Components
import {Page} from '@influxdata/clockface'
import NotebookHeader from 'src/notebooks/components/header'
import PipeList from 'src/notebooks/components/PipeList'
import MiniMap from 'src/notebooks/components/minimap/MiniMap'
// Contexts
import {ResultsProvider} from 'src/notebooks/context/results' import {ResultsProvider} from 'src/notebooks/context/results'
import {RefProvider} from 'src/notebooks/context/refs' import {RefProvider} from 'src/notebooks/context/refs'
import CurrentNotebook from 'src/notebooks/context/notebook.current' import CurrentNotebookProvider, {
NotebookContext,
} from 'src/notebooks/context/notebook.current'
import {ScrollProvider} from 'src/notebooks/context/scroll' import {ScrollProvider} from 'src/notebooks/context/scroll'
import FlowPage from 'src/notebooks/components/FlowPage'
const NotebookFromRoute = () => {
const {id} = useParams()
const {change} = useContext(NotebookContext)
useEffect(() => {
change(id)
}, [id, change])
return null
}
// NOTE: uncommon, but using this to scope the project // NOTE: uncommon, but using this to scope the project
// within the page and not bleed it's dependancies outside // within the page and not bleed it's dependancies outside
// of the feature flag // of the feature flag
@ -20,27 +28,16 @@ import 'src/notebooks/style.scss'
const NotebookPage: FC = () => { const NotebookPage: FC = () => {
return ( return (
<CurrentNotebook> <CurrentNotebookProvider>
<NotebookFromRoute />
<ResultsProvider> <ResultsProvider>
<RefProvider> <RefProvider>
<ScrollProvider> <ScrollProvider>
<Page titleTag="Flows"> <FlowPage />
<NotebookHeader />
<Page.Contents
fullWidth={true}
scrollable={false}
className="notebook-page"
>
<div className="notebook">
<MiniMap />
<PipeList />
</div>
</Page.Contents>
</Page>
</ScrollProvider> </ScrollProvider>
</RefProvider> </RefProvider>
</ResultsProvider> </ResultsProvider>
</CurrentNotebook> </CurrentNotebookProvider>
) )
} }

View File

@ -2,7 +2,7 @@
import React, {FC, useContext, useCallback} from 'react' import React, {FC, useContext, useCallback} from 'react'
// Contexts // Contexts
import {NotebookContext} from 'src/notebooks/context/notebook' import {NotebookContext} from 'src/notebooks/context/notebook.current'
import {TimeProvider, TimeContext, TimeBlock} from 'src/notebooks/context/time' import {TimeProvider, TimeContext, TimeBlock} from 'src/notebooks/context/time'
import AppSettingProvider from 'src/notebooks/context/app' import AppSettingProvider from 'src/notebooks/context/app'
@ -13,6 +13,7 @@ import TimeRangeDropdown from 'src/notebooks/components/header/TimeRangeDropdown
import AutoRefreshDropdown from 'src/notebooks/components/header/AutoRefreshDropdown' import AutoRefreshDropdown from 'src/notebooks/components/header/AutoRefreshDropdown'
import Submit from 'src/notebooks/components/header/Submit' import Submit from 'src/notebooks/components/header/Submit'
import PresentationMode from 'src/notebooks/components/header/PresentationMode' import PresentationMode from 'src/notebooks/components/header/PresentationMode'
import RenamablePageTitle from 'src/pageLayout/components/RenamablePageTitle'
const FULL_WIDTH = true const FULL_WIDTH = true
@ -22,12 +23,12 @@ export interface TimeContextProps {
} }
const NotebookHeader: FC = () => { const NotebookHeader: FC = () => {
const {id} = useContext(NotebookContext) const {id, update, notebook} = useContext(NotebookContext)
const {timeContext, addTimeContext, updateTimeContext} = useContext( const {timeContext, addTimeContext, updateTimeContext} = useContext(
TimeContext TimeContext
) )
const update = useCallback( const updateTime = useCallback(
(data: TimeBlock) => { (data: TimeBlock) => {
updateTimeContext(id, data) updateTimeContext(id, data)
}, },
@ -39,10 +40,19 @@ const NotebookHeader: FC = () => {
return null return null
} }
const handleRename = (name: string) => {
update({...notebook, name})
}
return ( return (
<> <>
<Page.Header fullWidth={FULL_WIDTH}> <Page.Header fullWidth={FULL_WIDTH}>
<Page.Title title="Flows" /> <RenamablePageTitle
onRename={handleRename}
name={notebook.name}
placeholder="Name this Flow"
maxLength={50}
/>
</Page.Header> </Page.Header>
<Page.ControlBar fullWidth={FULL_WIDTH}> <Page.ControlBar fullWidth={FULL_WIDTH}>
<Page.ControlBarLeft> <Page.ControlBarLeft>
@ -51,8 +61,8 @@ const NotebookHeader: FC = () => {
<Page.ControlBarRight> <Page.ControlBarRight>
<PresentationMode /> <PresentationMode />
<TimeZoneDropdown /> <TimeZoneDropdown />
<TimeRangeDropdown context={timeContext[id]} update={update} /> <TimeRangeDropdown context={timeContext[id]} update={updateTime} />
<AutoRefreshDropdown context={timeContext[id]} update={update} /> <AutoRefreshDropdown context={timeContext[id]} update={updateTime} />
</Page.ControlBarRight> </Page.ControlBarRight>
</Page.ControlBar> </Page.ControlBar>
</> </>

View File

@ -12,6 +12,7 @@ const useNotebookCurrentState = createPersistedState('current-notebook')
export interface NotebookContextType { export interface NotebookContextType {
id: string | null id: string | null
name: string
notebook: Notebook | null notebook: Notebook | null
change: (id: string) => void change: (id: string) => void
add: (data: Partial<PipeData>, index?: number) => string add: (data: Partial<PipeData>, index?: number) => string
@ -21,6 +22,7 @@ export interface NotebookContextType {
export const DEFAULT_CONTEXT: NotebookContextType = { export const DEFAULT_CONTEXT: NotebookContextType = {
id: null, id: null,
name: 'Name this Flow',
notebook: null, notebook: null,
add: () => '', add: () => '',
change: () => {}, change: () => {},
@ -114,6 +116,7 @@ export const NotebookProvider: FC = ({children}) => {
<NotebookContext.Provider <NotebookContext.Provider
value={{ value={{
id: currentID, id: currentID,
name,
notebook: notebooks[currentID], notebook: notebooks[currentID],
add: addPipe, add: addPipe,
update: updateCurrent, update: updateCurrent,

View File

@ -20,6 +20,7 @@ export interface NotebookListContextType extends NotebookList {
} }
export const EMPTY_NOTEBOOK: NotebookState = { export const EMPTY_NOTEBOOK: NotebookState = {
name: 'Name this Flow',
data: { data: {
byID: {}, byID: {},
allIDs: [], allIDs: [],
@ -57,6 +58,7 @@ export const NotebookListProvider: FC = ({children}) => {
} }
} else { } else {
_notebook = { _notebook = {
name: notebook.name,
data: notebook.data.serialize(), data: notebook.data.serialize(),
meta: notebook.meta.serialize(), meta: notebook.meta.serialize(),
readOnly: notebook.readOnly, readOnly: notebook.readOnly,
@ -79,6 +81,7 @@ export const NotebookListProvider: FC = ({children}) => {
setNotebooks({ setNotebooks({
...notebooks, ...notebooks,
[id]: { [id]: {
name: notebook.name,
data: notebook.data.serialize(), data: notebook.data.serialize(),
meta: notebook.meta.serialize(), meta: notebook.meta.serialize(),
readOnly: notebook.readOnly, readOnly: notebook.readOnly,
@ -111,6 +114,7 @@ export const NotebookListProvider: FC = ({children}) => {
} }
acc[curr] = { acc[curr] = {
name: notebooks[curr].name,
data: _asResource(notebooks[curr].data, data => { data: _asResource(notebooks[curr].data, data => {
stateUpdater('data', data) stateUpdater('data', data)
}), }),

View File

@ -14,6 +14,7 @@ export interface PipeMeta {
// TODO: this is screaming for normalization. figure out frontend uuids for cells // TODO: this is screaming for normalization. figure out frontend uuids for cells
export interface NotebookContextType { export interface NotebookContextType {
id: string id: string
name: string
pipes: PipeData[] pipes: PipeData[]
meta: PipeMeta[] // data only used for the view layer for Notebooks meta: PipeMeta[] // data only used for the view layer for Notebooks
results: FluxResult[] results: FluxResult[]
@ -21,16 +22,19 @@ export interface NotebookContextType {
updatePipe: (idx: number, pipe: Partial<PipeData>) => void updatePipe: (idx: number, pipe: Partial<PipeData>) => void
updateMeta: (idx: number, pipe: Partial<PipeMeta>) => void updateMeta: (idx: number, pipe: Partial<PipeMeta>) => void
updateResult: (idx: number, result: Partial<FluxResult>) => void updateResult: (idx: number, result: Partial<FluxResult>) => void
updateName: (name: string) => void
movePipe: (currentIdx: number, newIdx: number) => void movePipe: (currentIdx: number, newIdx: number) => void
removePipe: (idx: number) => void removePipe: (idx: number) => void
} }
export const DEFAULT_CONTEXT: NotebookContextType = { export const DEFAULT_CONTEXT: NotebookContextType = {
id: 'new', id: 'new',
name: 'Name this Flow',
pipes: [], pipes: [],
meta: [], meta: [],
results: [], results: [],
addPipe: () => {}, addPipe: () => {},
updateName: () => {},
updatePipe: () => {}, updatePipe: () => {},
updateMeta: () => {}, updateMeta: () => {},
updateResult: () => {}, updateResult: () => {},
@ -78,11 +82,14 @@ export const NotebookProvider: FC = ({children}) => {
const [pipes, setPipes] = useState(DEFAULT_CONTEXT.pipes) const [pipes, setPipes] = useState(DEFAULT_CONTEXT.pipes)
const [meta, setMeta] = useState(DEFAULT_CONTEXT.meta) const [meta, setMeta] = useState(DEFAULT_CONTEXT.meta)
const [results, setResults] = useState(DEFAULT_CONTEXT.results) const [results, setResults] = useState(DEFAULT_CONTEXT.results)
const [name, setName] = useState(DEFAULT_CONTEXT.name)
const _setPipes = useCallback(setPipes, [id, setPipes]) const _setPipes = useCallback(setPipes, [id, setPipes])
const _setMeta = useCallback(setMeta, [id, setMeta]) const _setMeta = useCallback(setMeta, [id, setMeta])
const _setResults = useCallback(setResults, [id, setResults]) const _setResults = useCallback(setResults, [id, setResults])
const updateName = (newName: string) => setName(newName)
const addPipe = useCallback( const addPipe = useCallback(
(pipe: PipeData, insertAtIndex?: number) => { (pipe: PipeData, insertAtIndex?: number) => {
let add = data => { let add = data => {
@ -221,9 +228,11 @@ export const NotebookProvider: FC = ({children}) => {
<NotebookContext.Provider <NotebookContext.Provider
value={{ value={{
id, id,
name,
pipes, pipes,
meta, meta,
results, results,
updateName,
updatePipe, updatePipe,
updateMeta, updateMeta,
updateResult, updateResult,

View File

@ -56,12 +56,14 @@ export interface ResourceManipulator<T> {
} }
export interface NotebookState { export interface NotebookState {
name: string
data: Resource<PipeData> data: Resource<PipeData>
meta: Resource<PipeMeta> meta: Resource<PipeMeta>
readOnly?: boolean readOnly?: boolean
} }
export interface Notebook { export interface Notebook {
name: string
data: ResourceManipulator<PipeData> data: ResourceManipulator<PipeData>
meta: ResourceManipulator<PipeMeta> meta: ResourceManipulator<PipeMeta>
results: FluxResult results: FluxResult

View File

@ -108,17 +108,17 @@ export const generateNavItems = (orgID: string): NavItem[] => {
activeKeywords: ['data-explorer'], activeKeywords: ['data-explorer'],
}, },
{ {
id: 'notebooks', id: 'flows',
testID: 'nav-item-notebooks', testID: 'nav-item-flows',
icon: IconFont.Erlenmeyer, icon: IconFont.Erlenmeyer,
label: 'Flows', label: 'Flows',
featureFlag: 'notebooks', featureFlag: 'notebooks',
shortLabel: 'Flows', shortLabel: 'Flows',
link: { link: {
type: 'link', type: 'link',
location: `${orgPrefix}/notebooks`, location: `${orgPrefix}/flows`,
}, },
activeKeywords: ['notebooks'], activeKeywords: ['flows'],
}, },
{ {
id: 'dashboards', id: 'dashboards',

View File

@ -29,6 +29,7 @@ import MembersIndex from 'src/members/containers/MembersIndex'
import RouteToDashboardList from 'src/dashboards/components/RouteToDashboardList' import RouteToDashboardList from 'src/dashboards/components/RouteToDashboardList'
import ClientLibrariesPage from 'src/writeData/containers/ClientLibrariesPage' import ClientLibrariesPage from 'src/writeData/containers/ClientLibrariesPage'
import TelegrafPluginsPage from 'src/writeData/containers/TelegrafPluginsPage' import TelegrafPluginsPage from 'src/writeData/containers/TelegrafPluginsPage'
import FlowsIndex from 'src/notebooks/components/FlowsIndex'
// Types // Types
import {AppState, Organization, ResourceType} from 'src/types' import {AppState, Organization, ResourceType} from 'src/types'
@ -134,9 +135,13 @@ const SetOrg: FC<Props> = ({
component={RouteToDashboardList} component={RouteToDashboardList}
/> />
{/* Flows */} {/* Flows */}
{isFlagEnabled('notebooks') && ( {isFlagEnabled('notebooks') && (
<Route path={`${orgPath}/notebooks`} component={NotebookPage} /> <Route path={`${orgPath}/notebooks/:id`} component={NotebookPage} />
)}
{isFlagEnabled('notebooks') && (
<Route path={`${orgPath}/flows`} component={FlowsIndex} />
)} )}
{/* Write Data */} {/* Write Data */}