Add batch actions to local user table

- Create TableToolbar component for table batch actions
- Added Toast warning type and toast title message translations
- Update vue-i18n package to latest v8.15.3 to use improved
  pluarlization features

Signed-off-by: Yoshie Muranaka <yoshiemuranaka@gmail.com>
Change-Id: I455beba4f56b8209b1201bbc5ff3f616e960d189
diff --git a/package-lock.json b/package-lock.json
index d07b4e7..63763cb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7198,10 +7198,7 @@
         "debug": {
           "version": "4.1.1",
           "resolved": false,
-          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
-          "requires": {
-            "ms": "^2.1.1"
-          }
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw=="
         },
         "deep-extend": {
           "version": "0.6.0",
@@ -7414,11 +7411,6 @@
             "minimist": "0.0.8"
           }
         },
-        "ms": {
-          "version": "2.1.1",
-          "resolved": false,
-          "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg=="
-        },
         "needle": {
           "version": "2.3.0",
           "resolved": false,
@@ -7426,16 +7418,28 @@
           "dev": true,
           "optional": true,
           "requires": {
+            "debug": "^4.1.0",
             "iconv-lite": "^0.4.4",
             "sax": "^1.2.4"
           },
           "dependencies": {
             "debug": {
-              "version": "3.2.6",
-              "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz",
-              "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==",
+              "version": "4.1.1",
+              "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+              "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+              "dev": true,
+              "optional": true,
               "requires": {
                 "ms": "^2.1.1"
+              },
+              "dependencies": {
+                "ms": {
+                  "version": "2.1.2",
+                  "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+                  "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+                  "dev": true,
+                  "optional": true
+                }
               }
             }
           }
diff --git a/package.json b/package.json
index 361adb2..14a0a80 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,7 @@
     "js-cookie": "^2.2.1",
     "vue": "2.6.11",
     "vue-date-fns": "1.1.0",
-    "vue-i18n": "8.0.0",
+    "vue-i18n": "8.15.3",
     "vue-router": "3.1.3",
     "vuelidate": "^0.7.4",
     "vuex": "3.0.1"
diff --git a/src/assets/styles/_form-components.scss b/src/assets/styles/_form-components.scss
index 7194d9e..89abfb3 100644
--- a/src/assets/styles/_form-components.scss
+++ b/src/assets/styles/_form-components.scss
@@ -20,9 +20,6 @@
   color: $gray-900 !important;
   font-size: 16px;
   border-color: $gray-400 !important;
-  &::before {
-    border-color: $primary;
-  }
   &.is-invalid,
   &:invalid {
     border-bottom: 2px solid $danger !important;
diff --git a/src/assets/styles/_table.scss b/src/assets/styles/_table.scss
index ff1ed30..7d265c8 100644
--- a/src/assets/styles/_table.scss
+++ b/src/assets/styles/_table.scss
@@ -1,3 +1,8 @@
+table {
+  position: relative;
+  z-index: $zindex-dropdown;
+}
+
 .table-light {
   td {
     border-top: none;
@@ -18,4 +23,4 @@
     padding-top: 0;
     padding-bottom: 0;
   }
-}
+}
\ No newline at end of file
diff --git a/src/assets/styles/_toast.scss b/src/assets/styles/_toast.scss
index 3f2f08c..538f996 100644
--- a/src/assets/styles/_toast.scss
+++ b/src/assets/styles/_toast.scss
@@ -29,4 +29,8 @@
 
 .b-toast-danger .toast {
   border-left-color: $danger!important;
+}
+
+.b-toast-warning .toast {
+  border-left-color: $warning!important;
 }
\ No newline at end of file
diff --git a/src/components/Global/TableToolbar.vue b/src/components/Global/TableToolbar.vue
new file mode 100644
index 0000000..fc3736d
--- /dev/null
+++ b/src/components/Global/TableToolbar.vue
@@ -0,0 +1,119 @@
+<template>
+  <transition name="slide">
+    <div v-if="isToolbarActive" class="toolbar-container">
+      <div class="toolbar-content">
+        <p class="toolbar-selected">
+          {{ selectedItemsCount }} {{ $t('global.actions.selected') }}
+        </p>
+        <div class="toolbar-actions d-flex">
+          <b-button
+            v-for="(action, index) in actions"
+            :key="index"
+            variant="primary"
+            class="d-block"
+            @click="$emit('batchAction', action.value)"
+          >
+            {{ $t(action.labelKey) }}
+          </b-button>
+          <b-button
+            variant="primary"
+            class="d-block"
+            @click="$emit('clearSelected')"
+          >
+            {{ $t('global.actions.cancel') }}
+          </b-button>
+        </div>
+      </div>
+    </div>
+  </transition>
+</template>
+
+<script>
+export default {
+  name: 'TableToolbar',
+  props: {
+    selectedItemsCount: {
+      type: Number,
+      required: true
+    },
+    actions: {
+      type: Array,
+      required: true,
+      validator: prop => {
+        return prop.every(action => {
+          return (
+            action.hasOwnProperty('value') && action.hasOwnProperty('labelKey')
+          );
+        });
+      }
+    }
+  },
+  data() {
+    return {
+      isToolbarActive: false
+    };
+  },
+  watch: {
+    selectedItemsCount: function(selectedItemsCount) {
+      if (selectedItemsCount > 0) {
+        this.isToolbarActive = true;
+      } else {
+        this.isToolbarActive = false;
+      }
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+$toolbar-height: 46px;
+
+.toolbar-container {
+  width: 100%;
+  position: relative;
+}
+
+.toolbar-content {
+  height: $toolbar-height;
+  background-color: $primary;
+  color: $white;
+  position: absolute;
+  left: 0;
+  right: 0;
+  top: -$toolbar-height;
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+}
+
+.toolbar-actions {
+  > :last-child {
+    position: relative;
+    &::before {
+      content: '';
+      position: absolute;
+      height: $toolbar-height / 2;
+      border-left: 2px solid $white;
+      left: -2px;
+      top: $toolbar-height / 4;
+    }
+  }
+}
+
+.toolbar-selected {
+  line-height: $toolbar-height;
+  margin: 0;
+  padding: 0 $spacer;
+}
+
+.slide-enter-active {
+  transition: transform $duration--moderate-02 $entrance-easing--productive;
+}
+.slide-leave-active {
+  transition: transform $duration--moderate-02 $exit-easing--productive;
+}
+.slide-enter,
+.slide-leave-to {
+  transform: translateY($toolbar-height);
+}
+</style>
diff --git a/src/components/Mixins/BVTableSelectableMixin.js b/src/components/Mixins/BVTableSelectableMixin.js
new file mode 100644
index 0000000..fba2f2b
--- /dev/null
+++ b/src/components/Mixins/BVTableSelectableMixin.js
@@ -0,0 +1,44 @@
+const BVTableSelectableMixin = {
+  data() {
+    return {
+      tableHeaderCheckboxModel: false,
+      tableHeaderCheckboxIndeterminate: false,
+      selectedRows: []
+    };
+  },
+  methods: {
+    clearSelectedRows(tableRef) {
+      if (tableRef) tableRef.clearSelected();
+    },
+    toggleSelectRow(tableRef, rowIndex) {
+      if (tableRef && rowIndex !== undefined) {
+        tableRef.isRowSelected(rowIndex)
+          ? tableRef.unselectRow(rowIndex)
+          : tableRef.selectRow(rowIndex);
+      }
+    },
+    onRowSelected(selectedRows, totalRowsCount) {
+      if (selectedRows && totalRowsCount !== undefined) {
+        this.selectedRows = selectedRows;
+        if (selectedRows.length === 0) {
+          this.tableHeaderCheckboxIndeterminate = false;
+          this.tableHeaderCheckboxModel = false;
+        } else if (selectedRows.length === totalRowsCount) {
+          this.tableHeaderCheckboxIndeterminate = false;
+          this.tableHeaderCheckboxModel = true;
+        } else {
+          this.tableHeaderCheckboxIndeterminate = true;
+          this.tableHeaderCheckboxModel = false;
+        }
+      }
+    },
+    onChangeHeaderCheckbox(tableRef) {
+      if (tableRef) {
+        if (this.tableHeaderCheckboxModel) tableRef.clearSelected();
+        else tableRef.selectAllRows();
+      }
+    }
+  }
+};
+
+export default BVTableSelectableMixin;
diff --git a/src/components/Mixins/BVToastMixin.js b/src/components/Mixins/BVToastMixin.js
index 489173c..a46f5e5 100644
--- a/src/components/Mixins/BVToastMixin.js
+++ b/src/components/Mixins/BVToastMixin.js
@@ -1,22 +1,33 @@
+import i18n from '../../i18n';
+
 const BVToastMixin = {
   methods: {
-    successToast(message) {
+    successToast(message, title = i18n.t('global.response.success')) {
       this.$root.$bvToast.toast(message, {
-        title: 'Success',
+        title,
         variant: 'success',
         autoHideDelay: 10000, //auto hide in milliseconds
         isStatus: true,
         solid: true
       });
     },
-    errorToast(message) {
+    errorToast(message, title = i18n.t('global.response.error')) {
       this.$root.$bvToast.toast(message, {
-        title: 'Error',
+        title,
         variant: 'danger',
         noAutoHide: true,
         isStatus: true,
         solid: true
       });
+    },
+    warningToast(message, title = i18n.t('global.response.warning')) {
+      this.$root.$bvToast.toast(message, {
+        title,
+        variant: 'warning',
+        noAutoHide: true,
+        isStatus: true,
+        solid: true
+      });
     }
   }
 };
diff --git a/src/locales/en.json b/src/locales/en.json
index 2b6fa07..9d89ce6 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -6,12 +6,27 @@
     "on": "on",
     "off": "off",
     "actions": {
-      "confirm": "Confirm"
+      "confirm": "Confirm",
+      "cancel": "Cancel",
+      "delete": "Delete",
+      "selected": "Selected"
+    },
+    "response": {
+      "success": "Success",
+      "error": "Error",
+      "warning": "Warning"
     }
   },
   "ariaLabels": {
     "showPassword": "Show password as plain text. Note: this will visually expose your password on the screen."
   },
+  "pageTitle": {
+    "localUserMgmt": "Local user management",
+    "login": "Login",
+    "overview": "Overview",
+    "unauthorized": "Unauthorized",
+    "rebootBmc": "Reboot BMC"
+  },
   "login": {
     "language": {
       "label": "Language"
@@ -67,13 +82,6 @@
       "solConsole": "Serial over LAN console"
     }
   },
-  "pageTitle": {
-    "localUserMgmt": "Local user management",
-    "login": "Login",
-    "overview": "Overview",
-    "unauthorized": "Unauthorized",
-    "rebootBmc": "Reboot BMC"
-  },
   "pageRebootBmc": {
     "rebootInformation": "When you reboot the BMC, your web browser loses contact with the BMC for several minutes. When the BMC is back online, you may need to log in again.",
     "rebootBmc": "Reboot BMC",
@@ -85,5 +93,20 @@
       "successRebootStart": "Rebooting BMC.",
       "errorRebootStart": "Error rebooting BMC."
     }
+  },
+  "localUserManagement": {
+    "tableActions": {
+      "delete": "@:global.actions.delete",
+      "enable": "Enable",
+      "disable": "Disable"
+    },
+    "toastMessages": {
+      "successDeleteUsers": "Successfully deleted %{count} user. | Successfully deleted %{count} users.",
+      "errorDeleteUsers": "Error deleting %{count} user. | Error deleting %{count} users.",
+      "successEnableUsers": "Successfully enabled %{count} user. | Successfully enabled %{count} users.",
+      "errorEnableUsers": "Error enabling %{count} user. | Error enabling %{count} users.",
+      "successDisableUsers": "Successfully disabled %{count} user. | Successfully disabled %{count} users.",
+      "errorDisableUsers": "Error disabling %{count} user. | Error disabling %{count} users."
+    }
   }
 }
\ No newline at end of file
diff --git a/src/store/api.js b/src/store/api.js
index 0f8c948..8fdbdd2 100644
--- a/src/store/api.js
+++ b/src/store/api.js
@@ -40,5 +40,8 @@
   },
   all(promises) {
     return Axios.all(promises);
+  },
+  spread(callback) {
+    return Axios.spread(callback);
   }
 };
diff --git a/src/store/modules/AccessControl/LocalUserMangementStore.js b/src/store/modules/AccessControl/LocalUserMangementStore.js
index bc14c73..eb5822e 100644
--- a/src/store/modules/AccessControl/LocalUserMangementStore.js
+++ b/src/store/modules/AccessControl/LocalUserMangementStore.js
@@ -1,4 +1,20 @@
 import api from '../../api';
+import i18n from '../../../i18n';
+
+const getResponseCount = responses => {
+  let successCount = 0;
+  let errorCount = 0;
+
+  responses.forEach(response => {
+    if (response instanceof Error) errorCount++;
+    else successCount++;
+  });
+
+  return {
+    successCount,
+    errorCount
+  };
+};
 
 const LocalUserManagementStore = {
   namespaced: true,
@@ -73,6 +89,132 @@
           console.log(error);
           throw new Error(`Error deleting user '${username}'.`);
         });
+    },
+    async deleteUsers({ dispatch }, users) {
+      const promises = users.map(({ username }) => {
+        return api
+          .delete(`/redfish/v1/AccountService/Accounts/${username}`)
+          .catch(error => {
+            console.log(error);
+            return error;
+          });
+      });
+      return await api
+        .all(promises)
+        .then(response => {
+          dispatch('getUsers');
+          return response;
+        })
+        .then(
+          api.spread((...responses) => {
+            const { successCount, errorCount } = getResponseCount(responses);
+            let toastMessages = [];
+
+            if (successCount) {
+              const message = i18n.tc(
+                'localUserManagement.toastMessages.successDeleteUsers',
+                successCount
+              );
+              toastMessages.push({ type: 'success', message });
+            }
+
+            if (errorCount) {
+              const message = i18n.tc(
+                'localUserManagement.toastMessages.errorDeleteUsers',
+                errorCount
+              );
+              toastMessages.push({ type: 'error', message });
+            }
+
+            return toastMessages;
+          })
+        );
+    },
+    async enableUsers({ dispatch }, users) {
+      const data = {
+        Enabled: true
+      };
+      const promises = users.map(({ username }) => {
+        return api
+          .patch(`/redfish/v1/AccountService/Accounts/${username}`, data)
+          .catch(error => {
+            console.log(error);
+            return error;
+          });
+      });
+      return await api
+        .all(promises)
+        .then(response => {
+          dispatch('getUsers');
+          return response;
+        })
+        .then(
+          api.spread((...responses) => {
+            const { successCount, errorCount } = getResponseCount(responses);
+            let toastMessages = [];
+
+            if (successCount) {
+              const message = i18n.tc(
+                'localUserManagement.toastMessages.successEnableUsers',
+                successCount
+              );
+              toastMessages.push({ type: 'success', message });
+            }
+
+            if (errorCount) {
+              const message = i18n.tc(
+                'localUserManagement.toastMessages.errorEnableUsers',
+                errorCount
+              );
+              toastMessages.push({ type: 'error', message });
+            }
+
+            return toastMessages;
+          })
+        );
+    },
+    async disableUsers({ dispatch }, users) {
+      const data = {
+        Enabled: false
+      };
+      const promises = users.map(({ username }) => {
+        return api
+          .patch(`/redfish/v1/AccountService/Accounts/${username}`, data)
+          .catch(error => {
+            console.log(error);
+            return error;
+          });
+      });
+      return await api
+        .all(promises)
+        .then(response => {
+          dispatch('getUsers');
+          return response;
+        })
+        .then(
+          api.spread((...responses) => {
+            const { successCount, errorCount } = getResponseCount(responses);
+            let toastMessages = [];
+
+            if (successCount) {
+              const message = i18n.tc(
+                'localUserManagement.toastMessages.successDisableUsers',
+                successCount
+              );
+              toastMessages.push({ type: 'success', message });
+            }
+
+            if (errorCount) {
+              const message = i18n.tc(
+                'localUserManagement.toastMessages.errorDisableUsers',
+                errorCount
+              );
+              toastMessages.push({ type: 'error', message });
+            }
+
+            return toastMessages;
+          })
+        );
     }
   }
 };
diff --git a/src/views/AccessControl/LocalUserManagement/LocalUserManagement.vue b/src/views/AccessControl/LocalUserManagement/LocalUserManagement.vue
index a5ba7ba..d68c953 100644
--- a/src/views/AccessControl/LocalUserManagement/LocalUserManagement.vue
+++ b/src/views/AccessControl/LocalUserManagement/LocalUserManagement.vue
@@ -15,7 +15,37 @@
     </b-row>
     <b-row>
       <b-col xl="9">
-        <b-table show-empty :fields="fields" :items="tableItems">
+        <table-toolbar
+          ref="toolbar"
+          :selected-items-count="selectedRows.length"
+          :actions="tableToolbarActions"
+          @clearSelected="clearSelectedRows($refs.table)"
+          @batchAction="onBatchAction"
+        />
+        <b-table
+          ref="table"
+          selectable
+          no-select-on-click
+          :fields="fields"
+          :items="tableItems"
+          @row-selected="onRowSelected($event, tableItems.length)"
+        >
+          <!-- Checkbox column -->
+          <template v-slot:head(checkbox)>
+            <b-form-checkbox
+              v-model="tableHeaderCheckboxModel"
+              :indeterminate="tableHeaderCheckboxIndeterminate"
+              @change="onChangeHeaderCheckbox($refs.table)"
+            />
+          </template>
+          <template v-slot:cell(checkbox)="row">
+            <b-form-checkbox
+              v-model="row.rowSelected"
+              @change="toggleSelectRow($refs.table, row.index)"
+            />
+          </template>
+
+          <!-- table actions column -->
           <template v-slot:cell(actions)="data">
             <b-button
               aria-label="Edit user"
@@ -63,10 +93,13 @@
 import IconSettings from '@carbon/icons-vue/es/settings/20';
 import IconChevron from '@carbon/icons-vue/es/chevron--up/20';
 
-import TableRoles from './TableRoles';
 import ModalUser from './ModalUser';
 import ModalSettings from './ModalSettings';
 import PageTitle from '../../../components/Global/PageTitle';
+import TableRoles from './TableRoles';
+import TableToolbar from '../../../components/Global/TableToolbar';
+
+import BVTableSelectableMixin from '../../../components/Mixins/BVTableSelectableMixin';
 import BVToastMixin from '../../../components/Mixins/BVToastMixin';
 
 export default {
@@ -79,15 +112,22 @@
     IconTrashcan,
     ModalSettings,
     ModalUser,
+    PageTitle,
     TableRoles,
-    PageTitle
+    TableToolbar
   },
-  mixins: [BVToastMixin],
+  mixins: [BVTableSelectableMixin, BVToastMixin],
   data() {
     return {
       activeUser: null,
       settings: null,
       fields: [
+        {
+          key: 'checkbox',
+          label: '',
+          tdClass: 'table-cell__checkbox'
+        },
+        'checkbox',
         'username',
         'privilege',
         'status',
@@ -96,6 +136,20 @@
           label: '',
           tdClass: 'table-cell__actions'
         }
+      ],
+      tableToolbarActions: [
+        {
+          value: 'delete',
+          labelKey: 'localUserManagement.tableActions.delete'
+        },
+        {
+          value: 'enable',
+          labelKey: 'localUserManagement.tableActions.enable'
+        },
+        {
+          value: 'disable',
+          labelKey: 'localUserManagement.tableActions.disable'
+        }
       ]
     };
   },
@@ -174,15 +228,48 @@
         .dispatch('localUsers/deleteUser', username)
         .then(success => this.successToast(success))
         .catch(({ message }) => this.errorToast(message));
+    },
+    onBatchAction(action) {
+      switch (action) {
+        case 'delete':
+          this.$store
+            .dispatch('localUsers/deleteUsers', this.selectedRows)
+            .then(messages => {
+              messages.forEach(({ type, message }) => {
+                if (type === 'success') this.successToast(message);
+                if (type === 'error') this.errorToast(message);
+              });
+            });
+          break;
+        case 'enable':
+          this.$store
+            .dispatch('localUsers/enableUsers', this.selectedRows)
+            .then(messages => {
+              messages.forEach(({ type, message }) => {
+                if (type === 'success') this.successToast(message);
+                if (type === 'error') this.errorToast(message);
+              });
+            });
+          break;
+        case 'disable':
+          this.$store
+            .dispatch('localUsers/disableUsers', this.selectedRows)
+            .then(messages => {
+              messages.forEach(({ type, message }) => {
+                if (type === 'success') this.successToast(message);
+                if (type === 'error') this.errorToast(message);
+              });
+            });
+          break;
+        default:
+          break;
+      }
     }
   }
 };
 </script>
 
 <style lang="scss" scoped>
-h1 {
-  margin-bottom: 2rem;
-}
 .btn.collapsed {
   svg {
     transform: rotate(180deg);