diff options
Diffstat (limited to 'app/assets/javascripts/vue_merge_request_widget')
32 files changed, 996 insertions, 391 deletions
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue new file mode 100644 index 00000000000..0f9d1b8395b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue @@ -0,0 +1,212 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import eventHub from '../../event_hub'; +import approvalsMixin from '../../mixins/approvals'; +import MrWidgetContainer from '../mr_widget_container.vue'; +import MrWidgetIcon from '../mr_widget_icon.vue'; +import ApprovalsSummary from './approvals_summary.vue'; +import ApprovalsSummaryOptional from './approvals_summary_optional.vue'; +import { FETCH_LOADING, FETCH_ERROR, APPROVE_ERROR, UNAPPROVE_ERROR } from './messages'; + +export default { + name: 'MRWidgetApprovals', + components: { + MrWidgetContainer, + MrWidgetIcon, + ApprovalsSummary, + ApprovalsSummaryOptional, + GlButton, + }, + mixins: [approvalsMixin], + props: { + mr: { + type: Object, + required: true, + }, + service: { + type: Object, + required: true, + }, + isOptionalDefault: { + type: Boolean, + required: false, + default: null, + }, + approveDefault: { + type: Function, + required: false, + default: null, + }, + modalId: { + type: String, + required: false, + default: null, + }, + requirePasswordToApprove: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + fetchingApprovals: true, + hasApprovalAuthError: false, + isApproving: false, + }; + }, + computed: { + isBasic() { + return this.mr.approvalsWidgetType === 'base'; + }, + isApproved() { + return Boolean(this.approvals.approved); + }, + isOptional() { + return this.isOptionalDefault !== null ? this.isOptionalDefault : !this.approvedBy.length; + }, + hasAction() { + return Boolean(this.action); + }, + approvals() { + return this.mr.approvals || {}; + }, + approvedBy() { + return this.approvals.approved_by ? this.approvals.approved_by.map(x => x.user) : []; + }, + userHasApproved() { + return Boolean(this.approvals.user_has_approved); + }, + userCanApprove() { + return Boolean(this.approvals.user_can_approve); + }, + showApprove() { + return !this.userHasApproved && this.userCanApprove && this.mr.isOpen; + }, + showUnapprove() { + return this.userHasApproved && !this.userCanApprove && this.mr.state !== 'merged'; + }, + approvalText() { + return this.isApproved && this.approvedBy.length > 0 + ? s__('mrWidget|Approve additionally') + : s__('mrWidget|Approve'); + }, + action() { + // Use the default approve action, only if we aren't using the auth component for it + if (this.showApprove) { + return { + text: this.approvalText, + category: this.isApproved ? 'secondary' : 'primary', + variant: 'info', + action: () => this.approve(), + }; + } else if (this.showUnapprove) { + return { + text: s__('mrWidget|Revoke approval'), + variant: 'warning', + category: 'secondary', + action: () => this.unapprove(), + }; + } + + return null; + }, + }, + created() { + this.refreshApprovals() + .then(() => { + this.fetchingApprovals = false; + }) + .catch(() => createFlash(FETCH_ERROR)); + }, + methods: { + approve() { + if (this.requirePasswordToApprove) { + this.$root.$emit('bv::show::modal', this.modalId); + return; + } + + this.updateApproval( + () => this.service.approveMergeRequest(), + () => createFlash(APPROVE_ERROR), + ); + }, + approveWithAuth(data) { + this.updateApproval( + () => this.service.approveMergeRequestWithAuth(data), + error => { + if (error && error.response && error.response.status === 401) { + this.hasApprovalAuthError = true; + return; + } + createFlash(APPROVE_ERROR); + }, + ); + }, + unapprove() { + this.updateApproval( + () => this.service.unapproveMergeRequest(), + () => createFlash(UNAPPROVE_ERROR), + ); + }, + updateApproval(serviceFn, errFn) { + this.isApproving = true; + this.clearError(); + return serviceFn() + .then(data => { + this.mr.setApprovals(data); + eventHub.$emit('MRWidgetUpdateRequested'); + this.$emit('updated'); + }) + .catch(errFn) + .then(() => { + this.isApproving = false; + }); + }, + }, + FETCH_LOADING, +}; +</script> +<template> + <mr-widget-container> + <div class="js-mr-approvals d-flex align-items-start align-items-md-center"> + <mr-widget-icon name="approval" /> + <div v-if="fetchingApprovals">{{ $options.FETCH_LOADING }}</div> + <template v-else> + <gl-button + v-if="action" + :variant="action.variant" + :category="action.category" + :loading="isApproving" + class="mr-3" + data-qa-selector="approve_button" + @click="action.action" + > + {{ action.text }} + </gl-button> + <approvals-summary-optional + v-if="isOptional" + :can-approve="hasAction" + :help-path="mr.approvalsHelpPath" + /> + <approvals-summary + v-else + :approved="isApproved" + :approvals-left="approvals.approvals_left || 0" + :rules-left="approvals.approvalRuleNamesLeft" + :approvers="approvedBy" + /> + <slot + :is-approving="isApproving" + :approve-with-auth="approveWithAuth" + :hasApproval-auth-error="hasApprovalAuthError" + ></slot> + </template> + </div> + <template #footer> + <slot name="footer"></slot> + </template> + </mr-widget-container> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue new file mode 100644 index 00000000000..fb342a5d340 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue @@ -0,0 +1,70 @@ +<script> +import { n__, sprintf } from '~/locale'; +import { toNounSeriesText } from '~/lib/utils/grammar'; +import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; +import { APPROVED_MESSAGE } from '~/vue_merge_request_widget/components/approvals/messages'; + +export default { + components: { + UserAvatarList, + }, + props: { + approved: { + type: Boolean, + required: true, + }, + approvalsLeft: { + type: Number, + required: true, + }, + rulesLeft: { + type: Array, + required: false, + default: () => [], + }, + approvers: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + message() { + if (this.approved) { + return APPROVED_MESSAGE; + } + + if (!this.rulesLeft.length) { + return n__('Requires approval.', 'Requires %d more approvals.', this.approvalsLeft); + } + + return sprintf( + n__( + 'Requires approval from %{names}.', + 'Requires %{count} more approvals from %{names}.', + this.approvalsLeft, + ), + { + names: toNounSeriesText(this.rulesLeft), + count: this.approvalsLeft, + }, + false, + ); + }, + hasApprovers() { + return Boolean(this.approvers.length); + }, + }, + APPROVED_MESSAGE, +}; +</script> + +<template> + <div data-qa-selector="approvals_summary_content"> + <strong>{{ message }}</strong> + <template v-if="hasApprovers"> + <span>{{ s__('mrWidget|Approved by') }}</span> + <user-avatar-list class="d-inline-block align-middle" :items="approvers" /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue new file mode 100644 index 00000000000..66af0c5a83e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue @@ -0,0 +1,50 @@ +<script> +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { + OPTIONAL, + OPTIONAL_CAN_APPROVE, +} from '~/vue_merge_request_widget/components/approvals/messages'; + +export default { + components: { + GlLink, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + canApprove: { + type: Boolean, + required: true, + }, + helpPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + message() { + return this.canApprove ? OPTIONAL_CAN_APPROVE : OPTIONAL; + }, + }, +}; +</script> + +<template> + <div class="d-flex align-items-center"> + <span class="text-muted">{{ message }}</span> + <gl-link + v-if="canApprove && helpPath" + v-gl-tooltip + :href="helpPath" + :title="__('About this feature')" + target="_blank" + class="d-flex-center pl-1" + > + <icon name="question" /> + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js b/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js new file mode 100644 index 00000000000..1d9368f71aa --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js @@ -0,0 +1,11 @@ +import { __, s__ } from '~/locale'; + +export const FETCH_LOADING = __('Checking approval status'); +export const FETCH_ERROR = s__( + 'mrWidget|An error occurred while retrieving approval data for this merge request.', +); +export const APPROVE_ERROR = s__('mrWidget|An error occurred while submitting your approval.'); +export const UNAPPROVE_ERROR = s__('mrWidget|An error occurred while removing your approval.'); +export const APPROVED_MESSAGE = s__('mrWidget|Merge request approved.'); +export const OPTIONAL_CAN_APPROVE = s__('mrWidget|No approval required; you can still approve'); +export const OPTIONAL = s__('mrWidget|No approval required'); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/loading.vue b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue index 78dc28ee92b..cd4e31e0dae 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/loading.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue @@ -9,7 +9,7 @@ export default { </script> <template> - <div class="prepend-top-default"> + <div class="gl-mt-3"> <div class="mr-widget-heading p-3"> <gl-skeleton-loader :width="577" :height="12"> <rect width="86" height="12" rx="2" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue index 294871ca5c2..24174c29d51 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue @@ -1,11 +1,11 @@ <script> -import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; export default { components: { - GlDeprecatedButton, + GlButton, GlLoadingIcon, Icon, }, @@ -58,16 +58,17 @@ export default { </div> <template v-else> - <gl-deprecated-button - class="btn-blank btn s32 square append-right-default" + <button + class="btn-blank btn s32 square gl-mr-3" + type="button" :aria-label="ariaLabel" :disabled="isLoading" @click="toggleCollapsed" > <gl-loading-icon v-if="isLoading" /> <icon v-else :name="arrowIconName" class="js-icon" /> - </gl-deprecated-button> - <gl-deprecated-button + </button> + <gl-button variant="link" class="js-title" :disabled="isLoading" @@ -76,7 +77,7 @@ export default { > <template v-if="isCollapsed">{{ title }}</template> <template v-else>{{ __('Collapse') }}</template> - </gl-deprecated-button> + </gl-button> </template> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue index 84937aa9510..598b08f4c16 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue @@ -27,7 +27,7 @@ export default { return this.author.webUrl || this.author.web_url; }, avatarUrl() { - return this.author.avatarUrl || this.author.avatar_url; + return this.author.avatarUrl || this.author.avatar_url || gl.mrWidgetData.defaultAvatarUrl; }, }, }; @@ -40,6 +40,6 @@ export default { class="author-link inline" > <img :src="avatarUrl" class="avatar avatar-inline s16" /> - <span v-if="showAuthorName" class="author"> {{ author.name }} </span> + <span v-if="showAuthorName" class="author">{{ author.name }}</span> </a> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue new file mode 100644 index 00000000000..fd999540f4a --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue @@ -0,0 +1,72 @@ +<script> +import { __ } from '~/locale'; +import { GlButton, GlCollapse, GlIcon } from '@gitlab/ui'; + +/** + * Renders header section with icon and expand button + * Renders expanable content section with grey background + */ +export default { + name: 'MrWidgetExpanableSection', + components: { + GlButton, + GlCollapse, + GlIcon, + }, + props: { + iconName: { + type: String, + required: false, + default: 'status_warning', + }, + }, + data() { + return { + contentIsVisible: false, + }; + }, + computed: { + collapseButtonText() { + if (this.contentIsVisible) { + return __('Collapse'); + } + + return __('Expand'); + }, + }, + methods: { + updateContentVisibility() { + this.contentIsVisible = !this.contentIsVisible; + }, + }, +}; +</script> + +<template> + <div> + <div class="mr-widget-body gl-display-flex"> + <span + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-mr-3 gl-align-self-start gl-mt-1" + > + <gl-icon :name="iconName" :size="24" /> + </span> + + <div class="gl-display-flex gl-flex-fill-1 gl-flex-direction-column gl-md-flex-direction-row"> + <slot name="header"></slot> + + <div> + <gl-button @click="updateContentVisibility"> + {{ collapseButtonText }} + </gl-button> + </div> + </div> + </div> + + <gl-collapse + :visible="contentIsVisible" + class="gl-bg-gray-10 gl-border-t-solid gl-border-gray-100 gl-border-1" + > + <slot name="content"></slot> + </gl-collapse> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 0464c4b9c15..897f706290d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -1,4 +1,5 @@ <script> +import Mousetrap from 'mousetrap'; import { escape } from 'lodash'; import { n__, s__, sprintf } from '~/locale'; import { mergeUrlParams, webIDEUrl } from '~/lib/utils/url_utility'; @@ -74,10 +75,21 @@ export default { : ''; }, }, + mounted() { + Mousetrap.bind('b', this.copyBranchName); + }, + beforeDestroy() { + Mousetrap.unbind('b'); + }, + methods: { + copyBranchName() { + this.$refs.copyBranchNameButton.$el.click(); + }, + }, }; </script> <template> - <div class="d-flex mr-source-target append-bottom-default"> + <div class="d-flex mr-source-target gl-mb-3"> <mr-widget-icon name="git-merge" /> <div class="git-merge-container d-flex"> <div class="normal"> @@ -89,6 +101,7 @@ export default { class="label-branch label-truncate js-source-branch" v-html="mr.sourceBranchLink" /><clipboard-button + ref="copyBranchNameButton" :text="branchNameClipboardData" :title="__('Copy branch name')" css-class="btn-default btn-transparent btn-clipboard" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue index 57d4d8b7ae6..e1659d9a167 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue @@ -13,7 +13,7 @@ export default { </script> <template> - <div class="circle-icon-container append-right-default align-self-start align-self-lg-center"> + <div class="circle-icon-container gl-mr-3 align-self-start align-self-lg-center"> <icon :name="name" :size="24" /> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 6df53311ef0..a096eb1a1fe 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -1,21 +1,22 @@ <script> /* eslint-disable vue/require-default-prop */ -import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { GlIcon, GlLink, GlLoadingIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline'; -import { sprintf, s__ } from '~/locale'; -import PipelineStage from '~/pipelines/components/stage.vue'; +import { s__ } from '~/locale'; +import PipelineStage from '~/pipelines/components/pipelines_list/stage.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import Icon from '~/vue_shared/components/icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; export default { name: 'MRWidgetPipeline', components: { - PipelineStage, CiIcon, - Icon, - TooltipOnTruncate, GlLink, + GlLoadingIcon, + GlIcon, + GlSprintf, + PipelineStage, + TooltipOnTruncate, LinkedPipelinesMiniList: () => import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), }, @@ -54,7 +55,11 @@ export default { type: String, required: false, }, - troubleshootingDocsPath: { + mrTroubleshootingDocsPath: { + type: String, + required: true, + }, + ciTroubleshootingDocsPath: { type: String, required: true, }, @@ -64,10 +69,7 @@ export default { return this.pipeline && Object.keys(this.pipeline).length > 0; }, hasCIError() { - return (this.hasCi && !this.ciStatus) || this.hasPipelineMustSucceedConflict; - }, - hasPipelineMustSucceedConflict() { - return !this.hasCi && this.pipelineMustSucceed; + return this.hasPipeline && !this.ciStatus; }, status() { return this.pipeline.details && this.pipeline.details.status @@ -82,22 +84,6 @@ export default { hasCommitInfo() { return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0; }, - errorText() { - if (this.hasPipelineMustSucceedConflict) { - return s__('Pipeline|No pipeline has been run for this commit.'); - } - - return sprintf( - s__( - 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation%{linkEnd}.', - ), - { - linkStart: `<a href="${this.troubleshootingDocsPath}">`, - linkEnd: '</a>', - }, - false, - ); - }, isTriggeredByMergeRequest() { return Boolean(this.pipeline.merge_request); }, @@ -118,31 +104,69 @@ export default { return ''; }, }, + errorText: s__( + 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation%{linkEnd}.', + ), + monitoringPipelineText: s__('Pipeline|Checking pipeline status.'), }; </script> <template> - <div class="ci-widget media js-ci-widget"> - <template v-if="!hasPipeline || hasCIError"> - <div class="add-border ci-status-icon ci-status-icon-failed ci-error js-ci-error"> - <icon :size="24" name="status_failed_borderless" /> + <div class="ci-widget media"> + <template v-if="hasCIError"> + <gl-icon name="status_failed" class="gl-text-red-500" :size="24" /> + <div + class="gl-flex-fill-1 gl-ml-5" + tabindex="0" + role="text" + :aria-label="$options.errorText" + data-testid="ci-error-message" + > + <gl-sprintf :message="$options.errorText"> + <template #link="{content}"> + <gl-link :href="mrTroubleshootingDocsPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + </template> + <template v-else-if="!hasPipeline"> + <gl-loading-icon size="md" /> + <div class="gl-flex-fill-1 gl-display-flex gl-ml-5" data-testid="monitoring-pipeline-message"> + <span tabindex="0" role="text" :aria-label="$options.monitoringPipelineText"> + <gl-sprintf :message="$options.monitoringPipelineText" /> + </span> + <gl-link + :href="ciTroubleshootingDocsPath" + target="_blank" + class="gl-display-flex gl-align-items-center gl-ml-2" + tabindex="0" + > + <gl-icon + name="question" + :small="12" + tabindex="0" + role="text" + :aria-label="__('Link to go to GitLab pipeline documentation')" + /> + </gl-link> </div> - <div class="media-body prepend-left-default" v-html="errorText"></div> </template> <template v-else-if="hasPipeline"> - <a :href="status.details_path" class="align-self-start append-right-default"> + <a :href="status.details_path" class="align-self-start gl-mr-3"> <ci-icon :status="status" :size="24" :borderless="true" class="add-border" /> </a> <div class="ci-widget-container d-flex"> <div class="ci-widget-content"> <div class="media-body"> <div - class="font-weight-bold js-pipeline-info-container" + class="gl-font-weight-bold" + data-testid="pipeline-info-container" data-qa-selector="merge_request_pipeline_info_content" > {{ pipeline.details.name }} <gl-link :href="pipeline.path" - class="pipeline-id font-weight-normal pipeline-number" + class="pipeline-id gl-font-weight-normal pipeline-number" + data-testid="pipeline-id" data-qa-selector="pipeline_link" >#{{ pipeline.id }}</gl-link > @@ -151,7 +175,8 @@ export default { {{ s__('Pipeline|for') }} <gl-link :href="pipeline.commit.commit_path" - class="commit-sha js-commit-link font-weight-normal" + class="commit-sha gl-font-weight-normal" + data-testid="commit-link" >{{ pipeline.commit.short_id }}</gl-link > </template> @@ -160,18 +185,18 @@ export default { <tooltip-on-truncate :title="sourceBranch" truncate-target="child" - class="label-branch label-truncate font-weight-normal" + class="label-branch label-truncate gl-font-weight-normal" v-html="sourceBranchLink" /> </template> </div> - <div v-if="pipeline.coverage" class="coverage"> + <div v-if="pipeline.coverage" class="coverage" data-testid="pipeline-coverage"> {{ s__('Pipeline|Coverage') }} {{ pipeline.coverage }}% <span v-if="pipelineCoverageDelta" - class="js-pipeline-coverage-delta" :class="coverageDeltaClass" + data-testid="pipeline-coverage-delta" > ({{ pipelineCoverageDelta }}%) </span> @@ -189,13 +214,13 @@ export default { :class="{ 'has-downstream': hasDownstream(i), }" - class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages" + class="stage-container dropdown mr-widget-pipeline-stages" + data-testid="widget-mini-pipeline-graph" > <pipeline-stage :stage="stage" /> </div> </template> </span> - <linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" /> </span> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index 8fba0e2981f..5c307b5ff0c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -82,7 +82,8 @@ export default { :pipeline-must-succeed="mr.onlyAllowMergeIfPipelineSucceeds" :source-branch="branch" :source-branch-link="branchLink" - :troubleshooting-docs-path="mr.troubleshootingDocsPath" + :mr-troubleshooting-docs-path="mr.mrTroubleshootingDocsPath" + :ci-troubleshooting-docs-path="mr.ciTroubleshootingDocsPath" /> <template #footer> <div v-if="mr.exposedArtifactsPath" class="js-exposed-artifacts"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index d0df8309dc7..82566682bca 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -33,7 +33,7 @@ export default { </script> <template> <div class="d-flex align-self-start"> - <div class="square s24 h-auto d-flex-center append-right-default"> + <div class="square s24 h-auto d-flex-center gl-mr-3"> <div v-if="isLoading" class="mr-widget-icon d-inline-flex"> <gl-loading-icon size="md" class="mr-loading-icon d-inline-flex" /> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue index 9942861d9e4..de01821a292 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue @@ -1,22 +1,31 @@ <script> -import { GlLink, GlSprintf } from '@gitlab/ui'; +import { GlLink, GlSprintf, GlButton } from '@gitlab/ui'; import MrWidgetIcon from './mr_widget_icon.vue'; -import PipelineTourState from './states/mr_widget_pipeline_tour.vue'; +import Tracking from '~/tracking'; +import { s__ } from '~/locale'; + +const trackingMixin = Tracking.mixin(); +const TRACK_LABEL = 'no_pipeline_noticed'; export default { name: 'MRWidgetSuggestPipeline', iconName: 'status_notfound', - popoverTarget: 'suggest-popover', - popoverContainer: 'suggest-pipeline', - trackLabel: 'no_pipeline_noticed', + trackLabel: TRACK_LABEL, linkTrackValue: 30, linkTrackEvent: 'click_link', + showTrackValue: 10, + showTrackEvent: 'click_button', + helpContent: s__( + `mrWidget|Use %{linkStart}CI pipelines to test your code%{linkEnd} by simply adding a GitLab CI configuration file to your project. It only takes a minute to make your code more secure and robust.`, + ), + helpURL: 'https://about.gitlab.com/blog/2019/07/12/guide-to-ci-cd-pipelines/', components: { GlLink, GlSprintf, + GlButton, MrWidgetIcon, - PipelineTourState, }, + mixins: [trackingMixin], props: { pipelinePath: { type: String, @@ -31,45 +40,89 @@ export default { required: true, }, }, + computed: { + tracking() { + return { + label: TRACK_LABEL, + property: this.humanAccess, + }; + }, + }, + mounted() { + this.track(); + }, }; </script> <template> - <div :id="$options.popoverContainer" class="d-flex mr-pipeline-suggest append-bottom-default"> - <mr-widget-icon :name="$options.iconName" /> - <div :id="$options.popoverTarget"> - <gl-sprintf - :message=" - s__(`mrWidget|%{prefixToLinkStart}No pipeline%{prefixToLinkEnd} + <div class="mr-widget-body mr-pipeline-suggest gl-mb-3"> + <div class="gl-display-flex gl-align-items-center"> + <mr-widget-icon :name="$options.iconName" /> + <div> + <gl-sprintf + :message=" + s__(`mrWidget|%{prefixToLinkStart}No pipeline%{prefixToLinkEnd} %{addPipelineLinkStart}Add the .gitlab-ci.yml file%{addPipelineLinkEnd} to create one.`) - " - > - <template #prefixToLink="{content}"> + " + > + <template #prefixToLink="{content}"> + <strong> + {{ content }} + </strong> + </template> + <template #addPipelineLink="{content}"> + <gl-link + :href="pipelinePath" + class="gl-ml-1" + data-testid="add-pipeline-link" + :data-track-property="humanAccess" + :data-track-value="$options.linkTrackValue" + :data-track-event="$options.linkTrackEvent" + :data-track-label="$options.trackLabel" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </div> + </div> + <div class="row"> + <div class="col-md-5 order-md-last col-12 gl-mt-5 mt-md-n3 svg-content svg-225"> + <img data-testid="pipeline-image" :src="pipelineSvgPath" /> + </div> + <div class="col-md-7 order-md-first col-12"> + <div class="ml-6 gl-pt-5"> <strong> - {{ content }} + {{ s__('mrWidget|Are you adding technical debt or code vulnerabilities?') }} </strong> - </template> - <template #addPipelineLink="{content}"> - <gl-link + <p class="gl-mt-2"> + <gl-sprintf :message="$options.helpContent"> + <template #link="{ content }"> + <gl-link + data-testid="help" + :href="$options.helpURL" + target="_blank" + class="font-size-inherit" + >{{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + <gl-button + data-testid="ok" + category="primary" + class="gl-mt-2" + variant="info" :href="pipelinePath" - class="ml-2 js-add-pipeline-path" :data-track-property="humanAccess" - :data-track-value="$options.linkTrackValue" - :data-track-event="$options.linkTrackEvent" + :data-track-value="$options.showTrackValue" + :data-track-event="$options.showTrackEvent" :data-track-label="$options.trackLabel" > - {{ content }} - </gl-link> - </template> - </gl-sprintf> - <pipeline-tour-state - :pipeline-path="pipelinePath" - :pipeline-svg-path="pipelineSvgPath" - :human-access="humanAccess" - :popover-target="$options.popoverTarget" - :popover-container="$options.popoverContainer" - :track-label="$options.trackLabel" - /> + {{ __('Show me how to add a pipeline') }} + </gl-button> + </div> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue deleted file mode 100644 index 2ef5e81b36b..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue +++ /dev/null @@ -1,139 +0,0 @@ -<script> -import { __ } from '~/locale'; -import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; -import flash from '~/flash'; -import Poll from '~/lib/utils/poll'; - -export default { - name: 'MRWidgetTerraformPlan', - components: { - GlIcon, - GlLink, - GlLoadingIcon, - GlSprintf, - }, - props: { - endpoint: { - type: String, - required: true, - }, - }, - data() { - return { - loading: true, - plans: {}, - }; - }, - computed: { - addNum() { - return Number(this.plan.create); - }, - changeNum() { - return Number(this.plan.update); - }, - deleteNum() { - return Number(this.plan.delete); - }, - logUrl() { - return this.plan.job_path; - }, - plan() { - const firstPlanKey = Object.keys(this.plans)[0]; - return this.plans[firstPlanKey] ?? {}; - }, - validPlanValues() { - return this.addNum + this.changeNum + this.deleteNum >= 0; - }, - }, - created() { - this.fetchPlans(); - }, - methods: { - fetchPlans() { - this.loading = true; - - const poll = new Poll({ - resource: { - fetchPlans: () => axios.get(this.endpoint), - }, - data: this.endpoint, - method: 'fetchPlans', - successCallback: ({ data }) => { - this.plans = data; - - if (Object.keys(this.plan).length) { - this.loading = false; - poll.stop(); - } - }, - errorCallback: () => { - this.plans = {}; - this.loading = false; - flash(__('An error occurred while loading terraform report')); - }, - }); - - poll.makeRequest(); - }, - }, -}; -</script> - -<template> - <section class="mr-widget-section"> - <div class="mr-widget-body media d-flex flex-row"> - <span class="append-right-default align-self-start align-self-lg-center"> - <gl-icon name="status_warning" :size="24" /> - </span> - - <div class="d-flex flex-fill flex-column flex-md-row"> - <div class="terraform-mr-plan-text normal d-flex flex-column flex-lg-row"> - <p class="m-0 pr-1">{{ __('A terraform report was generated in your pipelines.') }}</p> - - <gl-loading-icon v-if="loading" size="md" /> - - <p v-else-if="validPlanValues" class="m-0"> - <gl-sprintf - :message=" - __( - 'Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete', - ) - " - > - <template #addNum> - <strong>{{ addNum }}</strong> - </template> - - <template #changeNum> - <strong>{{ changeNum }}</strong> - </template> - - <template #deleteNum> - <strong>{{ deleteNum }}</strong> - </template> - </gl-sprintf> - </p> - - <p v-else class="m-0">{{ __('Changes are unknown') }}</p> - </div> - - <div class="terraform-mr-plan-actions"> - <gl-link - v-if="logUrl" - :href="logUrl" - target="_blank" - data-track-event="click_terraform_mr_plan_button" - data-track-label="mr_widget_terraform_mr_plan_button" - data-track-property="terraform_mr_plan_button" - class="btn btn-sm js-terraform-report-link" - rel="noopener" - > - {{ __('View full log') }} - <gl-icon name="external-link" /> - </gl-link> - </div> - </div> - </div> - </section> -</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue index acd8037cfb2..44bdc4a3be8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue @@ -29,7 +29,7 @@ export default { <textarea :id="inputId" :value="value" - class="form-control js-gfm-input append-bottom-default commit-message-edit" + class="form-control js-gfm-input gl-mb-3 commit-message-edit" dir="auto" required="required" rows="7" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue index e4f4032776b..d52e6d38ac6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue @@ -83,7 +83,7 @@ export default { <gl-deprecated-button :aria-label="ariaLabel" variant="blank" - class="commit-edit-toggle square s24 append-right-default" + class="commit-edit-toggle square s24 gl-mr-3" @click.stop="toggle()" > <icon :name="collapseIcon" :size="16" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index 92848e86e76..f02e0ac84da 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -87,7 +87,7 @@ export default { <status-icon status="success" /> <div class="media-body"> <h4 class="d-flex align-items-start"> - <span class="append-right-10"> + <span class="gl-mr-3"> <span class="js-status-text-before-author">{{ statusTextBeforeAuthor }}</span> <mr-widget-author :author="mr.setToAutoMergeBy" /> <span class="js-status-text-after-author">{{ statusTextAfterAuthor }}</span> @@ -113,9 +113,7 @@ export default { {{ s__('mrWidget|The source branch will be deleted') }} </p> <p v-else class="d-flex align-items-start"> - <span class="append-right-10">{{ - s__('mrWidget|The source branch will not be deleted') - }}</span> + <span class="gl-mr-3">{{ s__('mrWidget|The source branch will not be deleted') }}</span> <a v-if="canRemoveSourceBranch" :disabled="isRemovingSourceBranch" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue index a5e3115397a..e02be6dc2f7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue @@ -12,7 +12,7 @@ export default { <div class="mr-widget-body media"> <status-icon :show-disabled-button="true" status="loading" /> <div class="media-body space-children"> - <span class="bold"> {{ s__('mrWidget|Checking ability to merge automatically…') }} </span> + <span class="bold"> {{ s__('mrWidget|Checking if merge request can be merged…') }} </span> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue deleted file mode 100644 index f6bfb178437..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue +++ /dev/null @@ -1,136 +0,0 @@ -<script> -import { GlPopover, GlDeprecatedButton } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; -import Cookies from 'js-cookie'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import Tracking from '~/tracking'; - -const trackingMixin = Tracking.mixin(); - -const cookieKey = 'suggest_pipeline_dismissed'; - -export default { - name: 'MRWidgetPipelineTour', - dismissTrackValue: 20, - showTrackValue: 10, - trackEvent: 'click_button', - components: { - GlPopover, - GlDeprecatedButton, - Icon, - }, - mixins: [trackingMixin], - props: { - pipelinePath: { - type: String, - required: true, - }, - pipelineSvgPath: { - type: String, - required: true, - }, - humanAccess: { - type: String, - required: true, - }, - popoverTarget: { - type: String, - required: true, - }, - popoverContainer: { - type: String, - required: true, - }, - trackLabel: { - type: String, - required: true, - }, - }, - data() { - return { - popoverDismissed: parseBoolean(Cookies.get(cookieKey)), - tracking: { - label: this.trackLabel, - property: this.humanAccess, - }, - }; - }, - mounted() { - this.trackOnShow(); - }, - methods: { - trackOnShow() { - if (!this.popoverDismissed) { - this.track(); - } - }, - dismissPopover() { - this.popoverDismissed = true; - Cookies.set(cookieKey, this.popoverDismissed, { expires: 365 }); - }, - }, -}; -</script> -<template> - <gl-popover - v-if="!popoverDismissed" - show - :target="popoverTarget" - :container="popoverContainer" - placement="rightbottom" - > - <template #title> - <button - class="btn-blank float-right mt-1" - type="button" - :aria-label="__('Close')" - :data-track-property="humanAccess" - :data-track-value="$options.dismissTrackValue" - :data-track-event="$options.trackEvent" - :data-track-label="trackLabel" - @click="dismissPopover" - > - <icon name="close" aria-hidden="true" /> - </button> - {{ s__('mrWidget|Are you adding technical debt or code vulnerabilities?') }} - </template> - <div class="svg-content svg-150 pt-1"> - <img :src="pipelineSvgPath" /> - </div> - <p> - {{ - s__( - 'mrWidget|Detect issues before deployment with a CI pipeline that continuously tests your code. We created a quick guide that will show you how to create one. Make your code more secure and more robust in just a minute.', - ) - }} - </p> - <gl-deprecated-button - ref="ok" - category="primary" - class="mt-2 mb-0" - variant="info" - block - :href="pipelinePath" - :data-track-property="humanAccess" - :data-track-value="$options.showTrackValue" - :data-track-event="$options.trackEvent" - :data-track-label="trackLabel" - > - {{ __('Show me how') }} - </gl-deprecated-button> - <gl-deprecated-button - ref="no-thanks" - category="secondary" - class="mt-2 mb-0" - variant="info" - block - :data-track-property="humanAccess" - :data-track-value="$options.dismissTrackValue" - :data-track-event="$options.trackEvent" - :data-track-label="trackLabel" - @click="dismissPopover" - > - {{ __("No thanks, don't show this again") }} - </gl-deprecated-button> - </gl-popover> -</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 82be5eeb5ff..cc43135f50a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -45,7 +45,8 @@ export default { isMakingRequest: false, isMergingImmediately: false, commitMessage: this.mr.commitMessage, - squashBeforeMerge: this.mr.squash, + squashBeforeMerge: this.mr.squashIsSelected, + isSquashReadOnly: this.mr.squashIsReadonly, successSvg, warningSvg, squashCommitMessage: this.mr.squashCommitMessage, @@ -106,7 +107,12 @@ export default { return this.isMergeButtonDisabled; }, shouldShowSquashBeforeMerge() { - const { commitsCount, enableSquashBeforeMerge } = this.mr; + const { commitsCount, enableSquashBeforeMerge, squashIsReadonly, squashIsSelected } = this.mr; + + if (squashIsReadonly && !squashIsSelected) { + return false; + } + return enableSquashBeforeMerge && commitsCount > 1; }, shouldShowMergeControls() { @@ -344,21 +350,24 @@ export default { v-if="shouldShowSquashBeforeMerge" v-model="squashBeforeMerge" :help-path="mr.squashBeforeMergeHelpPath" - :is-disabled="isMergeButtonDisabled" + :is-disabled="isSquashReadOnly" /> </template> <template v-else> <div class="bold js-resolve-mr-widget-items-message"> - <gl-sprintf + <div v-if="hasPipelineMustSucceedConflict" - :message="pipelineMustSucceedConflictText" + class="gl-display-flex gl-align-items-center" > - <template #link="{ content }"> - <gl-link :href="mr.pipelineMustSucceedDocsPath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> + <gl-sprintf :message="pipelineMustSucceedConflictText" /> + <gl-link + :href="mr.pipelineMustSucceedDocsPath" + target="_blank" + class="gl-display-flex gl-ml-2" + > + <gl-icon name="question" /> + </gl-link> + </div> <gl-sprintf v-else :message="mergeDisabledText" /> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue index 5305894873f..efd58341a2d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue @@ -1,6 +1,7 @@ <script> import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; +import { __ } from '~/locale'; export default { components: { @@ -25,12 +26,22 @@ export default { default: false, }, }, + computed: { + tooltipTitle() { + return this.isDisabled ? __('Required in this project.') : false; + }, + }, }; </script> <template> <div class="inline"> - <label> + <label + v-tooltip + :class="{ 'gl-text-gray-600': isDisabled }" + data-testid="squashLabel" + :data-title="tooltipTitle" + > <input :checked="value" :disabled="isDisabled" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue new file mode 100644 index 00000000000..f6e21dc1ec1 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue @@ -0,0 +1,140 @@ +<script> +import { n__ } from '~/locale'; +import { GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import MrWidgetExpanableSection from '../mr_widget_expandable_section.vue'; +import Poll from '~/lib/utils/poll'; +import TerraformPlan from './terraform_plan.vue'; + +export default { + name: 'MRWidgetTerraformContainer', + components: { + GlSkeletonLoading, + GlSprintf, + MrWidgetExpanableSection, + TerraformPlan, + }, + props: { + endpoint: { + type: String, + required: true, + }, + }, + data() { + return { + loading: true, + plansObject: {}, + poll: null, + }; + }, + computed: { + inValidPlanCountText() { + if (this.numberOfInvalidPlans === 0) { + return null; + } + + return n__( + 'Terraform|%{number} Terraform report failed to generate', + 'Terraform|%{number} Terraform reports failed to generate', + this.numberOfInvalidPlans, + ); + }, + numberOfInvalidPlans() { + return Object.values(this.plansObject).filter(plan => plan.tf_report_error).length; + }, + numberOfPlans() { + return Object.keys(this.plansObject).length; + }, + numberOfValidPlans() { + return this.numberOfPlans - this.numberOfInvalidPlans; + }, + validPlanCountText() { + if (this.numberOfValidPlans === 0) { + return null; + } + + return n__( + 'Terraform|%{number} Terraform report was generated in your pipelines', + 'Terraform|%{number} Terraform reports were generated in your pipelines', + this.numberOfValidPlans, + ); + }, + }, + created() { + this.fetchPlans(); + }, + beforeDestroy() { + this.poll.stop(); + }, + methods: { + fetchPlans() { + this.loading = true; + + this.poll = new Poll({ + resource: { + fetchPlans: () => axios.get(this.endpoint), + }, + data: this.endpoint, + method: 'fetchPlans', + successCallback: ({ data }) => { + this.plansObject = data; + + if (this.numberOfPlans > 0) { + this.loading = false; + this.poll.stop(); + } + }, + errorCallback: () => { + this.plansObject = { bad_plan: { tf_report_error: 'api_error' } }; + this.loading = false; + this.poll.stop(); + }, + }); + + this.poll.makeRequest(); + }, + }, +}; +</script> + +<template> + <section class="mr-widget-section"> + <div v-if="loading" class="mr-widget-body"> + <gl-skeleton-loading /> + </div> + + <mr-widget-expanable-section v-else> + <template #header> + <div + data-testid="terraform-header-text" + class="gl-flex-fill-1 gl-display-flex gl-flex-direction-column" + > + <p v-if="validPlanCountText" class="gl-m-0"> + <gl-sprintf :message="validPlanCountText"> + <template #number> + <strong>{{ numberOfValidPlans }}</strong> + </template> + </gl-sprintf> + </p> + + <p v-if="inValidPlanCountText" class="gl-m-0"> + <gl-sprintf :message="inValidPlanCountText"> + <template #number> + <strong>{{ numberOfInvalidPlans }}</strong> + </template> + </gl-sprintf> + </p> + </div> + </template> + + <template #content> + <terraform-plan + v-for="(plan, key) in plansObject" + :key="key" + :plan="plan" + class="mr-widget-body" + /> + </template> + </mr-widget-expanable-section> + </section> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue new file mode 100644 index 00000000000..dc16d46dd8e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue @@ -0,0 +1,111 @@ +<script> +import { s__ } from '~/locale'; +import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; + +export default { + name: 'TerraformPlan', + components: { + GlIcon, + GlLink, + GlSprintf, + }, + props: { + plan: { + required: true, + type: Object, + }, + }, + computed: { + addNum() { + return Number(this.plan.create); + }, + changeNum() { + return Number(this.plan.update); + }, + deleteNum() { + return Number(this.plan.delete); + }, + iconType() { + return this.validPlanValues ? 'doc-changes' : 'warning'; + }, + reportChangeText() { + if (this.validPlanValues) { + return s__( + 'Terraform|Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete', + ); + } + + return s__('Terraform|Generating the report caused an error.'); + }, + reportHeaderText() { + if (this.validPlanValues) { + return this.plan.job_name + ? s__('Terraform|The Terraform report %{name} was generated in your pipelines.') + : s__('Terraform|A Terraform report was generated in your pipelines.'); + } + + return this.plan.job_name + ? s__('Terraform|The Terraform report %{name} failed to generate.') + : s__('Terraform|A Terraform report failed to generate.'); + }, + validPlanValues() { + return this.addNum + this.changeNum + this.deleteNum >= 0; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex"> + <span + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-mr-3 gl-align-self-start gl-mt-1" + > + <gl-icon :name="iconType" :size="18" data-testid="change-type-icon" /> + </span> + + <div class="gl-display-flex gl-flex-fill-1 gl-flex-direction-column flex-md-row"> + <div class="gl-flex-fill-1 gl-display-flex gl-flex-direction-column"> + <p class="gl-m-0 gl-pr-1"> + <gl-sprintf :message="reportHeaderText"> + <template #name> + <strong>{{ plan.job_name }}</strong> + </template> + </gl-sprintf> + </p> + + <p class="gl-m-0"> + <gl-sprintf :message="reportChangeText"> + <template #addNum> + <strong>{{ addNum }}</strong> + </template> + + <template #changeNum> + <strong>{{ changeNum }}</strong> + </template> + + <template #deleteNum> + <strong>{{ deleteNum }}</strong> + </template> + </gl-sprintf> + </p> + </div> + + <div> + <gl-link + v-if="plan.job_path" + :href="plan.job_path" + target="_blank" + data-testid="terraform-report-link" + data-track-event="click_terraform_mr_plan_button" + data-track-label="mr_widget_terraform_mr_plan_button" + data-track-property="terraform_mr_plan_button" + class="btn btn-sm" + rel="noopener" + > + {{ __('View full log') }} + <gl-icon name="external-link" /> + </gl-link> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index 6f6d145815e..1002bb728a0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -1,3 +1,4 @@ +export const SUCCESS = 'success'; export const WARNING = 'warning'; export const DANGER = 'danger'; diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index 7a9ef7e496e..068829912bf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -1,15 +1,23 @@ import Vue from 'vue'; import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue'; import Translate from '../vue_shared/translate'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; Vue.use(Translate); +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); export default () => { if (gl.mrWidget) return; gl.mrWidgetData.gitlabLogo = gon.gitlab_logo; + gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url; - const vm = new Vue(MrWidgetOptions); + const vm = new Vue({ ...MrWidgetOptions, apolloProvider }); window.gl.mrWidget = { checkStatus: vm.checkStatus, diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js new file mode 100644 index 00000000000..e50555ca875 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js @@ -0,0 +1,19 @@ +import { hideFlash } from '~/flash'; + +export default { + methods: { + clearError() { + this.$emit('clearError'); + this.hasApprovalAuthError = false; + const flashEl = document.querySelector('.flash-alert'); + if (flashEl) { + hideFlash(flashEl); + } + }, + refreshApprovals() { + return this.service.fetchApprovals().then(data => { + this.mr.setApprovals(data); + }); + }, + }, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index 39fa5e465b8..319b6c333f4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -2,7 +2,7 @@ import { __ } from '~/locale'; export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.'); export const PIPELINE_MUST_SUCCEED_CONFLICT_TEXT = __( - 'Pipelines must succeed for merge requests to be eligible to merge. Please enable pipelines for this project to continue. For more information, see the %{linkStart}documentation.%{linkEnd}', + 'A CI/CD pipeline must run and be successful before merge.', ); export default { 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 265ff81f39f..cff85fe232d 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 @@ -2,6 +2,7 @@ import { isEmpty } from 'lodash'; import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; +import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue'; import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; import { sprintf, s__, __ } from '~/locale'; import Project from '~/pages/projects/project'; @@ -36,7 +37,8 @@ import CheckingState from './components/states/mr_widget_checking.vue'; import eventHub from './event_hub'; import notify from '~/lib/utils/notify'; import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue'; -import TerraformPlan from './components/mr_widget_terraform_plan.vue'; +import TerraformPlan from './components/terraform/mr_widget_terraform_container.vue'; +import GroupedCodequalityReportsApp from '../reports/codequality_report/grouped_codequality_reports_app.vue'; import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue'; import { setFaviconOverlay } from '../lib/utils/common_utils'; import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue'; @@ -75,9 +77,11 @@ export default { 'mr-widget-auto-merge-failed': AutoMergeFailed, 'mr-widget-rebase': RebaseState, SourceBranchRemovalStatus, + GroupedCodequalityReportsApp, GroupedTestReportsApp, TerraformPlan, GroupedAccessibilityReportsApp, + MrWidgetApprovals, }, props: { mrData: { @@ -96,6 +100,9 @@ export default { }; }, computed: { + shouldRenderApprovals() { + return this.mr.state !== 'nothingToMerge'; + }, componentName() { return stateMaps.stateToComponentMap[this.mr.state]; }, @@ -111,6 +118,9 @@ export default { shouldSuggestPipelines() { return gon.features?.suggestPipeline && !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath; }, + shouldRenderCodeQuality() { + return this.mr?.codeclimate?.head_path; + }, shouldRenderRelatedLinks() { return Boolean(this.mr.relatedLinks) && !this.mr.isNothingToMergeState; }, @@ -216,6 +226,9 @@ export default { mergeRequestCachedWidgetPath: store.mergeRequestCachedWidgetPath, mergeActionsContentPath: store.mergeActionsContentPath, rebasePath: store.rebasePath, + apiApprovalsPath: store.apiApprovalsPath, + apiApprovePath: store.apiApprovePath, + apiUnapprovePath: store.apiUnapprovePath, }; }, createService(store) { @@ -365,7 +378,7 @@ export default { }; </script> <template> - <div v-if="mr" class="mr-state-widget prepend-top-default"> + <div v-if="mr" class="mr-state-widget gl-mt-3"> <mr-widget-header :mr="mr" /> <mr-widget-suggest-pipeline v-if="shouldSuggestPipelines" @@ -379,11 +392,27 @@ export default { class="mr-widget-workflow" :mr="mr" /> + <mr-widget-approvals + v-if="shouldRenderApprovals" + class="mr-widget-workflow" + :mr="mr" + :service="service" + /> <div class="mr-section-container mr-widget-workflow"> + <grouped-codequality-reports-app + v-if="shouldRenderCodeQuality" + :base-path="mr.codeclimate.base_path" + :head-path="mr.codeclimate.head_path" + :head-blob-path="mr.headBlobPath" + :base-blob-path="mr.baseBlobPath" + :codequality-help-path="mr.codequalityHelpPath" + /> + <grouped-test-reports-app v-if="mr.testResultsPath" class="js-reports-container" :endpoint="mr.testResultsPath" + :pipeline-path="mr.pipeline.path" /> <terraform-plan v-if="mr.terraformReportsPath" :endpoint="mr.terraformReportsPath" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js index c620023a6d6..ee9e3cc6d08 100644 --- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -3,6 +3,10 @@ import axios from '../../lib/utils/axios_utils'; export default class MRWidgetService { constructor(endpoints) { this.endpoints = endpoints; + + this.apiApprovalsPath = endpoints.apiApprovalsPath; + this.apiApprovePath = endpoints.apiApprovePath; + this.apiUnapprovePath = endpoints.apiUnapprovePath; } merge(data) { @@ -54,6 +58,18 @@ export default class MRWidgetService { return axios.post(this.endpoints.rebasePath); } + fetchApprovals() { + return axios.get(this.apiApprovalsPath).then(res => res.data); + } + + approveMergeRequest() { + return axios.post(this.apiApprovePath).then(res => res.data); + } + + unapproveMergeRequest() { + return axios.post(this.apiUnapprovePath).then(res => res.data); + } + static executeInlineAction(url) { return axios.post(url); } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index a2ee0bc3ca1..44e8167d6a3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -11,12 +11,12 @@ export default function deviseState(data) { return stateKey.checking; } else if (data.has_conflicts) { return stateKey.conflicts; - } else if (data.work_in_progress) { - return stateKey.workInProgress; } else if (this.shouldBeRebased) { return stateKey.rebase; } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { return stateKey.pipelineFailed; + } else if (data.work_in_progress) { + return stateKey.workInProgress; } else if (this.hasMergeableDiscussionsState) { return stateKey.unresolvedDiscussions; } else if (this.isPipelineBlocked) { 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 d61e122d612..8b9799d9775 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 @@ -9,12 +9,19 @@ export default class MergeRequestStore { this.sha = data.diff_head_sha; this.gitlabLogo = data.gitlabLogo; + this.apiApprovalsPath = data.api_approvals_path; + this.apiApprovePath = data.api_approve_path; + this.apiUnapprovePath = data.api_unapprove_path; + this.hasApprovalsAvailable = data.has_approvals_available; + this.setPaths(data); this.setData(data); } setData(data, isRebased) { + this.initApprovals(); + if (isRebased) { this.sha = data.diff_head_sha; } @@ -22,7 +29,10 @@ export default class MergeRequestStore { const pipelineStatus = data.pipeline ? data.pipeline.details.status : null; this.squash = data.squash; + this.squashIsEnabledByDefault = data.squash_enabled_by_default; + this.squashIsReadonly = data.squash_readonly; this.enableSquashBeforeMerge = this.enableSquashBeforeMerge || true; + this.squashIsSelected = data.squash_readonly ? data.squash_on_merge : data.squash; this.iid = data.iid; this.title = data.title; @@ -49,6 +59,7 @@ export default class MergeRequestStore { this.squashCommitMessage = data.default_squash_commit_message; this.rebaseInProgress = data.rebase_in_progress; this.mergeRequestDiffsPath = data.diffs_path; + this.approvalsWidgetType = data.approvals_widget_type; if (data.issues_links) { const links = data.issues_links; @@ -160,7 +171,8 @@ export default class MergeRequestStore { setPaths(data) { // Paths are set on the first load of the page and not auto-refreshed this.squashBeforeMergeHelpPath = data.squash_before_merge_help_path; - this.troubleshootingDocsPath = data.troubleshooting_docs_path; + this.mrTroubleshootingDocsPath = data.mr_troubleshooting_docs_path; + this.ciTroubleshootingDocsPath = data.ci_troubleshooting_docs_path; this.pipelineMustSucceedDocsPath = data.pipeline_must_succeed_docs_path; this.mergeRequestBasicPath = data.merge_request_basic_path; this.mergeRequestWidgetPath = data.merge_request_widget_path; @@ -177,10 +189,18 @@ export default class MergeRequestStore { this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path; this.eligibleApproversDocsPath = data.eligible_approvers_docs_path; this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path; + this.approvalsHelpPath = data.approvals_help_path; this.mergeRequestAddCiConfigPath = data.merge_request_add_ci_config_path; this.pipelinesEmptySvgPath = data.pipelines_empty_svg_path; this.humanAccess = data.human_access; this.newPipelinePath = data.new_project_pipeline_path; + + // codeclimate + const blobPath = data.blob_path || {}; + this.headBlobPath = blobPath.head_path || ''; + this.baseBlobPath = blobPath.base_path || ''; + this.codequalityHelpPath = data.codequality_help_path; + this.codeclimate = data.codeclimate; } get isNothingToMergeState() { @@ -240,4 +260,14 @@ export default class MergeRequestStore { return undefined; } + + initApprovals() { + this.isApproved = this.isApproved || false; + this.approvals = this.approvals || null; + } + + setApprovals(data) { + this.approvals = data; + this.isApproved = data.approved || false; + } } |