Merge pull request #3283 from influxdata/enhancement/generic-page-tabs

Generic Page Tabs
pull/10616/head
Alex Paxton 2018-04-23 09:54:56 -07:00 committed by GitHub
commit 1d594d8fcf
18 changed files with 358 additions and 269 deletions

View File

@ -1,140 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import {Tab, Tabs, TabPanel, TabPanels, TabList} from 'shared/components/Tabs'
import UsersTable from 'src/admin/components/UsersTable'
import RolesTable from 'src/admin/components/RolesTable'
import QueriesPage from 'src/admin/containers/QueriesPage'
import DatabaseManagerPage from 'src/admin/containers/DatabaseManagerPage'
const AdminTabs = ({
users,
roles,
permissions,
source,
hasRoles,
isEditingUsers,
isEditingRoles,
onClickCreate,
onEditUser,
onSaveUser,
onCancelEditUser,
onEditRole,
onSaveRole,
onCancelEditRole,
onDeleteRole,
onDeleteUser,
onFilterRoles,
onFilterUsers,
onUpdateRoleUsers,
onUpdateRolePermissions,
onUpdateUserRoles,
onUpdateUserPermissions,
onUpdateUserPassword,
}) => {
let tabs = [
{
type: 'Databases',
component: <DatabaseManagerPage source={source} />,
},
{
type: 'Users',
component: (
<UsersTable
users={users}
allRoles={roles}
hasRoles={hasRoles}
permissions={permissions}
isEditing={isEditingUsers}
onSave={onSaveUser}
onCancel={onCancelEditUser}
onClickCreate={onClickCreate}
onEdit={onEditUser}
onDelete={onDeleteUser}
onFilter={onFilterUsers}
onUpdatePermissions={onUpdateUserPermissions}
onUpdateRoles={onUpdateUserRoles}
onUpdatePassword={onUpdateUserPassword}
/>
),
},
{
type: 'Roles',
component: (
<RolesTable
roles={roles}
allUsers={users}
permissions={permissions}
isEditing={isEditingRoles}
onClickCreate={onClickCreate}
onEdit={onEditRole}
onSave={onSaveRole}
onCancel={onCancelEditRole}
onDelete={onDeleteRole}
onFilter={onFilterRoles}
onUpdateRoleUsers={onUpdateRoleUsers}
onUpdateRolePermissions={onUpdateRolePermissions}
/>
),
},
{
type: 'Queries',
component: <QueriesPage source={source} />,
},
]
if (!hasRoles) {
tabs = tabs.filter(t => t.type !== 'Roles')
}
return (
<Tabs className="row">
<TabList customClass="col-md-2 admin-tabs">
{tabs.map((t, i) => <Tab key={tabs[i].type}>{tabs[i].type}</Tab>)}
</TabList>
<TabPanels customClass="col-md-10 admin-tabs--content">
{tabs.map((t, i) => (
<TabPanel key={tabs[i].type}>{t.component}</TabPanel>
))}
</TabPanels>
</Tabs>
)
}
const {arrayOf, bool, func, shape, string} = PropTypes
AdminTabs.propTypes = {
users: arrayOf(
shape({
name: string.isRequired,
roles: arrayOf(
shape({
name: string,
})
),
})
),
roles: arrayOf(shape()),
source: shape(),
permissions: arrayOf(string),
isEditingUsers: bool,
isEditingRoles: bool,
onClickCreate: func.isRequired,
onEditUser: func.isRequired,
onSaveUser: func.isRequired,
onCancelEditUser: func.isRequired,
onEditRole: func.isRequired,
onSaveRole: func.isRequired,
onCancelEditRole: func.isRequired,
onDeleteRole: func.isRequired,
onDeleteUser: func.isRequired,
onFilterRoles: func.isRequired,
onFilterUsers: func.isRequired,
onUpdateRoleUsers: func.isRequired,
onUpdateRolePermissions: func.isRequired,
hasRoles: bool.isRequired,
onUpdateUserPermissions: func,
onUpdateUserRoles: func,
onUpdateUserPassword: func,
}
export default AdminTabs

View File

@ -150,7 +150,7 @@ class DatabaseRow extends Component {
ref={r => (this.name = r)}
autoFocus={true}
spellCheck={false}
autoComplete={false}
autoComplete="false"
/>
) : (
name
@ -167,7 +167,7 @@ class DatabaseRow extends Component {
ref={r => (this.duration = r)}
autoFocus={!isNew}
spellCheck={false}
autoComplete={false}
autoComplete="false"
/>
</td>
{isRFDisplayed ? (
@ -182,7 +182,7 @@ class DatabaseRow extends Component {
onKeyDown={this.handleKeyDown}
ref={r => (this.replication = r)}
spellCheck={false}
autoComplete={false}
autoComplete="false"
/>
</td>
) : null}

View File

@ -98,7 +98,7 @@ const Header = ({
onChange={onDatabaseDeleteConfirm(database)}
onKeyDown={onDatabaseDeleteConfirm(database)}
autoFocus={true}
autoComplete={false}
autoComplete="false"
spellCheck={false}
/>
<ConfirmOrCancel
@ -130,7 +130,7 @@ const EditHeader = ({database, onEdit, onKeyDown, onConfirm, onCancel}) => (
onKeyDown={onKeyDown(database)}
autoFocus={true}
spellCheck={false}
autoComplete={false}
autoComplete="false"
/>
<ConfirmOrCancel
item={database}

View File

@ -38,7 +38,7 @@ class RoleEditingRow extends Component {
onKeyPress={this.handleKeyPress(role)}
autoFocus={true}
spellCheck={false}
autoComplete={false}
autoComplete="false"
/>
</td>
)

View File

@ -38,7 +38,7 @@ class UserEditName extends Component {
onKeyPress={this.handleKeyPress(user)}
autoFocus={true}
spellCheck={false}
autoComplete={false}
autoComplete="false"
/>
</td>
)

View File

@ -34,7 +34,7 @@ class UserNewPassword extends Component {
onChange={this.handleEdit(user)}
onKeyPress={this.handleKeyPress(user)}
spellCheck={false}
autoComplete={false}
autoComplete="false"
/>
) : (
'--'

View File

@ -25,9 +25,13 @@ import {
filterRoles as filterRolesAction,
} from 'src/admin/actions/influxdb'
import AdminTabs from 'src/admin/components/AdminTabs'
import UsersTable from 'src/admin/components/UsersTable'
import RolesTable from 'src/admin/components/RolesTable'
import QueriesPage from 'src/admin/containers/QueriesPage'
import DatabaseManagerPage from 'src/admin/containers/DatabaseManagerPage'
import SourceIndicator from 'shared/components/SourceIndicator'
import FancyScrollbar from 'shared/components/FancyScrollbar'
import SubSections from 'shared/components/SubSections'
import {ErrorHandling} from 'src/shared/decorators/errors'
import {notify as notifyAction} from 'shared/actions/notifications'
@ -141,7 +145,7 @@ class AdminInfluxDBPage extends Component {
this.props.updateUserPassword(user, password)
}
render() {
getAdminSubSections = () => {
const {
users,
roles,
@ -154,6 +158,69 @@ class AdminInfluxDBPage extends Component {
const globalPermissions = permissions.find(p => p.scope === 'all')
const allowed = globalPermissions ? globalPermissions.allowed : []
return [
{
url: 'databases',
name: 'Databases',
enabled: true,
component: <DatabaseManagerPage source={source} />,
},
{
url: 'users',
name: 'Users',
enabled: true,
component: (
<UsersTable
users={users}
allRoles={roles}
hasRoles={hasRoles}
permissions={allowed}
isEditing={users.some(u => u.isEditing)}
onSave={this.handleSaveUser}
onCancel={this.handleCancelEditUser}
onClickCreate={this.handleClickCreate}
onEdit={this.handleEditUser}
onDelete={this.handleDeleteUser}
onFilter={filterUsers}
onUpdatePermissions={this.handleUpdateUserPermissions}
onUpdateRoles={this.handleUpdateUserRoles}
onUpdatePassword={this.handleUpdateUserPassword}
/>
),
},
{
url: 'roles',
name: 'Roles',
enabled: hasRoles,
component: (
<RolesTable
roles={roles}
allUsers={users}
permissions={allowed}
isEditing={roles.some(r => r.isEditing)}
onClickCreate={this.handleClickCreate}
onEdit={this.handleEditRole}
onSave={this.handleSaveRole}
onCancel={this.handleCancelEditRole}
onDelete={this.handleDeleteRole}
onFilter={filterRoles}
onUpdateRoleUsers={this.handleUpdateRoleUsers}
onUpdateRolePermissions={this.handleUpdateRolePermissions}
/>
),
},
{
url: 'queries',
name: 'Queries',
enabled: true,
component: <QueriesPage source={source} />,
},
]
}
render() {
const {users, source, params} = this.props
return (
<div className="page">
<div className="page-header">
@ -169,33 +236,12 @@ class AdminInfluxDBPage extends Component {
<FancyScrollbar className="page-contents">
{users ? (
<div className="container-fluid">
<div className="row">
<AdminTabs
users={users}
roles={roles}
source={source}
hasRoles={hasRoles}
permissions={allowed}
onFilterUsers={filterUsers}
onFilterRoles={filterRoles}
onEditUser={this.handleEditUser}
onEditRole={this.handleEditRole}
onSaveUser={this.handleSaveUser}
onSaveRole={this.handleSaveRole}
onDeleteUser={this.handleDeleteUser}
onDeleteRole={this.handleDeleteRole}
onClickCreate={this.handleClickCreate}
onCancelEditUser={this.handleCancelEditUser}
onCancelEditRole={this.handleCancelEditRole}
isEditingUsers={users.some(u => u.isEditing)}
isEditingRoles={roles.some(r => r.isEditing)}
onUpdateRoleUsers={this.handleUpdateRoleUsers}
onUpdateUserRoles={this.handleUpdateUserRoles}
onUpdateUserPassword={this.handleUpdateUserPassword}
onUpdateRolePermissions={this.handleUpdateRolePermissions}
onUpdateUserPermissions={this.handleUpdateUserPermissions}
/>
</div>
<SubSections
parentUrl="admin-influxdb"
sourceID={source.id}
activeSection={params.tab}
sections={this.getAdminSubSections()}
/>
</div>
) : (
<div className="page-spinner" />
@ -239,6 +285,9 @@ AdminInfluxDBPage.propTypes = {
updateUserRoles: func,
updateUserPassword: func,
notify: func.isRequired,
params: shape({
tab: string,
}).isRequired,
}
const mapStateToProps = ({adminInfluxDB: {users, roles, permissions}}) => ({

View File

@ -20,7 +20,7 @@ const RowValues = ({
onStartEdit={onStartEdit}
autoFocusTarget={autoFocusTarget}
spellCheck={false}
autoComplete={false}
autoComplete="false"
/>
)
}

View File

@ -147,7 +147,7 @@ class Root extends PureComponent<{}, State> {
component={KapacitorPage}
/>
<Route path="admin-chronograf" component={AdminChronografPage} />
<Route path="admin-influxdb" component={AdminInfluxDBPage} />
<Route path="admin-influxdb/:tab" component={AdminInfluxDBPage} />
<Route path="manage-sources" component={ManageSources} />
<Route path="manage-sources/new" component={SourcePage} />
<Route path="manage-sources/:id/edit" component={SourcePage} />

View File

@ -0,0 +1,65 @@
import React, {Component, ReactNode} from 'react'
import uuid from 'uuid'
import {withRouter, InjectedRouter} from 'react-router'
import SubSectionsTab from 'src/shared/components/SubSectionsTab'
import {ErrorHandling} from 'src/shared/decorators/errors'
import {PageSection} from 'src/types/shared'
interface Props {
sections: PageSection[]
activeSection: string
sourceID: string
router: InjectedRouter
parentUrl: string
}
@ErrorHandling
class SubSections extends Component<Props> {
constructor(props) {
super(props)
}
public render() {
const {sections, activeSection} = this.props
return (
<div className="row subsection">
<div className="col-md-2 subsection--nav" data-test="subsectionNav">
<div className="subsection--tabs">
{sections.map(
section =>
section.enabled && (
<SubSectionsTab
key={uuid.v4()}
section={section}
handleClick={this.handleTabClick(section.url)}
activeSection={activeSection}
/>
)
)}
</div>
</div>
<div
className="col-md-10 subsection--content"
data-test="subsectionContent"
>
{this.activeSectionComponent}
</div>
</div>
)
}
private get activeSectionComponent(): ReactNode {
const {sections, activeSection} = this.props
const {component} = sections.find(section => section.url === activeSection)
return component
}
public handleTabClick = url => () => {
const {router, sourceID, parentUrl} = this.props
router.push(`/sources/${sourceID}/${parentUrl}/${url}`)
}
}
export default withRouter(SubSections)

View File

@ -0,0 +1,25 @@
import React, {SFC} from 'react'
import {PageSection} from 'src/types/shared'
interface TabProps {
handleClick: () => void
section: PageSection
activeSection: string
}
const SubSectionsTab: SFC<TabProps> = ({
handleClick,
section,
activeSection,
}) => (
<div
className={`subsection--tab ${
section.url === activeSection ? 'active' : ''
}`}
onClick={handleClick}
>
{section.name}
</div>
)
export default SubSectionsTab

View File

@ -1,6 +1,7 @@
import React, {PureComponent, SFC, ReactNode, ReactElement} from 'react'
import {Link} from 'react-router'
import classnames from 'classnames'
import _ from 'lodash'
interface NavListItemProps {
link: string
@ -62,17 +63,14 @@ interface NavBlockProps {
icon: string
location?: string
className?: string
matcher?: string
highlightWhen: string[]
}
class NavBlock extends PureComponent<NavBlockProps> {
public render() {
const {location, className} = this.props
const isActive = React.Children.toArray(this.props.children).find(
(child: ReactElement<any>) => {
return location.startsWith(child.props.link) // if location is undefined, this will fail silently
}
)
const {location, className, highlightWhen} = this.props
const {length} = _.intersection(_.split(location, '/'), highlightWhen)
const isActive = !!length
const children = React.Children.map(
this.props.children,

View File

@ -62,19 +62,26 @@ class SideNav extends PureComponent<Props> {
</Link>
</div>
<NavBlock
highlightWhen={['hosts']}
icon="cubo-node"
link={`${sourcePrefix}/hosts`}
location={location}
>
<NavHeader link={`${sourcePrefix}/hosts`} title="Host List" />
</NavBlock>
<NavBlock icon="graphline" link={dataExplorerLink} location={location}>
<NavBlock
highlightWhen={['data-explorer', 'delorean']}
icon="graphline"
link={dataExplorerLink}
location={location}
>
<NavHeader link={dataExplorerLink} title="Data Explorer" />
<FeatureFlag name="time-machine">
<NavHeader link={`${sourcePrefix}/delorean`} title="Time Machine" />
</FeatureFlag>
</NavBlock>
<NavBlock
highlightWhen={['dashboards']}
icon="dash-h"
link={`${sourcePrefix}/dashboards`}
location={location}
@ -82,7 +89,7 @@ class SideNav extends PureComponent<Props> {
<NavHeader link={`${sourcePrefix}/dashboards`} title="Dashboards" />
</NavBlock>
<NavBlock
matcher="alerts"
highlightWhen={['alerts', 'alert-rules', 'tickscript']}
icon="alert-triangle"
link={`${sourcePrefix}/alert-rules`}
location={location}
@ -100,18 +107,20 @@ class SideNav extends PureComponent<Props> {
requiredRole={ADMIN_ROLE}
replaceWithIfNotUsingAuth={
<NavBlock
highlightWhen={['admin-influxdb']}
icon="crown2"
link={`${sourcePrefix}/admin-influxdb`}
link={`${sourcePrefix}/admin-influxdb/databases`}
location={location}
>
<NavHeader
link={`${sourcePrefix}/admin-influxdb`}
link={`${sourcePrefix}/admin-influxdb/databases`}
title="InfluxDB Admin"
/>
</NavBlock>
}
>
<NavBlock
highlightWhen={['admin-chronograf', 'admin-influxdb']}
icon="crown2"
link={`${sourcePrefix}/admin-chronograf`}
location={location}
@ -123,12 +132,13 @@ class SideNav extends PureComponent<Props> {
<NavListItem link={`${sourcePrefix}/admin-chronograf`}>
Chronograf
</NavListItem>
<NavListItem link={`${sourcePrefix}/admin-influxdb`}>
<NavListItem link={`${sourcePrefix}/admin-influxdb/databases`}>
InfluxDB
</NavListItem>
</NavBlock>
</Authorized>
<NavBlock
highlightWhen={['manage-sources', 'kapacitors']}
icon="cog-thick"
link={`${sourcePrefix}/manage-sources`}
location={location}

View File

@ -27,6 +27,7 @@
// Layout
@import 'layout/page';
@import 'layout/page-header';
@import 'layout/page-subsections';
@import 'layout/sidebar';
@import 'layout/overlay';

View File

@ -0,0 +1,64 @@
/*
Page Sub-Sections
----------------------------------------------------------------------------
*/
$subsection-font: 17px;
.subsection {
.panel {
border-top-left-radius: 0;
}
.panel-heading {
height: 60px;
padding-top: 0;
padding-bottom: 0;
}
.panel-heading + .panel-body {
padding-top: 0;
}
.panel-body {
min-height: 500px;
}
.panel-title {
font-size: $subsection-font;
}
}
.subsection--tabs {
display: flex;
flex-direction: column;
align-items: stretch;
}
.subsection--tab {
border-radius: $radius 0 0 $radius;
padding: 0 8px 0 16px;
height: $chronograf-page-header-height;
white-space: nowrap;
line-height: $chronograf-page-header-height;
text-align: left;
font-size: $subsection-font;
font-weight: 500;
color: $g11-sidewalk;
@include no-user-select();
transition: background-color 0.25s ease, color 0.25s ease;
&:hover,
&.active {
cursor: pointer;
color: $g18-cloud;
background-color: $g3-castle;
}
}
@media screen and (min-width: $grid--breakpoint-md) {
.subsection {
.subsection--nav {
padding-right: 0;
}
.subsection--content {
padding-left: 0;
}
}
}

View File

@ -3,84 +3,6 @@
----------------------------------------------------------------------------
*/
/*
Admin Tabs
----------------------------------------------------------------------------
*/
.admin-tabs .btn-group {
margin: 0;
width: 100%;
display: flex;
align-items: stretch;
.tab {
font-weight: 500 !important;
border-radius: $radius $radius 0 0 !important;
transition: background-color 0.25s ease, color 0.25s ease !important;
border: 0 !important;
text-align: left;
height: 60px !important;
line-height: 60px !important;
padding: 0 30px !important;
font-size: 17px;
background-color: transparent !important;
color: $g11-sidewalk !important;
&:hover,
&:active,
&:active:hover {
background-color: $g3-castle !important;
color: $g15-platinum !important;
}
&.active {
background-color: $g3-castle !important;
color: $g18-cloud !important;
}
}
}
.admin-tabs--content {
.panel {
border-top-left-radius: 0;
}
.panel-heading {
height: 60px;
}
.panel-title {
font-size: 17px;
font-weight: 400 !important;
color: $g12-forge;
padding: 6px 0;
}
.panel-body {
min-height: 300px;
}
.panel-heading + .panel-body {
padding-top: 0;
}
}
/*
Responsive Layout for Admin Tabs on Small Screens
----------------------------------------------------------------------------
*/
@media screen and (min-width: 992px) {
.admin-tabs {
padding-right: 0;
.btn-group {
flex-direction: column;
}
.btn-group .tab {
border-radius: $radius 0 0 $radius !important;
padding: 0 0 0 16px !important;
}
& + div {
padding-left: 0;
}
}
}
/*
Admin Table
----------------------------------------------------------------------------

View File

@ -1,3 +1,5 @@
import {ReactNode} from 'react'
export type DropdownItem =
| {
text: string
@ -9,3 +11,10 @@ export interface DropdownAction {
text: string
handler: () => void
}
export interface PageSection {
url: string
name: string
component: ReactNode
enabled: boolean
}

View File

@ -0,0 +1,86 @@
import {shallow} from 'enzyme'
import React from 'react'
import SubSections from 'src/shared/components/SubSections'
import SubSectionsTab from 'src/shared/components/SubSectionsTab'
const Guava = () => {
return <div />
}
const Mango = () => {
return <div />
}
const Pineapple = () => {
return <div />
}
const guavaURL = 'guava'
const mangoURL = 'mango'
const pineappleURL = 'pineapple'
const defaultProps = {
router: {
push: () => {},
replace: () => {},
go: () => {},
goBack: () => {},
goForward: () => {},
setRouteLeaveHook: () => {},
isActive: () => {},
},
sourceID: 'fruitstand',
parentUrl: 'fred-the-fruit-guy',
activeSection: guavaURL,
sections: [
{
url: guavaURL,
name: 'Guava',
component: <Guava />,
enabled: true,
},
{
url: mangoURL,
name: 'Mango',
component: <Mango />,
enabled: true,
},
{
url: pineappleURL,
name: 'Pineapple',
component: <Pineapple />,
enabled: false,
},
],
}
const setup = (override?: {}) => {
const props = {
...defaultProps,
...override,
}
return shallow(<SubSections {...props} />)
}
describe('SubSections', () => {
describe('render', () => {
it('renders the currently active tab', () => {
const wrapper = setup()
const content = wrapper.dive().find({'data-test': 'subsectionContent'})
expect(content.find(Guava).exists()).toBe(true)
})
it('only renders enabled tabs', () => {
const wrapper = setup()
const nav = wrapper.dive().find({'data-test': 'subsectionNav'})
const tabs = nav.find(SubSectionsTab)
tabs.forEach(tab => {
expect(tab.exists()).toBe(tab.props().section.enabled)
})
})
})
})