mirror of
				https://github.com/datahub-project/datahub.git
				synced 2025-10-31 18:59:23 +00:00 
			
		
		
		
	Creates a banner alerts component to handle banner notifications
This commit is contained in:
		
							parent
							
								
									e23f2744cf
								
							
						
					
					
						commit
						9ca78d9570
					
				
							
								
								
									
										49
									
								
								wherehows-web/app/components/application/banner-alerts.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								wherehows-web/app/components/application/banner-alerts.ts
									
									
									
									
									
										Normal 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(); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | } | ||||||
| @ -22,6 +22,12 @@ export default Controller.extend({ | |||||||
| 
 | 
 | ||||||
|   notifications: service(), |   notifications: service(), | ||||||
| 
 | 
 | ||||||
|  |   /** | ||||||
|  |    * Adds the service for banners in order to trigger the application to render the banners when | ||||||
|  |    * they are triggered | ||||||
|  |    */ | ||||||
|  |   banners: service(), | ||||||
|  | 
 | ||||||
|   init() { |   init() { | ||||||
|     this._super(...arguments); |     this._super(...arguments); | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										87
									
								
								wherehows-web/app/services/banners.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								wherehows-web/app/services/banners.ts
									
									
									
									
									
										Normal 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] | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -11,13 +11,21 @@ html { | |||||||
|  * Apply padding to top and bottom to account for navigation and footer |  * Apply padding to top and bottom to account for navigation and footer | ||||||
|  */ |  */ | ||||||
| body { | body { | ||||||
|   padding-top: $nav-min-height; |  | ||||||
|   padding-bottom: $nav-min-height; |  | ||||||
|   background-color: set-color(white, base); |   background-color: set-color(white, base); | ||||||
|   overflow-y: scroll; |   overflow-y: scroll; | ||||||
|   overflow-x: hidden; |   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 |  * 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 |  * Since `*` has a specificity of 0, it does not override the `html` value | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| @import 'navbar'; | @import 'navbar'; | ||||||
| @import 'hero'; | @import 'hero'; | ||||||
|  | @import 'application/all'; | ||||||
| @import 'avatar/all'; | @import 'avatar/all'; | ||||||
| @import 'dataset-author/all'; | @import 'dataset-author/all'; | ||||||
| @import 'dataset-compliance/all'; | @import 'dataset-compliance/all'; | ||||||
|  | |||||||
| @ -11,6 +11,7 @@ $item-spacing: 10px; | |||||||
|    * Explicitly sets the .navbar min-height to a shared value |    * Explicitly sets the .navbar min-height to a shared value | ||||||
|    */ |    */ | ||||||
|   min-height: $nav-min-height; |   min-height: $nav-min-height; | ||||||
|  |   transition: top 0.7s ease; | ||||||
| 
 | 
 | ||||||
|   &-inverse { |   &-inverse { | ||||||
|     /** |     /** | ||||||
| @ -32,6 +33,10 @@ $item-spacing: 10px; | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   &.navbar-top-offset { | ||||||
|  |     top: 52px; | ||||||
|  |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  | |||||||
| @ -0,0 +1 @@ | |||||||
|  | @import 'banner-alerts'; | ||||||
| @ -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; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -1,18 +1,22 @@ | |||||||
| {{#if session.isAuthenticated}} | {{#if session.isAuthenticated}} | ||||||
|  |   {{application/banner-alerts}} | ||||||
|  | 
 | ||||||
|   {{partial "navbar"}} |   {{partial "navbar"}} | ||||||
| 
 | 
 | ||||||
|   {{#hero-container}} |   <div class="app-container {{if banners.isShowingBanners "banner-alert-offset"}}"> | ||||||
|     <header class="nacho-hero__header"> |     {{#hero-container}} | ||||||
|       Search for datasets, metrics and flows |       <header class="nacho-hero__header"> | ||||||
|     </header> |         Search for datasets, metrics and flows | ||||||
|     <section class="nacho-hero__content"> |       </header> | ||||||
|       {{search-bar-form didSearch=(action "didSearch")}} |       <section class="nacho-hero__content"> | ||||||
|     </section> |         {{search-bar-form didSearch=(action "didSearch")}} | ||||||
|   {{/hero-container}} |       </section> | ||||||
|  |     {{/hero-container}} | ||||||
| 
 | 
 | ||||||
|   <section class="container-fluid"> |     <section class="container-fluid"> | ||||||
|     {{partial "main"}} |       {{partial "main"}} | ||||||
|   </section> |     </section> | ||||||
|  |   </div> | ||||||
| 
 | 
 | ||||||
|   {{notifications-service service=notifications}} |   {{notifications-service service=notifications}} | ||||||
| {{else}} | {{else}} | ||||||
|  | |||||||
| @ -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}} | ||||||
| @ -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="container"> | ||||||
|     <div class="navbar-header"> |     <div class="navbar-header"> | ||||||
|       <button class="navbar-toggle collapsed" aria-expanded="false" aria-controls="navbar"> |       <button class="navbar-toggle collapsed" aria-expanded="false" aria-controls="navbar"> | ||||||
|  | |||||||
| @ -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'); | ||||||
|  | }); | ||||||
							
								
								
									
										23
									
								
								wherehows-web/tests/unit/services/banners-test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								wherehows-web/tests/unit/services/banners-test.js
									
									
									
									
									
										Normal 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'); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 cptran777
						cptran777