diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-12-02 03:07:06 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-12-02 03:07:06 +0300 |
commit | b26eec8cbcf32085079eee0e196456eccefc993f (patch) | |
tree | 822f69aeec23b0b3512583b2221000a8035241f7 /app/assets/javascripts/ci | |
parent | 61666f277a484725307ae2b34697b13a300b2129 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/ci')
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" + > <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'; |