Add batch actions and row action to Event Logs

Adds ability to export and delete event logs by row or in a
table batch action.

- Modifications to TableRowAction component to allow single
  row export functionality

Signed-off-by: Yoshie Muranaka <yoshiemuranaka@gmail.com>
Change-Id: Ica50dd0868ac85cc2d6925a9448858b40da9c529
diff --git a/src/components/Global/TableRowAction.vue b/src/components/Global/TableRowAction.vue
index d41fd50..f86bce2 100644
--- a/src/components/Global/TableRowAction.vue
+++ b/src/components/Global/TableRowAction.vue
@@ -1,18 +1,36 @@
 <template>
-  <b-button
-    :aria-label="title"
-    :title="title"
-    variant="link"
-    :disabled="!enabled"
-    @click="$emit('click:tableAction', value)"
-  >
-    <slot name="icon">
-      {{ title }}
-    </slot>
-  </b-button>
+  <span>
+    <b-link
+      v-if="value === 'export'"
+      class="align-bottom btn-link py-0"
+      :download="download"
+      :href="href"
+      :title="title"
+      :aria-label="title"
+    >
+      <slot name="icon">
+        {{ $t('global.action.export') }}
+      </slot>
+    </b-link>
+    <b-button
+      v-else
+      variant="link"
+      class="py-0"
+      :aria-label="title"
+      :title="title"
+      :disabled="!enabled"
+      @click="$emit('click:tableAction', value)"
+    >
+      <slot name="icon">
+        {{ title }}
+      </slot>
+    </b-button>
+  </span>
 </template>
 
 <script>
+import { omit } from 'lodash';
+
 export default {
   name: 'TableRowAction',
   props: {
@@ -27,14 +45,26 @@
     title: {
       type: String,
       default: null
+    },
+    rowData: {
+      type: Object,
+      default: () => {}
+    },
+    exportName: {
+      type: String,
+      default: 'export'
+    }
+  },
+  computed: {
+    dataForExport() {
+      return JSON.stringify(omit(this.rowData, 'actions'));
+    },
+    download() {
+      return `${this.exportName}.json`;
+    },
+    href() {
+      return `data:text/json;charset=utf-8,${this.dataForExport}`;
     }
   }
 };
 </script>
-
-<style lang="scss" scoped>
-.btn.btn-link {
-  padding-top: 0;
-  padding-bottom: 0;
-}
-</style>
diff --git a/src/locales/en-US.json b/src/locales/en-US.json
index a09047e..4636503 100644
--- a/src/locales/en-US.json
+++ b/src/locales/en-US.json
@@ -98,6 +98,10 @@
     "unauthorized": "Unauthorized"
   },
   "pageEventLogs": {
+    "modal": {
+      "deleteTitle": "Delete log | Delete logs",
+      "deleteMessage": "Are you sure you want to delete %{count} log? This action cannot be undone. | Are you sure you want to delete %{count} logs? This action cannot be undone."
+    },
     "table": {
       "date": "Date",
       "description": "Description",
@@ -105,6 +109,10 @@
       "id": "ID",
       "severity": "Severity",
       "type": "Type"
+    },
+    "toast": {
+      "errorDelete": "Error deleting %{count} log. | Error deleting %{count} logs.",
+      "successDelete": "Successfully deleted %{count} log. | Successfully deleted %{count} logs."
     }
   },
   "pageLdap": {
diff --git a/src/store/api.js b/src/store/api.js
index 4a8b8e8..63fd75c 100644
--- a/src/store/api.js
+++ b/src/store/api.js
@@ -55,3 +55,18 @@
     return Axios.spread(callback);
   }
 };
+
+export const getResponseCount = responses => {
+  let successCount = 0;
+  let errorCount = 0;
+
+  responses.forEach(response => {
+    if (response instanceof Error) errorCount++;
+    else successCount++;
+  });
+
+  return {
+    successCount,
+    errorCount
+  };
+};
diff --git a/src/store/modules/Health/EventLogStore.js b/src/store/modules/Health/EventLogStore.js
index 2f0b800..2b93ffa 100644
--- a/src/store/modules/Health/EventLogStore.js
+++ b/src/store/modules/Health/EventLogStore.js
@@ -1,4 +1,5 @@
-import api from '../../api';
+import api, { getResponseCount } from '@/store/api';
+import i18n from '@/i18n';
 
 const getHealthStatus = events => {
   let status = 'OK';
@@ -37,22 +38,60 @@
       return await api
         .get('/redfish/v1/Systems/system/LogServices/EventLog/Entries')
         .then(({ data: { Members = [] } = {} }) => {
-          const eventLogs = Members.map(
-            ({ Id, Severity, Created, EntryType, Message }) => {
-              return {
-                id: Id,
-                severity: Severity,
-                date: new Date(Created),
-                type: EntryType,
-                description: Message
-              };
-            }
-          );
+          const eventLogs = Members.map(log => {
+            const { Id, Severity, Created, EntryType, Message } = log;
+            return {
+              id: Id,
+              severity: Severity,
+              date: new Date(Created),
+              type: EntryType,
+              description: Message,
+              uri: log['@odata.id']
+            };
+          });
           commit('setAllEvents', eventLogs);
         })
         .catch(error => {
           console.log('Event Log Data:', error);
         });
+    },
+    async deleteEventLogs({ dispatch }, uris = []) {
+      const promises = uris.map(uri =>
+        api.delete(uri).catch(error => {
+          console.log(error);
+          return error;
+        })
+      );
+      return await api
+        .all(promises)
+        .then(response => {
+          dispatch('getEventLogData');
+          return response;
+        })
+        .then(
+          api.spread((...responses) => {
+            const { successCount, errorCount } = getResponseCount(responses);
+            const toastMessages = [];
+
+            if (successCount) {
+              const message = i18n.tc(
+                'pageEventLogs.toast.successDelete',
+                successCount
+              );
+              toastMessages.push({ type: 'success', message });
+            }
+
+            if (errorCount) {
+              const message = i18n.tc(
+                'pageEventLogs.toast.errorDelete',
+                errorCount
+              );
+              toastMessages.push({ type: 'error', message });
+            }
+
+            return toastMessages;
+          })
+        );
     }
   }
 };
diff --git a/src/views/Health/EventLogs/EventLogs.vue b/src/views/Health/EventLogs/EventLogs.vue
index d7a64c9..a5ef375 100644
--- a/src/views/Health/EventLogs/EventLogs.vue
+++ b/src/views/Health/EventLogs/EventLogs.vue
@@ -8,27 +8,81 @@
     </b-row>
     <b-row>
       <b-col>
+        <table-toolbar
+          ref="toolbar"
+          :selected-items-count="selectedRows.length"
+          :actions="batchActions"
+          @clearSelected="clearSelectedRows($refs.table)"
+          @batchAction="onBatchAction"
+        >
+          <template v-slot:export>
+            <table-toolbar-export
+              :data="batchExportData"
+              :file-name="$t('appPageTitle.eventLogs')"
+            />
+          </template>
+        </table-toolbar>
         <b-table
           id="table-event-logs"
-          :fields="fields"
-          :items="filteredLogs"
+          ref="table"
+          selectable
+          no-select-on-click
           sort-icon-left
           no-sort-reset
           sort-desc
           show-empty
           sort-by="date"
+          :fields="fields"
+          :items="filteredLogs"
           :sort-compare="onSortCompare"
           :empty-text="$t('pageEventLogs.table.emptyMessage')"
           :per-page="perPage"
           :current-page="currentPage"
+          @row-selected="onRowSelected($event, filteredLogs.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>
+
+          <!-- Severity column -->
           <template v-slot:cell(severity)="{ value }">
             <status-icon :status="getStatus(value)" />
             {{ value }}
           </template>
+
+          <!-- Date column -->
           <template v-slot:cell(date)="{ value }">
             {{ value | formatDate }} {{ value | formatTime }}
           </template>
+
+          <!-- Actions column -->
+          <template v-slot:cell(actions)="{ item }">
+            <table-row-action
+              v-for="(action, index) in item.actions"
+              :key="index"
+              :value="action.value"
+              :title="action.title"
+              :row-data="item"
+              :export-name="item.id"
+              @click:tableAction="onTableRowAction($event, item)"
+            >
+              <template v-slot:icon>
+                <icon-export v-if="action.value === 'export'" />
+                <icon-trashcan v-if="action.value === 'delete'" />
+              </template>
+            </table-row-action>
+          </template>
         </b-table>
       </b-col>
     </b-row>
@@ -61,27 +115,51 @@
 </template>
 
 <script>
+import IconTrashcan from '@carbon/icons-vue/es/trash-can/20';
+import IconExport from '@carbon/icons-vue/es/export/20';
+import { omit } from 'lodash';
+
 import PageTitle from '@/components/Global/PageTitle';
 import StatusIcon from '@/components/Global/StatusIcon';
 import TableFilter from '@/components/Global/TableFilter';
+import TableRowAction from '@/components/Global/TableRowAction';
+import TableToolbar from '@/components/Global/TableToolbar';
+import TableToolbarExport from '@/components/Global/TableToolbarExport';
 
 import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin';
 import TableFilterMixin from '@/components/Mixins/TableFilterMixin';
 import BVPaginationMixin from '@/components/Mixins/BVPaginationMixin';
+import BVTableSelectableMixin from '@/components/Mixins/BVTableSelectableMixin';
+import BVToastMixin from '@/components/Mixins/BVToastMixin';
 
 const SEVERITY = ['OK', 'Warning', 'Critical'];
 
 export default {
   components: {
+    IconExport,
+    IconTrashcan,
     PageTitle,
     StatusIcon,
-    TableFilter
+    TableFilter,
+    TableRowAction,
+    TableToolbar,
+    TableToolbarExport
   },
-  mixins: [LoadingBarMixin, TableFilterMixin, BVPaginationMixin],
+  mixins: [
+    BVPaginationMixin,
+    BVTableSelectableMixin,
+    BVToastMixin,
+    LoadingBarMixin,
+    TableFilterMixin
+  ],
   data() {
     return {
       fields: [
         {
+          key: 'checkbox',
+          sortable: false
+        },
+        {
           key: 'id',
           label: this.$t('pageEventLogs.table.id'),
           sortable: true
@@ -104,6 +182,12 @@
         {
           key: 'description',
           label: this.$t('pageEventLogs.table.description')
+        },
+        {
+          key: 'actions',
+          sortable: false,
+          label: '',
+          tdClass: 'text-right'
         }
       ],
       tableFilters: [
@@ -112,12 +196,32 @@
           values: SEVERITY
         }
       ],
-      activeFilters: []
+      activeFilters: [],
+      batchActions: [
+        {
+          value: 'delete',
+          label: this.$t('global.action.delete')
+        }
+      ]
     };
   },
   computed: {
     allLogs() {
-      return this.$store.getters['eventLog/allEvents'];
+      return this.$store.getters['eventLog/allEvents'].map(event => {
+        return {
+          ...event,
+          actions: [
+            {
+              value: 'export',
+              title: this.$t('global.action.export')
+            },
+            {
+              value: 'delete',
+              title: this.$t('global.action.delete')
+            }
+          ]
+        };
+      });
     },
     filteredLogs: {
       get: function() {
@@ -126,6 +230,9 @@
       set: function(newVal) {
         return newVal;
       }
+    },
+    batchExportData() {
+      return this.selectedRows.map(row => omit(row, 'actions'));
     }
   },
   created() {
@@ -153,6 +260,17 @@
           return '';
       }
     },
+    deleteLogs(uris) {
+      this.$store.dispatch('eventLog/deleteEventLogs', uris).then(messages => {
+        messages.forEach(({ type, message }) => {
+          if (type === 'success') {
+            this.successToast(message);
+          } else if (type === 'error') {
+            this.errorToast(message);
+          }
+        });
+      });
+    },
     onFilterChange({ activeFilters }) {
       this.activeFilters = activeFilters;
       this.filteredLogs = this.getFilteredTableData(
@@ -164,6 +282,40 @@
       if (key === 'severity') {
         return SEVERITY.indexOf(a.status) - SEVERITY.indexOf(b.status);
       }
+    },
+    onTableRowAction(action, { uri }) {
+      if (action === 'delete') {
+        this.$bvModal
+          .msgBoxConfirm(this.$tc('pageEventLogs.modal.deleteMessage'), {
+            title: this.$tc('pageEventLogs.modal.deleteTitle'),
+            okTitle: this.$t('global.action.delete')
+          })
+          .then(deleteConfirmed => {
+            if (deleteConfirmed) this.deleteLogs([uri]);
+          });
+      }
+    },
+    onBatchAction(action) {
+      if (action === 'delete') {
+        const uris = this.selectedRows.map(row => row.uri);
+        this.$bvModal
+          .msgBoxConfirm(
+            this.$tc(
+              'pageEventLogs.modal.deleteMessage',
+              this.selectedRows.length
+            ),
+            {
+              title: this.$tc(
+                'pageEventLogs.modal.deleteTitle',
+                this.selectedRows.length
+              ),
+              okTitle: this.$t('global.action.delete')
+            }
+          )
+          .then(deleteConfirmed => {
+            if (deleteConfirmed) this.deleteLogs(uris);
+          });
+      }
     }
   }
 };