diff options
author | Filipa Lacerda <filipa@gitlab.com> | 2018-07-20 18:54:06 +0100 |
---|---|---|
committer | Filipa Lacerda <filipa@gitlab.com> | 2018-07-20 18:54:06 +0100 |
commit | 5cfe99006ed539fbc9e0a184b09af4be63e6d91b (patch) | |
tree | c48db7c2e0d2f6d1e168a3ddf17963fe8ae713f1 | |
parent | 8e74f14c26b9df6a4fdc4fb79322b13b06c3c509 (diff) | |
download | gitlab-ce-5cfe99006ed539fbc9e0a184b09af4be63e6d91b.tar.gz |
Render grouped test report in MR widget
16 files changed, 292 insertions, 111 deletions
diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue index 016806b84c3..c3f775013eb 100644 --- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue +++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue @@ -1,40 +1,77 @@ <script> -import ReportSection from '~/vue_shared/components/reports/report_section.vue'; -import SummaryRow from '~/vue_shared/components/reports/summary_row.vue'; -import IssuesList from '~/vue_shared/components/reports/issues_list.vue'; -import { mapActions } from 'vuex'; - -export default { - name: 'GroupedTestReportsApp', - // store: createStore(), - components: { - ReportSection, - SummaryRow, - IssuesList, - }, - props: { - endpoint: { - type: String, - required: true, + import { mapActions, mapGetters } from 'vuex'; + import { s__ } from '~/locale'; + import ReportSection from '~/vue_shared/components/reports/report_section.vue'; + import SummaryRow from '~/vue_shared/components/reports/summary_row.vue'; + import IssuesList from '~/vue_shared/components/reports/issues_list.vue'; + import Modal from './modal.vue'; + import createStore from '../store'; + import { textBuilder, statusIcon } from '../store/utils'; + + export default { + name: 'GroupedTestReportsApp', + store: createStore(), + components: { + ReportSection, + SummaryRow, + IssuesList, + Modal, + }, + props: { + endpoint: { + type: String, + required: true, + }, + }, + computed: { + ...mapGetters([ + 'reports', + 'summaryStatus', + 'isLoading', + 'hasError', + 'summaryCounts', + 'modalTitle', + 'modalData', + ]), + + groupedSummaryText() { + if (this.isLoading) { + return s__('Reports|Test summary results are being parsed'); + } + + if (this) { + return s__('Reports|Test summary failed loading results'); + } + + const { failed, total, resolved } = this.summaryCounts; + + return textBuilder(s__('Reports|Test summary'), failed, resolved, total); + }, + }, + created() { + this.setEndpoint(this.endpoint); + + this.fetchReports(); }, - }, - created() { - this.setEndpoint(this.endpoint); - - this.fetchReports(); - }, - methods: { - ...mapActions(['setEndpoint', 'fetchReports']) - }, -} + methods: { + ...mapActions(['setEndpoint', 'fetchReports']), + reportText(report) { + const summary = report.summary || {}; // TODO_SAM: Check with backend if summary is always present. if it is we may be able to remove this check. + return textBuilder(report.name, summary.failed, summary.resolved, summary.total); + }, + getReportIcon(report) { + return statusIcon(report.status); + }, + }, + }; </script> <template> <report-section - :status="" - :success-text="" - :loading-text="" - :error-text="" - :has-issues="true" + :status="summaryStatus" + :success-text="groupedSummaryText" + :loading-text="groupedSummaryText" + :error-text="groupedSummaryText" + :has-issues="reports.length > 0" class="mr-widget-border-top grouped-security-reports" > <div @@ -43,20 +80,27 @@ export default { > <template v-for="(report, i) in reports" - :key="i" > <summary-row - :summary="" - :status-icon="" + :summary="reportText(report)" + :status-icon="getReportIcon(report)" + :key="`summary-row-${i}`" + /> <issues-list - :unresolved-issues="sast.newIssues" - :resolved-issues="sast.resolvedIssues" - :all-issues="sast.allIssues" + :unresolved-issues="report.new_failures" + :resolved-issues="report.resolved_failures" + :all-issues="report.existing_failures" + :key="`issues-list-${i}`" type="test" class="report-block-group-list" /> </template> + + <modal + :title="modalTitle" + :modal-data="modalData" + /> </div> </report-section> </template> diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/components/modal.vue index 61004399aa4..3eb7094f166 100644 --- a/app/assets/javascripts/reports/components/modal.vue +++ b/app/assets/javascripts/reports/components/modal.vue @@ -20,24 +20,15 @@ type: Object, required: true, }, - actions: { - type: Array, - required: true, - }, - }, - - computed: { - shouldRenderFooterSection() { - return this.actions.length > 0; - }, }, + fieldTypes, }; </script> <template> <modal - id="modal-mrwidget-security-issue" + id="modal-mrwidget-reports" :header-title-text="title" - :class="{ 'modal-hide-footer': !shouldRenderFooterSection }" + :class="{ 'modal-hide-footer': false }" class="modal-security-report-dast" > <slot> @@ -52,25 +43,25 @@ </label> <div class="col-sm-10 text-secondary"> - <template v-if="field.type === fieldTypes.codeBlock"> - <code>{{field.value}}</code> + <template v-if="field.type === $options.fieldTypes.codeBock"> + <pre> {{ field.value }} </pre> </template> - <template v-else-if="field.type === fieldTypes.link"> + <template v-else-if="field.type === $options.fieldTypes.link"> <a :href="field.value" target="_blank" rel="noopener noreferrer" > - {{ field.value}} + {{ field.value }} </a> </template> - <template v-else-if="field.type === fieldTypes.miliseconds"> + <template v-else-if="field.type === $options.fieldTypes.miliseconds"> {{ field.value }} ms </template> - <template v-else-if="field.type === fieldTypes.text"> + <template v-else-if="field.type === $options.fieldTypes.text"> {{ field.value }} </template> </div> diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue new file mode 100644 index 00000000000..21dd1840807 --- /dev/null +++ b/app/assets/javascripts/reports/components/test_issue_body.vue @@ -0,0 +1,40 @@ +<script> + import { mapActions } from 'vuex'; + + export default { + props: { + issue: { + type: Object, + required: true, + }, + // failed || success + status: { + type: String, + required: true, + }, + }, + methods: { + ...mapActions(['openModal']), + handleIssueClick() { + const { issue, status, openModal } = this; + openModal({ issue, status }); + }, + }, + }; +</script> +<template> + <div class="report-block-list-issue-description prepend-top-5 append-bottom-5"> + <div class="report-block-list-issue-description-text"> + <!-- TODO_SAM: I've duplicated this from modal_open_name, because it's expecting a title and we have name + Not sure if it's worth refactring now, or if it's ok to leave it duplciated and fix it when we move the components around + --> + <button + type="button" + class="btn-link btn-blank text-left break-link vulnerability-name-button" + @click="handleIssueClick()" + > + {{ issue.name }} + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js index 36306f33153..882617c20f1 100644 --- a/app/assets/javascripts/reports/constants.js +++ b/app/assets/javascripts/reports/constants.js @@ -1,5 +1,3 @@ -import { s__ } from '~/locale'; - export const fieldTypes = { codeBock: 'codeBlock', link: 'link', @@ -7,54 +5,6 @@ export const fieldTypes = { text: 'text', }; -/** - * Map of data show in modalbox - * - * Each key will only be rendered if `value` is present. - * - * Keys in this map have the same format as the backend json response. - * - * When the user clicks on the issue name, in modal_open_name, - * the store will set the modal data based on this map - * and the keys available in the store. - */ -export const modalData = { - class: { - value: null, - text: s__('Reports|Class'), - type: fieldTypes.link, - }, - execution_time: { - value: null, - text: s__('Reports|Execution time'), - type: fieldTypes.miliseconds, - }, - failure: { - value: null, - text: s__('Reports|Failure'), - type: fieldTypes.codeBock, - }, - system_output: { - value: null, - text: s__('Reports|System output'), - type: fieldTypes.codeBock, - }, -}; - -export const modalActions = [ - { - title: s__('Reports|Cancel'), - attributes: { - type: 'button', - class: 'btn btn-default', - 'data-dismiss': 'modal', - }, - }, - { - title: s__('Reports|Create Issue'), - attributes: { - type: 'button', - class: 'btn btn-success btn-inverted', - }, - } -]; +export const LOADING = 'LOADING'; +export const ERROR = 'ERROR'; +export const SUCCESS = 'SUCCESS'; diff --git a/app/assets/javascripts/reports/store/actions.js b/app/assets/javascripts/reports/store/actions.js index 6ce0a00a03e..66d579b7556 100644 --- a/app/assets/javascripts/reports/store/actions.js +++ b/app/assets/javascripts/reports/store/actions.js @@ -1,4 +1,5 @@ import Visibility from 'visibilityjs'; +import $ from 'jquery'; import axios from '../../lib/utils/axios_utils'; import Poll from '../../lib/utils/poll'; import * as types from './mutation_types'; @@ -63,5 +64,13 @@ export const receiveReportsSuccess = ({ commit }, response) => export const receiveReportsError = ({ commit }) => commit(types.RECEIVE_REPORTS_ERROR); +export const openModal = ({ dispatch }, payload) => { + dispatch('setModalData', payload); + + $('#modal-mrwidget-reports').modal('show'); +}; + +export const setModalData = ({ commit }, payload) => commit(types.SET_ISSUE_MODAL_DATA, payload); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/reports/store/getters.js b/app/assets/javascripts/reports/store/getters.js new file mode 100644 index 00000000000..8d3efa0b2bf --- /dev/null +++ b/app/assets/javascripts/reports/store/getters.js @@ -0,0 +1,20 @@ +import { LOADING, ERROR, SUCCESS } from '../constants'; + +export const reports = state => state.reports; +export const summaryCounts = state => state.summary; +export const isLoading = state => state.isLoading; +export const hasError = state => state.hasError; +export const modalTitle = state => state.modal.title || ''; +export const modalData = state => state.modal.data || {}; + +export const summaryStatus = state => { + if (state.isLoading) { + return LOADING; + } + + if (state.hasError) { + return ERROR; + } + + return SUCCESS; +}; diff --git a/app/assets/javascripts/reports/store/index.js b/app/assets/javascripts/reports/store/index.js index af4f9688fb4..1eb2960910b 100644 --- a/app/assets/javascripts/reports/store/index.js +++ b/app/assets/javascripts/reports/store/index.js @@ -1,6 +1,8 @@ import Vue from 'vue'; import Vuex from 'vuex'; import * as actions from './actions'; +import * as getters from './getters'; + import mutations from './mutations'; import state from './state'; @@ -9,5 +11,6 @@ Vue.use(Vuex); export default () => new Vuex.Store({ actions, mutations, + getters, state: state(), }); diff --git a/app/assets/javascripts/reports/store/mutation_types.js b/app/assets/javascripts/reports/store/mutation_types.js index 77722974c45..599d4862dfe 100644 --- a/app/assets/javascripts/reports/store/mutation_types.js +++ b/app/assets/javascripts/reports/store/mutation_types.js @@ -3,3 +3,4 @@ export const SET_ENDPOINT = 'SET_ENDPOINT'; export const REQUEST_REPORTS = 'REQUEST_REPORTS'; export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS'; export const RECEIVE_REPORTS_ERROR = 'RECEIVE_REPORTS_ERROR'; +export const SET_ISSUE_MODAL_DATA = 'SET_ISSUE_MODAL_DATA'; diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/store/mutations.js index 3851f2f5799..4ff9b10f28c 100644 --- a/app/assets/javascripts/reports/store/mutations.js +++ b/app/assets/javascripts/reports/store/mutations.js @@ -17,6 +17,7 @@ export default { Vue.set(state.summary, 'resolved', response.summary.resolved); Vue.set(state.summary, 'failed', response.summary.failed); + state.status = response.status; state.reports = response.suites; }, @@ -24,4 +25,13 @@ export default { state.isLoading = false; state.hasError = true; }, + [types.SET_ISSUE_MODAL_DATA](state, payload) { + Vue.set(state.modal, 'title', payload.issue.name); + + Object.keys(payload.issue).forEach((key) => { + if (Object.prototype.hasOwnProperty.call(state.modal.data, key)) { + Vue.set(state.modal.data[key], 'value', payload.issue[key]); + } + }); + }, }; diff --git a/app/assets/javascripts/reports/store/state.js b/app/assets/javascripts/reports/store/state.js index 97f9d0a6859..acb937351e4 100644 --- a/app/assets/javascripts/reports/store/state.js +++ b/app/assets/javascripts/reports/store/state.js @@ -1,9 +1,14 @@ +import { s__ } from '~/locale'; +import { fieldTypes } from '../constants'; + export default () => ({ endpoint: null, isLoading: false, hasError: false, + status: null, + summary: { total: 0, resolved: 0, @@ -25,4 +30,30 @@ export default () => ({ * } */ reports: [], + + modal: { + title: null, + data: { + class: { + value: null, + text: s__('Reports|Class'), + type: fieldTypes.link, + }, + execution_time: { + value: null, + text: s__('Reports|Execution time'), + type: fieldTypes.miliseconds, + }, + failure: { + value: null, + text: s__('Reports|Failure'), + type: fieldTypes.codeBock, + }, + system_output: { + value: null, + text: s__('Reports|System output'), + type: fieldTypes.codeBock, + }, + }, + }, }); diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/store/utils.js new file mode 100644 index 00000000000..f921bb8ad2d --- /dev/null +++ b/app/assets/javascripts/reports/store/utils.js @@ -0,0 +1,44 @@ +import { sprintf, n__, s__ } from '~/locale'; + +export const textBuilder = (name = '', failed = 0, resolved = 0, total = 0) => { + if (failed === 0 && resolved === 0) { + // TODO: Check with dimitrie + return sprintf(s__('Reports|%{name} found no tests'), { name }); + } + + const text = [name]; + + if (failed > 0 && resolved === 0) { + text.push(n__('found %d failed test', 'found %d failed tests', failed)); + } + + if (failed > 0 && resolved > 0) { + text.push( + `${n__('found %d failed test', 'found %d failed tests', failed)} ${n__( + 'and %d fixed test', + 'and %d fixed tests', + resolved, + )}`, + ); + } + + if (failed === 0 && resolved > 0) { + text.push(n__('found %d fixed test', 'found %d fixed tests', resolved)); + } + + if (failed === 0 && resolved === 0) { + text.push(s__('found no tests')); + } + + text.push(sprintf('out of %{total} total', { total })); + + return text.join(' '); +}; + +export const statusIcon = (status) => { + if (status === 'failed') { + return 'warning'; + } + + return 'success'; +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index b5de3dd6d73..f356236fd11 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -36,6 +36,7 @@ import { notify, SourceBranchRemovalStatus, } from './dependencies'; +import GroupedTestReportsApp from '~/reports/components/grouped_test_reports_app.vue'; import { setFaviconOverlay } from '../lib/utils/common_utils'; export default { @@ -68,6 +69,7 @@ export default { 'mr-widget-auto-merge-failed': AutoMergeFailed, 'mr-widget-rebase': RebaseState, SourceBranchRemovalStatus, + GroupedTestReportsApp, }, props: { mrData: { @@ -259,6 +261,12 @@ export default { :key="deployment.id" :deployment="deployment" /> + + <grouped-test-reports-app + v-if="mr.testResultsPath" + :endpoint="mr.testResultsPath" + /> + <div class="mr-section-container"> <div class="mr-widget-section"> <component diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index e84c436905d..d96f96c5df6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -108,6 +108,8 @@ export default class MergeRequestStore { this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false; this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null; + this.testResultsPath = data.test_results_path || 'foo'; + this.setState(data); } diff --git a/app/assets/javascripts/vue_shared/components/reports/report_issues.vue b/app/assets/javascripts/vue_shared/components/reports/report_issues.vue index ecffb02a3a0..5ce3ece4cc5 100644 --- a/app/assets/javascripts/vue_shared/components/reports/report_issues.vue +++ b/app/assets/javascripts/vue_shared/components/reports/report_issues.vue @@ -1,10 +1,12 @@ <script> import Icon from '~/vue_shared/components/icon.vue'; +import Issue from '~/reports/components/test_issue_body.vue'; export default { name: 'ReportIssues', components: { Icon, + Issue, }, props: { issues: { @@ -40,6 +42,10 @@ export default { isStatusNeutral() { return this.status === 'neutral'; }, + isTypeTest() { + // TODO: Remove this. It's needed because of the EE port of this. Ideally there would be no type here. + return this.type === 'test'; + }, }, }; </script> @@ -66,6 +72,12 @@ export default { /> </div> + <issue + v-if="isTypeTest" + :issue="issue" + :status="status" + /> + </li> </ul> </div> diff --git a/app/assets/javascripts/vue_shared/components/reports/summary_row.vue b/app/assets/javascripts/vue_shared/components/reports/summary_row.vue index 997bad960e2..b85103d95f2 100644 --- a/app/assets/javascripts/vue_shared/components/reports/summary_row.vue +++ b/app/assets/javascripts/vue_shared/components/reports/summary_row.vue @@ -29,7 +29,8 @@ export default { }, popoverOptions: { type: Object, - required: true, + required: false, + default: null, }, }, computed: { @@ -60,7 +61,10 @@ export default { {{ summary }} </div> - <popover :options="popoverOptions" /> + <popover + v-if="popoverOptions" + :options="popoverOptions" + /> </div> </div> </template> diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index c8349a4ef79..d61c7eb6a0e 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -15,6 +15,10 @@ } } +.mr-widget-border-top { + border-top: 1px solid $border-color; +} + .mr-widget-heading { position: relative; border: 1px solid $border-color; @@ -54,6 +58,14 @@ padding: 0; } + .grouped-security-reports { + padding: 0; + + > .media { + padding: $gl-padding; + } + } + form { margin-bottom: 0; |