Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-12-02 03:07:06 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-12-02 03:07:06 +0300
commitb26eec8cbcf32085079eee0e196456eccefc993f (patch)
tree822f69aeec23b0b3512583b2221000a8035241f7 /app/assets/javascripts/ci
parent61666f277a484725307ae2b34697b13a300b2129 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/ci')
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/components/codequality_issue_body.vue76
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/constants.js53
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/store/actions.js30
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/store/getters.js63
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/store/index.js17
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js5
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/store/mutations.js27
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/store/state.js16
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js29
-rw-r--r--app/assets/javascripts/ci/reports/components/grouped_issues_list.vue106
-rw-r--r--app/assets/javascripts/ci/reports/components/issue_body.js17
-rw-r--r--app/assets/javascripts/ci/reports/components/issue_status_icon.vue54
-rw-r--r--app/assets/javascripts/ci/reports/components/issues_list.vue119
-rw-r--r--app/assets/javascripts/ci/reports/components/report_item.vue67
-rw-r--r--app/assets/javascripts/ci/reports/components/report_link.vue30
-rw-r--r--app/assets/javascripts/ci/reports/components/report_section.vue237
-rw-r--r--app/assets/javascripts/ci/reports/components/summary_row.vue93
-rw-r--r--app/assets/javascripts/ci/reports/constants.js38
18 files changed, 1077 insertions, 0 deletions
diff --git a/app/assets/javascripts/ci/reports/codequality_report/components/codequality_issue_body.vue b/app/assets/javascripts/ci/reports/codequality_report/components/codequality_issue_body.vue
new file mode 100644
index 00000000000..5a7ee9c9b28
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/components/codequality_issue_body.vue
@@ -0,0 +1,76 @@
+<script>
+/**
+ * Renders Code quality body text
+ * Fixed: [name] in [link]:[line]
+ */
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import ReportLink from '~/ci/reports/components/report_link.vue';
+import { STATUS_SUCCESS, STATUS_NEUTRAL } from '~/ci/reports/constants';
+import { SEVERITY_CLASSES, SEVERITY_ICONS } from '../constants';
+
+export default {
+ name: 'CodequalityIssueBody',
+ components: {
+ GlIcon,
+ ReportLink,
+ },
+ directives: {
+ tooltip: GlTooltipDirective,
+ },
+ props: {
+ status: {
+ type: String,
+ required: false,
+ default: STATUS_NEUTRAL,
+ },
+ issue: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ issueName() {
+ return `${this.severityLabel} - ${this.issue.name}`;
+ },
+ issueSeverity() {
+ return this.issue.severity?.toLowerCase();
+ },
+ isStatusSuccess() {
+ return this.status === STATUS_SUCCESS;
+ },
+ severityClass() {
+ return SEVERITY_CLASSES[this.issueSeverity] || SEVERITY_CLASSES.unknown;
+ },
+ severityIcon() {
+ return SEVERITY_ICONS[this.issueSeverity] || SEVERITY_ICONS.unknown;
+ },
+ severityLabel() {
+ return this.$options.severityText[this.issueSeverity] || this.$options.severityText.unknown;
+ },
+ },
+ severityText: {
+ info: s__('severity|Info'),
+ minor: s__('severity|Minor'),
+ major: s__('severity|Major'),
+ critical: s__('severity|Critical'),
+ blocker: s__('severity|Blocker'),
+ unknown: s__('severity|Unknown'),
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-mt-2 gl-mb-2 gl-w-full">
+ <span :class="severityClass" class="gl-mr-5" data-testid="codequality-severity-icon">
+ <gl-icon v-tooltip="severityLabel" :name="severityIcon" :size="12" />
+ </span>
+ <div class="gl-flex-grow-1">
+ <div>
+ <strong v-if="isStatusSuccess">{{ s__('ciReport|Fixed:') }}</strong>
+ {{ issueName }}
+ </div>
+
+ <report-link v-if="issue.path" :issue="issue" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/reports/codequality_report/constants.js b/app/assets/javascripts/ci/reports/codequality_report/constants.js
new file mode 100644
index 00000000000..5e81245037f
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/constants.js
@@ -0,0 +1,53 @@
+export const SEVERITY_CLASSES = {
+ info: 'text-primary-400',
+ minor: 'text-warning-200',
+ major: 'text-warning-400',
+ critical: 'text-danger-600',
+ blocker: 'text-danger-800',
+ unknown: 'text-secondary-400',
+};
+
+export const SEVERITY_ICONS = {
+ info: 'severity-info',
+ minor: 'severity-low',
+ major: 'severity-medium',
+ critical: 'severity-high',
+ blocker: 'severity-critical',
+ unknown: 'severity-unknown',
+};
+
+export const SEVERITY_ICONS_MR_WIDGET = {
+ info: 'severityInfo',
+ minor: 'severityLow',
+ major: 'severityMedium',
+ critical: 'severityHigh',
+ blocker: 'severityCritical',
+ unknown: 'severityUnknown',
+};
+
+export const SEVERITIES = {
+ info: {
+ class: SEVERITY_CLASSES.info,
+ name: SEVERITY_ICONS.info,
+ },
+ minor: {
+ class: SEVERITY_CLASSES.minor,
+ name: SEVERITY_ICONS.minor,
+ },
+ major: {
+ class: SEVERITY_CLASSES.major,
+ name: SEVERITY_ICONS.major,
+ },
+ critical: {
+ class: SEVERITY_CLASSES.critical,
+ name: SEVERITY_ICONS.critical,
+ },
+ blocker: {
+ class: SEVERITY_CLASSES.blocker,
+ name: SEVERITY_ICONS.blocker,
+ },
+ unknown: {
+ class: SEVERITY_CLASSES.unknown,
+ name: SEVERITY_ICONS.unknown,
+ },
+};
diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/actions.js b/app/assets/javascripts/ci/reports/codequality_report/store/actions.js
new file mode 100644
index 00000000000..04aca11b945
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/store/actions.js
@@ -0,0 +1,30 @@
+import pollUntilComplete from '~/lib/utils/poll_until_complete';
+import { STATUS_NOT_FOUND } from '../../constants';
+import * as types from './mutation_types';
+import { parseCodeclimateMetrics } from './utils/codequality_parser';
+
+export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths);
+
+export const fetchReports = ({ state, dispatch, commit }) => {
+ commit(types.REQUEST_REPORTS);
+
+ return pollUntilComplete(state.reportsPath)
+ .then(({ data }) => {
+ if (data.status === STATUS_NOT_FOUND) {
+ return dispatch('receiveReportsError', data);
+ }
+ return dispatch('receiveReportsSuccess', {
+ newIssues: parseCodeclimateMetrics(data.new_errors, state.headBlobPath),
+ resolvedIssues: parseCodeclimateMetrics(data.resolved_errors, state.baseBlobPath),
+ });
+ })
+ .catch((error) => dispatch('receiveReportsError', error));
+};
+
+export const receiveReportsSuccess = ({ commit }, data) => {
+ commit(types.RECEIVE_REPORTS_SUCCESS, data);
+};
+
+export const receiveReportsError = ({ commit }, error) => {
+ commit(types.RECEIVE_REPORTS_ERROR, error);
+};
diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/getters.js b/app/assets/javascripts/ci/reports/codequality_report/store/getters.js
new file mode 100644
index 00000000000..70d11e96a54
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/store/getters.js
@@ -0,0 +1,63 @@
+import { spriteIcon } from '~/lib/utils/common_utils';
+import { sprintf, s__, n__ } from '~/locale';
+import { LOADING, ERROR, SUCCESS, STATUS_NOT_FOUND } from '../../constants';
+
+export const hasCodequalityIssues = (state) =>
+ Boolean(state.newIssues?.length || state.resolvedIssues?.length);
+
+export const codequalityStatus = (state) => {
+ if (state.isLoading) {
+ return LOADING;
+ }
+ if (state.hasError) {
+ return ERROR;
+ }
+
+ return SUCCESS;
+};
+
+export const codequalityText = (state) => {
+ const { newIssues, resolvedIssues } = state;
+ let text;
+ if (!newIssues.length && !resolvedIssues.length) {
+ text = s__('ciReport|No changes to code quality');
+ } else if (newIssues.length && resolvedIssues.length) {
+ text = sprintf(
+ s__(`ciReport|Code quality scanning detected %{issueCount} changes in merged results`),
+ {
+ issueCount: newIssues.length + resolvedIssues.length,
+ },
+ );
+ } else if (resolvedIssues.length) {
+ text = n__(
+ `ciReport|Code quality improved due to 1 resolved issue`,
+ `ciReport|Code quality improved due to %d resolved issues`,
+ resolvedIssues.length,
+ );
+ } else if (newIssues.length) {
+ text = n__(
+ `ciReport|Code quality degraded due to 1 new issue`,
+ `ciReport|Code quality degraded due to %d new issues`,
+ newIssues.length,
+ );
+ }
+
+ return text;
+};
+
+export const codequalityPopover = (state) => {
+ if (state.status === STATUS_NOT_FOUND) {
+ return {
+ title: s__('ciReport|Base pipeline codequality artifact not found'),
+ content: sprintf(
+ s__('ciReport|%{linkStartTag}Learn more about codequality reports %{linkEndTag}'),
+ {
+ linkStartTag: `<a href="${state.helpPath}" target="_blank" rel="noopener noreferrer">`,
+ linkEndTag: `${spriteIcon('external-link', 's16')}</a>`,
+ },
+ false,
+ ),
+ };
+ }
+ return {};
+};
diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/index.js b/app/assets/javascripts/ci/reports/codequality_report/store/index.js
new file mode 100644
index 00000000000..5bfcd69edec
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/store/index.js
@@ -0,0 +1,17 @@
+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';
+
+Vue.use(Vuex);
+
+export const getStoreConfig = (initialState) => ({
+ actions,
+ getters,
+ mutations,
+ state: state(initialState),
+});
+
+export default (initialState) => new Vuex.Store(getStoreConfig(initialState));
diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js b/app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js
new file mode 100644
index 00000000000..c362c973ae1
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js
@@ -0,0 +1,5 @@
+export const SET_PATHS = 'SET_PATHS';
+
+export const REQUEST_REPORTS = 'REQUEST_REPORTS';
+export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS';
+export const RECEIVE_REPORTS_ERROR = 'RECEIVE_REPORTS_ERROR';
diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/mutations.js b/app/assets/javascripts/ci/reports/codequality_report/store/mutations.js
new file mode 100644
index 00000000000..249c2f35c0b
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/store/mutations.js
@@ -0,0 +1,27 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_PATHS](state, paths) {
+ state.baseBlobPath = paths.baseBlobPath;
+ state.headBlobPath = paths.headBlobPath;
+ state.reportsPath = paths.reportsPath;
+ state.helpPath = paths.helpPath;
+ },
+ [types.REQUEST_REPORTS](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_REPORTS_SUCCESS](state, data) {
+ state.hasError = false;
+ state.status = '';
+ state.statusReason = '';
+ state.isLoading = false;
+ state.newIssues = data.newIssues;
+ state.resolvedIssues = data.resolvedIssues;
+ },
+ [types.RECEIVE_REPORTS_ERROR](state, error) {
+ state.isLoading = false;
+ state.hasError = true;
+ state.status = error?.status || '';
+ state.statusReason = error?.response?.data?.status_reason;
+ },
+};
diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/state.js b/app/assets/javascripts/ci/reports/codequality_report/store/state.js
new file mode 100644
index 00000000000..f68dbc2a5fa
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/store/state.js
@@ -0,0 +1,16 @@
+export default () => ({
+ reportsPath: null,
+
+ baseBlobPath: null,
+ headBlobPath: null,
+
+ isLoading: false,
+ hasError: false,
+ status: '',
+ statusReason: '',
+
+ newIssues: [],
+ resolvedIssues: [],
+
+ helpPath: null,
+});
diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js b/app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js
new file mode 100644
index 00000000000..417297df43c
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js
@@ -0,0 +1,29 @@
+export const parseCodeclimateMetrics = (issues = [], blobPath = '') => {
+ return issues.map((issue) => {
+ // the `file_path` attribute from the artifact is returned as `file` by GraphQL
+ const issuePath = issue.file_path || issue.path;
+ const parsedIssue = {
+ name: issue.description,
+ path: issuePath,
+ urlPath: `${blobPath}/${issuePath}#L${issue.line}`,
+ ...issue,
+ };
+
+ if (issue?.location?.path) {
+ let parseCodeQualityUrl = `${blobPath}/${issue.location.path}`;
+ parsedIssue.path = issue.location.path;
+
+ if (issue?.location?.lines?.begin) {
+ parsedIssue.line = issue.location.lines.begin;
+ parseCodeQualityUrl += `#L${issue.location.lines.begin}`;
+ } else if (issue?.location?.positions?.begin?.line) {
+ parsedIssue.line = issue.location.positions.begin.line;
+ parseCodeQualityUrl += `#L${issue.location.positions.begin.line}`;
+ }
+
+ parsedIssue.urlPath = parseCodeQualityUrl;
+ }
+
+ return parsedIssue;
+ });
+};
diff --git a/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue b/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue
new file mode 100644
index 00000000000..b21a486e259
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue
@@ -0,0 +1,106 @@
+<script>
+import { s__ } from '~/locale';
+import ReportItem from '~/ci/reports/components/report_item.vue';
+import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
+
+export default {
+ components: {
+ ReportItem,
+ SmartVirtualList,
+ },
+ props: {
+ component: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ nestedLevel: {
+ type: Number,
+ required: false,
+ default: 0,
+ validator: (value) => [0, 1, 2].includes(value),
+ },
+ resolvedIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ unresolvedIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ resolvedHeading: {
+ type: String,
+ required: false,
+ default: s__('ciReport|Fixed'),
+ },
+ unresolvedHeading: {
+ type: String,
+ required: false,
+ default: s__('ciReport|New'),
+ },
+ },
+ groups: ['unresolved', 'resolved'],
+ typicalReportItemHeight: 32,
+ maxShownReportItems: 20,
+ computed: {
+ groups() {
+ return this.$options.groups
+ .map((group) => ({
+ name: group,
+ issues: this[`${group}Issues`],
+ heading: this[`${group}Heading`],
+ }))
+ .filter(({ issues }) => issues.length > 0);
+ },
+ listLength() {
+ // every group has a header which is rendered as a list item
+ const groupsCount = this.groups.length;
+ const issuesCount = this.groups.reduce(
+ (totalIssues, { issues }) => totalIssues + issues.length,
+ 0,
+ );
+
+ return groupsCount + issuesCount;
+ },
+ listClasses() {
+ return {
+ 'gl-pl-9': this.nestedLevel === 1,
+ 'gl-pl-11-5': this.nestedLevel === 2,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <smart-virtual-list
+ :length="listLength"
+ :remain="$options.maxShownReportItems"
+ :size="$options.typicalReportItemHeight"
+ :class="listClasses"
+ class="report-block-container"
+ wtag="ul"
+ wclass="report-block-list"
+ >
+ <template v-for="(group, groupIndex) in groups">
+ <h2
+ :key="group.name"
+ :data-testid="`${group.name}Heading`"
+ :class="[groupIndex > 0 ? 'mt-2' : 'mt-0']"
+ class="h5 mb-1"
+ >
+ {{ group.heading }}
+ </h2>
+ <report-item
+ v-for="(issue, issueIndex) in group.issues"
+ :key="`${group.name}-${issue.name}-${group.name}-${issueIndex}`"
+ :issue="issue"
+ :show-report-section-status-icon="false"
+ :component="component"
+ status="none"
+ />
+ </template>
+ </smart-virtual-list>
+</template>
diff --git a/app/assets/javascripts/ci/reports/components/issue_body.js b/app/assets/javascripts/ci/reports/components/issue_body.js
new file mode 100644
index 00000000000..daff1be30ff
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/components/issue_body.js
@@ -0,0 +1,17 @@
+import IssueStatusIcon from '~/ci/reports/components/issue_status_icon.vue';
+
+export const components = {
+ CodequalityIssueBody: () => import('../codequality_report/components/codequality_issue_body.vue'),
+};
+
+export const componentNames = {
+ CodequalityIssueBody: 'CodequalityIssueBody',
+};
+
+export const iconComponents = {
+ IssueStatusIcon,
+};
+
+export const iconComponentNames = {
+ IssueStatusIcon: IssueStatusIcon.name,
+};
diff --git a/app/assets/javascripts/ci/reports/components/issue_status_icon.vue b/app/assets/javascripts/ci/reports/components/issue_status_icon.vue
new file mode 100644
index 00000000000..bd41b8d23f1
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/components/issue_status_icon.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '../constants';
+
+export default {
+ name: 'IssueStatusIcon',
+ components: {
+ GlIcon,
+ },
+ props: {
+ status: {
+ type: String,
+ required: true,
+ },
+ statusIconSize: {
+ type: Number,
+ required: false,
+ default: 24,
+ },
+ },
+ computed: {
+ iconName() {
+ if (this.isStatusFailed) {
+ return 'status_failed_borderless';
+ } else if (this.isStatusSuccess) {
+ return 'status_success_borderless';
+ }
+
+ return 'dash';
+ },
+ isStatusFailed() {
+ return this.status === STATUS_FAILED;
+ },
+ isStatusSuccess() {
+ return this.status === STATUS_SUCCESS;
+ },
+ isStatusNeutral() {
+ return this.status === STATUS_NEUTRAL;
+ },
+ },
+};
+</script>
+<template>
+ <div
+ :class="{
+ failed: isStatusFailed,
+ success: isStatusSuccess,
+ neutral: isStatusNeutral,
+ }"
+ class="report-block-list-icon"
+ >
+ <gl-icon :name="iconName" :size="statusIconSize" :data-qa-selector="`status_${status}_icon`" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/reports/components/issues_list.vue b/app/assets/javascripts/ci/reports/components/issues_list.vue
new file mode 100644
index 00000000000..ababd4b5e49
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/components/issues_list.vue
@@ -0,0 +1,119 @@
+<script>
+import ReportItem from '~/ci/reports/components/report_item.vue';
+import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/ci/reports/constants';
+import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
+
+const wrapIssueWithState = (status, isNew = false) => (issue) => ({
+ status: issue.status || status,
+ isNew,
+ issue,
+});
+
+/**
+ * Renders block of issues
+ */
+export default {
+ components: {
+ SmartVirtualList,
+ ReportItem,
+ },
+ // Typical height of a report item in px
+ typicalReportItemHeight: 32,
+ /*
+ The maximum amount of shown issues. This is calculated by
+ ( max-height of report-block-list / typicalReportItemHeight ) + some safety margin
+ We will use VirtualList if we have more items than this number.
+ For entries lower than this number, the virtual scroll list calculates the total height of the element wrongly.
+ */
+ maxShownReportItems: 20,
+ props: {
+ newIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ unresolvedIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ resolvedIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ neutralIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ component: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showReportSectionStatusIcon: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ issuesUlElementClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ issueItemClass: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ nestedLevel: {
+ type: Number,
+ required: false,
+ default: 0,
+ validator: (value) => [0, 1, 2].includes(value),
+ },
+ },
+ computed: {
+ issuesWithState() {
+ return [
+ ...this.newIssues.map(wrapIssueWithState(STATUS_FAILED, true)),
+ ...this.unresolvedIssues.map(wrapIssueWithState(STATUS_FAILED)),
+ ...this.neutralIssues.map(wrapIssueWithState(STATUS_NEUTRAL)),
+ ...this.resolvedIssues.map(wrapIssueWithState(STATUS_SUCCESS)),
+ ];
+ },
+ wclass() {
+ return `report-block-list ${this.issuesUlElementClass}`;
+ },
+ listClasses() {
+ return {
+ 'gl-pl-9': this.nestedLevel === 1,
+ 'gl-pl-11-5': this.nestedLevel === 2,
+ };
+ },
+ },
+};
+</script>
+<template>
+ <smart-virtual-list
+ :length="issuesWithState.length"
+ :remain="$options.maxShownReportItems"
+ :size="$options.typicalReportItemHeight"
+ class="report-block-container"
+ :class="listClasses"
+ wtag="ul"
+ :wclass="wclass"
+ >
+ <report-item
+ v-for="(wrapped, index) in issuesWithState"
+ :key="index"
+ :issue="wrapped.issue"
+ :status="wrapped.status"
+ :component="component"
+ :is-new="wrapped.isNew"
+ :show-report-section-status-icon="showReportSectionStatusIcon"
+ :class="issueItemClass"
+ />
+ </smart-virtual-list>
+</template>
diff --git a/app/assets/javascripts/ci/reports/components/report_item.vue b/app/assets/javascripts/ci/reports/components/report_item.vue
new file mode 100644
index 00000000000..97d4ac7bf6f
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/components/report_item.vue
@@ -0,0 +1,67 @@
+<script>
+import {
+ components,
+ componentNames,
+ iconComponents,
+ iconComponentNames,
+} from 'ee_else_ce/ci/reports/components/issue_body';
+
+export default {
+ name: 'ReportItem',
+ components: {
+ ...components,
+ ...iconComponents,
+ },
+ props: {
+ issue: {
+ type: Object,
+ required: true,
+ },
+ component: {
+ type: String,
+ required: false,
+ default: '',
+ validator: (value) => value === '' || Object.values(componentNames).includes(value),
+ },
+ iconComponent: {
+ type: String,
+ required: false,
+ default: iconComponentNames.IssueStatusIcon,
+ validator: (value) => Object.values(iconComponentNames).includes(value),
+ },
+ // failed || success
+ status: {
+ type: String,
+ required: true,
+ },
+ statusIconSize: {
+ type: Number,
+ required: false,
+ default: 24,
+ },
+ isNew: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showReportSectionStatusIcon: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+};
+</script>
+<template>
+ <li class="report-block-list-issue align-items-center" data-qa-selector="report_item_row">
+ <component
+ :is="iconComponent"
+ v-if="showReportSectionStatusIcon"
+ :status="status"
+ :status-icon-size="statusIconSize"
+ class="gl-mr-2"
+ />
+
+ <component :is="component" v-if="component" :issue="issue" :status="status" :is-new="isNew" />
+ </li>
+</template>
diff --git a/app/assets/javascripts/ci/reports/components/report_link.vue b/app/assets/javascripts/ci/reports/components/report_link.vue
new file mode 100644
index 00000000000..1f68f79e487
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/components/report_link.vue
@@ -0,0 +1,30 @@
+<script>
+/* eslint-disable @gitlab/vue-require-i18n-strings */
+export default {
+ name: 'ReportIssueLink',
+ props: {
+ issue: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="report-block-list-issue-description-link">
+ in
+
+ <a
+ v-if="issue.urlPath"
+ :href="issue.urlPath"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ class="break-link"
+ >
+ {{ issue.path }}<template v-if="issue.line">:{{ issue.line }}</template>
+ </a>
+ <template v-else>
+ {{ issue.path }}<template v-if="issue.line">:{{ issue.line }}</template>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/reports/components/report_section.vue b/app/assets/javascripts/ci/reports/components/report_section.vue
new file mode 100644
index 00000000000..468c8916b8d
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/components/report_section.vue
@@ -0,0 +1,237 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import api from '~/api';
+import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { status, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '../constants';
+import IssuesList from './issues_list.vue';
+
+export default {
+ name: 'ReportSection',
+ components: {
+ GlButton,
+ IssuesList,
+ HelpPopover,
+ StatusIcon,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ alwaysOpen: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ component: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ status: {
+ type: String,
+ required: true,
+ },
+ loadingText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ errorText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ successText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ unresolvedIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ resolvedIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ neutralIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ infoText: {
+ type: [String, Boolean],
+ required: false,
+ default: false,
+ },
+ hasIssues: {
+ type: Boolean,
+ required: true,
+ },
+ popoverOptions: {
+ type: Object,
+ default: () => ({}),
+ required: false,
+ },
+ showReportSectionStatusIcon: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ issuesUlElementClass: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ issuesListContainerClass: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ issueItemClass: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ shouldEmitToggleEvent: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ trackAction: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+
+ data() {
+ return {
+ isCollapsed: true,
+ };
+ },
+
+ computed: {
+ isLoading() {
+ return this.status === status.LOADING;
+ },
+ loadingFailed() {
+ return this.status === status.ERROR;
+ },
+ isSuccess() {
+ return this.status === status.SUCCESS;
+ },
+ isCollapsible() {
+ return !this.alwaysOpen && this.hasIssues;
+ },
+ isExpanded() {
+ return this.alwaysOpen || !this.isCollapsed;
+ },
+ statusIconName() {
+ if (this.isLoading) {
+ return 'loading';
+ }
+ if (this.loadingFailed || this.unresolvedIssues.length || this.neutralIssues.length) {
+ return 'warning';
+ }
+ return 'success';
+ },
+ headerText() {
+ if (this.isLoading) {
+ return this.loadingText;
+ }
+
+ if (this.isSuccess) {
+ return this.successText;
+ }
+
+ if (this.loadingFailed) {
+ return this.errorText;
+ }
+
+ return '';
+ },
+ hasPopover() {
+ return Object.keys(this.popoverOptions).length > 0;
+ },
+ slotName() {
+ if (this.isSuccess) {
+ return SLOT_SUCCESS;
+ } else if (this.isLoading) {
+ return SLOT_LOADING;
+ }
+
+ return SLOT_ERROR;
+ },
+ },
+ methods: {
+ toggleCollapsed() {
+ if (this.trackAction) {
+ api.trackRedisHllUserEvent(this.trackAction);
+ }
+
+ if (this.shouldEmitToggleEvent) {
+ this.$emit('toggleEvent');
+ }
+ this.isCollapsed = !this.isCollapsed;
+ },
+ },
+};
+</script>
+<template>
+ <section class="media-section">
+ <div class="media">
+ <status-icon :status="statusIconName" :size="24" class="align-self-center" />
+ <div class="media-body gl-display-flex gl-align-items-flex-start gl-flex-direction-row!">
+ <div
+ data-testid="report-section-code-text"
+ class="js-code-text code-text gl-align-self-center gl-flex-grow-1"
+ >
+ <div class="gl-display-flex gl-align-items-center">
+ <p class="gl-line-height-normal gl-m-0">{{ headerText }}</p>
+ <slot :name="slotName"></slot>
+ <help-popover
+ v-if="hasPopover"
+ :options="popoverOptions"
+ class="gl-ml-2 gl-display-inline-flex"
+ />
+ </div>
+ <slot name="sub-heading"></slot>
+ </div>
+
+ <slot name="action-buttons" :is-collapsible="isCollapsible"></slot>
+
+ <div
+ v-if="isCollapsible"
+ class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3"
+ >
+ <gl-button
+ data-testid="report-section-expand-button"
+ data-qa-selector="expand_report_button"
+ category="tertiary"
+ size="small"
+ :icon="isExpanded ? 'chevron-lg-up' : 'chevron-lg-down'"
+ @click="toggleCollapsed"
+ />
+ </div>
+ </div>
+ </div>
+
+ <div v-if="hasIssues" v-show="isExpanded" class="js-report-section-container">
+ <slot name="body">
+ <issues-list
+ :unresolved-issues="unresolvedIssues"
+ :resolved-issues="resolvedIssues"
+ :neutral-issues="neutralIssues"
+ :component="component"
+ :show-report-section-status-icon="showReportSectionStatusIcon"
+ :issues-ul-element-class="issuesUlElementClass"
+ :class="issuesListContainerClass"
+ :issue-item-class="issueItemClass"
+ />
+ </slot>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/ci/reports/components/summary_row.vue b/app/assets/javascripts/ci/reports/components/summary_row.vue
new file mode 100644
index 00000000000..ee55368c829
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/components/summary_row.vue
@@ -0,0 +1,93 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
+import { ICON_WARNING } from '../constants';
+
+/**
+ * Renders the summary row for each report
+ *
+ * Used both in MR widget and Pipeline's view for:
+ * - Unit tests reports
+ * - Security reports
+ */
+
+export default {
+ name: 'ReportSummaryRow',
+ components: {
+ CiIcon,
+ HelpPopover,
+ GlLoadingIcon,
+ },
+ props: {
+ nestedSummary: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ summary: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ statusIcon: {
+ type: String,
+ required: true,
+ },
+ popoverOptions: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ iconStatus() {
+ return {
+ group: this.statusIcon,
+ icon: `status_${this.statusIcon}`,
+ };
+ },
+ rowClasses() {
+ if (!this.nestedSummary) {
+ return ['gl-px-5'];
+ }
+ return ['gl-pl-9', 'gl-pr-5', { 'gl-bg-gray-10': this.statusIcon === ICON_WARNING }];
+ },
+ statusIconSize() {
+ if (!this.nestedSummary) {
+ return 24;
+ }
+ return 16;
+ },
+ },
+};
+</script>
+<template>
+ <div
+ class="gl-border-t-solid gl-border-t-gray-100 gl-border-t-1 gl-py-3 gl-display-flex gl-align-items-center"
+ :class="rowClasses"
+ >
+ <div class="gl-mr-3">
+ <gl-loading-icon
+ v-if="statusIcon === 'loading'"
+ css-class="report-block-list-loading-icon"
+ size="lg"
+ />
+ <ci-icon v-else :status="iconStatus" :size="statusIconSize" data-testid="summary-row-icon" />
+ </div>
+ <div class="report-block-list-issue-description">
+ <div class="report-block-list-issue-description-text" data-testid="summary-row-description">
+ <slot name="summary">{{ summary }}</slot
+ ><span v-if="popoverOptions" class="text-nowrap"
+ >&nbsp;<help-popover v-if="popoverOptions" :options="popoverOptions" class="align-top" />
+ </span>
+ </div>
+ </div>
+ <div
+ v-if="$slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */"
+ class="text-right flex-fill d-flex justify-content-end flex-column flex-sm-row"
+ >
+ <slot></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/reports/constants.js b/app/assets/javascripts/ci/reports/constants.js
new file mode 100644
index 00000000000..bad6fa1e7b9
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/constants.js
@@ -0,0 +1,38 @@
+export const fieldTypes = {
+ codeBlock: 'codeBlock',
+ link: 'link',
+ seconds: 'seconds',
+ 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 STATUS_NEUTRAL = 'neutral';
+export const STATUS_NOT_FOUND = 'not_found';
+
+export const ICON_WARNING = 'warning';
+export const ICON_SUCCESS = 'success';
+export const ICON_NOTFOUND = 'notfound';
+export const ICON_PENDING = 'pending';
+export const ICON_FAILED = 'failed';
+
+export const status = {
+ LOADING,
+ ERROR,
+ SUCCESS,
+};
+
+export const ACCESSIBILITY_ISSUE_ERROR = 'error';
+export const ACCESSIBILITY_ISSUE_WARNING = 'warning';
+
+/**
+ * Slot names for the ReportSection component, corresponding to the success,
+ * loading and error statuses.
+ */
+export const SLOT_SUCCESS = 'success';
+export const SLOT_LOADING = 'loading';
+export const SLOT_ERROR = 'error';