summaryrefslogtreecommitdiff
path: root/app/assets/javascripts
diff options
context:
space:
mode:
authorFilipa Lacerda <filipa@gitlab.com>2018-07-31 16:50:34 +0100
committerFilipa Lacerda <filipa@gitlab.com>2018-08-02 17:59:22 +0100
commitbfeb7c2c3f61a6ea8f36479e7260ba97459152c9 (patch)
tree9b813c6ab41cf1456d8f306ec4b8ee19be7b0872 /app/assets/javascripts
parent28c15d4f94cda8c6635d832d6bde7a131e207023 (diff)
downloadgitlab-ce-bfeb7c2c3f61a6ea8f36479e7260ba97459152c9.tar.gz
Adds frontend support to render test reports on theMR widget
Creates an app to render grouped test reports in the MR widget Ports CSS from EE into CE Creates a reusable code component Adds getters and utils to the existing reports store
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/reports/components/grouped_test_reports_app.vue114
-rw-r--r--app/assets/javascripts/reports/components/modal.vue73
-rw-r--r--app/assets/javascripts/reports/components/test_issue_body.vue48
-rw-r--r--app/assets/javascripts/reports/constants.js16
-rw-r--r--app/assets/javascripts/reports/store/actions.js9
-rw-r--r--app/assets/javascripts/reports/store/getters.js24
-rw-r--r--app/assets/javascripts/reports/store/index.js2
-rw-r--r--app/assets/javascripts/reports/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/reports/store/mutations.js15
-rw-r--r--app/assets/javascripts/reports/store/state.js35
-rw-r--r--app/assets/javascripts/reports/store/utils.js60
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/code_block.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/reports/issue_body.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/reports/issues_list.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/reports/report_issues.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/reports/summary_row.vue9
18 files changed, 457 insertions, 4 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
new file mode 100644
index 00000000000..d2e72e72421
--- /dev/null
+++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
@@ -0,0 +1,114 @@
+<script>
+ import { mapActions, mapGetters } from 'vuex';
+ import { s__ } from '~/locale';
+ import { componentNames } from '~/vue_shared/components/reports/issue_body';
+ 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 { summaryTextBuilder, reportTextBuilder, statusIcon } from '../store/utils';
+
+ export default {
+ name: 'GroupedTestReportsApp',
+ store: createStore(),
+ components: {
+ ReportSection,
+ SummaryRow,
+ IssuesList,
+ Modal,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ componentNames,
+ computed: {
+ ...mapGetters([
+ 'reports',
+ 'summaryStatus',
+ 'isLoading',
+ 'hasError',
+ 'summaryCounts',
+ 'modalTitle',
+ 'modalData',
+ 'isCreatingNewIssue',
+ ]),
+
+ groupedSummaryText() {
+ if (this.isLoading) {
+ return s__('Reports|Test summary results are being parsed');
+ }
+
+ if (this.hasError || !this.summaryCounts) {
+ return s__('Reports|Test summary failed loading results');
+ }
+
+ return summaryTextBuilder(s__('Reports|Test summary'), this.summaryCounts);
+ },
+ },
+ created() {
+ this.setEndpoint(this.endpoint);
+
+ this.fetchReports();
+ },
+ methods: {
+ ...mapActions(['setEndpoint', 'fetchReports']),
+ reportText(report) {
+ const summary = report.summary || {};
+ return reportTextBuilder(report.name, summary);
+ },
+ getReportIcon(report) {
+ return statusIcon(report.status);
+ },
+ shouldRenderIssuesList(report) {
+ return (
+ report.existing_failures.length > 0 ||
+ report.new_failures.length > 0 ||
+ report.resolved_failures > 0
+ );
+ },
+ },
+ };
+</script>
+<template>
+ <report-section
+ :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
+ slot="body"
+ class="mr-widget-grouped-section report-block"
+ >
+ <template
+ v-for="(report, i) in reports"
+ >
+ <summary-row
+ :summary="reportText(report)"
+ :status-icon="getReportIcon(report)"
+ :key="`summary-row-${i}`"
+ />
+ <issues-list
+ v-if="shouldRenderIssuesList(report)"
+ :unresolved-issues="report.existing_failures"
+ :new-issues="report.new_failures"
+ :resolved-issues="report.resolved_failures"
+ :key="`issues-list-${i}`"
+ :component="$options.componentNames.TestIssueBody"
+ 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
new file mode 100644
index 00000000000..6257ebf71fc
--- /dev/null
+++ b/app/assets/javascripts/reports/components/modal.vue
@@ -0,0 +1,73 @@
+<script>
+ import Modal from '~/vue_shared/components/gl_modal.vue';
+ import LoadingButton from '~/vue_shared/components/loading_button.vue';
+ import CodeBlock from '~/vue_shared/components/code_block.vue';
+ import { fieldTypes } from '../constants';
+
+ export default {
+ components: {
+ Modal,
+ LoadingButton,
+ CodeBlock,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ modalData: {
+ type: Object,
+ required: true,
+ },
+ },
+ fieldTypes,
+ };
+</script>
+<template>
+ <modal
+ id="modal-mrwidget-reports"
+ :header-title-text="title"
+ class="modal-security-report-dast modal-hide-footer"
+ >
+ <slot>
+ <div
+ v-for="(field, key, index) in modalData"
+ v-if="field.value"
+ :key="index"
+ class="row prepend-top-10 append-bottom-10"
+ >
+ <label class="col-sm-2 text-right font-weight-bold">
+ {{ field.text }}:
+ </label>
+
+ <div class="col-sm-10 text-secondary">
+ <code-block
+ v-if="field.type === $options.fieldTypes.codeBock"
+ :code="field.value"
+ />
+
+ <template v-else-if="field.type === $options.fieldTypes.link">
+ <a
+ :href="field.value"
+ target="_blank"
+ rel="noopener noreferrer"
+ class="js-modal-link"
+ >
+ {{ field.value }}
+ </a>
+ </template>
+
+ <template v-else-if="field.type === $options.fieldTypes.miliseconds">
+ {{ field.value }} ms
+ </template>
+
+ <template v-else-if="field.type === $options.fieldTypes.text">
+ {{ field.value }}
+ </template>
+ </div>
+ </div>
+ </slot>
+ <div slot="footer">
+ </div>
+ </modal>
+</template>
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..13b7fdf58bc
--- /dev/null
+++ b/app/assets/javascripts/reports/components/test_issue_body.vue
@@ -0,0 +1,48 @@
+<script>
+ import { mapActions } from 'vuex';
+
+ export default {
+ name: 'TestIssueBody',
+ props: {
+ issue: {
+ type: Object,
+ required: true,
+ },
+ // failed || success
+ status: {
+ type: String,
+ required: true,
+ },
+ isNew: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ 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">
+ <button
+ type="button"
+ class="btn-link btn-blank text-left break-link vulnerability-name-button"
+ @click="handleIssueClick()"
+ >
+ <div
+ v-if="isNew"
+ class="badge badge-danger append-right-5"
+ >
+ {{ s__('New') }}
+ </div>{{ issue.name }}
+ </button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js
new file mode 100644
index 00000000000..807ecb1039e
--- /dev/null
+++ b/app/assets/javascripts/reports/constants.js
@@ -0,0 +1,16 @@
+export const fieldTypes = {
+ codeBock: 'codeBlock',
+ link: 'link',
+ miliseconds: 'miliseconds',
+ text: 'text',
+};
+
+export const LOADING = 'LOADING';
+export const ERROR = 'ERROR';
+export const SUCCESS = 'SUCCESS';
+
+export const STATUS_FAILED = 'failed';
+export const STATUS_SUCCESS = 'success';
+export const ICON_WARNING = 'warning';
+export const ICON_SUCCESS = 'success';
+export const ICON_NOTFOUND = 'notfound';
diff --git a/app/assets/javascripts/reports/store/actions.js b/app/assets/javascripts/reports/store/actions.js
index 15c077b0fd8..edbf860ecc6 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..5fbafa201b5
--- /dev/null
+++ b/app/assets/javascripts/reports/store/getters.js
@@ -0,0 +1,24 @@
+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 isCreatingNewIssue = state => state.modal.isLoading;
+
+export const summaryStatus = state => {
+ if (state.isLoading) {
+ return LOADING;
+ }
+
+ if (state.hasError) {
+ return ERROR;
+ }
+
+ return SUCCESS;
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/reports/store/index.js b/app/assets/javascripts/reports/store/index.js
index af4f9688fb4..9d8f7dc3b74 100644
--- a/app/assets/javascripts/reports/store/index.js
+++ b/app/assets/javascripts/reports/store/index.js
@@ -1,6 +1,7 @@
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 +10,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..82bda31df5d 100644
--- a/app/assets/javascripts/reports/store/mutation_types.js
+++ b/app/assets/javascripts/reports/store/mutation_types.js
@@ -3,3 +3,5 @@ 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 d9d301826cf..6b900356bfc 100644
--- a/app/assets/javascripts/reports/store/mutations.js
+++ b/app/assets/javascripts/reports/store/mutations.js
@@ -16,11 +16,26 @@ export default {
state.summary.resolved = response.summary.resolved;
state.summary.failed = response.summary.failed;
+ state.status = response.status;
state.reports = response.suites;
},
[types.RECEIVE_REPORTS_ERROR](state) {
state.isLoading = false;
state.hasError = true;
+ state.reports = [];
+ },
+ [types.SET_ISSUE_MODAL_DATA](state, payload) {
+ state.modal.title = payload.issue.name;
+ state.modal.status = payload.status;
+
+ Object.keys(payload.issue).forEach((key) => {
+ if (Object.prototype.hasOwnProperty.call(state.modal.data, key)) {
+ state.modal.data[key] = {
+ ...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..fd1819dbfea 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,34 @@ export default () => ({
* }
*/
reports: [],
+
+ modal: {
+ title: null,
+
+ status: 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..caa4039af72
--- /dev/null
+++ b/app/assets/javascripts/reports/store/utils.js
@@ -0,0 +1,60 @@
+import { sprintf, n__, s__ } from '~/locale';
+import {
+ STATUS_FAILED,
+ STATUS_SUCCESS,
+ ICON_WARNING,
+ ICON_SUCCESS,
+ ICON_NOTFOUND,
+} from '../constants';
+
+const textBuilder = results => {
+ const { failed, resolved, total } = results;
+
+ const failedString = failed
+ ? n__('%d failed test result', '%d failed test results', failed)
+ : null;
+ const resolvedString = resolved
+ ? n__('%d fixed test result', '%d fixed test results', resolved)
+ : null;
+ const totalString = total ? n__('out of %d total test', 'out of %d total tests', total) : null;
+ let resultsString;
+
+ if (failed) {
+ if (resolved) {
+ resultsString = sprintf(s__('Reports|%{failedString} and %{resolvedString}'), {
+ failedString,
+ resolvedString,
+ });
+ } else {
+ resultsString = failedString;
+ }
+ } else if (resolved) {
+ resultsString = resolvedString;
+ } else {
+ resultsString = s__('Reports|no changed test results');
+ }
+
+ return `${resultsString} ${totalString}`;
+};
+
+export const summaryTextBuilder = (name = '', results) => {
+ const resultsString = textBuilder(results);
+ return `${name} contained ${resultsString}`;
+};
+
+export const reportTextBuilder = (name = '', results) => {
+ const resultsString = textBuilder(results);
+ return `${name} found ${resultsString}`;
+};
+
+export const statusIcon = status => {
+ if (status === STATUS_FAILED) {
+ return ICON_WARNING;
+ }
+
+ if (status === STATUS_SUCCESS) {
+ return ICON_SUCCESS;
+ }
+
+ return ICON_NOTFOUND;
+};
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..80593d1f34a 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: {
@@ -260,6 +262,10 @@ export default {
:deployment="deployment"
/>
<div class="mr-section-container">
+ <grouped-test-reports-app
+ v-if="mr.testResultsPath"
+ :endpoint="mr.testResultsPath"
+ />
<div class="mr-widget-section">
<component
:is="componentName"
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..672e5280b5e 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_reports_path;
+
this.setState(data);
}
diff --git a/app/assets/javascripts/vue_shared/components/code_block.vue b/app/assets/javascripts/vue_shared/components/code_block.vue
new file mode 100644
index 00000000000..30920a2a86d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/code_block.vue
@@ -0,0 +1,16 @@
+<script>
+export default {
+ name: 'CodeBlock',
+ props: {
+ code: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <pre class="code-block build-trace-rounded">
+ <code class="bash">{{ code }}</code>
+ </pre>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/reports/issue_body.js b/app/assets/javascripts/vue_shared/components/reports/issue_body.js
index f2141e519da..54dfb7b16bf 100644
--- a/app/assets/javascripts/vue_shared/components/reports/issue_body.js
+++ b/app/assets/javascripts/vue_shared/components/reports/issue_body.js
@@ -1,3 +1,9 @@
-export const components = {};
+import TestIssueBody from '~/reports/components/test_issue_body.vue';
-export const componentNames = {};
+export const components = {
+ TestIssueBody,
+};
+
+export const componentNames = {
+ TestIssueBody: TestIssueBody.name,
+};
diff --git a/app/assets/javascripts/vue_shared/components/reports/issues_list.vue b/app/assets/javascripts/vue_shared/components/reports/issues_list.vue
index c01f77c2509..2545e84f932 100644
--- a/app/assets/javascripts/vue_shared/components/reports/issues_list.vue
+++ b/app/assets/javascripts/vue_shared/components/reports/issues_list.vue
@@ -18,6 +18,11 @@ export default {
failed: STATUS_FAILED,
neutral: STATUS_NEUTRAL,
props: {
+ newIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
unresolvedIssues: {
type: Array,
required: false,
@@ -45,6 +50,15 @@ export default {
<div class="report-block-container">
<issues-block
+ v-if="newIssues.length"
+ :component="component"
+ :issues="newIssues"
+ class="js-mr-code-new-issues"
+ status="failed"
+ is-new
+ />
+
+ <issues-block
v-if="unresolvedIssues.length"
:component="component"
:issues="unresolvedIssues"
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 2d1f3d82234..1f13e555b31 100644
--- a/app/assets/javascripts/vue_shared/components/reports/report_issues.vue
+++ b/app/assets/javascripts/vue_shared/components/reports/report_issues.vue
@@ -24,6 +24,11 @@ export default {
type: String,
required: true,
},
+ isNew: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
};
</script>
@@ -46,6 +51,7 @@ export default {
:is="component"
:issue="issue"
:status="issue.status || status"
+ :is-new="isNew"
/>
</li>
</ul>
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..28156d7c983 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,11 @@ export default {
{{ summary }}
</div>
- <popover :options="popoverOptions" />
+ <popover
+ v-if="popoverOptions"
+ :options="popoverOptions"
+ />
+
</div>
</div>
</template>