diff --git a/.gitignore b/.gitignore index f0413244..6e4b76d6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ /docs/htmlcov /node_modules/ /vue/dist/ +/vue/dist-tests/ diff --git a/package.json b/package.json index a0dee377..0e22fa7d 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/vue.config.js b/vue.config.js index 51d0f192..a4b60767 100644 --- a/vue.config.js +++ b/vue.config.js @@ -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 = { @@ -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, @@ -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. diff --git a/vue/src/App.vue b/vue/src/App.vue index b6b58cd0..11f0f5c2 100644 --- a/vue/src/App.vue +++ b/vue/src/App.vue @@ -76,13 +76,12 @@ - + - diff --git a/vue/src/assets/locales/i18n/en.json b/vue/src/assets/locales/i18n/en.json index 7198bb7f..7da78846 100644 --- a/vue/src/assets/locales/i18n/en.json +++ b/vue/src/assets/locales/i18n/en.json @@ -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", @@ -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", diff --git a/vue/src/assets/locales/i18n/qqq.json b/vue/src/assets/locales/i18n/qqq.json index e14c3f99..e4b06535 100644 --- a/vue/src/assets/locales/i18n/qqq.json +++ b/vue/src/assets/locales/i18n/qqq.json @@ -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.", @@ -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", diff --git a/vue/src/assets/styles/index.css b/vue/src/assets/styles/index.css index 2dcb8b1f..ace3ea10 100644 --- a/vue/src/assets/styles/index.css +++ b/vue/src/assets/styles/index.css @@ -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; +} diff --git a/vue/src/main.js b/vue/src/main.js index 9e15946f..003220d2 100644 --- a/vue/src/main.js +++ b/vue/src/main.js @@ -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, diff --git a/vue/src/plugins/notify/component.vue b/vue/src/plugins/notify/component.vue new file mode 100644 index 00000000..0cb549ab --- /dev/null +++ b/vue/src/plugins/notify/component.vue @@ -0,0 +1,47 @@ + + + diff --git a/vue/src/plugins/notify/index.js b/vue/src/plugins/notify/index.js new file mode 100644 index 00000000..54ba77f1 --- /dev/null +++ b/vue/src/plugins/notify/index.js @@ -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 +}; diff --git a/vue/src/plugins/notify/index.spec.js b/vue/src/plugins/notify/index.spec.js new file mode 100644 index 00000000..83218e52 --- /dev/null +++ b/vue/src/plugins/notify/index.spec.js @@ -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' ); + } ); + } ); + +} ); diff --git a/vue/src/plugins/notify/vuex.js b/vue/src/plugins/notify/vuex.js new file mode 100644 index 00000000..871e4d76 --- /dev/null +++ b/vue/src/plugins/notify/vuex.js @@ -0,0 +1,160 @@ +let globalIdCounter = Date.now(); + +export const getters = {}; + +export const actions = { + /** + * Post a message. + * + * @param {Object} context - Vuex context + * @param {Object} payload + * @param {string|null} payload.message - content + * @param {string|null} payload.type - success, info, warning, error + * @param {boolean|null} payload.prominent - Draw more attention + * @param {number|null} payload.timeout - Dismiss after N milliseconds + * @return {number} Message id + */ + message( context, payload ) { + const mid = globalIdCounter++; + if ( payload.timeout ) { + payload.timeout = window.setTimeout( () => { + context.commit( 'onClearMessage', mid ); + }, Number( payload.timeout ) ); + } + context.commit( 'onMessage', { + id: mid, + message: payload.message, + type: payload.type, + prominent: payload.prominent, + timeoutID: payload.timeout || null + } ); + return mid; + }, + + /** + * Announce a successful action. + * + * @param {Object} context - Vuex context + * @param {Object} payload + * @param {string} payload.message - Content + * @param {number|null} payload.timeout - Time in milliseconds to display + * @return {number} Message id + */ + success( context, payload ) { + return context.dispatch( 'message', { + message: payload.message, + timeout: payload.timeout || null, + type: 'success' + } ); + }, + + /** + * Present information to the user. + * + * @param {Object} context - Vuex context + * @param {Object} payload + * @param {string} payload.message - Content + * @param {number|null} payload.timeout - Time in milliseconds to display + * @return {number} Message id + */ + info( context, payload ) { + return context.dispatch( 'message', { + message: payload.message, + timeout: payload.timeout || null, + type: 'info' + } ); + }, + + /** + * Raise a warning. + * + * @param {Object} context - Vuex context + * @param {string} msg - Warning + * @return {number} Message id + */ + warning( context, msg ) { + return context.dispatch( 'message', { + message: msg, + type: 'warning', + prominent: true + } ); + }, + + /** + * Signal an error. + * + * @param {Object} context - Vuex context + * @param {string} msg - Error message + * @return {number} Message id + */ + error( context, msg ) { + return context.dispatch( 'message', { + message: msg, + type: 'error', + prominent: true + } ); + }, + + /** + * Remove a message. + * + * @param {Object} context - Vuex context + * @param {number} messageId - Message id + */ + clearMessage( context, messageId ) { + context.commit( 'onClearMessage', messageId ); + } +}; + +export const mutations = { + /** + * Persist a message. + * + * @param {Object} state - Vuex state tree. + * @param {Object} payload - mutation payload. + * @param {number} payload.id - message id + * @param {string|null} payload.message - content + * @param {string|null} payload.type - success, info, warning, error + * @param {boolean|null} payload.prominent - Draw more attention + * @param {number|null} payload.timeoutID - Active timer id + */ + onMessage( state, payload ) { + if ( payload && payload.id ) { + state.messages.push( { + id: payload.id, + message: payload.message, + type: payload.type, + prominent: payload.prominent, + timeoutID: payload.timeoutID || false + } ); + } + }, + + /** + * Remove a message. + * + * @param {Object} state - Vuex state tree. + * @param {number} messageId - Message id + */ + onClearMessage( state, messageId ) { + if ( state.messages && state.messages.length ) { + state.messages = state.messages.filter( ( item ) => { + if ( item.id === messageId && item.timeoutID ) { + window.clearTimeout( item.timeoutID ); + } + return item.id !== messageId; + } ); + } + } +}; + +export default { + namespaced: true, + state: { + messages: [] + }, + getters: getters, + actions: actions, + mutations: mutations, + strict: process.env.NODE_ENV !== 'production' +}; diff --git a/vue/src/plugins/notify/vuex.spec.js b/vue/src/plugins/notify/vuex.spec.js new file mode 100644 index 00000000..d6498ae4 --- /dev/null +++ b/vue/src/plugins/notify/vuex.spec.js @@ -0,0 +1,126 @@ +'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 { actions, mutations } from './vuex'; + +describe( 'notify/vuex', () => { + describe( 'actions', () => { + it( 'message', () => { + const commit = sinon.spy(); + + const payload = { + message: 'test-message', + type: 'info', + prominent: false + }; + + const mid = actions.message( { commit }, payload ); + + expect( commit ).to.have.been.calledOnce; + expect( commit ).to.have.been.calledWithExactly( + 'onMessage', + // eslint-disable-next-line es/no-object-assign + Object.assign( payload, { id: mid, timeoutID: null } ) + ); + } ); + + it( 'message with timeout', () => { + const commit = sinon.spy(); + const expectTimeout = 12345; + const setTimeout = sinon.stub( window, 'setTimeout' ) + .returns( expectTimeout ); + + const payload = { + message: 'test-message', + type: 'info', + prominent: false, + timeout: 31337 + }; + + const mid = actions.message( { commit }, payload ); + + expect( setTimeout ).to.have.been.calledOnce; + expect( setTimeout ).to.have.been.calledWithExactly( + sinon.match.func, + 31337 + ); + expect( setTimeout ).to.have.been.calledBefore( commit ); + expect( commit ).to.have.been.calledOnce; + expect( commit ).to.have.been.calledWithExactly( + 'onMessage', { + id: mid, + message: payload.message, + type: payload.type, + prominent: payload.prominent, + timeoutID: expectTimeout + } + ); + } ); + + it( 'clearMessage', () => { + const commit = sinon.spy(); + + actions.clearMessage( { commit }, 31337 ); + + expect( commit ).to.have.been.calledOnce; + expect( commit ).to.have.been.calledWithExactly( + 'onClearMessage', 31337 + ); + } ); + } ); + + describe( 'mutations', () => { + it( 'onMessage', () => { + const state = { messages: [] }; + + const payload = { + id: 10100111001, + message: 'test-message', + type: 'success', + prominent: false, + timeoutID: false + }; + mutations.onMessage( state, payload ); + + expect( state.messages ).to.deep.equal( [ payload ] ); + } ); + + it( 'onClearMessage', () => { + const state = { messages: [ { id: 31337 } ] }; + + mutations.onClearMessage( state, 31337 ); + expect( state.messages ).to.be.an( 'array' ).that.is.empty; + } ); + + it( 'onClearMessage with unknown id', () => { + const state = { messages: [] }; + + mutations.onClearMessage( state, 1337 ); + expect( state.messages ).to.be.an( 'array' ).that.is.empty; + } ); + + it( 'onClearMessage with timeout', () => { + const clearTimeout = sinon.stub( window, 'clearTimeout' ); + + const state = { messages: [ + { id: 1, timeoutID: false }, + { id: 2, timeoutID: 1337 }, + { id: 3, timeoutID: false } + ] }; + + mutations.onClearMessage( state, 2 ); + + expect( clearTimeout ).to.have.been.calledOnce; + expect( clearTimeout ).to.have.been.calledWithExactly( 1337 ); + expect( state.messages ).to.be.an( 'array' ) + .that.has.lengthOf( 2 ) + .but.not.deep.include( { id: 2, timeoutID: 1337 } ); + } ); + } ); + +} ); diff --git a/vue/src/store/auditlogs.js b/vue/src/store/auditlogs.js index ff0553ee..18e2db88 100644 --- a/vue/src/store/auditlogs.js +++ b/vue/src/store/auditlogs.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import SwaggerClient from 'swagger-client'; +import i18n from '@/plugins/i18n'; Vue.use( Vuex ); @@ -8,17 +9,12 @@ export default { namespaced: true, state: { auditLogs: [], - numLogs: 0, - apiErrorMsg: '' + numLogs: 0 }, mutations: { AUDIT_LOGS( state, logs ) { state.auditLogs = logs.results; state.numLogs = logs.count; - state.apiErrorMsg = ''; - }, - ERROR( state, error ) { - state.apiErrorMsg = error; } }, actions: { @@ -45,7 +41,9 @@ export default { const resp = await context.dispatch( 'makeApiCall', url ); if ( resp.error ) { - context.commit( 'ERROR', resp.error ); + this._vm.$notify.error( + i18n.t( 'auditlogs-apierror', [ page, resp.error ] ) + ); return; } diff --git a/vue/src/views/AuditLogs.vue b/vue/src/views/AuditLogs.vue index 08044d30..ad9418b5 100644 --- a/vue/src/views/AuditLogs.vue +++ b/vue/src/views/AuditLogs.vue @@ -14,19 +14,6 @@ - - - - {{ $t( 'apierror' ) }} {{ apiErrorMsg }} - - - -