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",
"rules": "/rules",
"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
import React, {FC} from 'react'
import React, {FC, useContext, useEffect} from 'react'
import {useParams} from 'react-router-dom'
// 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 {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 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
// within the page and not bleed it's dependancies outside
// of the feature flag
@ -20,27 +28,16 @@ import 'src/notebooks/style.scss'
const NotebookPage: FC = () => {
return (
<CurrentNotebook>
<CurrentNotebookProvider>
<NotebookFromRoute />
<ResultsProvider>
<RefProvider>
<ScrollProvider>
<Page titleTag="Flows">
<NotebookHeader />
<Page.Contents
fullWidth={true}
scrollable={false}
className="notebook-page"
>
<div className="notebook">
<MiniMap />
<PipeList />
</div>
</Page.Contents>
</Page>
<FlowPage />
</ScrollProvider>
</RefProvider>
</ResultsProvider>
</CurrentNotebook>
</CurrentNotebookProvider>
)
}

View File

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

View File

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

View File

@ -20,6 +20,7 @@ export interface NotebookListContextType extends NotebookList {
}
export const EMPTY_NOTEBOOK: NotebookState = {
name: 'Name this Flow',
data: {
byID: {},
allIDs: [],
@ -57,6 +58,7 @@ export const NotebookListProvider: FC = ({children}) => {
}
} else {
_notebook = {
name: notebook.name,
data: notebook.data.serialize(),
meta: notebook.meta.serialize(),
readOnly: notebook.readOnly,
@ -79,6 +81,7 @@ export const NotebookListProvider: FC = ({children}) => {
setNotebooks({
...notebooks,
[id]: {
name: notebook.name,
data: notebook.data.serialize(),
meta: notebook.meta.serialize(),
readOnly: notebook.readOnly,
@ -111,6 +114,7 @@ export const NotebookListProvider: FC = ({children}) => {
}
acc[curr] = {
name: notebooks[curr].name,
data: _asResource(notebooks[curr].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
export interface NotebookContextType {
id: string
name: string
pipes: PipeData[]
meta: PipeMeta[] // data only used for the view layer for Notebooks
results: FluxResult[]
@ -21,16 +22,19 @@ export interface NotebookContextType {
updatePipe: (idx: number, pipe: Partial<PipeData>) => void
updateMeta: (idx: number, pipe: Partial<PipeMeta>) => void
updateResult: (idx: number, result: Partial<FluxResult>) => void
updateName: (name: string) => void
movePipe: (currentIdx: number, newIdx: number) => void
removePipe: (idx: number) => void
}
export const DEFAULT_CONTEXT: NotebookContextType = {
id: 'new',
name: 'Name this Flow',
pipes: [],
meta: [],
results: [],
addPipe: () => {},
updateName: () => {},
updatePipe: () => {},
updateMeta: () => {},
updateResult: () => {},
@ -78,11 +82,14 @@ export const NotebookProvider: FC = ({children}) => {
const [pipes, setPipes] = useState(DEFAULT_CONTEXT.pipes)
const [meta, setMeta] = useState(DEFAULT_CONTEXT.meta)
const [results, setResults] = useState(DEFAULT_CONTEXT.results)
const [name, setName] = useState(DEFAULT_CONTEXT.name)
const _setPipes = useCallback(setPipes, [id, setPipes])
const _setMeta = useCallback(setMeta, [id, setMeta])
const _setResults = useCallback(setResults, [id, setResults])
const updateName = (newName: string) => setName(newName)
const addPipe = useCallback(
(pipe: PipeData, insertAtIndex?: number) => {
let add = data => {
@ -221,9 +228,11 @@ export const NotebookProvider: FC = ({children}) => {
<NotebookContext.Provider
value={{
id,
name,
pipes,
meta,
results,
updateName,
updatePipe,
updateMeta,
updateResult,

View File

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

View File

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

View File

@ -29,6 +29,7 @@ import MembersIndex from 'src/members/containers/MembersIndex'
import RouteToDashboardList from 'src/dashboards/components/RouteToDashboardList'
import ClientLibrariesPage from 'src/writeData/containers/ClientLibrariesPage'
import TelegrafPluginsPage from 'src/writeData/containers/TelegrafPluginsPage'
import FlowsIndex from 'src/notebooks/components/FlowsIndex'
// Types
import {AppState, Organization, ResourceType} from 'src/types'
@ -134,9 +135,13 @@ const SetOrg: FC<Props> = ({
component={RouteToDashboardList}
/>
{/* Flows */}
{/* Flows */}
{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 */}