Creates a banner alerts component to handle banner notifications

This commit is contained in:
cptran777 2018-05-01 11:37:30 -07:00
parent e23f2744cf
commit 9ca78d9570
13 changed files with 349 additions and 14 deletions

View File

@ -0,0 +1,49 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { computed, get, set } from '@ember/object';
import ComputedProperty from '@ember/object/computed';
import BannerService, { IBanner } from 'wherehows-web/services/banners';
export default class BannerAlerts extends Component {
/**
* Imports the service used to handle actual activation and dismissal of banners. The service also
* maintains the banners list
* @type {Serivce}
*/
banners: ComputedProperty<BannerService> = service();
/**
* Sets the tagname for the html element rendered by this component
*/
tagName = 'section';
/**
* Sets the classnames to attach to the html element rendered by this component
*/
classNames = ['banner-alerts'];
/**
* Binds classnames to specific truthy values of properties on this component
*/
classNameBindings = ['isShowingBanners:banner-alerts--show:banner-alerts--hide'];
/**
* References the banners service computation on whether or not we should be showing banners
*/
isShowingBanners: ComputedProperty<boolean> = computed.alias('banners.isShowingBanners');
actions = {
/**
* Triggered by the user by clicking the dismiss icon on the banner, triggers the exiting state on the
* topmost (first in queue) banner and starts the timer/css animation for the dismissal action
* @param this - explicit this declaration for typescript
* @param {IBanner} banner - the banner as a subject for the dismissal action
*/
onDismissBanner(this: BannerAlerts, banner: IBanner) {
const banners = get(this, 'banners');
set(banner, 'isExiting', true);
banners.dequeue();
}
};
}

View File

@ -22,6 +22,12 @@ export default Controller.extend({
notifications: service(),
/**
* Adds the service for banners in order to trigger the application to render the banners when
* they are triggered
*/
banners: service(),
init() {
this._super(...arguments);

View File

@ -0,0 +1,87 @@
import Service from '@ember/service';
import { computed, get } from '@ember/object';
import { NotificationEvent } from 'wherehows-web/services/notifications';
import { delay } from 'wherehows-web/utils/promise-delay';
/**
* Expected properties to be found on a basic banner object added to our list
*/
export interface IBanner {
content: string;
type: NotificationEvent;
isExiting: boolean;
isDismissable: boolean;
iconName: string;
}
/**
* When creating a new banner, helps to determine whether that banner isDismissable
*/
const isDismissableMap: { [key: string]: boolean } = {
[NotificationEvent['info']]: true,
[NotificationEvent['confirm']]: true,
[NotificationEvent['error']]: false
};
/**
* When creating a new banner, helps to determine the kind of font awesome icon indicator will be on
* the left side of the banner
*/
const iconNameMap: { [key: string]: string } = {
[NotificationEvent['info']]: 'info-circle',
[NotificationEvent['confirm']]: 'exclamation-circle',
[NotificationEvent['error']]: 'times-circle'
};
export default class BannerService extends Service {
/**
* Our cached list of banner objects to be rendered
* @type {Array<IBanner>}
*/
banners: Array<IBanner> = [];
/**
* Computes the banners list elements to toggle the isShowingBanners flag, which not only activates the
* banner component but also triggers the navbar and app-container body to shift up/down to accommodate
* for the banner component space
* @type {ComputedProperty<boolean>}
*/
isShowingBanners = computed('banners.@each.isExiting', function() {
const banners = get(this, 'banners');
// Note: If we have no banners, flag should always be false. If we have more than one banner, flag
// should always be true, BUT if we only have one banner and it is in an exiting state we can go ahead
// and trigger this to be false to line up with the animation
return banners.length > 0 && (banners.length > 1 || !banners[0].isExiting);
});
/**
* Method to actually take care of removing the first banner from our queue.
*/
async dequeue(): Promise<void> {
// Note: Since dequeuing the banner will remove it from the DOM, we don't want to actually dequeue
// until the removal animation, which takes 0.7 seconds, is completed.
const animationSpeed = 0.8;
const dismissDelay = delay(animationSpeed);
const banners = get(this, 'banners');
await dismissDelay;
banners.removeAt(0, 1);
}
/**
* Method to add the banner to our queue. Takes the content and type of banner and creates a
* standardized interface that our servie can understand
* @param message - the message to put in the banner's content box
* @param {NotificationEvent} type - what type of banner notification we are going for (which will
* determine the appearance and interaction properties)
*/
addBanner(message: string, type: NotificationEvent = NotificationEvent['info']): void {
get(this, 'banners').addObject({
type,
content: message,
isExiting: false,
isDismissable: isDismissableMap[type],
iconName: iconNameMap[type]
});
}
}

View File

@ -11,13 +11,21 @@ html {
* Apply padding to top and bottom to account for navigation and footer
*/
body {
padding-top: $nav-min-height;
padding-bottom: $nav-min-height;
background-color: set-color(white, base);
overflow-y: scroll;
overflow-x: hidden;
}
.app-container {
margin-top: $nav-min-height;
margin-bottom: $nav-min-height;
transition: margin 0.7s ease;
&.banner-alert-offset {
margin-top: $nav-min-height + 52px;
}
}
/**
* Make all elements from the DOM inherit from the parent box-sizing
* Since `*` has a specificity of 0, it does not override the `html` value

View File

@ -1,5 +1,6 @@
@import 'navbar';
@import 'hero';
@import 'application/all';
@import 'avatar/all';
@import 'dataset-author/all';
@import 'dataset-compliance/all';

View File

@ -11,6 +11,7 @@ $item-spacing: 10px;
* Explicitly sets the .navbar min-height to a shared value
*/
min-height: $nav-min-height;
transition: top 0.7s ease;
&-inverse {
/**
@ -32,6 +33,10 @@ $item-spacing: 10px;
}
}
}
&.navbar-top-offset {
top: 52px;
}
}
/**

View File

@ -0,0 +1 @@
@import 'banner-alerts';

View File

@ -0,0 +1,82 @@
.banner-alerts {
position: fixed;
top: 0;
width: 100vw;
transition: height 0.6s ease;
overflow: hidden;
&--show {
height: 52px;
}
&--hide {
height: 0;
}
.banner-alert {
transition: height 0.6s ease;
position: fixed;
top: 0;
left: 0;
width: 100vw;
min-width: 1128px;
overflow: hidden;
padding: 8px 24px;
display: flex;
flex-wrap: nowrap;
align-items: center;
color: get-color(white);
min-height: 0;
&--active {
height: 52px;
}
&--exiting {
height: 0;
padding: 0 24px;
}
&--info {
background-color: get-color(slate6);
border-bottom: 1px solid get-color(slate3, 0.5);
}
&--confirm {
background-color: get-color(orange5);
border-bottom: 1px solid get-color(orange3, 0.5);
}
&--error {
background-color: get-color(red5);
border-bottom: 1px solid get-color(red3, 0.5);
}
&:first-child {
z-index: 999;
}
&:nth-child(2) {
z-index: 99;
}
&:nth-child(n + 3) {
z-index: 1;
}
&__icon {
margin-right: 16px;
}
&__content {
width: 90vw;
min-width: 1056px;
}
&__dismiss {
background-color: transparent;
border: none;
cursor: pointer;
}
}
}

View File

@ -1,18 +1,22 @@
{{#if session.isAuthenticated}}
{{application/banner-alerts}}
{{partial "navbar"}}
{{#hero-container}}
<header class="nacho-hero__header">
Search for datasets, metrics and flows
</header>
<section class="nacho-hero__content">
{{search-bar-form didSearch=(action "didSearch")}}
</section>
{{/hero-container}}
<div class="app-container {{if banners.isShowingBanners "banner-alert-offset"}}">
{{#hero-container}}
<header class="nacho-hero__header">
Search for datasets, metrics and flows
</header>
<section class="nacho-hero__content">
{{search-bar-form didSearch=(action "didSearch")}}
</section>
{{/hero-container}}
<section class="container-fluid">
{{partial "main"}}
</section>
<section class="container-fluid">
{{partial "main"}}
</section>
</div>
{{notifications-service service=notifications}}
{{else}}

View File

@ -0,0 +1,13 @@
{{#each banners.banners as |banner|}}
<div class="banner-alert {{if banner.isExiting "banner-alert--exiting" "banner-alert--active"}} banner-alert--{{banner.type}}">
{{fa-icon banner.iconName class="banner-alert__icon"}}
<div class="banner-alert__content">
{{banner.content}}
</div>
{{#if banner.isDismissable}}
<button {{action "onDismissBanner" banner}} class="banner-alert__dismiss">
{{fa-icon "close"}}
</button>
{{/if}}
</div>
{{/each}}

View File

@ -1,4 +1,4 @@
<nav class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<nav class="navbar navbar-inverse navbar-fixed-top {{if banners.isShowingBanners "navbar-top-offset"}}" role="navigation">
<div class="container">
<div class="navbar-header">
<button class="navbar-toggle collapsed" aria-expanded="false" aria-controls="navbar">

View File

@ -0,0 +1,56 @@
import { moduleForComponent, test } from 'ember-qunit';
import Service from '@ember/service';
import hbs from 'htmlbars-inline-precompile';
const banners = [
{
content: 'He told ne enough! He told me you killed him!',
type: 'info',
isExiting: false,
isDimissable: true,
iconName: 'info-circle'
},
{
content: 'No, Luke, I am your father',
type: 'confirm',
isExiting: false,
isDimissable: true,
iconName: 'exclamation-circle'
}
];
// Stubbing the banner service to use in the integration test
const bannersStub = Service.extend({
banners,
dequeue() {
return true;
}
});
moduleForComponent('application/banner-alerts', 'Integration | Component | application/banner alerts', {
integration: true,
beforeEach() {
this.register('service:banners', bannersStub);
this.inject.service('banners', { as: 'banners' });
}
});
const bannerAlertClass = '.banner-alert';
test('it renders', function(assert) {
this.render(hbs`{{application/banner-alerts}}`);
assert.ok(this.$(), 'Renders without errors');
});
test('it renders the correct information', function(assert) {
this.render(hbs`{{application/banner-alerts}}`);
assert.equal(this.$(bannerAlertClass).length, 2, 'Renders the correct amount of banners');
assert.equal(
this.$(`${bannerAlertClass}__content:eq(0)`)
.text()
.trim(),
banners[0].content,
'Renders the correct text'
);
assert.equal(this.$(`.fa-${banners[0].iconName}`).length, 1, 'Renders the correct types of banners');
});

View File

@ -0,0 +1,23 @@
import { moduleFor, test } from 'ember-qunit';
moduleFor('service:banners', 'Unit | Service | banners', {});
test('it exists', function(assert) {
const service = this.subject();
assert.ok(service, 'Existence is a good start');
});
test('it operates correctly', function(assert) {
const service = this.subject();
const message = 'Ash Ketchum from Pallet Town';
service.addBanner('Ash Ketchum from Pallet Town', 'info');
assert.equal(service.banners.length, 1, 'Created a banner');
assert.equal(service.banners[0].content, message, 'Creates a banner with the right message');
assert.equal(service.banners[0].isDismissable, true, 'Creates a banner with the right dismiss');
service.dequeue().then(() => {
assert.equal(service.banners.length, 0, 'Removes a banner correctly');
});
});