Skip to content

Commit

Permalink
ui: Add a global notification component
Browse files Browse the repository at this point in the history
plugins/notify is a new locally developed vue + vuetify + vuex plugin
implementing a global notification service.

* <Notifications /> displays notifications sent to the user.
* Vue.$notify.{ success, info, warning, error } allow easy addition of
  messages from anywhere with access to the Vue object (often via `this`
  in a .vue component).
* The 'notify' vuex module can be called directly to send notices from
  a vuex context.
* Notifications stay on-screen until manually dismissed by default.
* An optional timeout can be provided to automatically dismiss success
  and info notifications.

This plugin is installed and the Notifications component has been added
to the root App. Notifications are displayed using v-alert vutify
components in a container attached to the bottom of the user's viewport.

This commit also converts the AuditLogs view and its associated vuex
store to use the new system rather than it's own error tracking and
display code. Follow up commits should convert other views.

Bug: T272185
Change-Id: I30e3aeb9bd7450d393ca48d7b511393f988a2cc8
  • Loading branch information
bd808 authored and srish committed Jan 26, 2021
1 parent 97bfdf1 commit ca4bde5
Show file tree
Hide file tree
Showing 15 changed files with 565 additions and 38 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
/docs/htmlcov
/node_modules/
/vue/dist/
/vue/dist-tests/
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
"lint:banana": "banana-checker vue/src/assets/locales/i18n/",
"lint:css-rtl": "bash -c \"if grep -rnHIE --color '\\b[mp][lr]-(n?[0-9][0-6]?|auto)\\b' vue/src; then echo 'ERROR: Use S and E helper classes, not L and R. (T269056)'; echo; exit 1; fi\"",
"lint:eslint": "eslint .",
"lint:stylelint": "stylelint -f verbose \"**/*.{css,scss,sass,vue}\"",
"lint:stylelint": "stylelint -f verbose '**/*.{css,scss,sass,vue}'",
"lint:vue": "vue-cli-service lint",
"schemas:generate": "jsonschema-tools materialize-all",
"serve:vue": "vue-cli-service serve",
"test": "npm run test:jsonschema && npm run test:vue",
"test:jsonschema": "mocha tests/jsonschema",
"test:vue": "nyc vue-cli-service test:unit --colors vue/src/**/*.spec.js"
"test:vue": "NODE_ENV=test nyc vue-cli-service test:unit --colors 'vue/src/**/*.spec.js'"
},
"dependencies": {
"@wikimedia/language-data": "^1.0.0",
Expand Down Expand Up @@ -141,6 +141,7 @@
"rules": {
"camelcase": "off",
"no-undef": "off",
"no-underscore-dangle": [ "error", { "allowAfterThis": true } ],
"vue/singleline-html-element-content-newline": "off",
"vuetify/grid-unknown-attributes": "error",
"vuetify/no-deprecated-classes": "error",
Expand Down
33 changes: 20 additions & 13 deletions vue.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const path = require( 'path' );
const BundleTracker = require( 'webpack-bundle-tracker' );

const isProduction = process.env.NODE_ENV === 'production';
const isTest = process.env.NODE_ENV === 'test';
const PORT = process.env.PORT || 8001;

const pages = {
Expand All @@ -14,7 +15,11 @@ const pages = {
}
};

const buildDir = 'vue/dist';
// Keep bundles built for running test separate from bundles for the dev/prod
// server. Test bundles skip building somethings and do not split into the
// same chunks as dev/prod bundles. Why? Good question. Webpack gets angry is
// the best answer I have at the moment. :/
const buildDir = isTest ? 'vue/dist-tests' : 'vue/dist';

module.exports = {
pages: pages,
Expand All @@ -35,20 +40,22 @@ module.exports = {
devtool: isProduction ? false : 'cheap-source-map'
},
chainWebpack: ( config ) => {
// Separate vendored js into its own bundle
config.optimization.splitChunks(
{
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'chunk-vendors',
chunks: 'all',
priority: 1,
enforce: true
if ( !isTest ) {
// Separate vendored js into its own bundle
config.optimization.splitChunks(
{
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'chunk-vendors',
chunks: 'all',
priority: 1,
enforce: true
}
}
}
}
);
);
}

// We are not serving pages directly, so remove plugins that generate
// stubs for pages and including js/css.
Expand Down
3 changes: 1 addition & 2 deletions vue/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,12 @@
</v-list-item>
</v-list>
</v-navigation-drawer>

<v-main>
<v-container fluid>
<router-view />
</v-container>
<Notifications />
</v-main>

<v-footer app />
</v-app>
</template>
Expand Down
2 changes: 2 additions & 0 deletions vue/src/assets/locales/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"auditlog-summary": "$1 a $2",
"auditlogs": "Audit logs",
"auditlogs-pagetitle": "This page lists all log entries",
"auditlogs-apierror": "Error fetching page $1: $2",
"authors": "Author(s)",
"bugtracker": "Bug Tracker",
"browsetool": "Browse tool",
Expand All @@ -40,6 +41,7 @@
"locale-select": "Select language",
"login": "Login",
"logout": "Logout",
"message-close": "Close message",
"moreinfo": "More information",
"mostrecentcrawledurls": "Most recent crawled URL(s)",
"my-account": "My Account",
Expand Down
2 changes: 2 additions & 0 deletions vue/src/assets/locales/i18n/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"auditlog-summary": "Audit log summary label. Parameters:\n* $1 - action taken\n* $2 - object action was taken on",
"auditlogs": "Navigation menu label, links to Audit Logs page.",
"auditlogs-pagetitle": "Page title.",
"auditlogs-apierror": "Error message shown when fetching auditlog information from the backend server fails. Parameters:\n* $1 - numeric page number\n* $2 - error message returned by API call (possibly not localized)",
"authors": "Tool information label.",
"bugtracker": "Link label, links to bug tracker for a tool.",
"browsetool": "Button label, links to an external tool.",
Expand All @@ -44,6 +45,7 @@
"login": "Button label.\n{{Identical:Login}}",
"logout": "Button label.\n{{Identical|Logout}}",
"moreinfo": "Section title.",
"message-close": "Label for button that closes a notiication message.",
"mostrecentcrawledurls": "Table information label.",
"my-account": "Alt text for icon.",
"newtoolsfound": "Crawler run statistics label. Parameters:\n* $1 - number of tools found",
Expand Down
25 changes: 25 additions & 0 deletions vue/src/assets/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,28 @@ div.home-tool-card {
top: 0;
right: 0;
}

#notifications {
/* z-index just below the level of our nav menu */
z-index: 5;
position: fixed;
bottom: 0%;
}

@media only screen and ( min-width: 960px ) {
.v-application--is-ltr #notifications {
margin-left: 28px;
left: 50%;
transform: translateX( -50% );
}

.v-application--is-rtl #notifications {
margin-right: 28px;
right: 50%;
transform: translateX( 50% );
}
}

#notifications .v-alert {
border: thin solid currentColor !important;
}
3 changes: 3 additions & 0 deletions vue/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import router from './router';
import store from './store';
import vuetify from './plugins/vuetify';
import i18n from './plugins/i18n';
import notify from './plugins/notify';

Vue.config.productionTip = false;

Vue.use( notify, { store: store } );

new Vue( {
vuetify,
router,
Expand Down
47 changes: 47 additions & 0 deletions vue/src/plugins/notify/component.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<template>
<v-container id="notifications">
<v-row>
<v-col cols="12">
<v-alert
v-for="message in messages"
:key="message.id"
:type="message.type"
:prominent="message.prominent"
:dense="message.prominent"
border="left"
class="mx-auto"
dismissible
transition="fade-transition"
:close-label="$t( 'message-close' )"
@input="close( message )"
>
{{ message.message }}
</v-alert>
</v-col>
</v-row>
</v-container>
</template>

<script>
import { mapState, mapMutations } from 'vuex';
export default {
computed: {
...mapState( 'notify', [ 'messages' ] )
},
methods: {
...mapMutations( 'notify', [ 'onClearMessage' ] ),
/**
* Remove a message.
*
* @param {Object} payload - Message object
* @param {string} payload.id = Message id
*/
close( payload ) {
this.onClearMessage( payload.id );
}
}
};
</script>
89 changes: 89 additions & 0 deletions vue/src/plugins/notify/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import module from './vuex';
import Notifications from './component';

export let $store;

export function setStore( store ) {
$store = store;
}

export const methods = {
/**
* Signal a successful action.
*
* @param {string} message - Content
* @param {number} [timeout=0] - Time in milliseconds to display
* @return {number} Message id
*/
success( message, timeout = 0 ) {
return $store.dispatch( 'notify/success', { message, timeout } );
},

/**
* Present information to the user.
*
* @param {string} message - Content
* @param {number} [timeout=0] - Time in milliseconds to display
* @return {number} Message id
*/
info( message, timeout = 0 ) {
return $store.dispatch( 'notify/info', { message, timeout } );
},

/**
* Raise a warning.
*
* @param {string} msg - Warning
* @return {number} Message id
*/
warning( msg ) {
return $store.dispatch( 'notify/warning', msg );
},

/**
* Signal an error.
*
* @param {string} msg - Error message
* @return {number} Message id
*/
error( msg ) {
return $store.dispatch( 'notify/error', msg );
},

/**
* Remove a notification.
*
* @param {number} messageId - Message id
* @return {undefined}
*/
clear( messageId ) {
return $store.dispatch( 'notify/clearMessage', messageId );
}
};

/**
* Install this plugin.
*
* @param {Object} Vue - Vue instance installing plugin
* @param {Object} options - Plugin options
* @param {Object} options.store - Vuex store
*/
export function install( Vue, options ) {
if ( install.installed ) {
return;
}
if ( !options.store ) {
throw new Error( 'Required options.store Vuex instance missing.' );
}

install.installed = true;
setStore( options.store );

options.store.registerModule( 'notify', module );
Vue.component( 'Notifications', Notifications );
Vue.prototype.$notify = methods;
}

export default {
install
};
80 changes: 80 additions & 0 deletions vue/src/plugins/notify/index.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
'use strict';
import chai from 'chai';
import sinon from 'sinon';

chai.use( require( 'sinon-chai' ) );
const expect = chai.expect;
/* eslint-disable no-unused-expressions */

import { setStore, methods, install } from './index';

let $store;

describe( 'notify/index', () => {
beforeEach( () => {
$store = { dispatch: sinon.spy() };
setStore( $store );
} );

describe( 'methods', () => {
it( 'success', () => {
methods.success( 'success message' );

expect( $store.dispatch ).to.have.been.calledOnce;
expect( $store.dispatch ).to.have.been.calledWithExactly(
'notify/success', { message: 'success message', timeout: 0 }
);
} );

it( 'info', () => {
methods.info( 'info message' );

expect( $store.dispatch ).to.have.been.calledOnce;
expect( $store.dispatch ).to.have.been.calledWithExactly(
'notify/info', { message: 'info message', timeout: 0 }
);
} );

it( 'warning', () => {
methods.warning( 'warning message' );

expect( $store.dispatch ).to.have.been.calledOnce;
expect( $store.dispatch ).to.have.been.calledWithExactly(
'notify/warning', 'warning message'
);
} );

it( 'error', () => {
methods.error( 'error message' );

expect( $store.dispatch ).to.have.been.calledOnce;
expect( $store.dispatch ).to.have.been.calledWithExactly(
'notify/error', 'error message'
);
} );

it( 'clear', () => {
methods.clear( 31337 );

expect( $store.dispatch ).to.have.been.calledOnce;
expect( $store.dispatch ).to.have.been.calledWithExactly(
'notify/clearMessage', 31337
);
} );
} );

describe( 'install', () => {
it( 'happy path', () => {
const vue = { component: sinon.spy(), prototype: sinon.spy() };
const store = { registerModule: sinon.spy() };

install( vue, { store: store } );

expect( install.installed ).to.be.a( 'boolean' ).that.is.true;
expect( store.registerModule ).to.have.been.calledOnce;
expect( vue.component ).to.have.been.calledOnce;
expect( vue.prototype.$notify ).to.be.an( 'object' );
} );
} );

} );
Loading

0 comments on commit ca4bde5

Please sign in to comment.