diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-09 18:10:12 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-09 18:10:12 +0300 |
commit | e91cb68359c900aa51ffdb1863502168742e94f0 (patch) | |
tree | b7dd1749da6e2a11899905b4eae258236cd4f6a6 /app | |
parent | 1361891b0a87187364d1586395df176a8984e914 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
29 files changed, 568 insertions, 207 deletions
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 48e81b168ec..347828888dc 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -1,13 +1,14 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ import { isEmpty } from 'lodash'; -import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; +import { GlTooltipDirective, GlIcon, GlLink } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import CommitComponent from '~/vue_shared/components/commit.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import eventHub from '../event_hub'; import ActionsComponent from './environment_actions.vue'; import ExternalUrlComponent from './environment_external_url.vue'; @@ -30,6 +31,7 @@ export default { CommitComponent, ExternalUrlComponent, GlIcon, + GlLink, MonitoringButtonComponent, PinComponent, DeleteComponent, @@ -38,6 +40,7 @@ export default { TerminalButtonComponent, TooltipOnTruncate, UserAvatarLink, + CiIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -81,6 +84,24 @@ export default { }, /** + * @returns {Object|Undefined} The `upcoming_deployment` object if it exists. + * Otherwise, `undefined`. + */ + upcomingDeployment() { + return this.model?.upcoming_deployment; + }, + + /** + * @returns {String} Text that will be shown in the tooltip when + * the user hovers over the upcoming deployment's status icon. + */ + upcomingDeploymentTooltipText() { + return sprintf(s__('Environments|Deployment %{status}'), { + status: this.upcomingDeployment.deployable.status.text, + }); + }, + + /** * Checkes whether the row displayed is a folder. * * @returns {Boolean} @@ -235,6 +256,18 @@ export default { }, /** + * Same as `userImageAltDescription`, but for the + * upcoming deployment's user + * + * @returns {String} + */ + upcomingDeploymentUserImageAltDescription() { + return sprintf(__("%{username}'s avatar"), { + username: this.upcomingDeployment.user.username, + }); + }, + + /** * If provided, returns the commit tag. * * @returns {String|Undefined} @@ -382,6 +415,15 @@ export default { }, /** + * Same as `deploymentInternalId`, but for the upcoming deployment + * + * @returns {String} + */ + upcomingDeploymentInternalId() { + return `#${this.upcomingDeployment.iid}`; + }, + + /** * Verifies if the user object is present under last_deployment object. * * @returns {Boolean} @@ -503,6 +545,13 @@ export default { folderIconName() { return this.model.isOpen ? 'chevron-down' : 'chevron-right'; }, + + upcomingDeploymentCellClasses() { + return [ + this.tableData.upcoming.spacing, + { 'gl-display-none gl-display-md-block': !this.upcomingDeployment }, + ]; + }, }, methods: { @@ -512,6 +561,19 @@ export default { onClickFolder() { eventHub.$emit('toggleFolder', this.model); }, + + /** + * Returns the field title that will be shown in the field's row + * in the mobile view. + * + * @returns `field.mobileTitle` if present; + * if not, falls back to `field.title`. + */ + getMobileViewTitleForField(fieldName) { + const field = this.tableData[fieldName]; + + return field.mobileTitle || field.title; + }, }, }; </script> @@ -530,7 +592,7 @@ export default { role="gridcell" > <div v-if="!isFolder" class="table-mobile-header" role="rowheader"> - {{ tableData.name.title }} + {{ getMobileViewTitleForField('name') }} </div> <span v-if="shouldRenderDeployBoard" class="deploy-board-icon" @click="toggleDeployBoard"> @@ -609,7 +671,9 @@ export default { </div> <div v-if="!isFolder" class="table-section" :class="tableData.commit.spacing" role="gridcell"> - <div role="rowheader" class="table-mobile-header">{{ tableData.commit.title }}</div> + <div role="rowheader" class="table-mobile-header"> + {{ getMobileViewTitleForField('commit') }} + </div> <div v-if="hasLastDeploymentKey" class="js-commit-component table-mobile-content"> <commit-component :tag="commitTag" @@ -623,7 +687,9 @@ export default { </div> <div v-if="!isFolder" class="table-section" :class="tableData.date.spacing" role="gridcell"> - <div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div> + <div role="rowheader" class="table-mobile-header"> + {{ getMobileViewTitleForField('date') }} + </div> <span v-if="canShowDeploymentDate" v-gl-tooltip @@ -636,8 +702,51 @@ export default { </span> </div> + <div + v-if="!isFolder" + class="table-section" + :class="upcomingDeploymentCellClasses" + role="gridcell" + data-testid="upcoming-deployment" + > + <div role="rowheader" class="table-mobile-header"> + {{ getMobileViewTitleForField('upcoming') }} + </div> + <div + v-if="upcomingDeployment" + class="gl-w-full gl-display-flex gl-flex-direction-row gl-md-flex-direction-column! gl-justify-content-end" + data-testid="upcoming-deployment-content" + > + <div class="gl-display-flex gl-align-items-center"> + <span class="gl-mr-2">{{ upcomingDeploymentInternalId }}</span> + <gl-link + v-if="upcomingDeployment.deployable" + v-gl-tooltip + :href="upcomingDeployment.deployable.build_path" + :title="upcomingDeploymentTooltipText" + data-testid="upcoming-deployment-status-link" + > + <ci-icon class="gl-mr-2" :status="upcomingDeployment.deployable.status" /> + </gl-link> + </div> + <div class="gl-display-flex"> + <span v-if="upcomingDeployment.user" class="text-break-word"> + by + <user-avatar-link + :link-href="upcomingDeployment.user.web_url" + :img-src="upcomingDeployment.user.avatar_url" + :img-alt="upcomingDeploymentUserImageAltDescription" + :tooltip-text="upcomingDeployment.user.username" + /> + </span> + </div> + </div> + </div> + <div v-if="!isFolder" class="table-section" :class="tableData.autoStop.spacing" role="gridcell"> - <div role="rowheader" class="table-mobile-header">{{ tableData.autoStop.title }}</div> + <div role="rowheader" class="table-mobile-header"> + {{ getMobileViewTitleForField('autoStop') }} + </div> <span v-if="canShowAutoStopDate" v-gl-tooltip diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index c1b3eabec16..3cfff686c01 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -71,7 +71,7 @@ export default { // percent spacing for cols, should add up to 100 name: { title: s__('Environments|Environment'), - spacing: 'section-15', + spacing: 'section-10', }, deploy: { title: s__('Environments|Deployment'), @@ -83,18 +83,23 @@ export default { }, commit: { title: s__('Environments|Commit'), - spacing: 'section-20', + spacing: 'section-15', }, date: { title: s__('Environments|Updated'), spacing: 'section-10', }, + upcoming: { + title: s__('Environments|Upcoming'), + mobileTitle: s__('Environments|Upcoming deployment'), + spacing: 'section-10', + }, autoStop: { title: s__('Environments|Auto stop in'), - spacing: 'section-5', + spacing: 'section-10', }, actions: { - spacing: 'section-25', + spacing: 'section-20', }, }; }, @@ -160,6 +165,9 @@ export default { <div class="table-section" :class="tableData.date.spacing" role="columnheader"> {{ tableData.date.title }} </div> + <div class="table-section" :class="tableData.upcoming.spacing" role="columnheader"> + {{ tableData.upcoming.title }} + </div> <div class="table-section" :class="tableData.autoStop.spacing" role="columnheader"> {{ tableData.autoStop.title }} </div> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue index 769fff6b111..9ca4dc1e27a 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue @@ -2,7 +2,6 @@ import { escape, capitalize } from 'lodash'; import { GlLoadingIcon } from '@gitlab/ui'; import StageColumnComponentLegacy from './stage_column_component_legacy.vue'; -import GraphWidthMixin from '../../mixins/graph_width_mixin'; import LinkedPipelinesColumnLegacy from './linked_pipelines_column_legacy.vue'; import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin'; import { UPSTREAM, DOWNSTREAM, MAIN } from './constants'; @@ -14,7 +13,7 @@ export default { LinkedPipelinesColumnLegacy, StageColumnComponentLegacy, }, - mixins: [GraphWidthMixin, GraphBundleMixin], + mixins: [GraphBundleMixin], props: { isLoading: { type: Boolean, @@ -183,87 +182,83 @@ export default { class="pipeline-visualization pipeline-graph" :class="{ 'pipeline-tab-content': !isLinkedPipeline }" > - <div - :style="{ - paddingLeft: `${graphLeftPadding}px`, - paddingRight: `${graphRightPadding}px`, - }" - > - <gl-loading-icon v-if="isLoading" class="m-auto" size="lg" /> - - <pipeline-graph-legacy - v-if="pipelineTypeUpstream" - :type="$options.upstream" - class="d-inline-block upstream-pipeline" - :class="`js-upstream-pipeline-${expandedUpstream.id}`" - :is-loading="false" - :pipeline="expandedUpstream" - :is-linked-pipeline="true" - :mediator="mediator" - @onClickUpstreamPipeline="clickUpstreamPipeline" - @refreshPipelineGraph="requestRefreshPipelineGraph" - /> + <div class="gl-w-full"> + <div class="container-fluid container-limited"> + <gl-loading-icon v-if="isLoading" class="m-auto" size="lg" /> + <pipeline-graph-legacy + v-if="pipelineTypeUpstream" + :type="$options.upstream" + class="d-inline-block upstream-pipeline" + :class="`js-upstream-pipeline-${expandedUpstream.id}`" + :is-loading="false" + :pipeline="expandedUpstream" + :is-linked-pipeline="true" + :mediator="mediator" + @onClickUpstreamPipeline="clickUpstreamPipeline" + @refreshPipelineGraph="requestRefreshPipelineGraph" + /> - <linked-pipelines-column-legacy - v-if="hasUpstream" - :type="$options.upstream" - :linked-pipelines="upstreamPipelines" - :column-title="__('Upstream')" - :project-id="pipelineProjectId" - @linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)" - /> + <linked-pipelines-column-legacy + v-if="hasUpstream" + :type="$options.upstream" + :linked-pipelines="upstreamPipelines" + :column-title="__('Upstream')" + :project-id="pipelineProjectId" + @linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)" + /> - <ul - v-if="!isLoading" - :class="{ - 'inline js-has-linked-pipelines': hasDownstream || hasUpstream, - }" - class="stage-column-list align-top" - > - <stage-column-component-legacy - v-for="(stage, index) in graph" - :key="stage.name" + <ul + v-if="!isLoading" :class="{ - 'has-upstream gl-ml-11': hasUpstreamColumn(index), - 'has-only-one-job': hasOnlyOneJob(stage), - 'gl-mr-26': shouldAddRightMargin(index), + 'inline js-has-linked-pipelines': hasDownstream || hasUpstream, }" - :title="capitalizeStageName(stage.name)" - :groups="stage.groups" - :stage-connector-class="stageConnectorClass(index, stage)" - :is-first-column="isFirstColumn(index)" - :has-upstream="hasUpstream" - :action="stage.status.action" - :job-hovered="jobName" - :pipeline-expanded="pipelineExpanded" - @refreshPipelineGraph="refreshPipelineGraph" - /> - </ul> + class="stage-column-list align-top" + > + <stage-column-component-legacy + v-for="(stage, index) in graph" + :key="stage.name" + :class="{ + 'has-upstream gl-ml-11': hasUpstreamColumn(index), + 'has-only-one-job': hasOnlyOneJob(stage), + 'gl-mr-26': shouldAddRightMargin(index), + }" + :title="capitalizeStageName(stage.name)" + :groups="stage.groups" + :stage-connector-class="stageConnectorClass(index, stage)" + :is-first-column="isFirstColumn(index)" + :has-upstream="hasUpstream" + :action="stage.status.action" + :job-hovered="jobName" + :pipeline-expanded="pipelineExpanded" + @refreshPipelineGraph="refreshPipelineGraph" + /> + </ul> - <linked-pipelines-column-legacy - v-if="hasDownstream" - :type="$options.downstream" - :linked-pipelines="downstreamPipelines" - :column-title="__('Downstream')" - :project-id="pipelineProjectId" - @linkedPipelineClick="handleClickedDownstream" - @downstreamHovered="setJob" - @pipelineExpandToggle="setPipelineExpanded" - /> + <linked-pipelines-column-legacy + v-if="hasDownstream" + :type="$options.downstream" + :linked-pipelines="downstreamPipelines" + :column-title="__('Downstream')" + :project-id="pipelineProjectId" + @linkedPipelineClick="handleClickedDownstream" + @downstreamHovered="setJob" + @pipelineExpandToggle="setPipelineExpanded" + /> - <pipeline-graph-legacy - v-if="pipelineTypeDownstream" - :type="$options.downstream" - class="d-inline-block" - :class="`js-downstream-pipeline-${expandedDownstream.id}`" - :is-loading="false" - :pipeline="expandedDownstream" - :is-linked-pipeline="true" - :style="{ 'margin-top': downstreamMarginTop }" - :mediator="mediator" - @onClickDownstreamPipeline="clickDownstreamPipeline" - @refreshPipelineGraph="requestRefreshPipelineGraph" - /> + <pipeline-graph-legacy + v-if="pipelineTypeDownstream" + :type="$options.downstream" + class="d-inline-block" + :class="`js-downstream-pipeline-${expandedDownstream.id}`" + :is-loading="false" + :pipeline="expandedDownstream" + :is-linked-pipeline="true" + :style="{ 'margin-top': downstreamMarginTop }" + :mediator="mediator" + @onClickDownstreamPipeline="clickDownstreamPipeline" + @refreshPipelineGraph="requestRefreshPipelineGraph" + /> + </div> </div> </div> </div> diff --git a/app/assets/javascripts/pipelines/mixins/graph_width_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_width_mixin.js deleted file mode 100644 index 2dbaa5a5c9a..00000000000 --- a/app/assets/javascripts/pipelines/mixins/graph_width_mixin.js +++ /dev/null @@ -1,50 +0,0 @@ -import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; -import { LAYOUT_CHANGE_DELAY } from '~/pipelines/constants'; - -export default { - debouncedResize: null, - sidebarMutationObserver: null, - data() { - return { - graphLeftPadding: 0, - graphRightPadding: 0, - }; - }, - beforeDestroy() { - window.removeEventListener('resize', this.$options.debouncedResize); - - if (this.$options.sidebarMutationObserver) { - this.$options.sidebarMutationObserver.disconnect(); - } - }, - created() { - this.$options.debouncedResize = debounceByAnimationFrame(this.setGraphPadding); - window.addEventListener('resize', this.$options.debouncedResize); - }, - mounted() { - this.setGraphPadding(); - - this.$options.sidebarMutationObserver = new MutationObserver(this.handleLayoutChange); - this.$options.sidebarMutationObserver.observe(document.querySelector('.layout-page'), { - attributes: true, - childList: false, - subtree: false, - }); - }, - methods: { - setGraphPadding() { - // only add padding to main graph (not inline upstream/downstream graphs) - if (this.type && this.type !== 'main') return; - - const container = document.querySelector('.js-pipeline-container'); - if (!container) return; - - this.graphLeftPadding = container.offsetLeft; - this.graphRightPadding = window.innerWidth - container.offsetLeft - container.offsetWidth; - }, - handleLayoutChange() { - // wait until animations finish, then recalculate padding - window.setTimeout(this.setGraphPadding, LAYOUT_CHANGE_DELAY); - }, - }, -}; diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue index 69eabfe5339..b47126cdeb3 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -60,6 +60,7 @@ export default { }, data() { return { + formattedMarkdown: null, parsedSource: parseSourceFile(this.preProcess(true, this.content)), editorMode: EDITOR_TYPES.wysiwyg, hasMatter: false, @@ -140,10 +141,14 @@ export default { onSubmit() { const preProcessedContent = this.preProcess(false, this.parsedSource.content()); this.$emit('submit', { + formattedMarkdown: this.formattedMarkdown, content: preProcessedContent, images: this.$options.imageRepository.getAll(), }); }, + onEditorLoad({ formattedMarkdown }) { + this.formattedMarkdown = formattedMarkdown; + }, }, }; </script> @@ -167,6 +172,7 @@ export default { @modeChange="onModeChange" @input="onInputChange" @uploadImage="onUploadImage" + @load="onEditorLoad" /> <unsaved-changes-confirm-dialog :modified="isSaveable" /> <publish-toolbar diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js index d6a54176a3b..4cabd943e22 100644 --- a/app/assets/javascripts/static_site_editor/constants.js +++ b/app/assets/javascripts/static_site_editor/constants.js @@ -15,6 +15,14 @@ export const LOAD_CONTENT_ERROR = __( 'An error ocurred while loading your content. Please try again.', ); +export const DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE = s__( + 'StaticSiteEditor|Automatic formatting changes', +); + +export const DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION = s__( + 'StaticSiteEditor|Markdown formatting preferences introduced by the Static Site Editor', +); + export const DEFAULT_HEADING = s__('StaticSiteEditor|Static site editor'); export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit'; diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js index 4137ede49c6..1bd79d40071 100644 --- a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js +++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js @@ -4,7 +4,17 @@ import savedContentMetaQuery from '../queries/saved_content_meta.query.graphql'; const submitContentChangesResolver = ( _, - { input: { project: projectId, username, sourcePath, content, images, mergeRequestMeta } }, + { + input: { + project: projectId, + username, + sourcePath, + content, + images, + mergeRequestMeta, + formattedMarkdown, + }, + }, { cache }, ) => { return submitContentChanges({ @@ -14,6 +24,7 @@ const submitContentChangesResolver = ( content, images, mergeRequestMeta, + formattedMarkdown, }).then(savedContentMeta => { const data = produce(savedContentMeta, draftState => { return { diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index 68943113c14..1e52e73294e 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -53,6 +53,7 @@ export default { return { content: null, images: null, + formattedMarkdown: null, submitChangesError: null, isSavingChanges: false, }; @@ -79,9 +80,10 @@ export default { onDismissError() { this.submitChangesError = null; }, - onPrepareSubmit({ content, images }) { + onPrepareSubmit({ formattedMarkdown, content, images }) { this.content = content; this.images = images; + this.formattedMarkdown = formattedMarkdown; this.isSavingChanges = true; this.$refs.editMetaModal.show(); @@ -110,6 +112,7 @@ export default { username: this.appData.username, sourcePath: this.appData.sourcePath, content: this.content, + formattedMarkdown: this.formattedMarkdown, images: this.images, mergeRequestMeta, }, diff --git a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js index e7aeb73e88b..e57028ea05a 100644 --- a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js +++ b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js @@ -12,6 +12,8 @@ import { TRACKING_ACTION_CREATE_MERGE_REQUEST, USAGE_PING_TRACKING_ACTION_CREATE_COMMIT, USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST, + DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE, + DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION, } from '../constants'; const createBranch = (projectId, branch) => @@ -47,7 +49,15 @@ const createImageActions = (images, markdown) => { return actions; }; -const commitContent = (projectId, message, branch, sourcePath, content, images) => { +const createUpdateSourceFileAction = (sourcePath, content) => [ + convertObjectPropsToSnakeCase({ + action: 'update', + filePath: sourcePath, + content, + }), +]; + +const commit = (projectId, message, branch, actions) => { Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_COMMIT); Api.trackRedisCounterEvent(USAGE_PING_TRACKING_ACTION_CREATE_COMMIT); @@ -56,14 +66,7 @@ const commitContent = (projectId, message, branch, sourcePath, content, images) convertObjectPropsToSnakeCase({ branch, commitMessage: message, - actions: [ - convertObjectPropsToSnakeCase({ - action: 'update', - filePath: sourcePath, - content, - }), - ...createImageActions(images, content), - ], + actions, }), ).catch(() => { throw new Error(SUBMIT_CHANGES_COMMIT_ERROR); @@ -100,6 +103,7 @@ const submitContentChanges = ({ content, images, mergeRequestMeta, + formattedMarkdown, }) => { const branch = generateBranchName(username); const { title: mergeRequestTitle, description: mergeRequestDescription } = mergeRequestMeta; @@ -107,10 +111,25 @@ const submitContentChanges = ({ return createBranch(projectId, branch) .then(({ data: { web_url: url } }) => { + const message = `${DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE}\n\n${DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION}`; + Object.assign(meta, { branch: { label: branch, url } }); - return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content, images); + return formattedMarkdown + ? commit( + projectId, + message, + branch, + createUpdateSourceFileAction(sourcePath, formattedMarkdown), + ) + : meta; }) + .then(() => + commit(projectId, mergeRequestTitle, branch, [ + ...createUpdateSourceFileAction(sourcePath, content), + ...createImageActions(images, content), + ]), + ) .then(({ data: { short_id: label, web_url: url } }) => { Object.assign(meta, { commit: { label, url } }); 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 bb4b66009de..3f1f2144d8e 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 @@ -469,6 +469,8 @@ export default { :pipeline-id="mr.pipeline.id" :project-id="mr.sourceProjectId" :security-reports-docs-path="mr.securityReportsDocsPath" + :target-project-full-path="mr.targetProjectFullPath" + :mr-iid="mr.iid" /> <grouped-test-reports-app diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue index 9eacf74bba8..fe50a459e52 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue @@ -105,6 +105,8 @@ export default { registerHTMLToMarkdownRenderer(editorApi); this.addListeners(editorApi); + + this.$emit('load', { formattedMarkdown: editorApi.getMarkdown() }); }, onOpenAddImageModal() { this.$refs.addImageModal.show(); diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue new file mode 100644 index 00000000000..d7c1e27ff3e --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue @@ -0,0 +1,48 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; + +export default { + name: 'SecurityReportDownloadDropdown', + components: { + GlDropdown, + GlDropdownItem, + }, + props: { + artifacts: { + type: Array, + required: true, + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + artifactText({ name }) { + return sprintf(s__('SecurityReports|Download %{artifactName}'), { + artifactName: name, + }); + }, + }, +}; +</script> + +<template> + <gl-dropdown + :text="s__('SecurityReports|Download results')" + :loading="loading" + icon="download" + right + > + <gl-dropdown-item + v-for="artifact in artifacts" + :key="artifact.path" + :href="artifact.path" + download + > + {{ artifactText(artifact) }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js index 413b4a70b40..68241a8c5be 100644 --- a/app/assets/javascripts/vue_shared/security_reports/constants.js +++ b/app/assets/javascripts/vue_shared/security_reports/constants.js @@ -1,3 +1,5 @@ +import { invert } from 'lodash'; + export const FEEDBACK_TYPE_DISMISSAL = 'dismissal'; export const FEEDBACK_TYPE_ISSUE = 'issue'; export const FEEDBACK_TYPE_MERGE_REQUEST = 'merge_request'; @@ -7,3 +9,24 @@ export const FEEDBACK_TYPE_MERGE_REQUEST = 'merge_request'; */ export const REPORT_TYPE_SAST = 'sast'; export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection'; + +/** + * SecurityReportTypeEnum values for use with GraphQL. + * + * These should correspond to the lowercase security scan report types. + */ +export const SECURITY_REPORT_TYPE_ENUM_SAST = 'SAST'; +export const SECURITY_REPORT_TYPE_ENUM_SECRET_DETECTION = 'SECRET_DETECTION'; + +/** + * A mapping from security scan report types to SecurityReportTypeEnum values. + */ +export const reportTypeToSecurityReportTypeEnum = { + [REPORT_TYPE_SAST]: SECURITY_REPORT_TYPE_ENUM_SAST, + [REPORT_TYPE_SECRET_DETECTION]: SECURITY_REPORT_TYPE_ENUM_SECRET_DETECTION, +}; + +/** + * A mapping from SecurityReportTypeEnum values to security scan report types. + */ +export const securityReportTypeEnumToReportType = invert(reportTypeToSecurityReportTypeEnum); diff --git a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql new file mode 100644 index 00000000000..310d8d88904 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql @@ -0,0 +1,23 @@ +query securityReportDownloadPaths( + $projectPath: ID! + $iid: String! + $reportTypes: [SecurityReportTypeEnum!] +) { + project(fullPath: $projectPath) { + mergeRequest(iid: $iid) { + headPipeline { + jobs(securityReportTypes: $reportTypes) { + nodes { + name + artifacts { + nodes { + downloadPath + fileType + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue index b61783ed7b0..3f4a790d24e 100644 --- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue +++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue @@ -8,10 +8,17 @@ import { s__ } from '~/locale'; import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; import createFlash from '~/flash'; import Api from '~/api'; +import SecurityReportDownloadDropdown from './components/security_report_download_dropdown.vue'; import SecuritySummary from './components/security_summary.vue'; import store from './store'; import { MODULE_SAST, MODULE_SECRET_DETECTION } from './store/constants'; -import { REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION } from './constants'; +import { + REPORT_TYPE_SAST, + REPORT_TYPE_SECRET_DETECTION, + reportTypeToSecurityReportTypeEnum, +} from './constants'; +import securityReportDownloadPathsQuery from './queries/security_report_download_paths.query.graphql'; +import { extractSecurityReportArtifacts } from './utils'; export default { store, @@ -20,6 +27,7 @@ export default { GlLink, GlSprintf, ReportSection, + SecurityReportDownloadDropdown, SecuritySummary, }, mixins: [glFeatureFlagsMixin()], @@ -46,6 +54,16 @@ export default { required: false, default: '', }, + targetProjectFullPath: { + type: String, + required: false, + default: '', + }, + mrIid: { + type: Number, + required: false, + default: 0, + }, }, data() { return { @@ -60,8 +78,44 @@ export default { status: ERROR, }; }, + apollo: { + reportArtifacts: { + query: securityReportDownloadPathsQuery, + variables() { + return { + projectPath: this.targetProjectFullPath, + iid: String(this.mrIid), + reportTypes: this.$options.reportTypes.map( + reportType => reportTypeToSecurityReportTypeEnum[reportType], + ), + }; + }, + skip() { + return !this.canShowDownloads; + }, + update(data) { + return extractSecurityReportArtifacts(this.$options.reportTypes, data); + }, + error(error) { + this.showError(error); + }, + result({ loading }) { + if (loading) { + return; + } + + // Query has completed, so populate the availableSecurityReports. + this.onCheckingAvailableSecurityReports( + this.reportArtifacts.map(({ reportType }) => reportType), + ); + }, + }, + }, computed: { ...mapGetters(['groupedSummaryText', 'summaryStatus']), + canShowDownloads() { + return this.glFeatures.coreSecurityMrWidgetDownloads; + }, hasSecurityReports() { return this.availableSecurityReports.length > 0; }, @@ -71,23 +125,26 @@ export default { hasSecretDetectionReports() { return this.availableSecurityReports.includes(REPORT_TYPE_SECRET_DETECTION); }, - isLoaded() { - return this.summaryStatus !== LOADING; + isLoadingReportArtifacts() { + return this.$apollo.queries.reportArtifacts.loading; + }, + shouldShowDownloadGuidance() { + return !this.canShowDownloads && this.summaryStatus !== LOADING; + }, + scansHaveRunMessage() { + return this.canShowDownloads + ? this.$options.i18n.scansHaveRun + : this.$options.i18n.scansHaveRunWithDownloadGuidance; }, }, created() { - this.checkAvailableSecurityReports(this.$options.reportTypes) - .then(availableSecurityReports => { - this.availableSecurityReports = Array.from(availableSecurityReports); - this.fetchCounts(); - }) - .catch(error => { - createFlash({ - message: this.$options.i18n.apiError, - captureError: true, - error, - }); - }); + if (!this.canShowDownloads) { + this.checkAvailableSecurityReports(this.$options.reportTypes) + .then(availableSecurityReports => { + this.onCheckingAvailableSecurityReports(Array.from(availableSecurityReports)); + }) + .catch(this.showError); + } }, methods: { ...mapActions(MODULE_SAST, { @@ -150,13 +207,25 @@ export default { window.mrTabs.tabShown('pipelines'); } }, + onCheckingAvailableSecurityReports(availableSecurityReports) { + this.availableSecurityReports = availableSecurityReports; + this.fetchCounts(); + }, + showError(error) { + createFlash({ + message: this.$options.i18n.apiError, + captureError: true, + error, + }); + }, }, reportTypes: [REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION], i18n: { apiError: s__( 'SecurityReports|Failed to get security report information. Please reload the page or try again later.', ), - scansHaveRun: s__( + scansHaveRun: s__('SecurityReports|Security scans have run'), + scansHaveRunWithDownloadGuidance: s__( 'SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports', ), downloadFromPipelineTab: s__( @@ -190,7 +259,7 @@ export default { </span> </template> - <template v-if="isLoaded" #sub-heading> + <template v-if="shouldShowDownloadGuidance" #sub-heading> <span class="gl-font-sm"> <gl-sprintf :message="$options.i18n.downloadFromPipelineTab"> <template #link="{ content }"> @@ -204,6 +273,13 @@ export default { </gl-sprintf> </span> </template> + + <template v-if="canShowDownloads" #action-buttons> + <security-report-download-dropdown + :artifacts="reportArtifacts" + :loading="isLoadingReportArtifacts" + /> + </template> </report-section> <!-- TODO: Remove this section when removing core_security_mr_widget_counts @@ -216,7 +292,7 @@ export default { data-testid="security-mr-widget" > <template #error> - <gl-sprintf :message="$options.i18n.scansHaveRun"> + <gl-sprintf :message="scansHaveRunMessage"> <template #link="{ content }"> <gl-link data-testid="show-pipelines" @click="activatePipelinesTab">{{ content @@ -233,5 +309,12 @@ export default { <gl-icon name="question" /> </gl-link> </template> + + <template v-if="canShowDownloads" #action-buttons> + <security-report-download-dropdown + :artifacts="reportArtifacts" + :loading="isLoadingReportArtifacts" + /> + </template> </report-section> </template> diff --git a/app/assets/javascripts/vue_shared/security_reports/utils.js b/app/assets/javascripts/vue_shared/security_reports/utils.js new file mode 100644 index 00000000000..827a87f9aaf --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/utils.js @@ -0,0 +1,22 @@ +import { securityReportTypeEnumToReportType } from './constants'; + +export const extractSecurityReportArtifacts = (reportTypes, data) => { + const jobs = data.project?.mergeRequest?.headPipeline?.jobs?.nodes ?? []; + + return jobs.reduce((acc, job) => { + const artifacts = job.artifacts?.nodes ?? []; + + artifacts.forEach(({ downloadPath, fileType }) => { + const reportType = securityReportTypeEnumToReportType[fileType]; + if (reportType && reportTypes.includes(reportType)) { + acc.push({ + name: job.name, + reportType, + path: downloadPath, + }); + } + }); + + return acc; + }, []); +}; diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index a3bb7c868df..ab330ed69c6 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -129,3 +129,17 @@ content: ''; display: flex; } + +// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1085 +.gl-md-flex-direction-column { + @media (min-width: $breakpoint-md) { + flex-direction: column; + } +} + +// Same as above +.gl-md-flex-direction-column\! { + @media (min-width: $breakpoint-md) { + flex-direction: column !important; + } +} diff --git a/app/controllers/boards/lists_controller.rb b/app/controllers/boards/lists_controller.rb index aecd287370f..19a4508c061 100644 --- a/app/controllers/boards/lists_controller.rb +++ b/app/controllers/boards/lists_controller.rb @@ -19,12 +19,12 @@ module Boards end def create - list = Boards::Lists::CreateService.new(board.resource_parent, current_user, create_list_params).execute(board) + response = Boards::Lists::CreateService.new(board.resource_parent, current_user, create_list_params).execute(board) - if list.valid? - render json: serialize_as_json(list) + if response.success? + render json: serialize_as_json(response.payload[:list]) else - render json: list.errors, status: :unprocessable_entity + render json: { errors: response.errors }, status: :unprocessable_entity end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 64faa2a15d9..212aef29a07 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -40,6 +40,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:default_merge_ref_for_diffs, @project) push_frontend_feature_flag(:core_security_mr_widget, @project, default_enabled: true) push_frontend_feature_flag(:core_security_mr_widget_counts, @project) + push_frontend_feature_flag(:core_security_mr_widget_downloads, @project) push_frontend_feature_flag(:remove_resolve_note, @project, default_enabled: true) push_frontend_feature_flag(:test_failure_history, @project) push_frontend_feature_flag(:diffs_gradual_load, @project) diff --git a/app/graphql/mutations/boards/lists/create.rb b/app/graphql/mutations/boards/lists/create.rb index 3fe1052315f..f6df63365b2 100644 --- a/app/graphql/mutations/boards/lists/create.rb +++ b/app/graphql/mutations/boards/lists/create.rb @@ -27,30 +27,16 @@ module Mutations board = authorized_find!(id: args[:board_id]) params = create_list_params(args) - authorize_list_type_resource!(board, params) - - list = create_list(board, params) + response = create_list(board, params) { - list: list.valid? ? list : nil, - errors: errors_on_object(list) + list: response.success? ? response.payload[:list] : nil, + errors: response.errors } end private - # Overridden in EE - def authorize_list_type_resource!(board, params) - return unless params[:label_id] - - labels = ::Labels::AvailableLabelsService.new(current_user, board.resource_parent, params) - .filter_labels_ids_in_param(:label_id) - - unless labels.present? - raise Gitlab::Graphql::Errors::ArgumentError, 'Label not found!' - end - end - def create_list(board, params) create_list_service = ::Boards::Lists::CreateService.new(board.resource_parent, current_user, params) diff --git a/app/models/concerns/enums/ci/pipeline.rb b/app/models/concerns/enums/ci/pipeline.rb index a5706af0e52..e1f07fa162c 100644 --- a/app/models/concerns/enums/ci/pipeline.rb +++ b/app/models/concerns/enums/ci/pipeline.rb @@ -25,8 +25,6 @@ module Enums schedule: 4, api: 5, external: 6, - # TODO: Rename `pipeline` to `cross_project_pipeline` in 13.0 - # https://gitlab.com/gitlab-org/gitlab/issues/195991 pipeline: 7, chat: 8, webide: 9, diff --git a/app/models/list.rb b/app/models/list.rb index ec211dfd497..1df565c83e6 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -7,7 +7,7 @@ class List < ApplicationRecord belongs_to :label has_many :list_user_preferences - enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3, milestone: 4 } + enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3, milestone: 4, iteration: 5 } validates :board, :list_type, presence: true, unless: :importing? validates :label, :position, presence: true, if: :label? diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 21be5bb5669..6231d8c9421 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -274,7 +274,7 @@ class MergeRequest < ApplicationRecord scope :with_api_entity_associations, -> { preload_routables .preload(:assignees, :author, :unresolved_notes, :labels, :milestone, - :timelogs, :latest_merge_request_diff, + :timelogs, :latest_merge_request_diff, :reviewers, target_project: :project_feature, metrics: [:latest_closed_by, :merged_by]) } diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb index 9c7a165776e..a21ceee083f 100644 --- a/app/services/boards/lists/create_service.rb +++ b/app/services/boards/lists/create_service.rb @@ -6,17 +6,21 @@ module Boards include Gitlab::Utils::StrongMemoize def execute(board) - List.transaction do - case type - when :backlog - create_backlog(board) - else - target = target(board) - position = next_position(board) - - create_list(board, type, target, position) - end - end + list = case type + when :backlog + create_backlog(board) + else + target = target(board) + position = next_position(board) + + return ServiceResponse.error(message: _('%{board_target} not found') % { board_target: type.to_s.capitalize }) if target.blank? + + create_list(board, type, target, position) + end + + return ServiceResponse.error(message: list.errors.full_messages) unless list.persisted? + + ServiceResponse.success(payload: { list: list }) end private @@ -33,7 +37,7 @@ module Boards def target(board) strong_memoize(:target) do - available_labels.find(params[:label_id]) + available_labels.find_by(id: params[:label_id]) # rubocop: disable CodeReuse/ActiveRecord end end diff --git a/app/services/boards/lists/generate_service.rb b/app/services/boards/lists/generate_service.rb index 4fbf1026019..d74320e92a3 100644 --- a/app/services/boards/lists/generate_service.rb +++ b/app/services/boards/lists/generate_service.rb @@ -7,7 +7,11 @@ module Boards return false unless board.lists.movable.empty? List.transaction do - label_params.each { |params| create_list(board, params) } + label_params.each do |params| + response = create_list(board, params) + + raise ActiveRecord::Rollback unless response.success? + end end true diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml index 52e3e0fd997..509ed62b39d 100644 --- a/app/views/projects/deployments/_commit.html.haml +++ b/app/views/projects/deployments/_commit.html.haml @@ -2,7 +2,7 @@ .branch-commit.cgray - if deployment.ref %span.icon-container.gl-display-inline-block - = deployment.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite') + = deployment.tag? ? sprite_icon('tag', css_class: 'sprite') : sprite_icon('fork', css_class: 'sprite') = link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name" .icon-container.commit-icon = custom_icon("icon_commit") diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 8955b568741..b41c3f4fc27 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -26,7 +26,7 @@ = render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project .tab-content - #js-tab-pipeline.tab-pane.gl-absolute.gl-left-0.gl-w-full + #js-tab-pipeline.tab-pane.gl-w-full #js-pipeline-graph-vue #js-tab-builds.tab-pane diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 416c10e46fe..de0016f64d6 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1610,6 +1610,14 @@ :weight: 1 :idempotent: :tags: [] +- :name: gitlab_performance_bar_stats + :feature_category: :metrics + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: gitlab_shell :feature_category: :source_code_management :has_external_dependencies: diff --git a/app/workers/gitlab_performance_bar_stats_worker.rb b/app/workers/gitlab_performance_bar_stats_worker.rb new file mode 100644 index 00000000000..d63f8111864 --- /dev/null +++ b/app/workers/gitlab_performance_bar_stats_worker.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class GitlabPerformanceBarStatsWorker + include ApplicationWorker + + LEASE_KEY = 'gitlab:performance_bar_stats' + LEASE_TIMEOUT = 600 + WORKER_DELAY = 120 + STATS_KEY = 'performance_bar_stats:pending_request_ids' + + feature_category :metrics + idempotent! + + def perform(lease_uuid) + Gitlab::Redis::SharedState.with do |redis| + request_ids = fetch_request_ids(redis, lease_uuid) + stats = Gitlab::PerformanceBar::Stats.new(redis) + + request_ids.each do |id| + stats.process(id) + end + end + end + + private + + def fetch_request_ids(redis, lease_uuid) + ids = redis.smembers(STATS_KEY) + redis.del(STATS_KEY) + Gitlab::ExclusiveLease.cancel(LEASE_KEY, lease_uuid) + + ids + end +end |