feat(demodata): Notify on demodata success and failure. (#17902)

* feat(demodata): Do not delete dashboard when delete demodata bucket

* feat(demodata): Refactor demodata dropdown

* feat(demodata): Fetch DD_Buckets in DD_Dropdown

* feat(demodata): Refactor notifications

Co-authored-by: alexpaxton <thealexpaxton@gmail.com>

* feat(demodata): Style demo data dropdown

* feat(demodata): Remove double console logging of error

* feat(demodata): Error on success and failure

* feat(demodata): Remove duplicate console.error

* feat(demodata): fix lint errors

* feat(demodata): Reintroduce Notification Style and add testID

* feat(demodata): Add dd success notification and links to notifications

Co-authored-by: alexpaxton <thealexpaxton@gmail.com>

* feat(demodata): fix test

* feat(demodata): Simplify getDemoDataBucketMembership function

* feat(demodata): Remove unused notification copy

* feat(demodata): Extend notification duration

* feat(demodata): Return null instead of false

* feat(demodata): Do not console.error if also notifying user

* feat(demodata): Add todo item

Co-authored-by: alexpaxton <thealexpaxton@gmail.com>
pull/17915/head
Deniz Kusefoglu 2020-04-29 14:52:36 -07:00 committed by GitHub
parent 463c8bab1f
commit 080d77751b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 385 additions and 571 deletions

View File

@ -61,7 +61,7 @@ describe('The Query Builder', () => {
// wait for the notification since it's highly animated
// we close the notification since it contains the name of the dashboard and interfers with cy.contains
cy.wait(250)
cy.get('.notification-close').click()
cy.get('.cf-notification--dismiss').click()
cy.wait(250)
// force a click on the hidden dashboard nav item (cypress can't do the hover)

View File

@ -33,16 +33,10 @@ import {
checkBucketLimits as checkBucketLimitsAction,
LimitStatus,
} from 'src/cloud/actions/limits'
import {
getDemoDataBuckets as getDemoDataBucketsAction,
getDemoDataBucketMembership as getDemoDataBucketMembershipAction,
} from 'src/cloud/actions/demodata'
// Utils
import {getNewDemoBuckets} from 'src/cloud/selectors/demodata'
import {extractBucketLimits} from 'src/cloud/utils/limits'
import {getAll} from 'src/resources/selectors'
import {isFlagEnabled} from 'src/shared/utils/featureFlag'
import {SortTypes} from 'src/shared/utils/sort'
// Types
@ -52,7 +46,6 @@ import {BucketSortKey} from 'src/shared/components/resource_sort_dropdown/genera
interface StateProps {
buckets: Bucket[]
limitStatus: LimitStatus
demoDataBuckets: Bucket[]
}
interface DispatchProps {
@ -60,8 +53,6 @@ interface DispatchProps {
updateBucket: typeof updateBucket
deleteBucket: typeof deleteBucket
checkBucketLimits: typeof checkBucketLimitsAction
getDemoDataBuckets: typeof getDemoDataBucketsAction
getDemoDataBucketMembership: typeof getDemoDataBucketMembershipAction
}
interface State {
@ -90,18 +81,10 @@ class BucketsTab extends PureComponent<Props, State> {
public componentDidMount() {
this.props.checkBucketLimits()
if (isFlagEnabled('demodata')) {
this.props.getDemoDataBuckets()
}
}
public render() {
const {
buckets,
limitStatus,
demoDataBuckets,
getDemoDataBucketMembership,
} = this.props
const {buckets, limitStatus} = this.props
const {searchTerm, sortKey, sortDirection, sortType} = this.state
const leftHeaderItems = (
@ -125,12 +108,7 @@ class BucketsTab extends PureComponent<Props, State> {
const rightHeaderItems = (
<>
<FeatureFlag name="demodata">
{demoDataBuckets.length > 0 && (
<DemoDataDropdown
buckets={demoDataBuckets}
getMembership={getDemoDataBucketMembership}
/>
)}
<DemoDataDropdown />
</FeatureFlag>
<CreateBucketButton />
</>
@ -229,7 +207,6 @@ const mstp = (state: AppState): StateProps => {
return {
buckets,
limitStatus: extractBucketLimits(state.cloud.limits),
demoDataBuckets: getNewDemoBuckets(state, buckets),
}
}
@ -238,8 +215,6 @@ const mdtp: DispatchProps = {
updateBucket,
deleteBucket,
checkBucketLimits: checkBucketLimitsAction,
getDemoDataBuckets: getDemoDataBucketsAction,
getDemoDataBucketMembership: getDemoDataBucketMembershipAction,
}
export default connect<StateProps, DispatchProps, {}>(

View File

@ -0,0 +1,20 @@
.demodata-dropdown--item-contents {
display: inline-flex;
align-items: center;
}
.demodata-dropdown--item-icon {
margin-right: $cf-marg-b;
opacity: 0;
}
.demodata-dropdown--item__added,
.demodata-dropdown--item__added:hover {
.demodata-dropdown--item-icon {
opacity: 1;
}
cursor: default;
background: none !important;
color: $c-honeydew !important;
}

View File

@ -1,31 +1,97 @@
// Libraries
import React, {FC} from 'react'
import _ from 'lodash'
import React, {FC, useEffect} from 'react'
import {connect} from 'react-redux'
import {get, sortBy} from 'lodash'
// Utils
import {getAll} from 'src/resources/selectors'
// Actions
import {
getDemoDataBucketMembership as getDemoDataBucketMembershipAction,
getDemoDataBuckets as getDemoDataBucketsAction,
} from 'src/cloud/actions/demodata'
// Components
import {IconFont, ComponentColor, Dropdown} from '@influxdata/clockface'
import {ComponentColor, Dropdown, Icon, IconFont} from '@influxdata/clockface'
// Types
import {Bucket} from 'src/types'
import {getDemoDataBucketMembership} from 'src/cloud/actions/demodata'
import {AppState, Bucket, ResourceType} from 'src/types'
interface Props {
buckets: Bucket[]
getMembership: typeof getDemoDataBucketMembership
interface StateProps {
ownBuckets: Bucket[]
demoDataBuckets: Bucket[]
}
const DemoDataDropdown: FC<Props> = ({buckets, getMembership}) => {
const demoDataItems = buckets.map(b => (
<Dropdown.Item
testID={`dropdown-item--demodata-${b.name}`}
id={b.id}
key={b.id}
value={b}
onClick={getMembership}
>
{b.name}
</Dropdown.Item>
))
interface DispatchProps {
getDemoDataBucketMembership: typeof getDemoDataBucketMembershipAction
getDemoDataBuckets: typeof getDemoDataBucketsAction
}
type Props = DispatchProps & StateProps
const DemoDataDropdown: FC<Props> = ({
ownBuckets,
demoDataBuckets,
getDemoDataBucketMembership,
getDemoDataBuckets,
}) => {
useEffect(() => {
getDemoDataBuckets()
}, [])
if (!demoDataBuckets.length) {
return null
}
const ownBucketNames = ownBuckets.map(o => o.name.toLocaleLowerCase())
const sortedBuckets = sortBy(demoDataBuckets, d => {
return d.name.toLocaleLowerCase()
})
const dropdownItems = sortedBuckets.map(b => {
if (ownBucketNames.includes(b.name.toLocaleLowerCase())) {
return (
<Dropdown.Item
testID={`dropdown-item--demodata-${b.name}`}
className="demodata-dropdown--item__added"
id={b.id}
key={b.id}
value={b}
selected={true}
>
<div className="demodata-dropdown--item-contents">
<Icon
glyph={IconFont.Checkmark}
className="demodata-dropdown--item-icon"
/>
{b.name}
</div>
</Dropdown.Item>
)
}
return (
<Dropdown.Item
testID={`dropdown-item--demodata-${b.name}`}
className="demodata-dropdown--item"
id={b.id}
key={b.id}
value={b}
onClick={getDemoDataBucketMembership}
selected={false}
>
<div className="demodata-dropdown--item-contents">
<Icon
glyph={IconFont.Checkmark}
className="demodata-dropdown--item-icon"
/>
{b.name}
</div>
</Dropdown.Item>
)
})
return (
<Dropdown
@ -43,10 +109,23 @@ const DemoDataDropdown: FC<Props> = ({buckets, getMembership}) => {
</Dropdown.Button>
)}
menu={onCollapse => (
<Dropdown.Menu onCollapse={onCollapse}>{demoDataItems}</Dropdown.Menu>
<Dropdown.Menu onCollapse={onCollapse}>{dropdownItems}</Dropdown.Menu>
)}
/>
)
}
export default DemoDataDropdown
const mstp = (state: AppState): StateProps => ({
ownBuckets: getAll<Bucket>(state, ResourceType.Buckets),
demoDataBuckets: get(state, 'cloud.demoData.buckets', []) as Bucket[],
})
const mdtp: DispatchProps = {
getDemoDataBucketMembership: getDemoDataBucketMembershipAction,
getDemoDataBuckets: getDemoDataBucketsAction,
}
export default connect<StateProps, DispatchProps, {}>(
mstp,
mdtp
)(DemoDataDropdown)

View File

@ -3,21 +3,26 @@ import {
getDemoDataBuckets as getDemoDataBucketsAJAX,
getDemoDataBucketMembership as getDemoDataBucketMembershipAJAX,
deleteDemoDataBucketMembership as deleteDemoDataBucketMembershipAJAX,
getNormalizedDemoDataBucket,
} from 'src/cloud/apis/demodata'
import {createDashboardFromTemplate} from 'src/templates/api'
import {deleteDashboard, getBucket} from 'src/client'
import {getBucket} from 'src/client'
// Actions
import {getDashboards} from 'src/dashboards/actions/thunks'
import {addBucket, removeBucket} from 'src/buckets/actions/creators'
import {notify} from 'src/shared/actions/notifications'
// Selectors
import {getOrg} from 'src/organizations/selectors'
import {getAll} from 'src/resources/selectors/getAll'
import {normalize} from 'normalizr'
import {getAll} from 'src/resources/selectors'
// Constants
import {DemoDataTemplates, DemoDataDashboards} from 'src/cloud/constants'
import {
demoDataAddBucketFailed,
demoDataDeleteBucketFailed,
demoDataSucceeded,
} from 'src/shared/copy/notifications'
// Types
import {
@ -25,11 +30,10 @@ import {
RemoteDataState,
GetState,
DemoBucket,
Dashboard,
ResourceType,
BucketEntities,
Dashboard,
} from 'src/types'
import {bucketSchema} from 'src/schemas'
import {reportError} from 'src/shared/utils/errors'
export type Actions =
| ReturnType<typeof setDemoDataStatus>
@ -57,88 +61,74 @@ export const getDemoDataBuckets = () => async (
if (status === RemoteDataState.NotStarted) {
dispatch(setDemoDataStatus(RemoteDataState.Loading))
}
try {
const buckets = await getDemoDataBucketsAJAX()
dispatch(setDemoDataStatus(RemoteDataState.Done))
dispatch(setDemoDataBuckets(buckets))
} catch (error) {
console.error(error)
reportError(error, {
name: 'getDemoDataBuckets function',
})
dispatch(setDemoDataStatus(RemoteDataState.Error))
}
}
export const getDemoDataBucketMembership = (bucket: DemoBucket) => async (
dispatch,
getState: GetState
) => {
export const getDemoDataBucketMembership = ({
name: bucketName,
id: bucketID,
}) => async (dispatch, getState: GetState) => {
const state = getState()
const {
me: {id: userID},
} = state
const {id: orgID} = getOrg(state)
try {
await getDemoDataBucketMembershipAJAX(bucket.id, userID)
await getDemoDataBucketMembershipAJAX(bucketID, userID)
const template = await DemoDataTemplates[bucket.name]
if (template) {
await createDashboardFromTemplate(template, orgID)
} else {
const normalizedBucket = await getNormalizedDemoDataBucket(bucketID)
dispatch(addBucket(normalizedBucket))
const template = await DemoDataTemplates[bucketName]
if (!template) {
throw new Error(
`Could not find template for demodata bucket ${bucket.name}`
`Could not find dashboard template for demodata bucket ${bucketName}`
)
}
const resp = await getBucket({bucketID: bucket.id})
if (resp.status !== 200) {
throw new Error('Request for demo data bucket membership did not succeed')
}
const newBucket = {
...resp.data,
type: 'demodata' as 'demodata',
labels: [],
} as DemoBucket
const normalizedBucket = normalize<Bucket, BucketEntities, string>(
newBucket,
bucketSchema
)
dispatch(addBucket(normalizedBucket))
// TODO: notify success and error appropriately
} catch (error) {
console.error(error)
}
}
export const deleteDemoDataDashboard = (dashboardName: string) => async (
dispatch,
getState: GetState
) => {
try {
await dispatch(getDashboards())
await createDashboardFromTemplate(template, orgID)
const updatedState = getState()
const ddDashboard = getAll(updatedState, ResourceType.Dashboards).find(
d => {
d.name === dashboardName
}
) as Dashboard
const allDashboards = getAll<Dashboard>(
updatedState,
ResourceType.Dashboards
)
if (ddDashboard) {
const deleteResp = await deleteDashboard({
dashboardID: ddDashboard.id,
})
if (deleteResp.status !== 204) {
throw new Error(deleteResp.data.message)
}
const createdDashboard = allDashboards.find(
d => d.name === DemoDataDashboards[bucketName]
)
if (!createdDashboard) {
throw new Error(
`Could not create dashboard for demodata bucket ${bucketName}`
)
}
const url = `/orgs/${orgID}/dashboards/${createdDashboard.id}`
dispatch(notify(demoDataSucceeded(bucketName, url)))
} catch (error) {
throw new Error(error)
dispatch(notify(demoDataAddBucketFailed(error)))
reportError(error, {
name: 'getDemoDataBucketMembership function',
})
}
}
@ -160,18 +150,11 @@ export const deleteDemoDataBucketMembership = (bucket: DemoBucket) => async (
}
dispatch(removeBucket(bucket.id))
const demoDashboardName = DemoDataDashboards[bucket.name]
if (!demoDashboardName) {
throw new Error(
`Could not find dashboard name for demo data bucket ${bucket.name}`
)
}
dispatch(deleteDemoDataDashboard(demoDashboardName))
// TODO: notify for success and error appropriately
} catch (error) {
console.error(error)
dispatch(notify(demoDataDeleteBucketFailed(bucket.name, error)))
reportError(error, {
name: 'deleteDemoDataBucketMembership function',
})
}
}

View File

@ -1,56 +1,50 @@
// Libraries
import {get} from 'lodash'
import {getBuckets} from 'src/client'
import {getBuckets, getBucket} from 'src/client'
import AJAX from 'src/utils/ajax'
//Utils
import {isFlagEnabled} from 'src/shared/utils/featureFlag'
//Types
import {Bucket, DemoBucket} from 'src/types'
import {Bucket, DemoBucket, BucketEntities} from 'src/types'
import {LIMIT} from 'src/resources/constants'
import {normalize} from 'normalizr'
import {bucketSchema} from 'src/schemas'
import {NormalizedSchema} from 'normalizr'
const baseURL = '/api/v2/experimental/sampledata'
export const getDemoDataBuckets = async (): Promise<Bucket[]> => {
try {
const {data} = await AJAX({
method: 'GET',
url: `${baseURL}/buckets`,
})
//todo (deniz) convert to fetch
const {data} = await AJAX({
method: 'GET',
url: `${baseURL}/buckets`,
})
// if sampledata endpoints are not available in a cluster
// gateway responds with a list of links where 'buckets' field is a string
const buckets = get(data, 'buckets', false)
if (!Array.isArray(buckets)) {
throw new Error('Could not reach demodata endpoint')
}
return buckets.filter(b => b.type == 'user') as Bucket[] // remove returned _tasks and _monitoring buckets
} catch (error) {
console.error(error)
throw error
// if sampledata endpoints are not available in a cluster
// gateway responds with a list of links where 'buckets' field is a string
const buckets = get(data, 'buckets', null)
if (!Array.isArray(buckets)) {
throw new Error('Could not reach demodata endpoint')
}
return buckets.filter(b => b.type == 'user') as Bucket[] // remove returned _tasks and _monitoring buckets
}
export const getDemoDataBucketMembership = async (
bucketID: string,
userID: string
) => {
try {
const response = await AJAX({
method: 'POST',
url: `${baseURL}/buckets/${bucketID}/members`,
data: {userID},
})
const response = await AJAX({
method: 'POST',
url: `${baseURL}/buckets/${bucketID}/members`,
data: {userID},
})
if (response.status === '200') {
// a failed or successful membership POST to sampledata should return 204
throw new Error('Could not reach demodata endpoint')
}
} catch (error) {
console.error(error)
throw error
if (response.status === '200') {
// a failed or successful membership POST to sampledata should return 204
throw new Error('Could not reach demodata endpoint')
}
}
@ -106,3 +100,30 @@ export const fetchDemoDataBuckets = async (): Promise<Bucket[]> => {
return [] // demodata bucket fetching errors should not effect regular bucket fetching
}
}
export const getNormalizedDemoDataBucket = async (
bucketID: string
): Promise<NormalizedSchema<BucketEntities, string>> => {
const resp = await getBucket({bucketID})
if (resp.status !== 200) {
throw new Error(
`Request for demo data bucket membership did not succeed: ${
resp.data.message
}`
)
}
const newBucket = {
...resp.data,
type: 'demodata' as 'demodata',
labels: [],
} as DemoBucket
const normalizedBucket = normalize<Bucket, BucketEntities, string>(
newBucket,
bucketSchema
)
return normalizedBucket
}

View File

@ -1,20 +0,0 @@
import {get, differenceBy, sortBy} from 'lodash'
import {AppState, Bucket, DemoBucket} from 'src/types'
export const getNewDemoBuckets = (state: AppState, ownBuckets: Bucket[]) => {
const demoDataBuckets = get(
state,
'cloud.demoData.buckets',
[]
) as DemoBucket[]
const newDemoDataBuckets = differenceBy(
demoDataBuckets,
ownBuckets,
b => b.id
)
return sortBy(newDemoDataBuckets, d => {
return d.name.toLocaleLowerCase()
})
}

View File

@ -104,7 +104,7 @@ export class OnboardingWizardPage extends PureComponent<Props, State> {
loading={this.state.loading}
spinnerComponent={<TechnoSpinner />}
>
<Notifications inPresentationMode={true} />
<Notifications />
<OnboardingWizard
onDecrementCurrentStepIndex={this.handleDecrementStepIndex}
onIncrementCurrentStepIndex={this.handleIncrementStepIndex}

View File

@ -0,0 +1,10 @@
.notification--button {
display: inline-block;
margin: $cf-marg-a;
margin-right: 0;
}
.notification--message {
display: inline-block;
margin-right: $cf-marg-a;
}

View File

@ -1,137 +0,0 @@
import React, {Component, CSSProperties} from 'react'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import {Notification as NotificationType} from 'src/types/notifications'
import classnames from 'classnames'
import {dismissNotification as dismissNotificationAction} from 'src/shared/actions/notifications'
import {NOTIFICATION_TRANSITION} from 'src/shared/constants/index'
import {ErrorHandling} from 'src/shared/decorators/errors'
interface Props {
notification: NotificationType
dismissNotification: (id: string) => void
}
interface State {
opacity: number
height: number
dismissed: boolean
}
@ErrorHandling
class Notification extends Component<Props, State> {
private notificationRef: HTMLElement
private dismissalTimer: number
private deletionTimer: number
constructor(props) {
super(props)
this.state = {
opacity: 1,
height: 0,
dismissed: false,
}
}
public componentDidMount() {
const {
notification: {duration},
} = this.props
this.updateHeight()
if (duration >= 0) {
// Automatically dismiss notification after duration prop
this.dismissalTimer = window.setTimeout(this.handleDismiss, duration)
}
}
public componentWillUnmount() {
clearTimeout(this.dismissalTimer)
clearTimeout(this.deletionTimer)
}
public render() {
const {
notification: {message, icon},
} = this.props
return (
<div className={this.containerClassname} style={this.notificationStyle}>
<div
className={this.notificationClassname}
ref={this.handleNotificationRef}
data-testid={this.dataTestID}
>
<span className={`icon ${icon}`} />
<div className="notification-message">{message}</div>
<button className="notification-close" onClick={this.handleDismiss} />
</div>
</div>
)
}
private get dataTestID(): string {
const {style} = this.props.notification
return `notification-${style}`
}
private get notificationClassname(): string {
const {
notification: {style},
} = this.props
return `notification notification-${style}`
}
private get containerClassname(): string {
const {height, dismissed} = this.state
return classnames('notification-container', {
show: !!height,
'notification-dismissed': dismissed,
})
}
private get notificationStyle(): CSSProperties {
return {height: '100%'}
}
private updateHeight = (): void => {
if (this.notificationRef) {
const {height} = this.notificationRef.getBoundingClientRect()
this.setState({height})
}
}
private handleDismiss = (): void => {
const {
notification: {id},
dismissNotification,
} = this.props
this.setState({dismissed: true})
this.deletionTimer = window.setTimeout(
() => dismissNotification(id),
NOTIFICATION_TRANSITION
)
}
private handleNotificationRef = (ref: HTMLElement): void => {
this.notificationRef = ref
this.updateHeight()
}
}
const mapDispatchToProps = dispatch => ({
dismissNotification: bindActionCreators(dismissNotificationAction, dispatch),
})
export default connect(
null,
mapDispatchToProps
)(Notification)

View File

@ -1,196 +0,0 @@
/*
Notifications
-----------------------------------------------------------------------------
*/
$notification-margin: 12px;
.notification-center {
position: fixed;
right: $notification-margin;
width: 360px;
top: $chronograf-page-header-height + $notification-margin;
z-index: 9999;
}
.notification-center__presentation-mode {
@extend .notification-center;
top: $notification-margin;
}
.notification {
border-style: solid;
border-width: 0;
border-radius: $ix-radius;
position: relative;
padding: 12px 40px;
@extend %no-user-select;
transform: translateX(105%);
transition: transform 0.25s ease 0.25s, opacity 0.25s ease;
> span.icon {
position: absolute;
top: 50%;
left: 20px;
transform: translate(-50%, -50%);
font-size: $ix-text-base-2;
}
}
.notification-message {
&:first-letter {
text-transform: uppercase;
}
font-weight: 500;
font-size: 14px;
line-height: 16px;
}
.notification-close {
outline: none;
position: absolute;
top: 50%;
border: 0;
background-color: transparent;
transform: translateY(-50%);
right: ($ix-marg-c - $ix-marg-a);
font-size: $ix-text-base;
width: 20px;
height: 20px;
opacity: 0.25;
transition: opacity 0.25s ease;
&:before,
&:after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 16px;
height: 2px;
border-radius: 1px;
background-color: $g20-white;
}
&:before {
transform: translate(-50%, -50%) rotate(-45deg);
}
&:after {
transform: translate(-50%, -50%) rotate(45deg);
}
&:hover {
cursor: pointer;
opacity: 1;
}
}
.notification-container {
overflow: hidden;
height: 0;
margin-bottom: $ix-marg-a;
transition: height 0.25s ease;
&.show .notification {
transform: translateX(0);
}
&.notification-dismissed {
height: 0 !important;
.notification {
opacity: 0;
}
}
}
// Mixin for Alert Themes
// ----------------------------------------------------------------------------
@mixin notification-styles(
$bg-color,
$bg-color-2,
$text-color,
$link-color,
$link-hover
) {
font-size: 16px;
@include gradient-h($bg-color, $bg-color-2);
color: $text-color;
a:link,
a:visited {
color: $link-color;
font-weight: 700;
text-decoration: underline;
transition: color 0.25s ease;
}
a:hover {
color: $link-hover;
border-color: $link-hover;
}
span.icon {
color: $text-color;
}
.notification-close:before,
.notification-close:after {
background-color: $text-color;
}
}
// Alert Themes
// ----------------------------------------------------------------------------
.notification-success {
@include notification-styles(
$c-rainforest,
$c-pool,
$g20-white,
$c-wasabi,
$g20-white
);
}
.notification-primary {
@include notification-styles(
$c-pool,
$c-ocean,
$g20-white,
$c-neutrino,
$g20-white
);
}
.notification-warning {
@include notification-styles(
$c-star,
$c-pool,
$g20-white,
$c-neutrino,
$g20-white
);
}
.notification-error {
@include notification-styles(
$c-curacao,
$c-star,
$g20-white,
$c-marmelade,
$g20-white
);
}
.notification-info {
@include notification-styles(
$g20-white,
$g16-pearl,
$g8-storm,
$ix-link-default,
$ix-link-default-hover
);
}
.notification-dark {
@include notification-styles(
$c-sapphire,
$c-shadow,
$c-moonstone,
$ix-link-default,
$ix-link-default-hover
);
}
.endpoint-description--textarea {
max-height: 150;
}

View File

@ -1,16 +1,42 @@
import React, {PureComponent} from 'react'
import {Link} from 'react-router'
import {connect} from 'react-redux'
import {Notification as NotificationType} from 'src/types/notifications'
import Notification from 'src/shared/components/notifications/Notification'
import {get} from 'lodash'
interface Props {
//Actions
import {dismissNotification as dismissNotificationAction} from 'src/shared/actions/notifications'
import {Notification, ComponentSize, Gradients} from '@influxdata/clockface'
//Types
import {
Notification as NotificationType,
NotificationStyle,
} from 'src/types/notifications'
interface StateProps {
notifications: NotificationType[]
inPresentationMode: boolean
}
interface DispatchProps {
dismissNotification: typeof dismissNotificationAction
}
type Props = StateProps & DispatchProps
const matchGradientToColor = (style: NotificationStyle): Gradients => {
const converter = {
[NotificationStyle.Primary]: Gradients.Primary,
[NotificationStyle.Warning]: Gradients.WarningLight,
[NotificationStyle.Success]: Gradients.HotelBreakfast,
[NotificationStyle.Error]: Gradients.DangerDark,
[NotificationStyle.Info]: Gradients.DefaultLight,
}
return get(converter, style, Gradients.DefaultLight)
}
class Notifications extends PureComponent<Props> {
public static defaultProps = {
inPresentationMode: false,
notifications: [],
}
@ -18,36 +44,56 @@ class Notifications extends PureComponent<Props> {
const {notifications} = this.props
return (
<div className={this.className}>
{notifications.map(n => (
<Notification key={n.id} notification={n} />
))}
</div>
<>
{notifications.map(
({id, style, icon, duration, message, link, linkText}) => {
const gradient = matchGradientToColor(style)
let button
if (link && linkText) {
button = (
<Link
to={link}
className="notification--button cf-button cf-button-xs cf-button-default"
>
{linkText}
</Link>
)
}
return (
<Notification
key={id}
id={id}
icon={icon}
duration={duration}
size={ComponentSize.ExtraSmall}
gradient={gradient}
onTimeout={this.props.dismissNotification}
onDismiss={this.props.dismissNotification}
testID={`notification-${style}`}
>
<span className="notification--message">{message}</span>
{button}
</Notification>
)
}
)}
</>
)
}
private get className(): string {
const {inPresentationMode} = this.props
if (inPresentationMode) {
return 'notification-center__presentation-mode'
}
return 'notification-center'
}
}
const mapStateToProps = ({
const mapStateToProps = ({notifications}): StateProps => ({
notifications,
app: {
ephemeral: {inPresentationMode},
},
}): Props => ({
notifications,
inPresentationMode,
})
const mdtp: DispatchProps = {
dismissNotification: dismissNotificationAction,
}
export default connect(
mapStateToProps,
null
mdtp
)(Notifications)

View File

@ -36,7 +36,7 @@ export const DASHBOARD_LAYOUT_ROW_HEIGHT = 83.5
export const NOTIFICATION_TRANSITION = 250
export const FIVE_SECONDS = 5000
export const TEN_SECONDS = 10000
export const INFINITE = -1
export const FIFTEEN_SECONDS = 15000
export const HOMEPAGE_PATHNAME = 'me'

View File

@ -2,13 +2,17 @@
import {binaryPrefixFormatter} from '@influxdata/giraffe'
// Types
import {Notification} from 'src/types'
import {NotificationStyle} from 'src/types/notifications'
import {Notification, NotificationStyle} from 'src/types'
// Constants
import {FIVE_SECONDS, TEN_SECONDS, INFINITE} from 'src/shared/constants/index'
import {
FIVE_SECONDS,
TEN_SECONDS,
FIFTEEN_SECONDS,
} from 'src/shared/constants/index'
import {QUICKSTART_SCRAPER_TARGET_URL} from 'src/dataLoaders/constants/pluginConfigs'
import {QUICKSTART_DASHBOARD_NAME} from 'src/onboarding/constants/index'
import {IconFont} from '@influxdata/clockface'
const bytesFormatter = binaryPrefixFormatter({
suffix: 'B',
@ -23,19 +27,19 @@ type NotificationExcludingMessage = Pick<
const defaultErrorNotification: NotificationExcludingMessage = {
style: NotificationStyle.Error,
icon: 'alert-triangle',
icon: IconFont.AlertTriangle,
duration: TEN_SECONDS,
}
const defaultSuccessNotification: NotificationExcludingMessage = {
style: NotificationStyle.Success,
icon: 'checkmark',
icon: IconFont.Checkmark,
duration: FIVE_SECONDS,
}
const defaultDeletionNotification: NotificationExcludingMessage = {
style: NotificationStyle.Primary,
icon: 'trash',
icon: IconFont.Trash,
duration: FIVE_SECONDS,
}
@ -44,8 +48,7 @@ const defaultDeletionNotification: NotificationExcludingMessage = {
export const newVersion = (version: string): Notification => ({
style: NotificationStyle.Info,
icon: 'cubo-uniform',
duration: INFINITE,
icon: IconFont.Cubouniform,
message: `Welcome to the latest Chronograf${version}. Local settings cleared.`,
})
@ -56,21 +59,20 @@ export const loadLocalSettingsFailed = (error: string): Notification => ({
export const presentationMode = (): Notification => ({
style: NotificationStyle.Primary,
icon: 'expand-b',
icon: IconFont.ExpandB,
duration: 7500,
message: 'Press ESC to exit Presentation Mode.',
})
export const sessionTimedOut = (): Notification => ({
style: NotificationStyle.Primary,
icon: 'triangle',
duration: INFINITE,
icon: IconFont.Triangle,
message: 'Your session has timed out. Log in again to continue.',
})
export const resultTooLarge = (bytesRead: number): Notification => ({
style: NotificationStyle.Error,
icon: 'triangle',
icon: IconFont.Triangle,
duration: FIVE_SECONDS,
message: `Large response truncated to first ${bytesFormatter(bytesRead)}`,
})
@ -145,19 +147,19 @@ export const dashboardGetFailed = (
error: string
): Notification => ({
...defaultErrorNotification,
icon: 'dash-h',
icon: IconFont.DashH,
message: `Failed to load dashboard with id "${dashboardID}": ${error}`,
})
export const dashboardUpdateFailed = (): Notification => ({
...defaultErrorNotification,
icon: 'dash-h',
icon: IconFont.DashH,
message: 'Could not update dashboard',
})
export const dashboardDeleted = (name: string): Notification => ({
...defaultSuccessNotification,
icon: 'dash-h',
icon: IconFont.DashH,
message: `Dashboard ${name} deleted successfully.`,
})
@ -194,7 +196,7 @@ export const cellAdded = (
dashboardName?: string
): Notification => ({
...defaultSuccessNotification,
icon: 'dash-h',
icon: IconFont.DashH,
message: `Added new cell ${cellName + ' '}to dashboard ${dashboardName}`,
})
@ -217,7 +219,7 @@ export const cellUpdateFailed = (): Notification => ({
export const cellDeleted = (): Notification => ({
...defaultDeletionNotification,
icon: 'dash-h',
icon: IconFont.DashH,
duration: 1900,
message: `Cell deleted from dashboard.`,
})
@ -235,7 +237,7 @@ export const removedDashboardLabelFailed = (): Notification => ({
// Variables & URL Queries
export const invalidTimeRangeValueInURLQuery = (): Notification => ({
...defaultErrorNotification,
icon: 'cube',
icon: IconFont.Cube,
message: `Invalid URL query value supplied for lower or upper time range.`,
})
@ -251,37 +253,37 @@ export const getVariableFailed = (): Notification => ({
export const createVariableFailed = (error: string): Notification => ({
...defaultErrorNotification,
icon: 'cube',
icon: IconFont.Cube,
message: `Failed to create variable: ${error}`,
})
export const createVariableSuccess = (name: string): Notification => ({
...defaultSuccessNotification,
icon: 'cube',
icon: IconFont.Cube,
message: `Successfully created new variable: ${name}.`,
})
export const deleteVariableFailed = (error: string): Notification => ({
...defaultErrorNotification,
icon: 'cube',
icon: IconFont.Cube,
message: `Failed to delete variable: ${error}`,
})
export const deleteVariableSuccess = (): Notification => ({
...defaultSuccessNotification,
icon: 'cube',
icon: IconFont.Cube,
message: 'Successfully deleted the variable',
})
export const updateVariableFailed = (error: string): Notification => ({
...defaultErrorNotification,
icon: 'cube',
icon: IconFont.Cube,
message: `Failed to update variable: ${error}`,
})
export const updateVariableSuccess = (name: string): Notification => ({
...defaultSuccessNotification,
icon: 'cube',
icon: IconFont.Cube,
message: `Successfully updated variable: ${name}.`,
})
@ -290,7 +292,7 @@ export const copyToClipboardSuccess = (
title: string = ''
): Notification => ({
...defaultSuccessNotification,
icon: 'dash-h',
icon: IconFont.Cube,
type: 'copyToClipboardSuccess',
message: `${title} '${text}' has been copied to clipboard.`,
})
@ -448,6 +450,32 @@ export const getBucketFailed = (
message: `Failed to fetch bucket with id ${bucketID}: ${error}`,
})
// Demodata buckets
export const demoDataAddBucketFailed = (error: string): Notification => ({
...defaultErrorNotification,
message: error,
})
export const demoDataDeleteBucketFailed = (
bucketName: string,
error: string
): Notification => ({
...defaultErrorNotification,
message: `Failed to delete demo data bucket: ${bucketName}: ${error}`,
})
export const demoDataSucceeded = (
bucketName: string,
link: string
): Notification => ({
...defaultSuccessNotification,
message: `Successfully added demodata bucket ${bucketName}, and demodata dashboard.`,
duration: FIFTEEN_SECONDS,
linkText: 'Go to dashboard',
link,
})
// Limits
export const readWriteCardinalityLimitReached = (
message: string
@ -537,7 +565,7 @@ export const taskUpdateSuccess = (): Notification => ({
export const taskImportFailed = (errorMessage: string): Notification => ({
...defaultErrorNotification,
duration: INFINITE,
duration: undefined,
message: `Failed to import Task: ${errorMessage}.`,
})

View File

@ -6,6 +6,8 @@ import {
import {notify, dismissNotification} from 'src/shared/actions/notifications'
import {FIVE_SECONDS} from 'src/shared/constants/index'
import {IconFont} from '@influxdata/clockface'
import {NotificationStyle} from 'src/types/notifications'
const notificationID = '000'
@ -15,7 +17,7 @@ const exampleNotification = {
style: NotificationStyle.Success,
message: 'Hell yeah you are a real notification!',
duration: FIVE_SECONDS,
icon: 'zap',
icon: IconFont.Zap,
}
const exampleNotifications = [exampleNotification]
@ -41,7 +43,7 @@ describe('Shared.Reducers.notifications', () => {
style: NotificationStyle.Error,
message: 'new notification',
duration: FIVE_SECONDS,
icon: 'zap',
icon: IconFont.Zap,
}
const actual = notificationsReducer(

View File

@ -13,7 +13,6 @@
@import 'src/shared/components/ColorDropdown.scss';
@import 'src/shared/components/avatar/Avatar.scss';
@import 'src/shared/components/tables/TableGraphs.scss';
@import 'src/shared/components/notifications/Notifications.scss';
@import 'src/shared/components/graph_tips/GraphTips.scss';
@import 'src/shared/components/cells/Dashboards.scss';
@import 'src/shared/components/code_mirror/CodeMirror.scss';
@ -123,12 +122,13 @@
@import 'src/clientLibraries/components/ClientLibraryOverlay.scss';
@import 'src/dashboards/components/DashboardsCardGrid.scss';
@import 'src/dashboards/components/DashboardLightMode.scss';
@import 'src/buckets/components/DemoDataDropdown.scss';
@import 'src/shared/components/notifications/Notification.scss';
// External
@import '../../node_modules/@influxdata/react-custom-scrollbars/dist/styles.css';
// TODO: delete this later when it's addressed in Clockface
.cf-resource-card {
margin-bottom: $cf-border;
margin-bottom: $cf-border;
}

View File

@ -1,14 +1,17 @@
import {Action} from 'src/shared/actions/notifications'
import {IconFont} from '@influxdata/clockface'
export type NotificationAction = Action
export interface Notification {
id?: string
style: NotificationStyle
icon: string
duration: number
icon: IconFont
duration?: number
message: string
type?: string
link?: string
linkText?: string
}
export enum NotificationStyle {