diff options
author | Dylan Griffith <dyl.griffith@gmail.com> | 2018-04-24 08:07:34 +0300 |
---|---|---|
committer | Dylan Griffith <dyl.griffith@gmail.com> | 2018-04-24 08:07:34 +0300 |
commit | 1b9c1ac3adb3d65e51f38e37c4705d46c5618f88 (patch) | |
tree | b724afd0596dd658f7ef7baddf9411ff3e599f7d /app | |
parent | 392c411bdc16386ef42c86afaf8c4d8e4cddb955 (diff) | |
parent | 2e00c1a72afc4b7388bb46bd6d58608e2ae61899 (diff) |
Merge branch 'master' into 10244-add-project-ci-cd-settings
Diffstat (limited to 'app')
220 files changed, 3514 insertions, 1924 deletions
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index e210d69895e..7144f4190e7 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -113,6 +113,8 @@ class List { issue.id = data.id; issue.iid = data.iid; issue.project = data.project; + issue.path = data.real_path; + issue.referencePath = data.reference_path; if (this.issuesSize > 1) { const moveBeforeId = this.issues[1].id; diff --git a/app/assets/javascripts/branches/branches_delete_modal.js b/app/assets/javascripts/branches/branches_delete_modal.js index 839e369eaf6..f34496f84c6 100644 --- a/app/assets/javascripts/branches/branches_delete_modal.js +++ b/app/assets/javascripts/branches/branches_delete_modal.js @@ -16,6 +16,7 @@ class DeleteModal { bindEvents() { this.$toggleBtns.on('click', this.setModalData.bind(this)); this.$confirmInput.on('input', this.setDeleteDisabled.bind(this)); + this.$deleteBtn.on('click', this.setDisableDeleteButton.bind(this)); } setModalData(e) { @@ -30,6 +31,16 @@ class DeleteModal { this.$deleteBtn.attr('disabled', e.currentTarget.value !== this.branchName); } + setDisableDeleteButton(e) { + if (this.$deleteBtn.is('[disabled]')) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + + return true; + } + updateModal() { this.$branchName.text(this.branchName); this.$confirmInput.val(''); diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index f8dcdf3f60a..9c12b89240c 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -1,96 +1,102 @@ <script> - import _ from 'underscore'; - import { s__, sprintf } from '../../locale'; - import applicationRow from './application_row.vue'; - import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; - import { - APPLICATION_INSTALLED, - INGRESS, - } from '../constants'; +import _ from 'underscore'; +import { s__, sprintf } from '../../locale'; +import applicationRow from './application_row.vue'; +import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; +import { APPLICATION_INSTALLED, INGRESS } from '../constants'; - export default { - components: { - applicationRow, - clipboardButton, +export default { + components: { + applicationRow, + clipboardButton, + }, + props: { + applications: { + type: Object, + required: false, + default: () => ({}), }, - props: { - applications: { - type: Object, - required: false, - default: () => ({}), - }, - helpPath: { - type: String, - required: false, - default: '', - }, - ingressHelpPath: { - type: String, - required: false, - default: '', - }, - ingressDnsHelpPath: { - type: String, - required: false, - default: '', - }, - managePrometheusPath: { - type: String, - required: false, - default: '', - }, + helpPath: { + type: String, + required: false, + default: '', }, - computed: { - generalApplicationDescription() { - return sprintf( - _.escape(s__( + ingressHelpPath: { + type: String, + required: false, + default: '', + }, + ingressDnsHelpPath: { + type: String, + required: false, + default: '', + }, + managePrometheusPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + generalApplicationDescription() { + return sprintf( + _.escape( + s__( `ClusterIntegration|Install applications on your Kubernetes cluster. Read more about %{helpLink}`, - )), { - helpLink: `<a href="${this.helpPath}"> + ), + ), + { + helpLink: `<a href="${this.helpPath}"> ${_.escape(s__('ClusterIntegration|installing applications'))} </a>`, - }, - false, - ); - }, - ingressId() { - return INGRESS; - }, - ingressInstalled() { - return this.applications.ingress.status === APPLICATION_INSTALLED; - }, - ingressExternalIp() { - return this.applications.ingress.externalIp; - }, - ingressDescription() { - const extraCostParagraph = sprintf( - _.escape(s__( + }, + false, + ); + }, + ingressId() { + return INGRESS; + }, + ingressInstalled() { + return this.applications.ingress.status === APPLICATION_INSTALLED; + }, + ingressExternalIp() { + return this.applications.ingress.externalIp; + }, + ingressDescription() { + const extraCostParagraph = sprintf( + _.escape( + s__( `ClusterIntegration|%{boldNotice} This will add some extra resources like a load balancer, which may incur additional costs depending on - the hosting provider your Kubernetes cluster is installed on. If you are using GKE, - you can %{pricingLink}.`, - )), { - boldNotice: `<strong>${_.escape(s__('ClusterIntegration|Note:'))}</strong>`, - pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb" target="_blank" rel="noopener noreferrer"> + the hosting provider your Kubernetes cluster is installed on. If you are using + Google Kubernetes Engine, you can %{pricingLink}.`, + ), + ), + { + boldNotice: `<strong>${_.escape(s__('ClusterIntegration|Note:'))}</strong>`, + pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb" target="_blank" rel="noopener noreferrer"> ${_.escape(s__('ClusterIntegration|check the pricing here'))}</a>`, - }, - false, - ); + }, + false, + ); - const externalIpParagraph = sprintf( - _.escape(s__( + const externalIpParagraph = sprintf( + _.escape( + s__( `ClusterIntegration|After installing Ingress, you will need to point your wildcard DNS at the generated external IP address in order to view your app after it is deployed. %{ingressHelpLink}`, - )), { - ingressHelpLink: `<a href="${this.ingressHelpPath}"> + ), + ), + { + ingressHelpLink: `<a href="${this.ingressHelpPath}"> ${_.escape(s__('ClusterIntegration|More information'))} </a>`, - }, - false, - ); + }, + false, + ); - return ` + return ` <p> ${extraCostParagraph} </p> @@ -98,22 +104,25 @@ ${externalIpParagraph} </p> `; - }, - prometheusDescription() { - return sprintf( - _.escape(s__( + }, + prometheusDescription() { + return sprintf( + _.escape( + s__( `ClusterIntegration|Prometheus is an open-source monitoring system with %{gitlabIntegrationLink} to monitor deployed applications.`, - )), { - gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html" + ), + ), + { + gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html" target="_blank" rel="noopener noreferrer"> ${_.escape(s__('ClusterIntegration|GitLab Integration'))}</a>`, - }, - false, - ); - }, + }, + false, + ); }, - }; + }, +}; </script> <template> @@ -205,7 +214,7 @@ > {{ s__(`ClusterIntegration|The IP address is in the process of being assigned. Please check your Kubernetes - cluster or Quotas on GKE if it takes a long time.`) }} + cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) }} <a :href="ingressHelpPath" diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index fb1fc9cd32e..a88b6971f90 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -84,20 +84,21 @@ export default class CreateMergeRequestDropdown { if (data.can_create_branch) { this.available(); this.enable(); + this.updateBranchName(data.suggested_branch_name); if (!this.droplabInitialized) { this.droplabInitialized = true; this.initDroplab(); this.bindEvents(); } - } else if (data.has_related_branch) { + } else { this.hide(); } }) .catch(() => { this.unavailable(); this.disable(); - Flash('Failed to check if a new branch can be created.'); + Flash(__('Failed to check related branches.')); }); } @@ -409,13 +410,16 @@ export default class CreateMergeRequestDropdown { this.unavailableButton.classList.remove('hide'); } + updateBranchName(suggestedBranchName) { + this.branchInput.value = suggestedBranchName; + this.updateCreatePaths('branch', suggestedBranchName); + } + updateInputState(target, ref, result) { // target - 'branch' or 'ref' - which the input field we are searching a ref for. // ref - string - what a user typed. // result - string - what has been found on backend. - const pathReplacement = `$1${ref}`; - // If a found branch equals exact the same text a user typed, // that means a new branch cannot be created as it already exists. if (ref === result) { @@ -426,18 +430,12 @@ export default class CreateMergeRequestDropdown { this.refIsValid = true; this.refInput.dataset.value = ref; this.showAvailableMessage('ref'); - this.createBranchPath = this.createBranchPath.replace(this.regexps.ref.createBranchPath, - pathReplacement); - this.createMrPath = this.createMrPath.replace(this.regexps.ref.createMrPath, - pathReplacement); + this.updateCreatePaths(target, ref); } } else if (target === 'branch') { this.branchIsValid = true; this.showAvailableMessage('branch'); - this.createBranchPath = this.createBranchPath.replace(this.regexps.branch.createBranchPath, - pathReplacement); - this.createMrPath = this.createMrPath.replace(this.regexps.branch.createMrPath, - pathReplacement); + this.updateCreatePaths(target, ref); } else { this.refIsValid = false; this.refInput.dataset.value = ref; @@ -457,4 +455,15 @@ export default class CreateMergeRequestDropdown { this.disableCreateAction(); } } + + // target - 'branch' or 'ref' + // ref - string - the new value to use as branch or ref + updateCreatePaths(target, ref) { + const pathReplacement = `$1${ref}`; + + this.createBranchPath = this.createBranchPath.replace(this.regexps[target].createBranchPath, + pathReplacement); + this.createMrPath = this.createMrPath.replace(this.regexps[target].createMrPath, + pathReplacement); + } } diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index 842a4255f08..4164149dd06 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -2,7 +2,9 @@ import $ from 'jquery'; import Pikaday from 'pikaday'; +import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; +import { timeFor } from './lib/utils/datetime_utility'; import { parsePikadayDate, pikadayToString } from './lib/utils/datefix'; class DueDateSelect { @@ -14,6 +16,7 @@ class DueDateSelect { this.$dropdownParent = $dropdownParent; this.$datePicker = $dropdownParent.find('.js-due-date-calendar'); this.$block = $block; + this.$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon'); this.$selectbox = $dropdown.closest('.selectbox'); this.$value = $block.find('.value'); this.$valueContent = $block.find('.value-content'); @@ -128,7 +131,8 @@ class DueDateSelect { submitSelectedDate(isDropdown) { const selectedDateValue = this.datePayload[this.abilityName].due_date; - const displayedDateStyle = this.displayedDate !== 'No due date' ? 'bold' : 'no-value'; + const hasDueDate = this.displayedDate !== 'No due date'; + const displayedDateStyle = hasDueDate ? 'bold' : 'no-value'; this.$loading.removeClass('hidden').fadeIn(); @@ -145,10 +149,13 @@ class DueDateSelect { return axios.put(this.issueUpdateURL, this.datePayload) .then(() => { + const tooltipText = hasDueDate ? `${__('Due date')}<br />${selectedDateValue} (${timeFor(selectedDateValue)})` : __('Due date'); if (isDropdown) { this.$dropdown.trigger('loaded.gl.dropdown'); this.$dropdown.dropdown('toggle'); } + this.$sidebarCollapsedValue.attr('data-original-title', tooltipText); + return this.$loading.fadeOut(); }); } diff --git a/app/assets/javascripts/ide/components/changed_file_icon.vue b/app/assets/javascripts/ide/components/changed_file_icon.vue index 037e3efb4ce..1fc11c84639 100644 --- a/app/assets/javascripts/ide/components/changed_file_icon.vue +++ b/app/assets/javascripts/ide/components/changed_file_icon.vue @@ -1,31 +1,87 @@ <script> -import icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import Icon from '~/vue_shared/components/icon.vue'; +import { pluralize } from '~/lib/utils/text_utility'; +import { __, sprintf } from '~/locale'; export default { components: { - icon, + Icon, + }, + directives: { + tooltip, }, props: { file: { type: Object, required: true, }, + showTooltip: { + type: Boolean, + required: false, + default: false, + }, + showStagedIcon: { + type: Boolean, + required: false, + default: false, + }, }, computed: { changedIcon() { - return this.file.tempFile ? 'file-addition' : 'file-modified'; + const suffix = this.file.staged && !this.showStagedIcon ? '-solid' : ''; + return this.file.tempFile ? `file-addition${suffix}` : `file-modified${suffix}`; + }, + stagedIcon() { + return `${this.changedIcon}-solid`; }, changedIconClass() { - return `multi-${this.changedIcon}`; + return `multi-${this.changedIcon} prepend-left-5 pull-left`; + }, + tooltipTitle() { + if (!this.showTooltip) return undefined; + + const type = this.file.tempFile ? 'addition' : 'modification'; + + if (this.file.changed && !this.file.staged) { + return sprintf(__('Unstaged %{type}'), { + type, + }); + } else if (!this.file.changed && this.file.staged) { + return sprintf(__('Staged %{type}'), { + type, + }); + } else if (this.file.changed && this.file.staged) { + return sprintf(__('Unstaged and staged %{type}'), { + type: pluralize(type), + }); + } + + return undefined; }, }, }; </script> <template> - <icon - :name="changedIcon" - :size="12" - :css-classes="`ide-file-changed-icon ${changedIconClass}`" - /> + <span + v-tooltip + :title="tooltipTitle" + data-container="body" + data-placement="right" + class="ide-file-changed-icon" + > + <icon + v-if="file.staged && showStagedIcon" + :name="stagedIcon" + :size="12" + :css-classes="changedIconClass" + /> + <icon + v-if="file.changed || file.tempFile || (file.staged && !showStagedIcon)" + :name="changedIcon" + :size="12" + :css-classes="changedIconClass" + /> + </span> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index 2cbd982af19..45321df191c 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -1,41 +1,27 @@ <script> - import { mapState } from 'vuex'; - import { sprintf, __ } from '~/locale'; - import * as consts from '../../stores/modules/commit/constants'; - import RadioGroup from './radio_group.vue'; +import { mapState } from 'vuex'; +import { sprintf, __ } from '~/locale'; +import * as consts from '../../stores/modules/commit/constants'; +import RadioGroup from './radio_group.vue'; - export default { - components: { - RadioGroup, +export default { + components: { + RadioGroup, + }, + computed: { + ...mapState(['currentBranchId']), + commitToCurrentBranchText() { + return sprintf( + __('Commit to %{branchName} branch'), + { branchName: `<strong class="monospace">${this.currentBranchId}</strong>` }, + false, + ); }, - computed: { - ...mapState([ - 'currentBranchId', - ]), - newMergeRequestHelpText() { - return sprintf( - __('Creates a new branch from %{branchName} and re-directs to create a new merge request'), - { branchName: this.currentBranchId }, - ); - }, - commitToCurrentBranchText() { - return sprintf( - __('Commit to %{branchName} branch'), - { branchName: `<strong>${this.currentBranchId}</strong>` }, - false, - ); - }, - commitToNewBranchText() { - return sprintf( - __('Creates a new branch from %{branchName}'), - { branchName: this.currentBranchId }, - ); - }, - }, - commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH, - commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH, - commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR, - }; + }, + commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH, + commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH, + commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR, +}; </script> <template> @@ -53,13 +39,11 @@ :value="$options.commitToNewBranch" :label="__('Create a new branch')" :show-input="true" - :help-text="commitToNewBranchText" /> <radio-group :value="$options.commitToNewBranchMR" :label="__('Create a new branch and merge request')" :show-input="true" - :help-text="newMergeRequestHelpText" /> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue new file mode 100644 index 00000000000..6424b93ce54 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue @@ -0,0 +1,93 @@ +<script> +import { mapActions, mapState, mapGetters } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; + +export default { + components: { + Icon, + }, + directives: { + tooltip, + }, + props: { + noChangesStateSvgPath: { + type: String, + required: true, + }, + committedStateSvgPath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['lastCommitMsg', 'rightPanelCollapsed']), + ...mapGetters(['collapseButtonIcon', 'collapseButtonTooltip']), + statusSvg() { + return this.lastCommitMsg ? this.committedStateSvgPath : this.noChangesStateSvgPath; + }, + }, + methods: { + ...mapActions(['toggleRightPanelCollapsed']), + }, +}; +</script> + +<template> + <div + class="multi-file-commit-panel-section ide-commit-empty-state js-empty-state" + > + <header + class="multi-file-commit-panel-header" + :class="{ + 'is-collapsed': rightPanelCollapsed, + }" + > + <button + v-tooltip + :title="collapseButtonTooltip" + data-container="body" + data-placement="left" + type="button" + class="btn btn-transparent multi-file-commit-panel-collapse-btn" + :aria-label="__('Toggle sidebar')" + @click.stop="toggleRightPanelCollapsed" + > + <icon + :name="collapseButtonIcon" + :size="18" + /> + </button> + </header> + <div + class="ide-commit-empty-state-container" + v-if="!rightPanelCollapsed" + > + <div class="svg-content svg-80"> + <img :src="statusSvg" /> + </div> + <div class="append-right-default prepend-left-default"> + <div + class="text-content text-center" + v-if="!lastCommitMsg" + > + <h4> + {{ __('No changes') }} + </h4> + <p> + {{ __('Edit files in the editor and commit changes here') }} + </p> + </div> + <div + class="text-content text-center" + v-else + > + <h4> + {{ __('All changes are committed') }} + </h4> + <p v-html="lastCommitMsg"></p> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index 453208f3f19..ff05ee8682a 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -1,56 +1,132 @@ <script> - import { mapState } from 'vuex'; - import icon from '~/vue_shared/components/icon.vue'; - import listItem from './list_item.vue'; - import listCollapsed from './list_collapsed.vue'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import { __, sprintf } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import ListItem from './list_item.vue'; +import ListCollapsed from './list_collapsed.vue'; - export default { - components: { - icon, - listItem, - listCollapsed, +export default { + components: { + Icon, + ListItem, + ListCollapsed, + }, + directives: { + tooltip, + }, + props: { + title: { + type: String, + required: true, }, - props: { - title: { - type: String, - required: true, - }, - fileList: { - type: Array, - required: true, - }, + fileList: { + type: Array, + required: true, }, - computed: { - ...mapState([ - 'currentProjectId', - 'currentBranchId', - 'rightPanelCollapsed', - ]), - isCommitInfoShown() { - return this.rightPanelCollapsed || this.fileList.length; - }, + showToggle: { + type: Boolean, + required: false, + default: true, }, - methods: { - toggleCollapsed() { - this.$emit('toggleCollapsed'); - }, + iconName: { + type: String, + required: true, }, - }; + action: { + type: String, + required: true, + }, + actionBtnText: { + type: String, + required: true, + }, + itemActionComponent: { + type: String, + required: true, + }, + stagedList: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapState(['rightPanelCollapsed']), + ...mapGetters(['collapseButtonIcon', 'collapseButtonTooltip']), + titleText() { + return sprintf(__('%{title} changes'), { + title: this.title, + }); + }, + }, + methods: { + ...mapActions(['toggleRightPanelCollapsed', 'stageAllChanges', 'unstageAllChanges']), + actionBtnClicked() { + this[this.action](); + }, + }, +}; </script> <template> <div + class="ide-commit-list-container" :class="{ - 'multi-file-commit-list': isCommitInfoShown + 'is-collapsed': rightPanelCollapsed, }" > + <header + class="multi-file-commit-panel-header" + > + <div + v-if="!rightPanelCollapsed" + class="multi-file-commit-panel-header-title" + :class="{ + 'append-right-10': showToggle, + }" + > + <icon + v-once + :name="iconName" + :size="18" + /> + {{ titleText }} + <button + type="button" + class="btn btn-blank btn-link ide-staged-action-btn" + @click="actionBtnClicked" + > + {{ actionBtnText }} + </button> + </div> + <button + v-if="showToggle" + v-tooltip + :title="collapseButtonTooltip" + data-container="body" + data-placement="left" + type="button" + class="btn btn-transparent multi-file-commit-panel-collapse-btn" + :aria-label="__('Toggle sidebar')" + @click.stop="toggleRightPanelCollapsed" + > + <icon + :name="collapseButtonIcon" + :size="18" + /> + </button> + </header> <list-collapsed v-if="rightPanelCollapsed" + :files="fileList" + :icon-name="iconName" + :title="title" /> <template v-else> <ul v-if="fileList.length" - class="list-unstyled append-bottom-0" + class="multi-file-commit-list list-unstyled append-bottom-0" > <li v-for="file in fileList" @@ -58,9 +134,18 @@ > <list-item :file="file" + :action-component="itemActionComponent" + :key-prefix="title" + :staged-list="stagedList" /> </li> </ul> + <p + v-else + class="multi-file-commit-list help-block" + > + {{ __('No changes') }} + </p> </template> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue index 15918ac9631..2254271c679 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue @@ -1,35 +1,110 @@ <script> - import { mapGetters } from 'vuex'; - import icon from '~/vue_shared/components/icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import { sprintf, n__, __ } from '~/locale'; - export default { - components: { - icon, +export default { + components: { + Icon, + }, + directives: { + tooltip, + }, + props: { + files: { + type: Array, + required: true, }, - computed: { - ...mapGetters([ - 'addedFiles', - 'modifiedFiles', - ]), + iconName: { + type: String, + required: true, }, - }; + title: { + type: String, + required: true, + }, + }, + computed: { + addedFilesLength() { + return this.files.filter(f => f.tempFile).length; + }, + modifiedFilesLength() { + return this.files.filter(f => !f.tempFile).length; + }, + addedFilesIconClass() { + return this.addedFilesLength ? 'multi-file-addition' : ''; + }, + modifiedFilesClass() { + return this.modifiedFilesLength ? 'multi-file-modified' : ''; + }, + additionsTooltip() { + return sprintf(n__('1 %{type} addition', '%d %{type} additions', this.addedFilesLength), { + type: this.title.toLowerCase(), + }); + }, + modifiedTooltip() { + return sprintf( + n__('1 %{type} modification', '%d %{type} modifications', this.modifiedFilesLength), + { type: this.title.toLowerCase() }, + ); + }, + titleTooltip() { + return sprintf(__('%{title} changes'), { title: this.title }); + }, + additionIconName() { + return this.title.toLowerCase() === 'staged' ? 'file-addition-solid' : 'file-addition'; + }, + modifiedIconName() { + return this.title.toLowerCase() === 'staged' ? 'file-modified-solid' : 'file-modified'; + }, + }, +}; </script> <template> <div class="multi-file-commit-list-collapsed text-center" > - <icon - name="file-addition" - :size="18" - css-classes="multi-file-addition append-bottom-10" - /> - {{ addedFiles.length }} - <icon - name="file-modified" - :size="18" - css-classes="multi-file-modified prepend-top-10 append-bottom-10" - /> - {{ modifiedFiles.length }} + <div + v-tooltip + :title="titleTooltip" + data-container="body" + data-placement="left" + class="append-bottom-15" + > + <icon + v-once + :name="iconName" + :size="18" + /> + </div> + <div + v-tooltip + :title="additionsTooltip" + data-container="body" + data-placement="left" + class="append-bottom-10" + > + <icon + :name="additionIconName" + :size="18" + :css-classes="addedFilesIconClass" + /> + </div> + {{ addedFilesLength }} + <div + v-tooltip + :title="modifiedTooltip" + data-container="body" + data-placement="left" + class="prepend-top-10 append-bottom-10" + > + <icon + :name="modifiedIconName" + :size="18" + :css-classes="modifiedFilesClass" + /> + </div> + {{ modifiedFilesLength }} </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 560cdd941cd..ad4713c40d5 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -1,34 +1,69 @@ <script> import { mapActions } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; +import StageButton from './stage_button.vue'; +import UnstageButton from './unstage_button.vue'; export default { components: { Icon, + StageButton, + UnstageButton, }, props: { file: { type: Object, required: true, }, + actionComponent: { + type: String, + required: true, + }, + keyPrefix: { + type: String, + required: false, + default: '', + }, + stagedList: { + type: Boolean, + required: false, + default: false, + }, }, computed: { iconName() { - return this.file.tempFile ? 'file-addition' : 'file-modified'; + const prefix = this.stagedList ? '-solid' : ''; + return this.file.tempFile ? `file-addition${prefix}` : `file-modified${prefix}`; }, iconClass() { - return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; + return `multi-file-${this.file.tempFile ? 'additions' : 'modified'} append-right-8`; }, }, methods: { - ...mapActions(['discardFileChanges', 'updateViewer', 'openPendingTab']), - openFileInEditor(file) { - return this.openPendingTab(file).then(changeViewer => { + ...mapActions([ + 'discardFileChanges', + 'updateViewer', + 'openPendingTab', + 'unstageChange', + 'stageChange', + ]), + openFileInEditor() { + return this.openPendingTab({ + file: this.file, + keyPrefix: this.keyPrefix.toLowerCase(), + }).then(changeViewer => { if (changeViewer) { this.updateViewer('diff'); } }); }, + fileAction() { + if (this.file.staged) { + this.unstageChange(this.file.path); + } else { + this.stageChange(this.file.path); + } + }, }, }; </script> @@ -38,7 +73,9 @@ export default { <button type="button" class="multi-file-commit-list-path" - @click="openFileInEditor(file)"> + @dblclick="fileAction" + @click="openFileInEditor" + > <span class="multi-file-commit-list-file-path"> <icon :name="iconName" @@ -47,12 +84,9 @@ export default { />{{ file.path }} </span> </button> - <button - type="button" - class="btn btn-blank multi-file-discard-btn" - @click="discardFileChanges(file.path)" - > - Discard - </button> + <component + :is="actionComponent" + :path="file.path" + /> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue new file mode 100644 index 00000000000..dcd934f76b7 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue @@ -0,0 +1,130 @@ +<script> +import { __, sprintf } from '../../../locale'; +import Icon from '../../../vue_shared/components/icon.vue'; +import popover from '../../../vue_shared/directives/popover'; +import { MAX_TITLE_LENGTH, MAX_BODY_LENGTH } from '../../constants'; + +export default { + directives: { + popover, + }, + components: { + Icon, + }, + props: { + text: { + type: String, + required: true, + }, + }, + data() { + return { + scrollTop: 0, + isFocused: false, + }; + }, + computed: { + allLines() { + return this.text.split('\n').map((line, i) => ({ + text: line.substr(0, this.getLineLength(i)) || ' ', + highlightedText: line.substr(this.getLineLength(i)), + })); + }, + }, + methods: { + handleScroll() { + if (this.$refs.textarea) { + this.$nextTick(() => { + this.scrollTop = this.$refs.textarea.scrollTop; + }); + } + }, + getLineLength(i) { + return i === 0 ? MAX_TITLE_LENGTH : MAX_BODY_LENGTH; + }, + onInput(e) { + this.$emit('input', e.target.value); + }, + updateIsFocused(isFocused) { + this.isFocused = isFocused; + }, + }, + popoverOptions: { + trigger: 'hover', + placement: 'top', + content: sprintf( + __(` + The character highligher helps you keep the subject line to %{titleLength} characters + and wrap the body at %{bodyLength} so they are readable in git. + `), + { titleLength: MAX_TITLE_LENGTH, bodyLength: MAX_BODY_LENGTH }, + ), + }, +}; +</script> + +<template> + <fieldset class="common-note-form ide-commit-message-field"> + <div + class="md-area" + :class="{ + 'is-focused': isFocused + }" + > + <div + v-once + class="md-header" + > + <ul class="nav-links"> + <li> + {{ __('Commit Message') }} + <span + v-popover="$options.popoverOptions" + class="help-block prepend-left-10" + > + <icon + name="question" + /> + </span> + </li> + </ul> + </div> + <div class="ide-commit-message-textarea-container"> + <div class="ide-commit-message-highlights-container"> + <div + class="note-textarea highlights monospace" + :style="{ + transform: `translate3d(0, ${-scrollTop}px, 0)` + }" + > + <div + v-for="(line, index) in allLines" + :key="index" + > + <span + v-text="line.text" + > + </span><mark + v-show="line.highlightedText" + v-text="line.highlightedText" + > + </mark> + </div> + </div> + </div> + <textarea + class="note-textarea ide-commit-message-textarea" + name="commit-message" + :placeholder="__('Write a commit message...')" + :value="text" + @scroll="handleScroll" + @input="onInput" + @focus="updateIsFocused(true)" + @blur="updateIsFocused(false)" + ref="textarea" + > + </textarea> + </div> + </div> + </fieldset> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue index 4310d762c78..b660a2961cb 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue @@ -1,52 +1,40 @@ <script> - import { mapActions, mapState, mapGetters } from 'vuex'; - import tooltip from '~/vue_shared/directives/tooltip'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import tooltip from '~/vue_shared/directives/tooltip'; - export default { - directives: { - tooltip, +export default { + directives: { + tooltip, + }, + props: { + value: { + type: String, + required: true, }, - props: { - value: { - type: String, - required: true, - }, - label: { - type: String, - required: false, - default: null, - }, - checked: { - type: Boolean, - required: false, - default: false, - }, - showInput: { - type: Boolean, - required: false, - default: false, - }, - helpText: { - type: String, - required: false, - default: null, - }, + label: { + type: String, + required: false, + default: null, }, - computed: { - ...mapState('commit', [ - 'commitAction', - ]), - ...mapGetters('commit', [ - 'newBranchName', - ]), + checked: { + type: Boolean, + required: false, + default: false, }, - methods: { - ...mapActions('commit', [ - 'updateCommitAction', - 'updateBranchName', - ]), + showInput: { + type: Boolean, + required: false, + default: false, }, - }; + }, + computed: { + ...mapState('commit', ['commitAction']), + ...mapGetters('commit', ['newBranchName']), + }, + methods: { + ...mapActions('commit', ['updateCommitAction', 'updateBranchName']), + }, +}; </script> <template> @@ -65,18 +53,6 @@ {{ label }} </template> <slot v-else></slot> - <span - v-if="helpText" - v-tooltip - class="help-block inline" - :title="helpText" - > - <i - class="fa fa-question-circle" - aria-hidden="true" - > - </i> - </span> </span> </label> <div @@ -85,7 +61,7 @@ > <input type="text" - class="form-control" + class="form-control monospace" :placeholder="newBranchName" @input="updateBranchName($event.target.value)" /> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue new file mode 100644 index 00000000000..52dce8412ab --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue @@ -0,0 +1,59 @@ +<script> +import { mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; + +export default { + components: { + Icon, + }, + directives: { + tooltip, + }, + props: { + path: { + type: String, + required: true, + }, + }, + methods: { + ...mapActions(['stageChange', 'discardFileChanges']), + }, +}; +</script> + +<template> + <div + v-once + class="multi-file-discard-btn" + > + <button + v-tooltip + type="button" + class="btn btn-blank append-right-5" + :aria-label="__('Stage changes')" + :title="__('Stage changes')" + data-container="body" + @click.stop="stageChange(path)" + > + <icon + name="mobile-issue-close" + :size="12" + /> + </button> + <button + v-tooltip + type="button" + class="btn btn-blank" + :aria-label="__('Discard changes')" + :title="__('Discard changes')" + data-container="body" + @click.stop="discardFileChanges(path)" + > + <icon + name="remove" + :size="12" + /> + </button> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue new file mode 100644 index 00000000000..123d60da47e --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue @@ -0,0 +1,45 @@ +<script> +import { mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; + +export default { + components: { + Icon, + }, + directives: { + tooltip, + }, + props: { + path: { + type: String, + required: true, + }, + }, + methods: { + ...mapActions(['unstageChange']), + }, +}; +</script> + +<template> + <div + v-once + class="multi-file-discard-btn" + > + <button + v-tooltip + type="button" + class="btn btn-blank" + :aria-label="__('Unstage changes')" + :title="__('Unstage changes')" + data-container="body" + @click="unstageChange(path)" + > + <icon + name="history" + :size="12" + /> + </button> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_context_bar.vue b/app/assets/javascripts/ide/components/ide_context_bar.vue index 79a83b47994..627fbeb9adf 100644 --- a/app/assets/javascripts/ide/components/ide_context_bar.vue +++ b/app/assets/javascripts/ide/components/ide_context_bar.vue @@ -1,5 +1,4 @@ <script> -import { mapActions, mapGetters, mapState } from 'vuex'; import icon from '~/vue_shared/components/icon.vue'; import panelResizer from '~/vue_shared/components/panel_resizer.vue'; import repoCommitSection from './repo_commit_section.vue'; @@ -22,13 +21,6 @@ export default { required: true, }, }, - computed: { - ...mapState(['changedFiles', 'rightPanelCollapsed']), - ...mapGetters(['currentIcon']), - }, - methods: { - ...mapActions(['setPanelCollapsedStatus']), - }, }; </script> @@ -41,40 +33,6 @@ export default { <div class="multi-file-commit-panel-section" > - <header - class="multi-file-commit-panel-header" - :class="{ - 'is-collapsed': rightPanelCollapsed, - }" - > - <div - class="multi-file-commit-panel-header-title" - v-if="!rightPanelCollapsed" - > - <div - v-if="changedFiles.length" - > - <icon - name="list-bulleted" - :size="18" - /> - Staged - </div> - </div> - <button - type="button" - class="btn btn-transparent multi-file-commit-panel-collapse-btn" - @click.stop="setPanelCollapsedStatus({ - side: 'right', - collapsed: !rightPanelCollapsed, - })" - > - <icon - :name="currentIcon" - :size="18" - /> - </button> - </header> <repo-commit-section :no-changes-state-svg-path="noChangesStateSvgPath" :committed-state-svg-path="committedStateSvgPath" diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 152a5f632ad..c13eeeace3f 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -22,13 +22,6 @@ export default { <template> <div class="ide-status-bar"> - <div class="ref-name"> - <icon - name="branch" - :size="12" - /> - {{ file.branchId }} - </div> <div> <div v-if="file.lastCommit && file.lastCommit.id"> Last commit: diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index d885ed5e301..877d1b5e026 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -1,20 +1,24 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; import tooltip from '~/vue_shared/directives/tooltip'; -import icon from '~/vue_shared/components/icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; -import commitFilesList from './commit_sidebar/list.vue'; +import CommitFilesList from './commit_sidebar/list.vue'; +import EmptyState from './commit_sidebar/empty_state.vue'; +import CommitMessageField from './commit_sidebar/message_field.vue'; import * as consts from '../stores/modules/commit/constants'; import Actions from './commit_sidebar/actions.vue'; export default { components: { DeprecatedModal, - icon, - commitFilesList, + Icon, + CommitFilesList, + EmptyState, Actions, LoadingButton, + CommitMessageField, }, directives: { tooltip, @@ -30,43 +34,19 @@ export default { }, }, computed: { - ...mapState([ - 'currentProjectId', - 'currentBranchId', - 'rightPanelCollapsed', - 'lastCommitMsg', - 'changedFiles', - ]), + ...mapState(['changedFiles', 'stagedFiles', 'rightPanelCollapsed']), ...mapState('commit', ['commitMessage', 'submitCommitLoading']), - ...mapGetters('commit', [ - 'commitButtonDisabled', - 'discardDraftButtonDisabled', - 'branchName', - ]), - statusSvg() { - return this.lastCommitMsg - ? this.committedStateSvgPath - : this.noChangesStateSvgPath; - }, + ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled', 'branchName']), }, methods: { - ...mapActions(['setPanelCollapsedStatus']), ...mapActions('commit', [ 'updateCommitMessage', 'discardDraft', 'commitChanges', 'updateCommitAction', ]), - toggleCollapsed() { - this.setPanelCollapsedStatus({ - side: 'right', - collapsed: !this.rightPanelCollapsed, - }); - }, forceCreateNewBranch() { - return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => - this.commitChanges(), - ); + return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges()); }, }, }; @@ -75,9 +55,6 @@ export default { <template> <div class="multi-file-commit-panel-section" - :class="{ - 'multi-file-commit-empty-state-container': !changedFiles.length - }" > <deprecated-modal id="ide-create-branch-modal" @@ -91,30 +68,36 @@ export default { Would you like to create a new branch?`) }} </template> </deprecated-modal> - <commit-files-list - title="Staged" - :file-list="changedFiles" - :collapsed="rightPanelCollapsed" - @toggleCollapsed="toggleCollapsed" - /> <template - v-if="changedFiles.length" + v-if="changedFiles.length || stagedFiles.length" > + <commit-files-list + icon-name="unstaged" + :title="__('Unstaged')" + :file-list="changedFiles" + action="stageAllChanges" + :action-btn-text="__('Stage all')" + item-action-component="stage-button" + /> + <commit-files-list + icon-name="staged" + :title="__('Staged')" + :file-list="stagedFiles" + action="unstageAllChanges" + :action-btn-text="__('Unstage all')" + item-action-component="unstage-button" + :show-toggle="false" + :staged-list="true" + /> <form class="form-horizontal multi-file-commit-form" @submit.prevent.stop="commitChanges" v-if="!rightPanelCollapsed" > - <div class="multi-file-commit-fieldset"> - <textarea - class="form-control multi-file-commit-message" - name="commit-message" - :value="commitMessage" - :placeholder="__('Write a commit message...')" - @input="updateCommitMessage($event.target.value)" - > - </textarea> - </div> + <commit-message-field + :text="commitMessage" + @input="updateCommitMessage" + /> <div class="clearfix prepend-top-15"> <actions /> <loading-button @@ -135,38 +118,10 @@ export default { </div> </form> </template> - <div - v-else-if="!rightPanelCollapsed" - class="row js-empty-state" - > - <div class="col-xs-10 col-xs-offset-1"> - <div class="svg-content svg-80"> - <img :src="statusSvg" /> - </div> - </div> - <div class="col-xs-10 col-xs-offset-1"> - <div - class="text-content text-center" - v-if="!lastCommitMsg" - > - <h4> - {{ __('No changes') }} - </h4> - <p> - {{ __('Edit files in the editor and commit changes here') }} - </p> - </div> - <div - class="text-content text-center" - v-else - > - <h4> - {{ __('All changes are committed') }} - </h4> - <p v-html="lastCommitMsg"> - </p> - </div> - </div> - </div> + <empty-state + v-else + :no-changes-state-svg-path="noChangesStateSvgPath" + :committed-state-svg-path="committedStateSvgPath" + /> </div> </template> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 711bafa17a9..3a04cdd8e46 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -20,7 +20,7 @@ export default { }, computed: { ...mapState(['rightPanelCollapsed', 'viewer', 'delayViewerUpdated', 'panelResizing']), - ...mapGetters(['currentMergeRequest']), + ...mapGetters(['currentMergeRequest', 'getStagedFile']), shouldHideEditor() { return this.file && this.file.binary && !this.file.content; }, @@ -120,7 +120,12 @@ export default { setupEditor() { if (!this.file || !this.editor.instance) return; - this.model = this.editor.createModel(this.file); + const head = this.getStagedFile(this.file.path); + + this.model = this.editor.createModel( + this.file, + this.file.staged && this.file.key.indexOf('unstaged-') === 0 ? head : null, + ); if (this.viewer === 'mrdiff') { this.editor.attachMergeRequestModel(this.model); diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue index 3b5068d4910..8b18c7d28b4 100644 --- a/app/assets/javascripts/ide/components/repo_file.vue +++ b/app/assets/javascripts/ide/components/repo_file.vue @@ -102,8 +102,11 @@ export default { v-if="file.mrChange" /> <changed-file-icon + v-if="file.changed || file.tempFile || file.staged" :file="file" - v-if="file.changed || file.tempFile" + :show-tooltip="true" + :show-staged-icon="true" + class="prepend-top-5 pull-right" /> </span> <new-dropdown diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index 304a73ed1ad..35a362b01e0 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -26,13 +26,16 @@ export default { }, computed: { closeLabel() { - if (this.tab.changed || this.tab.tempFile) { + if (this.fileHasChanged) { return `${this.tab.name} changed`; } return `Close ${this.tab.name}`; }, showChangedIcon() { - return this.tab.changed ? !this.tabMouseOver : false; + return this.fileHasChanged ? !this.tabMouseOver : false; + }, + fileHasChanged() { + return this.tab.changed || this.tab.tempFile || this.tab.staged; }, }, @@ -42,18 +45,18 @@ export default { this.updateDelayViewerUpdated(true); if (tab.pending) { - this.openPendingTab(tab); + this.openPendingTab({ file: tab, keyPrefix: tab.staged ? 'staged' : 'unstaged' }); } else { this.$router.push(`/project${tab.url}`); } }, mouseOverTab() { - if (this.tab.changed) { + if (this.fileHasChanged) { this.tabMouseOver = true; } }, mouseOutTab() { - if (this.tab.changed) { + if (this.fileHasChanged) { this.tabMouseOver = false; } }, diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js new file mode 100644 index 00000000000..b60d042e0be --- /dev/null +++ b/app/assets/javascripts/ide/constants.js @@ -0,0 +1,3 @@ +// Fuzzy file finder +export const MAX_TITLE_LENGTH = 50; +export const MAX_BODY_LENGTH = 72; diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index 20983666b4a..4a0a303d5a6 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -36,11 +36,11 @@ const router = new VueRouter({ base: `${gon.relative_url_root}/-/ide/`, routes: [ { - path: '/project/:namespace/:project', + path: '/project/:namespace/:project+', component: EmptyRouterComponent, children: [ { - path: ':targetmode/:branch/*', + path: ':targetmode(edit|tree|blob)/:branch/*', component: EmptyRouterComponent, }, { diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index e47adae99ed..016dcda1fa1 100644 --- a/app/assets/javascripts/ide/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -3,15 +3,16 @@ import Disposable from './disposable'; import eventHub from '../../eventhub'; export default class Model { - constructor(monaco, file) { + constructor(monaco, file, head = null) { this.monaco = monaco; this.disposable = new Disposable(); this.file = file; + this.head = head; this.content = file.content !== '' ? file.content : file.raw; this.disposable.add( (this.originalModel = this.monaco.editor.createModel( - this.file.raw, + head ? head.content : this.file.raw, undefined, new this.monaco.Uri(null, null, `original/${this.file.key}`), )), @@ -31,13 +32,15 @@ export default class Model { ); } - this.events = new Map(); + this.events = new Set(); this.updateContent = this.updateContent.bind(this); + this.updateNewContent = this.updateNewContent.bind(this); this.dispose = this.dispose.bind(this); eventHub.$on(`editor.update.model.dispose.${this.file.key}`, this.dispose); - eventHub.$on(`editor.update.model.content.${this.file.path}`, this.updateContent); + eventHub.$on(`editor.update.model.content.${this.file.key}`, this.updateContent); + eventHub.$on(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent); } get url() { @@ -73,22 +76,36 @@ export default class Model { } onChange(cb) { - this.events.set( - this.path, - this.disposable.add(this.model.onDidChangeContent(e => cb(this, e))), - ); + this.events.add(this.disposable.add(this.model.onDidChangeContent(e => cb(this, e)))); + } + + onDispose(cb) { + this.events.add(cb); } - updateContent(content) { + updateContent({ content, changed }) { this.getOriginalModel().setValue(content); + + if (!changed) { + this.getModel().setValue(content); + } + } + + updateNewContent(content) { this.getModel().setValue(content); } dispose() { this.disposable.dispose(); + + this.events.forEach(cb => { + if (typeof cb === 'function') cb(); + }); + this.events.clear(); eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose); - eventHub.$off(`editor.update.model.content.${this.file.path}`, this.updateContent); + eventHub.$off(`editor.update.model.content.${this.file.key}`, this.updateContent); + eventHub.$off(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent); } } diff --git a/app/assets/javascripts/ide/lib/common/model_manager.js b/app/assets/javascripts/ide/lib/common/model_manager.js index 0e7b563b5d6..7f643969480 100644 --- a/app/assets/javascripts/ide/lib/common/model_manager.js +++ b/app/assets/javascripts/ide/lib/common/model_manager.js @@ -17,12 +17,12 @@ export default class ModelManager { return this.models.get(key); } - addModel(file) { + addModel(file, head = null) { if (this.hasCachedModel(file.key)) { return this.getModel(file.key); } - const model = new Model(this.monaco, file); + const model = new Model(this.monaco, file, head); this.models.set(model.path, model); this.disposable.add(model); diff --git a/app/assets/javascripts/ide/lib/decorations/controller.js b/app/assets/javascripts/ide/lib/decorations/controller.js index 42904774747..13d477bb2cf 100644 --- a/app/assets/javascripts/ide/lib/decorations/controller.js +++ b/app/assets/javascripts/ide/lib/decorations/controller.js @@ -38,6 +38,15 @@ export default class DecorationsController { ); } + hasDecorations(model) { + return this.decorations.has(model.url); + } + + removeDecorations(model) { + this.decorations.delete(model.url); + this.editorDecorations.delete(model.url); + } + dispose() { this.decorations.clear(); this.editorDecorations.clear(); diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js index b136545ad11..f579424cf33 100644 --- a/app/assets/javascripts/ide/lib/diff/controller.js +++ b/app/assets/javascripts/ide/lib/diff/controller.js @@ -3,7 +3,7 @@ import { throttle } from 'underscore'; import DirtyDiffWorker from './diff_worker'; import Disposable from '../common/disposable'; -export const getDiffChangeType = (change) => { +export const getDiffChangeType = change => { if (change.modified) { return 'modified'; } else if (change.added) { @@ -16,12 +16,7 @@ export const getDiffChangeType = (change) => { }; export const getDecorator = change => ({ - range: new monaco.Range( - change.lineNumber, - 1, - change.endLineNumber, - 1, - ), + range: new monaco.Range(change.lineNumber, 1, change.endLineNumber, 1), options: { isWholeLine: true, linesDecorationsClassName: `dirty-diff dirty-diff-${getDiffChangeType(change)}`, @@ -31,6 +26,7 @@ export const getDecorator = change => ({ export default class DirtyDiffController { constructor(modelManager, decorationsController) { this.disposable = new Disposable(); + this.models = new Map(); this.editorSimpleWorker = null; this.modelManager = modelManager; this.decorationsController = decorationsController; @@ -42,7 +38,15 @@ export default class DirtyDiffController { } attachModel(model) { + if (this.models.has(model.url)) return; + model.onChange(() => this.throttledComputeDiff(model)); + model.onDispose(() => { + this.decorationsController.removeDecorations(model); + this.models.delete(model.url); + }); + + this.models.set(model.url, model); } computeDiff(model) { @@ -54,7 +58,11 @@ export default class DirtyDiffController { } reDecorate(model) { - this.decorationsController.decorate(model); + if (this.decorationsController.hasDecorations(model)) { + this.decorationsController.decorate(model); + } else { + this.computeDiff(model); + } } decorate({ data }) { @@ -65,6 +73,7 @@ export default class DirtyDiffController { dispose() { this.disposable.dispose(); + this.models.clear(); this.dirtyDiffWorker.removeEventListener('message', this.decorate); this.dirtyDiffWorker.terminate(); diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 001737d6ee8..2d3ee7d4f48 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -77,8 +77,8 @@ export default class Editor { } } - createModel(file) { - return this.modelManager.addModel(file); + createModel(file, head = null) { + return this.modelManager.addModel(file, head); } attachModel(model) { diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index c6ba679d99c..cecb4d215ba 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import Vue from 'vue'; import { visitUrl } from '~/lib/utils/url_utility'; import flash from '~/flash'; @@ -32,6 +33,22 @@ export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => { } }; +export const toggleRightPanelCollapsed = ( + { dispatch, state }, + e = undefined, +) => { + if (e) { + $(e.currentTarget) + .tooltip('hide') + .blur(); + } + + dispatch('setPanelCollapsedStatus', { + side: 'right', + collapsed: !state.rightPanelCollapsed, + }); +}; + export const setResizingStatus = ({ commit }, resizing) => { commit(types.SET_RESIZING_STATUS, resizing); }; @@ -104,6 +121,14 @@ export const scrollToTab = () => { }); }; +export const stageAllChanges = ({ state, commit }) => { + state.changedFiles.forEach(file => commit(types.STAGE_CHANGE, file.path)); +}; + +export const unstageAllChanges = ({ state, commit }) => { + state.stagedFiles.forEach(file => commit(types.UNSTAGE_CHANGE, file.path)); +}; + export const updateViewer = ({ commit }, viewer) => { commit(types.UPDATE_VIEWER, viewer); }; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 1a17320a1ea..d782e0a84d2 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -24,7 +24,10 @@ export const closeFile = ({ commit, state, dispatch }, file) => { if (nextFileToOpen.pending) { dispatch('updateViewer', 'diff'); - dispatch('openPendingTab', nextFileToOpen); + dispatch('openPendingTab', { + file: nextFileToOpen, + keyPrefix: nextFileToOpen.staged ? 'staged' : 'unstaged', + }); } else { dispatch('updateDelayViewerUpdated', true); router.push(`/project${nextFileToOpen.url}`); @@ -153,7 +156,7 @@ export const setFileViewMode = ({ state, commit }, { file, viewMode }) => { commit(types.SET_FILE_VIEWMODE, { file, viewMode }); }; -export const discardFileChanges = ({ state, commit }, path) => { +export const discardFileChanges = ({ dispatch, state, commit, getters }, path) => { const file = state.entries[path]; commit(types.DISCARD_FILE_CHANGES, path); @@ -161,17 +164,40 @@ export const discardFileChanges = ({ state, commit }, path) => { if (file.tempFile && file.opened) { commit(types.TOGGLE_FILE_OPEN, path); + } else if (getters.activeFile && file.path === getters.activeFile.path) { + dispatch('updateDelayViewerUpdated', true) + .then(() => { + router.push(`/project${file.url}`); + }) + .catch(e => { + throw e; + }); + } + + eventHub.$emit(`editor.update.model.new.content.${file.key}`, file.content); + eventHub.$emit(`editor.update.model.dispose.unstaged-${file.key}`, file.content); +}; + +export const stageChange = ({ commit, state }, path) => { + const stagedFile = state.stagedFiles.find(f => f.path === path); + + commit(types.STAGE_CHANGE, path); + + if (stagedFile) { + eventHub.$emit(`editor.update.model.new.content.staged-${stagedFile.key}`, stagedFile.content); } +}; - eventHub.$emit(`editor.update.model.content.${file.path}`, file.raw); +export const unstageChange = ({ commit }, path) => { + commit(types.UNSTAGE_CHANGE, path); }; -export const openPendingTab = ({ commit, getters, dispatch, state }, file) => { - if (getters.activeFile && getters.activeFile.path === file.path && state.viewer === 'diff') { +export const openPendingTab = ({ commit, getters, dispatch, state }, { file, keyPrefix }) => { + if (getters.activeFile && getters.activeFile === file && state.viewer === 'diff') { return false; } - commit(types.ADD_PENDING_TAB, { file }); + commit(types.ADD_PENDING_TAB, { file, keyPrefix }); dispatch('scrollToTab'); diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index b3882cb8d21..4eb23b2ee0f 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -5,45 +5,71 @@ import * as types from '../mutation_types'; export const getProjectData = ( { commit, state, dispatch }, { namespace, projectId, force = false } = {}, -) => new Promise((resolve, reject) => { - if (!state.projects[`${namespace}/${projectId}`] || force) { - commit(types.TOGGLE_LOADING, { entry: state }); - service.getProjectData(namespace, projectId) - .then(res => res.data) - .then((data) => { +) => + new Promise((resolve, reject) => { + if (!state.projects[`${namespace}/${projectId}`] || force) { commit(types.TOGGLE_LOADING, { entry: state }); - commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data }); - if (!state.currentProjectId) commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`); - resolve(data); - }) - .catch(() => { - flash('Error loading project data. Please try again.', 'alert', document, null, false, true); - reject(new Error(`Project not loaded ${namespace}/${projectId}`)); - }); - } else { - resolve(state.projects[`${namespace}/${projectId}`]); - } -}); + service + .getProjectData(namespace, projectId) + .then(res => res.data) + .then(data => { + commit(types.TOGGLE_LOADING, { entry: state }); + commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data }); + if (!state.currentProjectId) + commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`); + resolve(data); + }) + .catch(() => { + flash( + 'Error loading project data. Please try again.', + 'alert', + document, + null, + false, + true, + ); + reject(new Error(`Project not loaded ${namespace}/${projectId}`)); + }); + } else { + resolve(state.projects[`${namespace}/${projectId}`]); + } + }); export const getBranchData = ( { commit, state, dispatch }, { projectId, branchId, force = false } = {}, -) => new Promise((resolve, reject) => { - if ((typeof state.projects[`${projectId}`] === 'undefined' || - !state.projects[`${projectId}`].branches[branchId]) - || force) { - service.getBranchData(`${projectId}`, branchId) - .then(({ data }) => { - const { id } = data.commit; - commit(types.SET_BRANCH, { projectPath: `${projectId}`, branchName: branchId, branch: data }); - commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id }); - resolve(data); - }) - .catch(() => { - flash('Error loading branch data. Please try again.', 'alert', document, null, false, true); - reject(new Error(`Branch not loaded - ${projectId}/${branchId}`)); - }); - } else { - resolve(state.projects[`${projectId}`].branches[branchId]); - } -}); +) => + new Promise((resolve, reject) => { + if ( + typeof state.projects[`${projectId}`] === 'undefined' || + !state.projects[`${projectId}`].branches[branchId] || + force + ) { + service + .getBranchData(`${projectId}`, branchId) + .then(({ data }) => { + const { id } = data.commit; + commit(types.SET_BRANCH, { + projectPath: `${projectId}`, + branchName: branchId, + branch: data, + }); + commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id }); + commit(types.SET_CURRENT_BRANCH, branchId); + resolve(data); + }) + .catch(() => { + flash( + 'Error loading branch data. Please try again.', + 'alert', + document, + null, + false, + true, + ); + reject(new Error(`Branch not loaded - ${projectId}/${branchId}`)); + }); + } else { + resolve(state.projects[`${projectId}`].branches[branchId]); + } + }); diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index a77cdbc13c8..8518d2f6f06 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export const activeFile = state => state.openFiles.find(file => file.active) || null; export const addedFiles = state => state.changedFiles.filter(f => f.tempFile); @@ -29,9 +31,15 @@ export const currentMergeRequest = state => { }; // eslint-disable-next-line no-confusing-arrow -export const currentIcon = state => +export const collapseButtonIcon = state => state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right'; -export const hasChanges = state => !!state.changedFiles.length; +export const hasChanges = state => !!state.changedFiles.length || !!state.stagedFiles.length; + +// eslint-disable-next-line no-confusing-arrow +export const collapseButtonTooltip = state => + state.rightPanelCollapsed ? __('Expand sidebar') : __('Collapse sidebar'); export const hasMergeRequest = state => !!state.currentMergeRequestId; + +export const getStagedFile = state => path => state.stagedFiles.find(f => f.path === path); diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 367c45f7e2d..b26512e213a 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -98,40 +98,25 @@ export const updateFilesAfterCommit = ( { root: true }, ); - rootState.changedFiles.forEach(entry => { - commit( - rootTypes.SET_LAST_COMMIT_DATA, - { - entry, - lastCommit, - }, - { root: true }, - ); - - eventHub.$emit(`editor.update.model.content.${entry.path}`, entry.content); + rootState.stagedFiles.forEach(file => { + const changedFile = rootState.changedFiles.find(f => f.path === file.path); commit( - rootTypes.SET_FILE_RAW_DATA, + rootTypes.UPDATE_FILE_AFTER_COMMIT, { - file: entry, - raw: entry.content, + file, + lastCommit, }, { root: true }, ); - commit( - rootTypes.TOGGLE_FILE_CHANGED, - { - file: entry, - changed: false, - }, - { root: true }, - ); + eventHub.$emit(`editor.update.model.content.${file.key}`, { + content: file.content, + changed: !!changedFile, + }); }); - commit(rootTypes.REMOVE_ALL_CHANGES_FILES, null, { root: true }); - - if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) { + if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH && rootGetters.activeFile) { router.push( `/project/${rootState.currentProjectId}/blob/${branch}/${rootGetters.activeFile.path}`, ); @@ -184,6 +169,8 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState }) = { root: true }, ); } + + commit(rootTypes.CLEAR_STAGED_CHANGES, null, { root: true }); }) .then(() => dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH)); }) diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js index f7cdd6adb0c..9c3905a0b0d 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/getters.js +++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js @@ -1,12 +1,17 @@ import * as consts from './constants'; -export const discardDraftButtonDisabled = state => state.commitMessage === '' || state.submitCommitLoading; +const BRANCH_SUFFIX_COUNT = 5; + +export const discardDraftButtonDisabled = state => + state.commitMessage === '' || state.submitCommitLoading; export const commitButtonDisabled = (state, getters, rootState) => - getters.discardDraftButtonDisabled || !rootState.changedFiles.length; + getters.discardDraftButtonDisabled || !rootState.stagedFiles.length; export const newBranchName = (state, _, rootState) => - `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(-5)}`; + `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr( + -BRANCH_SUFFIX_COUNT, + )}`; export const branchName = (state, getters, rootState) => { if ( diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index e3f504e5ab0..f5f95b755c8 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -51,5 +51,10 @@ export const SET_FILE_MERGE_REQUEST_CHANGE = 'SET_FILE_MERGE_REQUEST_CHANGE'; export const UPDATE_VIEWER = 'UPDATE_VIEWER'; export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE'; +export const CLEAR_STAGED_CHANGES = 'CLEAR_STAGED_CHANGES'; +export const STAGE_CHANGE = 'STAGE_CHANGE'; +export const UNSTAGE_CHANGE = 'UNSTAGE_CHANGE'; + +export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT'; export const ADD_PENDING_TAB = 'ADD_PENDING_TAB'; export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index 5e5eb831662..fbe342f9126 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -49,6 +49,11 @@ export default { lastCommitMsg, }); }, + [types.CLEAR_STAGED_CHANGES](state) { + Object.assign(state, { + stagedFiles: [], + }); + }, [types.SET_ENTRIES](state, entries) { Object.assign(state, { entries, @@ -95,6 +100,22 @@ export default { delayViewerUpdated, }); }, + [types.UPDATE_FILE_AFTER_COMMIT](state, { file, lastCommit }) { + const changedFile = state.changedFiles.find(f => f.path === file.path); + + Object.assign(state.entries[file.path], { + raw: file.content, + changed: !!changedFile, + staged: false, + lastCommit: Object.assign(state.entries[file.path].lastCommit, { + id: lastCommit.commit.id, + url: lastCommit.commit_path, + message: lastCommit.commit.message, + author: lastCommit.commit.author_name, + updatedAt: lastCommit.commit.authored_date, + }), + }); + }, ...projectMutations, ...mergeRequestMutation, ...fileMutations, diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index eeb14b5490c..dd7dcba8ac7 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -57,7 +57,9 @@ export default { }); }, [types.UPDATE_FILE_CONTENT](state, { path, content }) { - const changed = content !== state.entries[path].raw; + const stagedFile = state.stagedFiles.find(f => f.path === path); + const rawContent = stagedFile ? stagedFile.content : state.entries[path].raw; + const changed = content !== rawContent; Object.assign(state.entries[path], { content, @@ -91,8 +93,10 @@ export default { }); }, [types.DISCARD_FILE_CHANGES](state, path) { + const stagedFile = state.stagedFiles.find(f => f.path === path); + Object.assign(state.entries[path], { - content: state.entries[path].raw, + content: stagedFile ? stagedFile.content : state.entries[path].raw, changed: false, }); }, @@ -106,16 +110,67 @@ export default { changedFiles: state.changedFiles.filter(f => f.path !== path), }); }, + [types.STAGE_CHANGE](state, path) { + const stagedFile = state.stagedFiles.find(f => f.path === path); + + Object.assign(state, { + changedFiles: state.changedFiles.filter(f => f.path !== path), + entries: Object.assign(state.entries, { + [path]: Object.assign(state.entries[path], { + staged: true, + changed: false, + }), + }), + }); + + if (stagedFile) { + Object.assign(stagedFile, { + ...state.entries[path], + }); + } else { + Object.assign(state, { + stagedFiles: state.stagedFiles.concat({ + ...state.entries[path], + }), + }); + } + }, + [types.UNSTAGE_CHANGE](state, path) { + const changedFile = state.changedFiles.find(f => f.path === path); + const stagedFile = state.stagedFiles.find(f => f.path === path); + + if (!changedFile && stagedFile) { + Object.assign(state.entries[path], { + ...stagedFile, + key: state.entries[path].key, + active: state.entries[path].active, + opened: state.entries[path].opened, + changed: true, + }); + + Object.assign(state, { + changedFiles: state.changedFiles.concat(state.entries[path]), + }); + } + + Object.assign(state, { + stagedFiles: state.stagedFiles.filter(f => f.path !== path), + entries: Object.assign(state.entries, { + [path]: Object.assign(state.entries[path], { + staged: false, + }), + }), + }); + }, [types.TOGGLE_FILE_CHANGED](state, { file, changed }) { Object.assign(state.entries[file.path], { changed, }); }, [types.ADD_PENDING_TAB](state, { file, keyPrefix = 'pending' }) { - const pendingTab = state.openFiles.find(f => f.path === file.path && f.pending); - let openFiles = state.openFiles.map(f => - Object.assign(f, { active: f.path === file.path, opened: false }), - ); + const key = `${keyPrefix}-${file.key}`; + const pendingTab = state.openFiles.find(f => f.key === key && f.pending); + let openFiles = state.openFiles.map(f => Object.assign(f, { active: false, opened: false })); if (!pendingTab) { const openFile = openFiles.find(f => f.path === file.path); @@ -126,10 +181,11 @@ export default { if (f.path === file.path) { return acc.concat({ ...f, + content: file.content, active: true, pending: true, opened: true, - key: `${keyPrefix}-${f.key}`, + key, }); } diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js index 7f7e470c9bb..1176c040fb9 100644 --- a/app/assets/javascripts/ide/stores/mutations/tree.js +++ b/app/assets/javascripts/ide/stores/mutations/tree.js @@ -17,12 +17,8 @@ export default { }); }, [types.SET_DIRECTORY_DATA](state, { data, treePath }) { - Object.assign(state, { - trees: Object.assign(state.trees, { - [treePath]: { - tree: data, - }, - }), + Object.assign(state.trees[treePath], { + tree: data, }); }, [types.SET_LAST_COMMIT_URL](state, { tree = state, url }) { diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index e5cc8814000..34975ac3144 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -3,6 +3,7 @@ export default () => ({ currentBranchId: '', currentMergeRequestId: '', changedFiles: [], + stagedFiles: [], endpoints: {}, lastCommitMsg: '', lastCommitPath: '', diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 05a019de54f..8a222da14c0 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -15,6 +15,7 @@ export const dataStructure = () => ({ opened: false, active: false, changed: false, + staged: false, lastCommitPath: '', lastCommit: { id: '', @@ -101,7 +102,7 @@ export const setPageTitle = title => { export const createCommitPayload = (branch, newBranch, state, rootState) => ({ branch, commit_message: state.commitMessage, - actions: rootState.changedFiles.map(f => ({ + actions: rootState.stagedFiles.map(f => ({ action: f.tempFile ? 'create' : 'update', file_path: f.path, content: f.content, diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 7470d634b99..f3d722409b0 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -30,10 +30,10 @@ export default class IssuableContext { const $selectbox = $block.find('.selectbox'); if ($selectbox.is(':visible')) { $selectbox.hide(); - $block.find('.value').show(); + $block.find('.value:not(.dont-hide)').show(); } else { $selectbox.show(); - $block.find('.value').hide(); + $block.find('.value:not(.dont-hide)').hide(); } if ($selectbox.is(':visible')) { diff --git a/app/assets/javascripts/jobs/components/header.vue b/app/assets/javascripts/jobs/components/header.vue index 357bc9aab17..21b545d6cab 100644 --- a/app/assets/javascripts/jobs/components/header.vue +++ b/app/assets/javascripts/jobs/components/header.vue @@ -1,82 +1,94 @@ <script> - import ciHeader from '../../vue_shared/components/header_ci_component.vue'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import ciHeader from '../../vue_shared/components/header_ci_component.vue'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import callout from '../../vue_shared/components/callout.vue'; - export default { - name: 'JobHeaderSection', - components: { - ciHeader, - loadingIcon, +export default { + name: 'JobHeaderSection', + components: { + ciHeader, + loadingIcon, + callout, + }, + props: { + job: { + type: Object, + required: true, }, - props: { - job: { - type: Object, - required: true, - }, - isLoading: { - type: Boolean, - required: true, - }, + isLoading: { + type: Boolean, + required: true, }, - data() { - return { - actions: this.getActions(), - }; + }, + data() { + return { + actions: this.getActions(), + }; + }, + computed: { + status() { + return this.job && this.job.status; }, - computed: { - status() { - return this.job && this.job.status; - }, - shouldRenderContent() { - return !this.isLoading && Object.keys(this.job).length; - }, - /** - * When job has not started the key will be `false` - * When job started the key will be a string with a date. - */ - jobStarted() { - return !this.job.started === false; - }, + shouldRenderContent() { + return !this.isLoading && Object.keys(this.job).length; }, - watch: { - job() { - this.actions = this.getActions(); - }, + shouldRenderReason() { + return !!(this.job.status && this.job.callout_message); }, - methods: { - getActions() { - const actions = []; + /** + * When job has not started the key will be `false` + * When job started the key will be a string with a date. + */ + jobStarted() { + return !this.job.started === false; + }, + }, + watch: { + job() { + this.actions = this.getActions(); + }, + }, + methods: { + getActions() { + const actions = []; - if (this.job.new_issue_path) { - actions.push({ - label: 'New issue', - path: this.job.new_issue_path, - cssClass: 'js-new-issue btn btn-new btn-inverted visible-md-block visible-lg-block', - type: 'link', - }); - } - return actions; - }, + if (this.job.new_issue_path) { + actions.push({ + label: 'New issue', + path: this.job.new_issue_path, + cssClass: 'js-new-issue btn btn-new btn-inverted visible-md-block visible-lg-block', + type: 'link', + }); + } + return actions; }, - }; + }, +}; </script> <template> - <div class="js-build-header build-header top-area"> - <ci-header - v-if="shouldRenderContent" - :status="status" - item-name="Job" - :item-id="job.id" - :time="job.created_at" - :user="job.user" - :actions="actions" - :has-sidebar-button="true" - :should-render-triggered-label="jobStarted" - /> - <loading-icon - v-if="isLoading" - size="2" - class="prepend-top-default append-bottom-default" + <header> + <div class="js-build-header build-header top-area"> + <ci-header + v-if="shouldRenderContent" + :status="status" + item-name="Job" + :item-id="job.id" + :time="job.created_at" + :user="job.user" + :actions="actions" + :has-sidebar-button="true" + :should-render-triggered-label="jobStarted" + /> + <loading-icon + v-if="isLoading" + size="2" + class="prepend-top-default append-bottom-default" + /> + </div> + + <callout + v-if="shouldRenderReason" + :message="job.callout_message" /> - </div> + </header> </template> diff --git a/app/assets/javascripts/jobs/components/sidebar_details_block.vue b/app/assets/javascripts/jobs/components/sidebar_details_block.vue index af47056d98f..4cd44bf7a76 100644 --- a/app/assets/javascripts/jobs/components/sidebar_details_block.vue +++ b/app/assets/javascripts/jobs/components/sidebar_details_block.vue @@ -1,80 +1,119 @@ <script> - import detailRow from './sidebar_detail_row.vue'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; - import timeagoMixin from '../../vue_shared/mixins/timeago'; - import { timeIntervalInWords } from '../../lib/utils/datetime_utility'; +import detailRow from './sidebar_detail_row.vue'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import timeagoMixin from '../../vue_shared/mixins/timeago'; +import { timeIntervalInWords } from '../../lib/utils/datetime_utility'; - export default { - name: 'SidebarDetailsBlock', - components: { - detailRow, - loadingIcon, +export default { + name: 'SidebarDetailsBlock', + components: { + detailRow, + loadingIcon, + }, + mixins: [timeagoMixin], + props: { + job: { + type: Object, + required: true, }, - mixins: [ - timeagoMixin, - ], - props: { - job: { - type: Object, - required: true, - }, - isLoading: { - type: Boolean, - required: true, - }, - runnerHelpUrl: { - type: String, - required: false, - default: '', - }, + isLoading: { + type: Boolean, + required: true, }, - computed: { - shouldRenderContent() { - return !this.isLoading && Object.keys(this.job).length > 0; - }, - coverage() { - return `${this.job.coverage}%`; - }, - duration() { - return timeIntervalInWords(this.job.duration); - }, - queued() { - return timeIntervalInWords(this.job.queued); - }, - runnerId() { - return `#${this.job.runner.id}`; - }, - hasTimeout() { - return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null; - }, - timeout() { - if (this.job.metadata == null) { - return ''; - } + canUserRetry: { + type: Boolean, + required: false, + default: false, + }, + runnerHelpUrl: { + type: String, + required: false, + default: '', + }, + }, + computed: { + shouldRenderContent() { + return !this.isLoading && Object.keys(this.job).length > 0; + }, + coverage() { + return `${this.job.coverage}%`; + }, + duration() { + return timeIntervalInWords(this.job.duration); + }, + queued() { + return timeIntervalInWords(this.job.queued); + }, + runnerId() { + return `#${this.job.runner.id}`; + }, + retryButtonClass() { + let className = 'js-retry-button pull-right btn btn-retry visible-md-block visible-lg-block'; + className += + this.job.status && this.job.recoverable + ? ' btn-primary' + : ' btn-inverted-secondary'; + return className; + }, + hasTimeout() { + return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null; + }, + timeout() { + if (this.job.metadata == null) { + return ''; + } - let t = this.job.metadata.timeout_human_readable; - if (this.job.metadata.timeout_source !== '') { - t += ` (from ${this.job.metadata.timeout_source})`; - } + let t = this.job.metadata.timeout_human_readable; + if (this.job.metadata.timeout_source !== '') { + t += ` (from ${this.job.metadata.timeout_source})`; + } - return t; - }, - renderBlock() { - return this.job.merge_request || - this.job.duration || - this.job.finished_data || - this.job.erased_at || - this.job.queued || - this.job.runner || - this.job.coverage || - this.job.tags.length || - this.job.cancel_path; - }, + return t; }, - }; + renderBlock() { + return ( + this.job.merge_request || + this.job.duration || + this.job.finished_data || + this.job.erased_at || + this.job.queued || + this.job.runner || + this.job.coverage || + this.job.tags.length || + this.job.cancel_path + ); + }, + }, +}; </script> <template> <div> + <div class="block"> + <strong class="inline prepend-top-8"> + {{ job.name }} + </strong> + <a + v-if="canUserRetry" + :class="retryButtonClass" + :href="job.retry_path" + data-method="post" + rel="nofollow" + > + {{ __('Retry') }} + </a> + <button + type="button" + :aria-label="__('Toggle Sidebar')" + class="btn btn-blank gutter-toggle pull-right + visible-xs-block visible-sm-block js-sidebar-build-toggle" + > + <i + aria-hidden="true" + data-hidden="true" + class="fa fa-angle-double-right" + ></i> + </button> + </div> <template v-if="shouldRenderContent"> <div class="block retry-link" @@ -85,16 +124,16 @@ class="js-new-issue btn btn-new btn-inverted" :href="job.new_issue_path" > - New issue + {{ __('New issue') }} </a> <a - v-if="job.retry_path" + v-if="canUserRetry" class="js-retry-job btn btn-inverted-secondary" :href="job.retry_path" data-method="post" rel="nofollow" > - Retry + {{ __('Retry') }} </a> </div> <div :class="{block : renderBlock }"> @@ -103,7 +142,7 @@ v-if="job.merge_request" > <span class="build-light-text"> - Merge Request: + {{ __('Merge Request:') }} </span> <a :href="job.merge_request.path"> !{{ job.merge_request.iid }} @@ -158,7 +197,7 @@ v-if="job.tags.length" > <span class="build-light-text"> - Tags: + {{ __('Tags:') }} </span> <span v-for="(tag, i) in job.tags" @@ -178,7 +217,7 @@ data-method="post" rel="nofollow" > - Cancel + {{ __('Cancel') }} </a> </div> </div> diff --git a/app/assets/javascripts/jobs/job_details_bundle.js b/app/assets/javascripts/jobs/job_details_bundle.js index 656676ead91..f2939ad4dbe 100644 --- a/app/assets/javascripts/jobs/job_details_bundle.js +++ b/app/assets/javascripts/jobs/job_details_bundle.js @@ -35,9 +35,11 @@ export default () => { }); // Sidebar information block + const detailsBlockElement = document.getElementById('js-details-block-vue'); + const detailsBlockDataset = detailsBlockElement.dataset; // eslint-disable-next-line new Vue({ - el: '#js-details-block-vue', + el: detailsBlockElement, components: { detailsBlock, }, @@ -50,6 +52,7 @@ export default () => { return createElement('details-block', { props: { isLoading: this.mediator.state.isLoading, + canUserRetry: !!('canUserRetry' in detailsBlockDataset), job: this.mediator.store.state.job, runnerHelpUrl: dataset.runnerHelpUrl, }, diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index d0050abb8e9..9b62cfb8206 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -83,7 +83,7 @@ export default class LabelsSelect { $dropdown.trigger('loading.gl.dropdown'); axios.put(issueUpdateURL, data) .then(({ data }) => { - var labelCount, template, labelTooltipTitle, labelTitles; + var labelCount, template, labelTooltipTitle, labelTitles, formattedLabels; $loading.fadeOut(); $dropdown.trigger('loaded.gl.dropdown'); $selectbox.hide(); @@ -115,8 +115,7 @@ export default class LabelsSelect { labelTooltipTitle = labelTitles.join(', '); } else { - labelTooltipTitle = ''; - $sidebarLabelTooltip.tooltip('destroy'); + labelTooltipTitle = __('Labels'); } $sidebarLabelTooltip diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index d0a2b27b0e6..7e9a50a885d 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -4,6 +4,7 @@ import $ from 'jquery'; import _ from 'underscore'; +import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; import { timeFor } from './lib/utils/datetime_utility'; import ModalStore from './boards/stores/modal_store'; @@ -25,7 +26,7 @@ export default class MilestoneSelect { } $els.each((i, dropdown) => { - let collapsedSidebarLabelTemplate, milestoneLinkNoneTemplate, milestoneLinkTemplate, selectedMilestone, selectedMilestoneDefault; + let milestoneLinkNoneTemplate, milestoneLinkTemplate, selectedMilestone, selectedMilestoneDefault; const $dropdown = $(dropdown); const projectId = $dropdown.data('projectId'); const milestonesUrl = $dropdown.data('milestones'); @@ -52,7 +53,6 @@ export default class MilestoneSelect { if (issueUpdateURL) { milestoneLinkTemplate = _.template('<a href="/<%- full_path %>/milestones/<%- iid %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>'); milestoneLinkNoneTemplate = '<span class="no-value">None</span>'; - collapsedSidebarLabelTemplate = _.template('<span class="has-tooltip" data-container="body" title="<%- name %><br /><%- remaining %>" data-placement="left" data-html="true"> <%- title %> </span>'); } return $dropdown.glDropdown({ showMenuAbove: showMenuAbove, @@ -214,10 +214,16 @@ export default class MilestoneSelect { data.milestone.remaining = timeFor(data.milestone.due_date); data.milestone.name = data.milestone.title; $value.html(milestoneLinkTemplate(data.milestone)); - return $sidebarCollapsedValue.find('span').html(collapsedSidebarLabelTemplate(data.milestone)); + return $sidebarCollapsedValue + .attr('data-original-title', `${data.milestone.name}<br />${data.milestone.remaining}`) + .find('span') + .text(data.milestone.title); } else { $value.html(milestoneLinkNoneTemplate); - return $sidebarCollapsedValue.find('span').text('No'); + return $sidebarCollapsedValue + .attr('data-original-title', __('Milestone')) + .find('span') + .text(__('None')); } }) .catch(() => { diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index b0573510ff9..96f2b3eac98 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -19,7 +19,6 @@ import AjaxCache from '~/lib/utils/ajax_cache'; import Vue from 'vue'; import syntaxHighlight from '~/syntax_highlight'; import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; -import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; import { getLocationHash } from './lib/utils/url_utility'; import Flash from './flash'; @@ -198,6 +197,8 @@ export default class Notes { ); this.$wrapperEl.on('click', '.js-toggle-lazy-diff', this.loadLazyDiff); + this.$wrapperEl.on('click', '.js-toggle-lazy-diff-retry-button', this.onClickRetryLazyLoad.bind(this)); + // fetch notes when tab becomes visible this.$wrapperEl.on('visibilitychange', this.visibilityChange); // when issue status changes, we need to refresh data @@ -244,6 +245,7 @@ export default class Notes { this.$wrapperEl.off('click', '.js-comment-resolve-button'); this.$wrapperEl.off('click', '.system-note-commit-list-toggler'); this.$wrapperEl.off('click', '.js-toggle-lazy-diff'); + this.$wrapperEl.off('click', '.js-toggle-lazy-diff-retry-button'); this.$wrapperEl.off('ajax:success', '.js-main-target-form'); this.$wrapperEl.off('ajax:success', '.js-discussion-note-form'); this.$wrapperEl.off('ajax:complete', '.js-main-target-form'); @@ -1425,22 +1427,21 @@ export default class Notes { const { discussion_html } = data; const lines = $(discussion_html).find('.line_holder'); lines.addClass('fade-in'); - $container.find('tbody').prepend(lines); + $container.find('.diff-content > table > tbody').prepend(lines); const fileHolder = $container.find('.file-holder'); $container.find('.line-holder-placeholder').remove(); syntaxHighlight(fileHolder); } - static renderDiffError($container) { - $container.find('.line_content').html( - $(` - <div class="nothing-here-block"> - ${__( - 'Unable to load the diff.', - )} <a class="js-toggle-lazy-diff" href="javascript:void(0)">Try again</a>? - </div> - `), - ); + onClickRetryLazyLoad(e) { + const $retryButton = $(e.currentTarget); + + $retryButton.prop('disabled', true); + + return this.loadLazyDiff(e) + .then(() => { + $retryButton.prop('disabled', false); + }); } loadLazyDiff(e) { @@ -1449,20 +1450,35 @@ export default class Notes { $container.find('.js-toggle-lazy-diff').removeClass('js-toggle-lazy-diff'); - const tableEl = $container.find('tbody'); - if (tableEl.length === 0) return; + const $tableEl = $container.find('tbody'); + if ($tableEl.length === 0) return; const fileHolder = $container.find('.file-holder'); const url = fileHolder.data('linesPath'); - axios + const $errorContainer = $container.find('.js-error-lazy-load-diff'); + const $successContainer = $container.find('.js-success-lazy-load'); + + /** + * We only fetch resolved discussions. + * Unresolved discussions don't have an endpoint being provided. + */ + if (url) { + return axios .get(url) .then(({ data }) => { + // Reset state in case last request returned error + $successContainer.removeClass('hidden'); + $errorContainer.addClass('hidden'); + Notes.renderDiffContent($container, data); }) .catch(() => { - Notes.renderDiffError($container); + $successContainer.addClass('hidden'); + $errorContainer.removeClass('hidden'); }); + } + return Promise.resolve(); } toggleCommitList(e) { diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 8ce938c958b..cbc2d80ee18 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -19,7 +19,7 @@ function getSystemDate(systemUtcOffsetSeconds) { const date = new Date(); const localUtcOffsetMinutes = 0 - date.getTimezoneOffset(); const systemUtcOffsetMinutes = systemUtcOffsetSeconds / 60; - date.setMinutes((date.getMinutes() - localUtcOffsetMinutes) + systemUtcOffsetMinutes); + date.setMinutes(date.getMinutes() - localUtcOffsetMinutes + systemUtcOffsetMinutes); return date; } @@ -35,18 +35,36 @@ function formatTooltipText({ date, count }) { return `${contribText}<br />${dateDayName} ${dateText}`; } -const initColorKey = () => d3.scaleLinear().range(['#acd5f2', '#254e77']).domain([0, 3]); +const initColorKey = () => + d3 + .scaleLinear() + .range(['#acd5f2', '#254e77']) + .domain([0, 3]); export default class ActivityCalendar { - constructor(container, timestamps, calendarActivitiesPath, utcOffset = 0) { + constructor(container, timestamps, calendarActivitiesPath, utcOffset = 0, firstDayOfWeek = 0) { this.calendarActivitiesPath = calendarActivitiesPath; this.clickDay = this.clickDay.bind(this); this.currentSelectedDate = ''; this.daySpace = 1; this.daySize = 15; - this.daySizeWithSpace = this.daySize + (this.daySpace * 2); - this.monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + this.daySizeWithSpace = this.daySize + this.daySpace * 2; + this.monthNames = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; this.months = []; + this.firstDayOfWeek = firstDayOfWeek; // Loop through the timestamps to create a group of objects // The group of objects will be grouped based on the day of the week they are @@ -70,7 +88,7 @@ export default class ActivityCalendar { // Create a new group array if this is the first day of the week // or if is first object - if ((day === 0 && i !== 0) || i === 0) { + if ((day === this.firstDayOfWeek && i !== 0) || i === 0) { this.timestampsTmp.push([]); group += 1; } @@ -109,21 +127,30 @@ export default class ActivityCalendar { } renderSvg(container, group) { - const width = ((group + 1) * this.daySizeWithSpace) + this.getExtraWidthPadding(group); - return d3.select(container) + const width = (group + 1) * this.daySizeWithSpace + this.getExtraWidthPadding(group); + return d3 + .select(container) .append('svg') - .attr('width', width) - .attr('height', 167) - .attr('class', 'contrib-calendar'); + .attr('width', width) + .attr('height', 167) + .attr('class', 'contrib-calendar'); + } + + dayYPos(day) { + return this.daySizeWithSpace * ((day + 7 - this.firstDayOfWeek) % 7); } renderDays() { - this.svg.selectAll('g').data(this.timestampsTmp).enter().append('g') + this.svg + .selectAll('g') + .data(this.timestampsTmp) + .enter() + .append('g') .attr('transform', (group, i) => { _.each(group, (stamp, a) => { if (a === 0 && stamp.day === 0) { const month = stamp.date.getMonth(); - const x = (this.daySizeWithSpace * i) + 1 + this.daySizeWithSpace; + const x = this.daySizeWithSpace * i + 1 + this.daySizeWithSpace; const lastMonth = _.last(this.months); if ( lastMonth == null || @@ -133,86 +160,113 @@ export default class ActivityCalendar { } } }); - return `translate(${(this.daySizeWithSpace * i) + 1 + this.daySizeWithSpace}, 18)`; + return `translate(${this.daySizeWithSpace * i + 1 + this.daySizeWithSpace}, 18)`; }) .selectAll('rect') - .data(stamp => stamp) - .enter() - .append('rect') - .attr('x', '0') - .attr('y', stamp => this.daySizeWithSpace * stamp.day) - .attr('width', this.daySize) - .attr('height', this.daySize) - .attr('fill', stamp => ( - stamp.count !== 0 ? this.color(Math.min(stamp.count, 40)) : '#ededed' - )) - .attr('title', stamp => formatTooltipText(stamp)) - .attr('class', 'user-contrib-cell js-tooltip') - .attr('data-container', 'body') - .on('click', this.clickDay); + .data(stamp => stamp) + .enter() + .append('rect') + .attr('x', '0') + .attr('y', stamp => this.dayYPos(stamp.day)) + .attr('width', this.daySize) + .attr('height', this.daySize) + .attr( + 'fill', + stamp => (stamp.count !== 0 ? this.color(Math.min(stamp.count, 40)) : '#ededed'), + ) + .attr('title', stamp => formatTooltipText(stamp)) + .attr('class', 'user-contrib-cell js-tooltip') + .attr('data-container', 'body') + .on('click', this.clickDay); } renderDayTitles() { const days = [ { text: 'M', - y: 29 + (this.daySizeWithSpace * 1), - }, { + y: 29 + this.dayYPos(1), + }, + { text: 'W', - y: 29 + (this.daySizeWithSpace * 3), - }, { + y: 29 + this.dayYPos(2), + }, + { text: 'F', - y: 29 + (this.daySizeWithSpace * 5), + y: 29 + this.dayYPos(3), }, ]; - this.svg.append('g') + this.svg + .append('g') .selectAll('text') - .data(days) - .enter() - .append('text') - .attr('text-anchor', 'middle') - .attr('x', 8) - .attr('y', day => day.y) - .text(day => day.text) - .attr('class', 'user-contrib-text'); + .data(days) + .enter() + .append('text') + .attr('text-anchor', 'middle') + .attr('x', 8) + .attr('y', day => day.y) + .text(day => day.text) + .attr('class', 'user-contrib-text'); } renderMonths() { - this.svg.append('g') + this.svg + .append('g') .attr('direction', 'ltr') .selectAll('text') - .data(this.months) - .enter() - .append('text') - .attr('x', date => date.x) - .attr('y', 10) - .attr('class', 'user-contrib-text') - .text(date => this.monthNames[date.month]); + .data(this.months) + .enter() + .append('text') + .attr('x', date => date.x) + .attr('y', 10) + .attr('class', 'user-contrib-text') + .text(date => this.monthNames[date.month]); } renderKey() { - const keyValues = ['no contributions', '1-9 contributions', '10-19 contributions', '20-29 contributions', '30+ contributions']; - const keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)]; + const keyValues = [ + 'no contributions', + '1-9 contributions', + '10-19 contributions', + '20-29 contributions', + '30+ contributions', + ]; + const keyColors = [ + '#ededed', + this.colorKey(0), + this.colorKey(1), + this.colorKey(2), + this.colorKey(3), + ]; - this.svg.append('g') - .attr('transform', `translate(18, ${(this.daySizeWithSpace * 8) + 16})`) + this.svg + .append('g') + .attr('transform', `translate(18, ${this.daySizeWithSpace * 8 + 16})`) .selectAll('rect') - .data(keyColors) - .enter() - .append('rect') - .attr('width', this.daySize) - .attr('height', this.daySize) - .attr('x', (color, i) => this.daySizeWithSpace * i) - .attr('y', 0) - .attr('fill', color => color) - .attr('class', 'js-tooltip') - .attr('title', (color, i) => keyValues[i]) - .attr('data-container', 'body'); + .data(keyColors) + .enter() + .append('rect') + .attr('width', this.daySize) + .attr('height', this.daySize) + .attr('x', (color, i) => this.daySizeWithSpace * i) + .attr('y', 0) + .attr('fill', color => color) + .attr('class', 'js-tooltip') + .attr('title', (color, i) => keyValues[i]) + .attr('data-container', 'body'); } initColor() { - const colorRange = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)]; - return d3.scaleThreshold().domain([0, 10, 20, 30]).range(colorRange); + const colorRange = [ + '#ededed', + this.colorKey(0), + this.colorKey(1), + this.colorKey(2), + this.colorKey(3), + ]; + return d3 + .scaleThreshold() + .domain([0, 10, 20, 30]) + .range(colorRange); } clickDay(stamp) { @@ -227,14 +281,15 @@ export default class ActivityCalendar { $('.user-calendar-activities').html(LOADING_HTML); - axios.get(this.calendarActivitiesPath, { - params: { - date, - }, - responseType: 'text', - }) - .then(({ data }) => $('.user-calendar-activities').html(data)) - .catch(() => flash(__('An error occurred while retrieving calendar activity'))); + axios + .get(this.calendarActivitiesPath, { + params: { + date, + }, + responseType: 'text', + }) + .then(({ data }) => $('.user-calendar-activities').html(data)) + .catch(() => flash(__('An error occurred while retrieving calendar activity'))); } else { this.currentSelectedDate = ''; $('.user-calendar-activities').html(''); diff --git a/app/assets/javascripts/performance_bar/services/performance_bar_service.js b/app/assets/javascripts/performance_bar/services/performance_bar_service.js index 3ebfaa87a4e..bc71911ae35 100644 --- a/app/assets/javascripts/performance_bar/services/performance_bar_service.js +++ b/app/assets/javascripts/performance_bar/services/performance_bar_service.js @@ -10,29 +10,25 @@ export default class PerformanceBarService { } static registerInterceptor(peekUrl, callback) { - vueResourceInterceptor = (request, next) => { - next(response => { - const requestId = response.headers['x-request-id']; - const requestUrl = response.url; - - if (requestUrl !== peekUrl && requestId) { - callback(requestId, requestUrl); - } - }); - }; - - Vue.http.interceptors.push(vueResourceInterceptor); - - return axios.interceptors.response.use(response => { + const interceptor = response => { const requestId = response.headers['x-request-id']; - const requestUrl = response.config.url; + // Get the request URL from response.config for Axios, and response for + // Vue Resource. + const requestUrl = (response.config || response).url; + const cachedResponse = response.headers['x-gitlab-from-cache'] === 'true'; - if (requestUrl !== peekUrl && requestId) { + if (requestUrl !== peekUrl && requestId && !cachedResponse) { callback(requestId, requestUrl); } return response; - }); + }; + + vueResourceInterceptor = (request, next) => next(interceptor); + + Vue.http.interceptors.push(vueResourceInterceptor); + + return axios.interceptors.response.use(interceptor); } static removeInterceptor(interceptor) { diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index e99d949801f..29ee73a2a6f 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -32,26 +32,38 @@ export default { required: true, }, - buttonDisabled: { + requestFinishedFor: { type: String, required: false, - default: null, + default: '', }, }, + data() { + return { + isDisabled: false, + linkRequested: '', + }; + }, + computed: { cssClass() { const actionIconDash = dasherize(this.actionIcon); return `${actionIconDash} js-icon-${actionIconDash}`; }, - isDisabled() { - return this.buttonDisabled === this.link; + }, + watch: { + requestFinishedFor() { + if (this.requestFinishedFor === this.linkRequested) { + this.isDisabled = false; + } }, }, - methods: { onClickAction() { $(this.$el).tooltip('hide'); eventHub.$emit('graphAction', this.link); + this.linkRequested = this.link; + this.isDisabled = true; }, }, }; @@ -62,7 +74,8 @@ export default { @click="onClickAction" v-tooltip :title="tooltipText" - class="btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper" + class="js-ci-action btn btn-blank +btn-transparent ci-action-icon-container ci-action-icon-wrapper" :class="cssClass" data-container="body" :disabled="isDisabled" diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue deleted file mode 100644 index 7c4fd65e36f..00000000000 --- a/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue +++ /dev/null @@ -1,53 +0,0 @@ -<script> - import icon from '../../../vue_shared/components/icon.vue'; - import tooltip from '../../../vue_shared/directives/tooltip'; - - /** - * Renders either a cancel, retry or play icon pointing to the given path. - * TODO: Remove UJS from here and use an async request instead. - */ - export default { - components: { - icon, - }, - - directives: { - tooltip, - }, - props: { - tooltipText: { - type: String, - required: true, - }, - - link: { - type: String, - required: true, - }, - - actionMethod: { - type: String, - required: true, - }, - - actionIcon: { - type: String, - required: true, - }, - }, - }; -</script> -<template> - <a - v-tooltip - :data-method="actionMethod" - :title="tooltipText" - :href="link" - rel="nofollow" - class="ci-action-icon-wrapper js-ci-status-icon" - data-container="body" - aria-label="Job's action" - > - <icon :name="actionIcon" /> - </a> -</template> diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue index be213c2ee78..43121dd38f3 100644 --- a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue @@ -1,77 +1,83 @@ <script> - import $ from 'jquery'; - import jobNameComponent from './job_name_component.vue'; - import jobComponent from './job_component.vue'; - import tooltip from '../../../vue_shared/directives/tooltip'; +import $ from 'jquery'; +import JobNameComponent from './job_name_component.vue'; +import JobComponent from './job_component.vue'; +import tooltip from '../../../vue_shared/directives/tooltip'; - /** - * Renders the dropdown for the pipeline graph. - * - * The following object should be provided as `job`: - * - * { - * "id": 4256, - * "name": "test", - * "status": { - * "icon": "icon_status_success", - * "text": "passed", - * "label": "passed", - * "group": "success", - * "details_path": "/root/ci-mock/builds/4256", - * "action": { - * "icon": "retry", - * "title": "Retry", - * "path": "/root/ci-mock/builds/4256/retry", - * "method": "post" - * } - * } - * } - */ - export default { - directives: { - tooltip, - }, +/** + * Renders the dropdown for the pipeline graph. + * + * The following object should be provided as `job`: + * + * { + * "id": 4256, + * "name": "test", + * "status": { + * "icon": "icon_status_success", + * "text": "passed", + * "label": "passed", + * "group": "success", + * "details_path": "/root/ci-mock/builds/4256", + * "action": { + * "icon": "retry", + * "title": "Retry", + * "path": "/root/ci-mock/builds/4256/retry", + * "method": "post" + * } + * } + * } + */ +export default { + directives: { + tooltip, + }, - components: { - jobComponent, - jobNameComponent, - }, + components: { + JobComponent, + JobNameComponent, + }, - props: { - job: { - type: Object, - required: true, - }, + props: { + job: { + type: Object, + required: true, }, - - computed: { - tooltipText() { - return `${this.job.name} - ${this.job.status.label}`; - }, + requestFinishedFor: { + type: String, + required: false, + default: '', }, + }, - mounted() { - this.stopDropdownClickPropagation(); + computed: { + tooltipText() { + return `${this.job.name} - ${this.job.status.label}`; }, + }, + + mounted() { + this.stopDropdownClickPropagation(); + }, - methods: { - /** - * When the user right clicks or cmd/ctrl + click in the job name - * the dropdown should not be closed and the link should open in another tab, - * so we stop propagation of the click event inside the dropdown. + methods: { + /** + * When the user right clicks or cmd/ctrl + click in the job name or the action icon + * the dropdown should not be closed so we stop propagation + * of the click event inside the dropdown. * * Since this component is rendered multiple times per page we need to guarantee we only * target the click event of this component. */ - stopDropdownClickPropagation() { - $(this.$el - .querySelectorAll('.js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item')) - .on('click', (e) => { - e.stopPropagation(); - }); - }, + stopDropdownClickPropagation() { + $( + '.js-grouped-pipeline-dropdown button, .js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item', + this.$el, + ).on('click', e => { + e.stopPropagation(); + }); }, - }; + }, +}; </script> <template> <div class="ci-job-dropdown-container"> @@ -101,8 +107,8 @@ :key="i"> <job-component :job="item" - :is-dropdown="true" css-class-job-name="mini-pipeline-graph-dropdown-item" + :request-finished-for="requestFinishedFor" /> </li> </ul> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index ac9ce7e47d6..7b8a5edcbff 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -7,7 +7,6 @@ export default { StageColumnComponent, LoadingIcon, }, - props: { isLoading: { type: Boolean, @@ -17,10 +16,10 @@ export default { type: Object, required: true, }, - actionDisabled: { + requestFinishedFor: { type: String, required: false, - default: null, + default: '', }, }, @@ -75,7 +74,7 @@ export default { :key="stage.name" :stage-connector-class="stageConnectorClass(index, stage)" :is-first-column="isFirstColumn(index)" - :action-disabled="actionDisabled" + :request-finished-for="requestFinishedFor" /> </ul> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue index c6e5ae6df41..4fcd4b79f4a 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue @@ -1,6 +1,5 @@ <script> import ActionComponent from './action_component.vue'; -import DropdownActionComponent from './dropdown_action_component.vue'; import JobNameComponent from './job_name_component.vue'; import tooltip from '../../../vue_shared/directives/tooltip'; @@ -32,10 +31,8 @@ import tooltip from '../../../vue_shared/directives/tooltip'; export default { components: { ActionComponent, - DropdownActionComponent, JobNameComponent, }, - directives: { tooltip, }, @@ -44,26 +41,17 @@ export default { type: Object, required: true, }, - cssClassJobName: { type: String, required: false, default: '', }, - - isDropdown: { - type: Boolean, - required: false, - default: false, - }, - - actionDisabled: { + requestFinishedFor: { type: String, required: false, - default: null, + default: '', }, }, - computed: { status() { return this.job && this.job.status ? this.job.status : {}; @@ -134,19 +122,11 @@ export default { </div> <action-component - v-if="hasAction && !isDropdown" - :tooltip-text="status.action.title" - :link="status.action.path" - :action-icon="status.action.icon" - :button-disabled="actionDisabled" - /> - - <dropdown-action-component - v-if="hasAction && isDropdown" + v-if="hasAction" :tooltip-text="status.action.title" :link="status.action.path" :action-icon="status.action.icon" - :action-method="status.action.method" + :request-finished-for="requestFinishedFor" /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index f6e6569e15b..5461fdbbadd 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -29,10 +29,11 @@ export default { required: false, default: '', }, - actionDisabled: { + + requestFinishedFor: { type: String, required: false, - default: null, + default: '', }, }, @@ -74,12 +75,12 @@ export default { v-if="job.size === 1" :job="job" css-class-job-name="build-content" - :action-disabled="actionDisabled" /> <dropdown-job-component v-if="job.size > 1" :job="job" + :request-finished-for="requestFinishedFor" /> </li> diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 900eb7855f4..6584f96130b 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -25,7 +25,7 @@ export default () => { data() { return { mediator, - actionDisabled: null, + requestFinishedFor: null, }; }, created() { @@ -36,15 +36,17 @@ export default () => { }, methods: { postAction(action) { - this.actionDisabled = action; + // Click was made, reset this variable + this.requestFinishedFor = null; - this.mediator.service.postAction(action) + this.mediator.service + .postAction(action) .then(() => { this.mediator.refreshPipeline(); - this.actionDisabled = null; + this.requestFinishedFor = action; }) .catch(() => { - this.actionDisabled = null; + this.requestFinishedFor = action; Flash(__('An error occurred while making the request.')); }); }, @@ -54,7 +56,7 @@ export default () => { props: { isLoading: this.mediator.state.isLoading, pipeline: this.mediator.store.state.pipeline, - actionDisabled: this.actionDisabled, + requestFinishedFor: this.requestFinishedFor, }, }); }, @@ -79,7 +81,8 @@ export default () => { }, methods: { postAction(action) { - this.mediator.service.postAction(action.path) + this.mediator.service + .postAction(action.path) .then(() => this.mediator.refreshPipeline()) .catch(() => Flash(__('An error occurred while making the request.'))); }, diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 2088a49590a..6eb0b62fa1c 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -5,6 +5,7 @@ import _ from 'underscore'; import Cookies from 'js-cookie'; import flash from './flash'; import axios from './lib/utils/axios_utils'; +import { __ } from './locale'; function Sidebar(currentUser) { this.toggleTodo = this.toggleTodo.bind(this); @@ -41,12 +42,14 @@ Sidebar.prototype.addEventListeners = function() { }; Sidebar.prototype.sidebarToggleClicked = function (e, triggered) { - var $allGutterToggleIcons, $this, $thisIcon; + var $allGutterToggleIcons, $this, isExpanded, tooltipLabel; e.preventDefault(); $this = $(this); - $thisIcon = $this.find('i'); + isExpanded = $this.find('i').hasClass('fa-angle-double-right'); + tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar'); $allGutterToggleIcons = $('.js-sidebar-toggle i'); - if ($thisIcon.hasClass('fa-angle-double-right')) { + + if (isExpanded) { $allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left'); $('aside.right-sidebar').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); $('.layout-page').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); @@ -57,6 +60,9 @@ Sidebar.prototype.sidebarToggleClicked = function (e, triggered) { if (gl.lazyLoader) gl.lazyLoader.loadCheck(); } + + $this.attr('data-original-title', tooltipLabel); + if (!triggered) { Cookies.set("collapsed_gutter", $('.right-sidebar').hasClass('right-sidebar-collapsed')); } diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index 1e7f46454bf..2d00e8ac7e0 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -1,6 +1,12 @@ <script> +import { __ } from '~/locale'; +import tooltip from '~/vue_shared/directives/tooltip'; + export default { name: 'Assignees', + directives: { + tooltip, + }, props: { rootPath: { type: String, @@ -14,6 +20,11 @@ export default { type: Boolean, required: true, }, + issuableType: { + type: String, + require: true, + default: 'issue', + }, }, data() { return { @@ -62,6 +73,12 @@ export default { names.push(`+ ${this.users.length - maxRender} more`); } + if (!this.users.length) { + const emptyTooltipLabel = this.issuableType === 'issue' ? + __('Assignee(s)') : __('Assignee'); + names.push(emptyTooltipLabel); + } + return names.join(', '); }, sidebarAvatarCounter() { @@ -109,7 +126,8 @@ export default { <div> <div class="sidebar-collapsed-icon sidebar-collapsed-user" - :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }" + :class="{ 'multiple-users': hasMoreThanOneAssignee }" + v-tooltip data-container="body" data-placement="left" :title="collapsedTooltipTitle" diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 3c6b9c27814..b04a2eff798 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -1,9 +1,9 @@ <script> -import Flash from '../../../flash'; +import Flash from '~/flash'; +import eventHub from '~/sidebar/event_hub'; +import Store from '~/sidebar/stores/sidebar_store'; import AssigneeTitle from './assignee_title.vue'; import Assignees from './assignees.vue'; -import Store from '../../stores/sidebar_store'; -import eventHub from '../../event_hub'; export default { name: 'SidebarAssignees', @@ -25,6 +25,11 @@ export default { required: false, default: false, }, + issuableType: { + type: String, + require: true, + default: 'issue', + }, }, data() { return { @@ -90,6 +95,7 @@ export default { :users="store.assignees" :editable="store.editable" @assign-self="assignSelf" + :issuable-type="issuableType" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue index ceb02309959..7f0de722f61 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -1,15 +1,19 @@ <script> -import Flash from '../../../flash'; +import { __ } from '~/locale'; +import Flash from '~/flash'; +import tooltip from '~/vue_shared/directives/tooltip'; +import Icon from '~/vue_shared/components/icon.vue'; +import eventHub from '~/sidebar/event_hub'; import editForm from './edit_form.vue'; -import Icon from '../../../vue_shared/components/icon.vue'; -import { __ } from '../../../locale'; -import eventHub from '../../event_hub'; export default { components: { editForm, Icon, }, + directives: { + tooltip, + }, props: { isConfidential: { required: true, @@ -33,6 +37,9 @@ export default { confidentialityIcon() { return this.isConfidential ? 'eye-slash' : 'eye'; }, + tooltipLabel() { + return this.isConfidential ? __('Confidential') : __('Not confidential'); + }, }, created() { eventHub.$on('closeConfidentialityForm', this.toggleForm); @@ -65,6 +72,10 @@ export default { <div class="sidebar-collapsed-icon" @click="toggleForm" + v-tooltip + data-container="body" + data-placement="left" + :title="tooltipLabel" > <icon :name="confidentialityIcon" diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue index e4893451af3..1a5e7b67eca 100644 --- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue @@ -1,15 +1,22 @@ <script> +import { __ } from '~/locale'; import Flash from '~/flash'; +import tooltip from '~/vue_shared/directives/tooltip'; +import issuableMixin from '~/vue_shared/mixins/issuable'; +import Icon from '~/vue_shared/components/icon.vue'; +import eventHub from '~/sidebar/event_hub'; import editForm from './edit_form.vue'; -import issuableMixin from '../../../vue_shared/mixins/issuable'; -import Icon from '../../../vue_shared/components/icon.vue'; -import eventHub from '../../event_hub'; export default { components: { editForm, Icon, }, + + directives: { + tooltip, + }, + mixins: [issuableMixin], props: { @@ -44,6 +51,10 @@ export default { isLockDialogOpen() { return this.mediator.store.isLockDialogOpen; }, + + tooltipLabel() { + return this.isLocked ? __('Locked') : __('Unlocked'); + }, }, created() { @@ -85,6 +96,10 @@ export default { <div class="sidebar-collapsed-icon" @click="toggleForm" + v-tooltip + data-container="body" + data-placement="left" + :title="tooltipLabel" > <icon :name="lockIcon" diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index 006a6d2905d..6d95153af28 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -1,9 +1,13 @@ <script> - import { __, n__, sprintf } from '../../../locale'; - import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; - import userAvatarImage from '../../../vue_shared/components/user_avatar/user_avatar_image.vue'; + import { __, n__, sprintf } from '~/locale'; + import tooltip from '~/vue_shared/directives/tooltip'; + import loadingIcon from '~/vue_shared/components/loading_icon.vue'; + import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; export default { + directives: { + tooltip, + }, components: { loadingIcon, userAvatarImage, @@ -72,7 +76,13 @@ <template> <div> - <div class="sidebar-collapsed-icon"> + <div + class="sidebar-collapsed-icon" + v-tooltip + data-container="body" + data-placement="left" + :title="participantLabel" + > <i class="fa fa-users" aria-hidden="true" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue index 3b86f1145d1..9d9ee9dea4d 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue @@ -1,12 +1,17 @@ <script> - import icon from '../../../vue_shared/components/icon.vue'; - import { abbreviateTime } from '../../../lib/utils/pretty_time'; + import { __, sprintf } from '~/locale'; + import { abbreviateTime } from '~/lib/utils/pretty_time'; + import icon from '~/vue_shared/components/icon.vue'; + import tooltip from '~/vue_shared/directives/tooltip'; export default { name: 'TimeTrackingCollapsedState', components: { icon, }, + directives: { + tooltip, + }, props: { showComparisonState: { type: Boolean, @@ -79,6 +84,21 @@ return ''; }, + timeTrackedTooltipText() { + let title; + if (this.showComparisonState) { + title = __('Time remaining'); + } else if (this.showEstimateOnlyState) { + title = __('Estimated'); + } else if (this.showSpentOnlyState) { + title = __('Time spent'); + } + + return sprintf('%{title}: %{text}', ({ title, text: this.text })); + }, + tooltipText() { + return this.showNoTimeTrackingState ? __('Time tracking') : this.timeTrackedTooltipText; + }, }, methods: { abbreviateTime(timeStr) { @@ -89,7 +109,13 @@ </script> <template> - <div class="sidebar-collapsed-icon"> + <div + class="sidebar-collapsed-icon" + v-tooltip + data-container="body" + data-placement="left" + :title="tooltipText" + > <icon name="timer" /> <div class="time-tracking-collapsed-summary"> <div :class="divClass"> diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 9f5d852260e..26eb4cffba3 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -27,6 +27,7 @@ function mountAssigneesComponent(mediator) { mediator, field: el.dataset.field, signedIn: el.hasAttribute('data-signed-in'), + issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request', }, }), }); diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 520a0b3f424..8486019897d 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -5,6 +5,7 @@ import $ from 'jquery'; import _ from 'underscore'; import axios from './lib/utils/axios_utils'; +import { __ } from './locale'; import ModalStore from './boards/stores/modal_store'; // TODO: remove eventHub hack after code splitting refactor @@ -182,7 +183,7 @@ function UsersSelect(currentUser, els, options = {}) { return axios.put(issueURL, data) .then(({ data }) => { - var user; + var user, tooltipTitle; $dropdown.trigger('loaded.gl.dropdown'); $loading.fadeOut(); if (data.assignee) { @@ -191,15 +192,17 @@ function UsersSelect(currentUser, els, options = {}) { username: data.assignee.username, avatar: data.assignee.avatar_url }; + tooltipTitle = _.escape(user.name); } else { user = { name: 'Unassigned', username: '', avatar: '' }; + tooltipTitle = __('Assignee'); } $value.html(assigneeTemplate(user)); - $collapsedSidebar.attr('title', _.escape(user.name)).tooltip('fixTitle'); + $collapsedSidebar.attr('title', tooltipTitle).tooltip('fixTitle'); return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); }); }; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js deleted file mode 100644 index 4d9a2ca530f..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js +++ /dev/null @@ -1,18 +0,0 @@ -import statusIcon from '../mr_widget_status_icon.vue'; - -export default { - name: 'MRWidgetPipelineBlocked', - components: { - statusIcon, - }, - template: ` - <div class="mr-widget-body media"> - <status-icon status="warning" :show-disabled-button="true" /> - <div class="media-body space-children"> - <span class="bold"> - The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure - </span> - </div> - </div> - `, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue new file mode 100644 index 00000000000..8d55477929f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue @@ -0,0 +1,25 @@ +<script> +import statusIcon from '../mr_widget_status_icon.vue'; + +export default { + name: 'PipelineFailed', + components: { + statusIcon, + }, +}; +</script> + +<template> + <div class="mr-widget-body media"> + <status-icon + status="warning" + :show-disabled-button="true" + /> + <div class="media-body space-children"> + <span class="bold"> + {{ s__(`mrWidget|The pipeline for this merge request failed. +Please retry the job or push a new commit to fix the failure`) }} + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 3c781ccddc8..0264625a526 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -1,3 +1,4 @@ +<script> import successSvg from 'icons/_icon_status_success.svg'; import warningSvg from 'icons/_icon_status_warning.svg'; import simplePoll from '~/lib/utils/simple_poll'; @@ -7,7 +8,10 @@ import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; export default { - name: 'MRWidgetReadyToMerge', + name: 'ReadyToMerge', + components: { + statusIcon, + }, props: { mr: { type: Object, required: true }, service: { type: Object, required: true }, @@ -26,9 +30,6 @@ export default { warningSvg, }; }, - components: { - statusIcon, - }, computed: { shouldShowMergeWhenPipelineSucceedsText() { return this.mr.isPipelineActive; @@ -217,136 +218,146 @@ export default { }); }, }, - template: ` - <div class="mr-widget-body media"> - <status-icon :status="iconClass" /> - <div class="media-body"> - <div class="mr-widget-body-controls media space-children"> - <span class="btn-group append-bottom-5"> - <button - @click="handleMergeButtonClick()" - :disabled="isMergeButtonDisabled" - :class="mergeButtonClass" - type="button" - class="qa-merge-button"> - <i - v-if="isMakingRequest" - class="fa fa-spinner fa-spin" - aria-hidden="true" /> - {{mergeButtonText}} - </button> +}; +</script> + +<template> + <div class="mr-widget-body media"> + <status-icon :status="iconClass" /> + <div class="media-body"> + <div class="mr-widget-body-controls media space-children"> + <span class="btn-group append-bottom-5"> + <button + @click="handleMergeButtonClick()" + :disabled="isMergeButtonDisabled" + :class="mergeButtonClass" + type="button" + class="qa-merge-button"> + <i + v-if="isMakingRequest" + class="fa fa-spinner fa-spin" + aria-hidden="true" + ></i> + {{ mergeButtonText }} + </button> + <button + v-if="shouldShowMergeOptionsDropdown" + :disabled="isMergeButtonDisabled" + type="button" + class="btn btn-sm btn-info dropdown-toggle js-merge-moment" + data-toggle="dropdown" + aria-label="Select merge moment"> + <i + class="fa fa-chevron-down" + aria-hidden="true" + ></i> + </button> + <ul + v-if="shouldShowMergeOptionsDropdown" + class="dropdown-menu dropdown-menu-right" + role="menu"> + <li> + <a + @click.prevent="handleMergeButtonClick(true)" + class="merge_when_pipeline_succeeds" + href="#"> + <span class="media"> + <span + v-html="successSvg" + class="merge-opt-icon" + aria-hidden="true"></span> + <span class="media-body merge-opt-title">Merge when pipeline succeeds</span> + </span> + </a> + </li> + <li> + <a + @click.prevent="handleMergeButtonClick(false, true)" + class="accept-merge-request" + href="#"> + <span class="media"> + <span + v-html="warningSvg" + class="merge-opt-icon" + aria-hidden="true"></span> + <span class="media-body merge-opt-title">Merge immediately</span> + </span> + </a> + </li> + </ul> + </span> + <div class="media-body-wrap space-children"> + <template v-if="shouldShowMergeControls()"> + <label v-if="mr.canRemoveSourceBranch"> + <input + id="remove-source-branch-input" + v-model="removeSourceBranch" + class="js-remove-source-branch-checkbox" + :disabled="isRemoveSourceBranchButtonDisabled" + type="checkbox"/> Remove source branch + </label> + + <!-- Placeholder for EE extension of this component --> + <squash-before-merge + v-if="shouldShowSquashBeforeMerge" + :mr="mr" + :is-merge-button-disabled="isMergeButtonDisabled" /> + + <span + v-if="mr.ffOnlyEnabled" + class="js-fast-forward-message"> + Fast-forward merge without a merge commit + </span> <button - v-if="shouldShowMergeOptionsDropdown" + v-else + @click="toggleCommitMessageEditor" :disabled="isMergeButtonDisabled" - type="button" - class="btn btn-sm btn-info dropdown-toggle js-merge-moment" - data-toggle="dropdown" - aria-label="Select merge moment"> - <i - class="fa fa-chevron-down" - aria-hidden="true" /> + class="js-modify-commit-message-button btn btn-default btn-xs" + type="button"> + Modify commit message </button> - <ul - v-if="shouldShowMergeOptionsDropdown" - class="dropdown-menu dropdown-menu-right" - role="menu"> - <li> - <a - @click.prevent="handleMergeButtonClick(true)" - class="merge_when_pipeline_succeeds" - href="#"> - <span class="media"> - <span - v-html="successSvg" - class="merge-opt-icon" - aria-hidden="true"></span> - <span class="media-body merge-opt-title">Merge when pipeline succeeds</span> - </span> - </a> - </li> - <li> - <a - @click.prevent="handleMergeButtonClick(false, true)" - class="accept-merge-request" - href="#"> - <span class="media"> - <span - v-html="warningSvg" - class="merge-opt-icon" - aria-hidden="true"></span> - <span class="media-body merge-opt-title">Merge immediately</span> - </span> - </a> - </li> - </ul> - </span> - <div class="media-body-wrap space-children"> - <template v-if="shouldShowMergeControls()"> - <label v-if="mr.canRemoveSourceBranch"> - <input - id="remove-source-branch-input" - v-model="removeSourceBranch" - class="js-remove-source-branch-checkbox" - :disabled="isRemoveSourceBranchButtonDisabled" - type="checkbox"/> Remove source branch - </label> - - <!-- Placeholder for EE extension of this component --> - <squash-before-merge - v-if="shouldShowSquashBeforeMerge" - :mr="mr" - :is-merge-button-disabled="isMergeButtonDisabled" /> - - <span - v-if="mr.ffOnlyEnabled" - class="js-fast-forward-message"> - Fast-forward merge without a merge commit - </span> - <button - v-else - @click="toggleCommitMessageEditor" - :disabled="isMergeButtonDisabled" - class="js-modify-commit-message-button btn btn-default btn-xs" - type="button"> - Modify commit message - </button> - </template> - <template v-else> - <span class="bold js-resolve-mr-widget-items-message"> - You can only merge once the items above are resolved - </span> - </template> - </div> + </template> + <template v-else> + <span class="bold js-resolve-mr-widget-items-message"> + You can only merge once the items above are resolved + </span> + </template> </div> - <div - v-if="showCommitMessageEditor" - class="prepend-top-default commit-message-editor"> - <div class="form-group clearfix"> - <label - class="control-label" - for="commit-message"> - Commit message - </label> - <div class="col-sm-10"> - <div class="commit-message-container"> - <div class="max-width-marker"></div> - <textarea - v-model="commitMessage" - class="form-control js-commit-message" - required="required" - rows="14" - name="Commit message"></textarea> - </div> - <p class="hint">Try to keep the first line under 52 characters and the others under 72</p> - <div class="hint"> - <a - @click.prevent="updateCommitMessage" - href="#">{{commitMessageLinkTitle}}</a> - </div> + </div> + <div + v-if="showCommitMessageEditor" + class="prepend-top-default commit-message-editor"> + <div class="form-group clearfix"> + <label + class="control-label" + for="commit-message"> + Commit message + </label> + <div class="col-sm-10"> + <div class="commit-message-container"> + <div class="max-width-marker"></div> + <textarea + id="commit-message" + v-model="commitMessage" + class="form-control js-commit-message" + required="required" + rows="14" + name="Commit message"></textarea> + </div> + <p class="hint"> + Try to keep the first line under 52 characters and the others under 72 + </p> + <div class="hint"> + <a + @click.prevent="updateCommitMessage" + href="#" + > + {{ commitMessageLinkTitle }} + </a> </div> </div> </div> </div> </div> - `, -}; + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index ed15fc6ab0f..3b5c973e4a0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -27,11 +27,11 @@ export { default as ConflictsState } from './components/states/mr_widget_conflic export { default as NothingToMergeState } from './components/states/nothing_to_merge.vue'; export { default as MissingBranchState } from './components/states/mr_widget_missing_branch.vue'; export { default as NotAllowedState } from './components/states/mr_widget_not_allowed.vue'; -export { default as ReadyToMergeState } from './components/states/mr_widget_ready_to_merge'; +export { default as ReadyToMergeState } from './components/states/ready_to_merge.vue'; export { default as ShaMismatchState } from './components/states/sha_mismatch.vue'; export { default as UnresolvedDiscussionsState } from './components/states/unresolved_discussions.vue'; export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked.vue'; -export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed'; +export { default as PipelineFailedState } from './components/states/pipeline_failed.vue'; export { default as MergeWhenPipelineSucceedsState } from './components/states/mr_widget_merge_when_pipeline_succeeds.vue'; export { default as RebaseState } from './components/states/mr_widget_rebase.vue'; export { default as AutoMergeFailed } from './components/states/mr_widget_auto_merge_failed.vue'; diff --git a/app/assets/javascripts/vue_shared/components/callout.vue b/app/assets/javascripts/vue_shared/components/callout.vue new file mode 100644 index 00000000000..ccf802c456c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/callout.vue @@ -0,0 +1,27 @@ +<script> +const calloutVariants = ['danger', 'success', 'info', 'warning']; + +export default { + props: { + category: { + type: String, + required: false, + default: calloutVariants[0], + validator: value => calloutVariants.includes(value), + }, + message: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div + :class="`bs-callout bs-callout-${category}`" + role="alert" + aria-live="assertive" + > + {{ message }} + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue index 5324d5dc797..0d64efcbf68 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -1,52 +1,52 @@ <script> - import ciIcon from './ci_icon.vue'; - import tooltip from '../directives/tooltip'; - /** - * Renders CI Badge link with CI icon and status text based on - * API response shared between all places where it is used. - * - * Receives status object containing: - * status: { - * details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url - * group:"running" // used for CSS class - * icon: "icon_status_running" // used to render the icon - * label:"running" // used for potential tooltip - * text:"running" // text rendered - * } - * - * Used in: - * - Pipelines table - first column - * - Jobs table - first column - * - Pipeline show view - header - * - Job show view - header - * - MR widget - */ +import CiIcon from './ci_icon.vue'; +import tooltip from '../directives/tooltip'; +/** + * Renders CI Badge link with CI icon and status text based on + * API response shared between all places where it is used. + * + * Receives status object containing: + * status: { + * details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url + * group:"running" // used for CSS class + * icon: "icon_status_running" // used to render the icon + * label:"running" // used for potential tooltip + * text:"running" // text rendered + * } + * + * Used in: + * - Pipelines table - first column + * - Jobs table - first column + * - Pipeline show view - header + * - Job show view - header + * - MR widget + */ - export default { - components: { - ciIcon, +export default { + components: { + CiIcon, + }, + directives: { + tooltip, + }, + props: { + status: { + type: Object, + required: true, }, - directives: { - tooltip, + showText: { + type: Boolean, + required: false, + default: true, }, - props: { - status: { - type: Object, - required: true, - }, - showText: { - type: Boolean, - required: false, - default: true, - }, + }, + computed: { + cssClass() { + const className = this.status.group; + return className ? `ci-status ci-${className}` : 'ci-status'; }, - computed: { - cssClass() { - const className = this.status.group; - return className ? `ci-status ci-${className}` : 'ci-status'; - }, - }, - }; + }, +}; </script> <template> <a diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index 8fea746f4de..fcab8f571dd 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -1,45 +1,44 @@ <script> - import icon from '../../vue_shared/components/icon.vue'; +import Icon from '../../vue_shared/components/icon.vue'; - /** - * Renders CI icon based on API response shared between all places where it is used. - * - * Receives status object containing: - * status: { - * details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url - * group:"running" // used for CSS class - * icon: "icon_status_running" // used to render the icon - * label:"running" // used for potential tooltip - * text:"running" // text rendered - * } - * - * Used in: - * - Pipelines table Badge - * - Pipelines table mini graph - * - Pipeline graph - * - Pipeline show view badge - * - Jobs table - * - Jobs show view header - * - Jobs show view sidebar - */ - export default { - components: { - icon, +/** + * Renders CI icon based on API response shared between all places where it is used. + * + * Receives status object containing: + * status: { + * details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url + * group:"running" // used for CSS class + * icon: "icon_status_running" // used to render the icon + * label:"running" // used for potential tooltip + * text:"running" // text rendered + * } + * + * Used in: + * - Pipelines table Badge + * - Pipelines table mini graph + * - Pipeline graph + * - Pipeline show view badge + * - Jobs table + * - Jobs show view header + * - Jobs show view sidebar + */ +export default { + components: { + Icon, + }, + props: { + status: { + type: Object, + required: true, }, - props: { - status: { - type: Object, - required: true, - }, + }, + computed: { + cssClass() { + const status = this.status.group; + return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`; }, - - computed: { - cssClass() { - const status = this.status.group; - return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`; - }, - }, - }; + }, +}; </script> <template> <span :class="cssClass"> diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index cab126a7eca..cb2cc3901ad 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -1,40 +1,50 @@ <script> - /** - * Falls back to the code used in `copy_to_clipboard.js` - */ - import tooltip from '../directives/tooltip'; +/** + * Falls back to the code used in `copy_to_clipboard.js` + * + * Renders a button with a clipboard icon that copies the content of `data-clipboard-text` + * when clicked. + * + * @example + * <clipboard-button + * title="Copy to clipbard" + * text="Content to be copied" + * css-class="btn-transparent" + * /> + */ +import tooltip from '../directives/tooltip'; - export default { - name: 'ClipboardButton', - directives: { - tooltip, +export default { + name: 'ClipboardButton', + directives: { + tooltip, + }, + props: { + text: { + type: String, + required: true, }, - props: { - text: { - type: String, - required: true, - }, - title: { - type: String, - required: true, - }, - tooltipPlacement: { - type: String, - required: false, - default: 'top', - }, - tooltipContainer: { - type: [String, Boolean], - required: false, - default: false, - }, - cssClass: { - type: String, - required: false, - default: 'btn-default', - }, + title: { + type: String, + required: true, }, - }; + tooltipPlacement: { + type: String, + required: false, + default: 'top', + }, + tooltipContainer: { + type: [String, Boolean], + required: false, + default: false, + }, + cssClass: { + type: String, + required: false, + default: 'btn-default', + }, + }, +}; </script> <template> diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue index 97789636787..8f250a6c989 100644 --- a/app/assets/javascripts/vue_shared/components/commit.vue +++ b/app/assets/javascripts/vue_shared/components/commit.vue @@ -1,119 +1,111 @@ <script> - import commitIconSvg from 'icons/_icon_commit.svg'; - import userAvatarLink from './user_avatar/user_avatar_link.vue'; - import tooltip from '../directives/tooltip'; - import icon from '../../vue_shared/components/icon.vue'; +import UserAvatarLink from './user_avatar/user_avatar_link.vue'; +import tooltip from '../directives/tooltip'; +import Icon from '../../vue_shared/components/icon.vue'; - export default { - directives: { - tooltip, +export default { + directives: { + tooltip, + }, + components: { + UserAvatarLink, + Icon, + }, + props: { + /** + * Indicates the existance of a tag. + * Used to render the correct icon, if true will render `fa-tag` icon, + * if false will render a svg sprite fork icon + */ + tag: { + type: Boolean, + required: false, + default: false, }, - components: { - userAvatarLink, - icon, + /** + * If provided is used to render the branch name and url. + * Should contain the following properties: + * name + * ref_url + */ + commitRef: { + type: Object, + required: false, + default: () => ({}), + }, + /** + * Used to link to the commit sha. + */ + commitUrl: { + type: String, + required: false, + default: '', }, - props: { - /** - * Indicates the existance of a tag. - * Used to render the correct icon, if true will render `fa-tag` icon, - * if false will render a svg sprite fork icon - */ - tag: { - type: Boolean, - required: false, - default: false, - }, - /** - * If provided is used to render the branch name and url. - * Should contain the following properties: - * name - * ref_url - */ - commitRef: { - type: Object, - required: false, - default: () => ({}), - }, - /** - * Used to link to the commit sha. - */ - commitUrl: { - type: String, - required: false, - default: '', - }, - /** - * Used to show the commit short sha that links to the commit url. - */ - shortSha: { - type: String, - required: false, - default: '', - }, - /** - * If provided shows the commit tile. - */ - title: { - type: String, - required: false, - default: '', - }, - /** - * If provided renders information about the author of the commit. - * When provided should include: - * `avatar_url` to render the avatar icon - * `web_url` to link to user profile - * `username` to render alt and title tags - */ - author: { - type: Object, - required: false, - default: () => ({}), - }, - showBranch: { - type: Boolean, - required: false, - default: true, - }, + /** + * Used to show the commit short sha that links to the commit url. + */ + shortSha: { + type: String, + required: false, + default: '', + }, + /** + * If provided shows the commit tile. + */ + title: { + type: String, + required: false, + default: '', + }, + /** + * If provided renders information about the author of the commit. + * When provided should include: + * `avatar_url` to render the avatar icon + * `web_url` to link to user profile + * `username` to render alt and title tags + */ + author: { + type: Object, + required: false, + default: () => ({}), + }, + showBranch: { + type: Boolean, + required: false, + default: true, }, - computed: { - /** - * Used to verify if all the properties needed to render the commit - * ref section were provided. - * - * @returns {Boolean} - */ - hasCommitRef() { - return this.commitRef && this.commitRef.name && this.commitRef.ref_url; - }, - /** - * Used to verify if all the properties needed to render the commit - * author section were provided. - * - * @returns {Boolean} - */ - hasAuthor() { - return this.author && - this.author.avatar_url && - this.author.path && - this.author.username; - }, - /** - * If information about the author is provided will return a string - * to be rendered as the alt attribute of the img tag. - * - * @returns {String} - */ - userImageAltDescription() { - return this.author && - this.author.username ? `${this.author.username}'s avatar` : null; - }, + }, + computed: { + /** + * Used to verify if all the properties needed to render the commit + * ref section were provided. + * + * @returns {Boolean} + */ + hasCommitRef() { + return this.commitRef && this.commitRef.name && this.commitRef.ref_url; }, - created() { - this.commitIconSvg = commitIconSvg; + /** + * Used to verify if all the properties needed to render the commit + * author section were provided. + * + * @returns {Boolean} + */ + hasAuthor() { + return this.author && this.author.avatar_url && this.author.path && this.author.username; }, - }; + /** + * If information about the author is provided will return a string + * to be rendered as the alt attribute of the img tag. + * + * @returns {String} + */ + userImageAltDescription() { + return this.author && this.author.username ? `${this.author.username}'s avatar` : null; + }, + }, +}; </script> <template> <div class="branch-commit"> @@ -141,11 +133,10 @@ {{ commitRef.name }} </a> </template> - <div - v-html="commitIconSvg" + <icon + name="commit" class="commit-icon js-commit-icon" - > - </div> + /> <a class="commit-sha" @@ -175,7 +166,7 @@ </a> </span> <span v-else> - Cant find HEAD commit for this branch + Can't find HEAD commit for this branch </span> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/expand_button.vue b/app/assets/javascripts/vue_shared/components/expand_button.vue index c943c8d98a4..9295be3e2b2 100644 --- a/app/assets/javascripts/vue_shared/components/expand_button.vue +++ b/app/assets/javascripts/vue_shared/components/expand_button.vue @@ -1,33 +1,33 @@ <script> - import { __ } from '~/locale'; - /** - * Port of detail_behavior expand button. - * - * @example - * <expand-button> - * <template slot="expanded"> - * Text goes here. - * </template> - * </expand-button> - */ - export default { - name: 'ExpandButton', - data() { - return { - isCollapsed: true, - }; +import { __ } from '~/locale'; +/** + * Port of detail_behavior expand button. + * + * @example + * <expand-button> + * <template slot="expanded"> + * Text goes here. + * </template> + * </expand-button> + */ +export default { + name: 'ExpandButton', + data() { + return { + isCollapsed: true, + }; + }, + computed: { + ariaLabel() { + return __('Click to expand text'); }, - computed: { - ariaLabel() { - return __('Click to expand text'); - }, + }, + methods: { + onClick() { + this.isCollapsed = !this.isCollapsed; }, - methods: { - onClick() { - this.isCollapsed = !this.isCollapsed; - }, - }, - }; + }, +}; </script> <template> <span> diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index a0cd0cbd200..088187ed348 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -1,78 +1,78 @@ <script> - import ciIconBadge from './ci_badge_link.vue'; - import loadingIcon from './loading_icon.vue'; - import timeagoTooltip from './time_ago_tooltip.vue'; - import tooltip from '../directives/tooltip'; - import userAvatarImage from './user_avatar/user_avatar_image.vue'; +import CiIconBadge from './ci_badge_link.vue'; +import LoadingIcon from './loading_icon.vue'; +import TimeagoTooltip from './time_ago_tooltip.vue'; +import tooltip from '../directives/tooltip'; +import UserAvatarImage from './user_avatar/user_avatar_image.vue'; - /** - * Renders header component for job and pipeline page based on UI mockups - * - * Used in: - * - job show page - * - pipeline show page - */ - export default { - components: { - ciIconBadge, - loadingIcon, - timeagoTooltip, - userAvatarImage, +/** + * Renders header component for job and pipeline page based on UI mockups + * + * Used in: + * - job show page + * - pipeline show page + */ +export default { + components: { + CiIconBadge, + LoadingIcon, + TimeagoTooltip, + UserAvatarImage, + }, + directives: { + tooltip, + }, + props: { + status: { + type: Object, + required: true, }, - directives: { - tooltip, + itemName: { + type: String, + required: true, }, - props: { - status: { - type: Object, - required: true, - }, - itemName: { - type: String, - required: true, - }, - itemId: { - type: Number, - required: true, - }, - time: { - type: String, - required: true, - }, - user: { - type: Object, - required: false, - default: () => ({}), - }, - actions: { - type: Array, - required: false, - default: () => [], - }, - hasSidebarButton: { - type: Boolean, - required: false, - default: false, - }, - shouldRenderTriggeredLabel: { - type: Boolean, - required: false, - default: true, - }, + itemId: { + type: Number, + required: true, }, + time: { + type: String, + required: true, + }, + user: { + type: Object, + required: false, + default: () => ({}), + }, + actions: { + type: Array, + required: false, + default: () => [], + }, + hasSidebarButton: { + type: Boolean, + required: false, + default: false, + }, + shouldRenderTriggeredLabel: { + type: Boolean, + required: false, + default: true, + }, + }, - computed: { - userAvatarAltText() { - return `${this.user.name}'s avatar`; - }, + computed: { + userAvatarAltText() { + return `${this.user.name}'s avatar`; }, + }, - methods: { - onClickAction(action) { - this.$emit('actionClicked', action); - }, + methods: { + onClickAction(action) { + this.$emit('actionClicked', action); }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue index 6a2e05000e1..1a0df49bc29 100644 --- a/app/assets/javascripts/vue_shared/components/icon.vue +++ b/app/assets/javascripts/vue_shared/components/icon.vue @@ -1,76 +1,75 @@ <script> +/* This is a re-usable vue component for rendering a svg sprite + icon - /* This is a re-usable vue component for rendering a svg sprite - icon + Sample configuration: - Sample configuration: + <icon + name="retry" + :size="32" + css-classes="top" + /> - <icon - name="retry" - :size="32" - css-classes="top" - /> +*/ +// only allow classes in images.scss e.g. s12 +const validSizes = [8, 12, 16, 18, 24, 32, 48, 72]; - */ - // only allow classes in images.scss e.g. s12 - const validSizes = [8, 12, 16, 18, 24, 32, 48, 72]; - - export default { - props: { - name: { - type: String, - required: true, - }, +export default { + props: { + name: { + type: String, + required: true, + }, - size: { - type: Number, - required: false, - default: 16, - validator(value) { - return validSizes.includes(value); - }, + size: { + type: Number, + required: false, + default: 16, + validator(value) { + return validSizes.includes(value); }, + }, - cssClasses: { - type: String, - required: false, - default: '', - }, + cssClasses: { + type: String, + required: false, + default: '', + }, - width: { - type: Number, - required: false, - default: null, - }, + width: { + type: Number, + required: false, + default: null, + }, - height: { - type: Number, - required: false, - default: null, - }, + height: { + type: Number, + required: false, + default: null, + }, - y: { - type: Number, - required: false, - default: null, - }, + y: { + type: Number, + required: false, + default: null, + }, - x: { - type: Number, - required: false, - default: null, - }, + x: { + type: Number, + required: false, + default: null, }, + }, - computed: { - spriteHref() { - return `${gon.sprite_icons}#${this.name}`; - }, - iconSizeClass() { - return this.size ? `s${this.size}` : ''; - }, + computed: { + spriteHref() { + return `${gon.sprite_icons}#${this.name}`; + }, + iconSizeClass() { + return this.size ? `s${this.size}` : ''; }, - }; + }, +}; </script> <template> @@ -79,7 +78,8 @@ :width="width" :height="height" :x="x" - :y="y"> + :y="y" + > <use v-bind="{ 'xlink:href':spriteHref }" /> </svg> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue index 5ede53d8d01..70b46a9c2bb 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue @@ -1,4 +1,5 @@ <script> +import $ from 'jquery'; import { __ } from '~/locale'; import LabelsSelect from '~/labels_select'; import LoadingIcon from '../../loading_icon.vue'; @@ -98,11 +99,18 @@ export default { this.labelsDropdown = new LabelsSelect(this.$refs.dropdownButton, { handleClick: this.handleClick, }); + $(this.$refs.dropdown).on('hidden.gl.dropdown', this.handleDropdownHidden); }, methods: { handleClick(label) { this.$emit('onLabelClick', label); }, + handleCollapsedValueClick() { + this.$emit('toggleCollapse'); + }, + handleDropdownHidden() { + this.$emit('onDropdownClose'); + }, }, }; </script> @@ -112,6 +120,7 @@ export default { <dropdown-value-collapsed v-if="showCreate" :labels="context.labels" + @onValueClick="handleCollapsedValueClick" /> <dropdown-title :can-edit="canEdit" @@ -133,7 +142,10 @@ export default { :name="hiddenInputName" :label="label" /> - <div class="dropdown"> + <div + class="dropdown" + ref="dropdown" + > <dropdown-button :ability-name="abilityName" :field-name="hiddenInputName" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue index 5cf728fe050..68fa2ab8d01 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue @@ -26,6 +26,11 @@ export default { return labelsString; }, }, + methods: { + handleClick() { + this.$emit('onValueClick'); + }, + }, }; </script> @@ -36,6 +41,7 @@ export default { data-placement="left" data-container="body" :title="labelsList" + @click="handleClick" > <i aria-hidden="true" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue index 8211d425b1f..de6f8c32e74 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue @@ -1,18 +1,29 @@ <script> - export default { - name: 'ToggleSidebar', - props: { - collapsed: { - type: Boolean, - required: true, - }, +import { __ } from '~/locale'; +import tooltip from '~/vue_shared/directives/tooltip'; + +export default { + name: 'ToggleSidebar', + directives: { + tooltip, + }, + props: { + collapsed: { + type: Boolean, + required: true, + }, + }, + computed: { + tooltipLabel() { + return this.collapsed ? __('Expand sidebar') : __('Collapse sidebar'); }, - methods: { - toggle() { - this.$emit('toggle'); - }, + }, + methods: { + toggle() { + this.$emit('toggle'); }, - }; + }, +}; </script> <template> @@ -20,6 +31,10 @@ type="button" class="btn btn-blank gutter-toggle btn-sidebar-action" @click="toggle" + v-tooltip + data-container="body" + data-placement="left" + :title="tooltipLabel" > <i aria-label="toggle collapse" diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 48f20029383..f4f5926e198 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -503,3 +503,7 @@ fieldset[disabled] .btn, @extend %disabled; } } + +.btn-no-padding { + padding: 0; +} diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 798f248dad4..64fff7463d2 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -16,7 +16,7 @@ .nav-header-btn { padding: 10px $gl-sidebar-padding; color: inherit; - transition-duration: .3s; + transition-duration: 0.3s; position: absolute; top: 0; cursor: pointer; @@ -137,6 +137,12 @@ } } +.issuable-sidebar .labels { + .value.dont-hide ~ .selectbox { + padding-top: $gl-padding-8; + } +} + .pikaday-container { .pika-single { margin-top: 2px; @@ -151,4 +157,3 @@ .sidebar-collapsed-icon .sidebar-collapsed-value { font-size: 12px; } - diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 37223175199..8c44ebc85ef 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -247,6 +247,7 @@ $btn-sm-side-margin: 7px; $btn-xs-side-margin: 5px; $issue-status-expired: $orange-500; $issuable-sidebar-color: $gl-text-color-secondary; +$sidebar-block-hover-color: #ebebeb; $group-path-color: #999; $namespace-kind-color: #aaa; $panel-heading-link-color: #777; @@ -373,6 +374,8 @@ $dropdown-hover-color: $blue-400; $link-active-background: rgba(0, 0, 0, 0.04); $link-hover-background: rgba(0, 0, 0, 0.06); $inactive-badge-background: rgba(0, 0, 0, 0.08); +$sidebar-toggle-height: 60px; +$sidebar-milestone-toggle-bottom-margin: 10px; /* * Buttons diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 7a6352e45f1..50f32660445 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -1,39 +1,56 @@ @keyframes fade-out-status { - 0%, 50% { opacity: 1; } - 100% { opacity: 0; } + 0%, + 50% { + opacity: 1; + } + + 100% { + opacity: 0; + } } @keyframes blinking-dots { 0% { background-color: rgba($white-light, 1); box-shadow: 12px 0 0 0 rgba($white-light, 0.2), - 24px 0 0 0 rgba($white-light, 0.2); + 24px 0 0 0 rgba($white-light, 0.2); } 25% { background-color: rgba($white-light, 0.4); box-shadow: 12px 0 0 0 rgba($white-light, 2), - 24px 0 0 0 rgba($white-light, 0.2); + 24px 0 0 0 rgba($white-light, 0.2); } 75% { background-color: rgba($white-light, 0.4); box-shadow: 12px 0 0 0 rgba($white-light, 0.2), - 24px 0 0 0 rgba($white-light, 1); + 24px 0 0 0 rgba($white-light, 1); } 100% { background-color: rgba($white-light, 1); box-shadow: 12px 0 0 0 rgba($white-light, 0.2), - 24px 0 0 0 rgba($white-light, 0.2); + 24px 0 0 0 rgba($white-light, 0.2); } } @keyframes blinking-scroll-button { - 0% { opacity: 0.2; } - 25% { opacity: 0.5; } - 50% { opacity: 0.7; } - 100% { opacity: 1; } + 0% { + opacity: 0.2; + } + + 25% { + opacity: 0.5; + } + + 50% { + opacity: 0.7; + } + + 100% { + opacity: 1; + } } .build-page { @@ -125,12 +142,12 @@ .btn-scroll.animate { .first-triangle { animation: blinking-scroll-button 1s ease infinite; - animation-delay: .3s; + animation-delay: 0.3s; } .second-triangle { animation: blinking-scroll-button 1s ease infinite; - animation-delay: .2s; + animation-delay: 0.2s; } .third-triangle { diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 86cdda0359e..e9384d41e00 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -180,10 +180,6 @@ justify-content: space-between; align-items: center; flex-grow: 1; - - .merge-request-branches & { - flex-direction: column; - } } .commit-content { diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 7f037582ca0..11052be40a8 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -160,6 +160,11 @@ } } } + + .diff-loading-error-block { + padding: $gl-padding * 2 $gl-padding; + text-align: center; + } } .image { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 2c0ed976301..b2dad4a358a 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -187,7 +187,12 @@ padding-left: 10px; &:hover { - color: $gray-darkest; + color: $gl-text-color; + } + + &:hover, + &:focus { + text-decoration: none; } } @@ -368,6 +373,14 @@ padding: 15px 0 0; border-bottom: 0; overflow: hidden; + + &:hover { + background-color: $sidebar-block-hover-color; + } + + &.issuable-sidebar-header { + padding-top: 0; + } } .participants { @@ -380,8 +393,17 @@ .gutter-toggle { width: 100%; + height: $sidebar-toggle-height; margin-left: 0; - padding-left: 25px; + padding-left: 0; + border-bottom: 1px solid $border-gray-dark; + } + + a.gutter-toggle { + display: flex; + justify-content: center; + flex-direction: column; + text-align: center; } .sidebar-collapsed-icon { @@ -428,10 +450,10 @@ .btn-clipboard { border: 0; + background: transparent; color: $issuable-sidebar-color; &:hover { - background: transparent; color: $gl-text-color; } } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 4692d0fb873..66db4917178 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -762,3 +762,20 @@ max-width: 100%; } } + +// Hack alert: we've rewritten `btn` class in a way that +// we've broken it and it is not possible to use with `btn-link` +// which causes a blank button when it's disabled and hovering +// The css in here is the boostrap one +.btn-link-retry { + &[disabled] { + cursor: not-allowed; + box-shadow: none; + opacity: .65; + + &:hover { + color: $file-mode-changed; + text-decoration: none; + } + } +} diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss index 3af8d80daab..bac3b70c734 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/pages/milestone.scss @@ -53,10 +53,6 @@ } .milestone-sidebar { - .gutter-toggle { - margin-bottom: 10px; - } - .milestone-progress { .title { padding-top: 5px; @@ -102,7 +98,17 @@ margin-right: 0; } + .right-sidebar-expanded & { + .gutter-toggle { + margin-bottom: $sidebar-milestone-toggle-bottom-margin; + } + } + .right-sidebar-collapsed & { + .milestone-progress { + padding-top: 0; + } + .reference { border-top: 1px solid $border-gray-normal; } diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 2c840cb407a..3a8ec779c14 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -14,6 +14,11 @@ .commit-title { margin: 0; + white-space: normal; + + @media (max-width: $screen-sm-max) { + justify-content: flex-end; + } } .ci-table { @@ -463,6 +468,14 @@ margin-bottom: 10px; white-space: normal; + .ci-job-dropdown-container { + // override dropdown.scss + .dropdown-menu li button { + padding: 0; + text-align: center; + } + } + // ensure .build-content has hover style when action-icon is hovered .ci-job-dropdown-container:hover .build-content { @extend .build-content:hover; diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 5f46e69a56d..450ef7d6b7e 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -68,6 +68,10 @@ .ide-file-changed-icon { margin-left: auto; + + > svg { + display: block; + } } .ide-new-btn { @@ -378,7 +382,11 @@ padding: $gl-bar-padding $gl-padding; background: $white-light; display: flex; - justify-content: space-between; + justify-content: flex-end; + + > div + div { + padding-left: $gl-padding; + } svg { vertical-align: middle; @@ -521,9 +529,13 @@ overflow: auto; } -.multi-file-commit-empty-state-container { - align-items: center; - justify-content: center; +.ide-commit-empty-state { + padding: 0 $gl-padding; +} + +.ide-commit-empty-state-container { + margin-top: auto; + margin-bottom: auto; } .multi-file-commit-panel-header { @@ -532,35 +544,22 @@ margin-bottom: 0; border-bottom: 1px solid $white-dark; padding: $gl-btn-padding 0; - - &.is-collapsed { - border-bottom: 1px solid $white-dark; - - svg { - margin-left: auto; - margin-right: auto; - } - - .multi-file-commit-panel-collapse-btn { - margin-right: auto; - margin-left: auto; - border-left: 0; - } - } } .multi-file-commit-panel-header-title { display: flex; flex: 1; - padding: 0 $gl-btn-padding; + padding-left: $grid-size; svg { margin-right: $gl-btn-padding; + color: $theme-gray-700; } } .multi-file-commit-panel-collapse-btn { border-left: 1px solid $white-dark; + margin-left: auto; } .multi-file-commit-list { @@ -574,12 +573,14 @@ display: flex; padding: 0; align-items: center; + border-radius: $border-radius-default; .multi-file-discard-btn { display: none; + margin-top: -2px; margin-left: auto; + margin-right: $grid-size; color: $gl-link-color; - padding: 0 2px; &:focus, &:hover { @@ -591,26 +592,31 @@ background: $white-normal; .multi-file-discard-btn { - display: block; + display: flex; } } } -.multi-file-addition { +.multi-file-additions, +.multi-file-additions-solid { fill: $green-500; } -.multi-file-modified { +.multi-file-modified, +.multi-file-modified-solid { fill: $orange-500; } .multi-file-commit-list-collapsed { display: flex; flex-direction: column; + padding: $gl-padding 0; - > svg { + svg { + display: block; margin-left: auto; margin-right: auto; + color: $theme-gray-700; } .file-status-icon { @@ -622,7 +628,7 @@ .multi-file-commit-list-path { padding: $grid-size / 2; - padding-left: $gl-padding; + padding-left: $grid-size; background: none; border: 0; text-align: left; @@ -662,11 +668,6 @@ } } -.multi-file-commit-message.form-control { - height: 160px; - resize: none; -} - .dirty-diff { // !important need to override monaco inline style width: 4px !important; @@ -812,6 +813,41 @@ } } +.ide-commit-list-container { + display: flex; + flex-direction: column; + width: 100%; + padding: 0 16px; + + &:not(.is-collapsed) { + flex: 1; + min-height: 140px; + } + + &.is-collapsed { + .multi-file-commit-panel-header { + margin-left: -$gl-padding; + margin-right: -$gl-padding; + + svg { + margin-left: auto; + margin-right: auto; + } + + .multi-file-commit-panel-collapse-btn { + margin-right: auto; + margin-left: auto; + border-left: 0; + } + } + } +} + +.ide-staged-action-btn { + margin-left: auto; + color: $gl-link-color; +} + .ide-commit-radios { label { font-weight: normal; @@ -839,3 +875,74 @@ align-items: center; font-weight: $gl-font-weight-bold; } + +.ide-commit-message-field { + height: 200px; + background-color: $white-light; + + .md-area { + display: flex; + flex-direction: column; + height: 100%; + } + + .nav-links { + height: 30px; + } + + .help-block { + margin-top: 2px; + color: $blue-500; + cursor: pointer; + } +} + +.ide-commit-message-textarea-container { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + + .note-textarea { + font-family: $monospace_font; + } +} + +.ide-commit-message-highlights-container { + position: absolute; + left: 0; + top: 0; + right: -100px; + bottom: 0; + padding-right: 100px; + pointer-events: none; + z-index: 1; + + .highlights { + white-space: pre-wrap; + word-wrap: break-word; + color: transparent; + } + + mark { + margin-left: -1px; + padding: 0 2px; + border-radius: $border-radius-small; + background-color: $orange-200; + color: transparent; + opacity: 0.6; + } +} + +.ide-commit-message-textarea { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + width: 100%; + height: 100%; + z-index: 2; + background: transparent; + resize: none; +} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 24651dd392c..0fdd4d2cb47 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -5,6 +5,7 @@ class ApplicationController < ActionController::Base include Gitlab::GonHelper include GitlabRoutingHelper include PageLayoutHelper + include SafeParamsHelper include SentryHelper include WorkhorseHelper include EnforcesTwoFactorAuthentication diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index ad4e936a3d4..0c34e49206a 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -217,7 +217,7 @@ module NotesActions def note_project strong_memoize(:note_project) do - return nil unless project + next nil unless project note_project_id = params[:note_project_id] @@ -228,7 +228,7 @@ module NotesActions project end - return access_denied! unless can?(current_user, :create_note, the_project) + next access_denied! unless can?(current_user, :create_note, the_project) the_project end diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index e89eaf7edda..f9e8fe624e8 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -86,7 +86,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController out_of_range = todos.current_page > total_pages if out_of_range - redirect_to url_for(params.merge(page: total_pages, only_path: true)) + redirect_to url_for(safe_params.merge(page: total_pages, only_path: true)) end out_of_range diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb index 6142e75b4c1..4d8a20de017 100644 --- a/app/controllers/groups/variables_controller.rb +++ b/app/controllers/groups/variables_controller.rb @@ -15,7 +15,7 @@ module Groups def update if @group.update(group_variables_params) respond_to do |format| - format.json { return render_group_variables } + format.json { render_group_variables } end else respond_to do |format| diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 5ac4b8710e2..79fa5818359 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -189,6 +189,6 @@ class GroupsController < Groups::ApplicationController params[:id] = group.to_param - url_for(params) + url_for(safe_params) end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 767e492f566..d69015c8665 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -134,11 +134,11 @@ class Projects::IssuesController < Projects::ApplicationController def can_create_branch can_create = current_user && can?(current_user, :push_code, @project) && - @issue.can_be_worked_on?(current_user) + @issue.can_be_worked_on? respond_to do |format| format.json do - render json: { can_create_branch: can_create, has_related_branch: @issue.has_related_branch? } + render json: { can_create_branch: can_create, suggested_branch_name: @issue.suggested_branch_name } end end end @@ -177,7 +177,7 @@ class Projects::IssuesController < Projects::ApplicationController end def authorize_create_merge_request! - render_404 unless can?(current_user, :push_code, @project) && @issue.can_be_worked_on?(current_user) + render_404 unless can?(current_user, :push_code, @project) && @issue.can_be_worked_on? end def render_issue_json diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 54e7d81de6a..62b739918e6 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -60,13 +60,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end format.patch do - return render_404 unless @merge_request.diff_refs + break render_404 unless @merge_request.diff_refs send_git_patch @project.repository, @merge_request.diff_refs end format.diff do - return render_404 unless @merge_request.diff_refs + break render_404 unless @merge_request.diff_refs send_git_diff @project.repository, @merge_request.diff_refs end diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb index 937b0e39cbd..d01f324e6fd 100644 --- a/app/controllers/projects/repositories_controller.rb +++ b/app/controllers/projects/repositories_controller.rb @@ -28,11 +28,12 @@ class Projects::RepositoriesController < Projects::ApplicationController end def assign_archive_vars - @id = params[:id] - - return unless @id - - @ref, @filename = extract_ref(@id) + if params[:id] + @ref, @filename = extract_ref(params[:id]) + else + @ref = params[:ref] + @filename = nil + end rescue InvalidPathError render_404 end diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index 517d0b026c2..bf09ea7e4d8 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -12,7 +12,7 @@ class Projects::VariablesController < Projects::ApplicationController def update if @project.update(variables_params) respond_to do |format| - format.json { return render_variables } + format.json { render_variables } end else respond_to do |format| diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index c4930d3d18d..1b0751f48c5 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -29,8 +29,7 @@ class Projects::WikisController < Projects::ApplicationController else return render('empty') unless can?(current_user, :create_wiki, @project) - @page = WikiPage.new(@project_wiki) - @page.title = params[:id] + @page = build_page(title: params[:id]) render 'edit' end @@ -54,7 +53,7 @@ class Projects::WikisController < Projects::ApplicationController else render 'edit' end - rescue WikiPage::PageChangedError, WikiPage::PageRenameError => e + rescue WikiPage::PageChangedError, WikiPage::PageRenameError, Gitlab::Git::Wiki::OperationError => e @error = e render 'edit' end @@ -70,6 +69,11 @@ class Projects::WikisController < Projects::ApplicationController else render action: "edit" end + rescue Gitlab::Git::Wiki::OperationError => e + @page = build_page(wiki_params) + @error = e + + render 'edit' end def history @@ -94,6 +98,9 @@ class Projects::WikisController < Projects::ApplicationController redirect_to project_wiki_path(@project, :home), status: 302, notice: "Page was successfully deleted" + rescue Gitlab::Git::Wiki::OperationError => e + @error = e + render 'edit' end def git_access @@ -116,4 +123,10 @@ class Projects::WikisController < Projects::ApplicationController def wiki_params params.require(:wiki).permit(:title, :content, :format, :message, :last_commit_sha) end + + def build_page(args) + WikiPage.new(@project_wiki).tap do |page| + page.update_attributes(args) + end + end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 37f14230196..a93b116c6fe 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -404,7 +404,7 @@ class ProjectsController < Projects::ApplicationController params[:namespace_id] = project.namespace.to_param params[:id] = project.to_param - url_for(params) + url_for(safe_params) end def project_export_enabled diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 956df4a0a16..31f47a7aa7c 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -146,6 +146,6 @@ class UsersController < ApplicationController end def build_canonical_path(user) - url_for(params.merge(username: user.to_param)) + url_for(safe_params.merge(username: user.to_param)) end end diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb index e72fd8eb3a5..051ea108e06 100644 --- a/app/finders/group_descendants_finder.rb +++ b/app/finders/group_descendants_finder.rb @@ -134,10 +134,8 @@ class GroupDescendantsFinder end def direct_child_projects - GroupProjectsFinder.new(group: parent_group, - current_user: current_user, - options: { only_owned: true }, - params: params).execute + GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params) + .execute end # Finds all projects nested under `parent_group` or any of its descendant diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb index edde8022ec9..65824a51919 100644 --- a/app/finders/users_finder.rb +++ b/app/finders/users_finder.rb @@ -32,6 +32,7 @@ class UsersFinder users = by_active(users) users = by_external_identity(users) users = by_external(users) + users = by_2fa(users) users = by_created_at(users) users = by_custom_attributes(users) @@ -76,4 +77,15 @@ class UsersFinder users.external end + + def by_2fa(users) + case params[:two_factor] + when 'enabled' + users.with_two_factor + when 'disabled' + users.without_two_factor + else + users + end + end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 866b8773db6..fef29789832 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -259,7 +259,7 @@ module BlobHelper options = [] if error == :collapsed - options << link_to('load it anyway', url_for(params.merge(viewer: viewer.type, expanded: true, format: nil))) + options << link_to('load it anyway', url_for(safe_params.merge(viewer: viewer.type, expanded: true, format: nil))) end # If the error is `:server_side_but_stored_externally`, the simple viewer will show the same error, diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index b5ca39711bc..1bb82fd8150 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -180,7 +180,7 @@ module DiffHelper private def diff_btn(title, name, selected) - params_copy = params.dup + params_copy = safe_params.dup params_copy[:view] = name # Always use HTML to handle case where JSON diff rendered this button diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 06c3e569c84..f39a62bccc8 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -9,6 +9,32 @@ module IssuablesHelper "right-sidebar-#{sidebar_gutter_collapsed? ? 'collapsed' : 'expanded'}" end + def sidebar_gutter_tooltip_text + sidebar_gutter_collapsed? ? _('Expand sidebar') : _('Collapse sidebar') + end + + def sidebar_assignee_tooltip_label(issuable) + if issuable.assignee + issuable.assignee.name + else + issuable.allows_multiple_assignees? ? _('Assignee(s)') : _('Assignee') + end + end + + def sidebar_due_date_tooltip_label(issuable) + if issuable.due_date + "#{_('Due date')}<br />#{due_date_remaining_days(issuable)}" + else + _('Due date') + end + end + + def due_date_remaining_days(issuable) + remaining_days_in_words = remaining_days_in_words(issuable) + + "#{issuable.due_date.to_s(:medium)} (#{remaining_days_in_words})" + end + def multi_label_name(current_labels, default_label) if current_labels && current_labels.any? title = current_labels.first.try(:title) @@ -153,10 +179,14 @@ module IssuablesHelper def issuable_labels_tooltip(labels, limit: 5) first, last = labels.partition.with_index { |_, i| i < limit } - label_names = first.collect(&:name) - label_names << "and #{last.size} more" unless last.empty? + if labels && labels.any? + label_names = first.collect(&:name) + label_names << "and #{last.size} more" unless last.empty? - label_names.join(', ') + label_names.join(', ') + else + _("Labels") + end end def issuables_state_counter_text(issuable_type, state, display_count) @@ -321,7 +351,7 @@ module IssuablesHelper def issuable_todo_button_data(issuable, todo, is_collapsed) { todo_text: "Add todo", - mark_text: "Mark done", + mark_text: "Mark todo as done", todo_icon: (is_collapsed ? icon('plus-square') : nil), mark_icon: (is_collapsed ? icon('check-square', class: 'todo-undone') : nil), issuable_id: issuable.id, diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index be8cb358de2..e8caab3e50c 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -1,4 +1,6 @@ module MilestonesHelper + include EntityDateHelper + def milestones_filter_path(opts = {}) if @project project_milestones_path(@project, opts) @@ -72,6 +74,19 @@ module MilestonesHelper end end + def milestone_progress_tooltip_text(milestone) + has_issues = milestone.total_issues_count(current_user) > 0 + + if has_issues + [ + _('Progress'), + _("%{percent}%% complete") % { percent: milestone.percent_complete(current_user) } + ].join('<br />') + else + _('Progress') + end + end + def milestone_progress_bar(milestone) options = { class: 'progress-bar progress-bar-success', @@ -95,27 +110,69 @@ module MilestonesHelper end def milestone_tooltip_title(milestone) - if milestone.due_date - [milestone.due_date.to_s(:medium), "(#{milestone_remaining_days(milestone)})"].join(' ') + if milestone + "#{milestone.title}<br />#{milestone_tooltip_due_date(milestone)}" + else + _('Milestone') end end - def milestone_remaining_days(milestone) - if milestone.expired? - content_tag(:strong, 'Past due') - elsif milestone.upcoming? - content_tag(:strong, 'Upcoming') - elsif milestone.due_date - time_ago = time_ago_in_words(milestone.due_date) - content = time_ago.gsub(/\d+/) { |match| "<strong>#{match}</strong>" } - content.slice!("about ") - content << " remaining" - content.html_safe - elsif milestone.start_date && milestone.start_date.past? - days = milestone.elapsed_days - content = content_tag(:strong, days) - content << " #{'day'.pluralize(days)} elapsed" + def milestone_time_for(date, date_type) + title = date_type == :start ? "Start date" : "End date" + + if date + time_ago = time_ago_in_words(date) + time_ago.slice!("about ") + + time_ago << if date.past? + " ago" + else + " remaining" + end + + content = [ + title, + "<br />", + date.to_s(:medium), + "(#{time_ago})" + ].join(" ") + content.html_safe + else + title + end + end + + def milestone_issues_tooltip_text(milestone) + issues = milestone.count_issues_by_state(current_user) + + return _("Issues") if issues.empty? + + content = [] + + content << n_("1 open issue", "%d open issues", issues["opened"]) % issues["opened"] if issues["opened"] + content << n_("1 closed issue", "%d closed issues", issues["closed"]) % issues["closed"] if issues["closed"] + + content.join('<br />').html_safe + end + + def milestone_merge_requests_tooltip_text(milestone) + merge_requests = milestone.merge_requests + + return _("Merge requests") if merge_requests.empty? + + content = [] + + content << n_("1 open merge request", "%d open merge requests", merge_requests.opened.count) % merge_requests.opened.count if merge_requests.opened.any? + content << n_("1 closed merge request", "%d closed merge requests", merge_requests.closed.count) % merge_requests.closed.count if merge_requests.closed.any? + content << n_("1 merged merge request", "%d merged merge requests", merge_requests.merged.count) % merge_requests.merged.count if merge_requests.merged.any? + + content.join('<br />').html_safe + end + + def milestone_tooltip_due_date(milestone) + if milestone.due_date + "#{milestone.due_date.to_s(:medium)} (#{remaining_days_in_words(milestone)})" end end diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 56c88e6eab0..7754c34d6f0 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -28,7 +28,7 @@ module NavHelper end elsif current_path?('jobs#show') %w[page-gutter build-sidebar right-sidebar-expanded] - elsif current_controller?('wikis') && current_action?('show', 'create', 'edit', 'update', 'history', 'git_access') + elsif current_controller?('wikis') && current_action?('show', 'create', 'edit', 'update', 'history', 'git_access', 'destroy') %w[page-gutter wiki-sidebar right-sidebar-expanded] else [] diff --git a/app/helpers/safe_params_helper.rb b/app/helpers/safe_params_helper.rb new file mode 100644 index 00000000000..b568e8810cc --- /dev/null +++ b/app/helpers/safe_params_helper.rb @@ -0,0 +1,11 @@ +module SafeParamsHelper + # Rails 5.0 requires to permit `params` if they're used in url helpers. + # Use this helper when generating links with `params.merge(...)` + def safe_params + if params.respond_to?(:permit!) + params.except(:host, :port, :protocol).permit! + else + params + end + end +end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index 5e7c20ef51e..dc42caa70e5 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -90,7 +90,7 @@ module TreeHelper end def commit_in_single_accessible_branch - branch_name = html_escape(selected_branch) + branch_name = ERB::Util.html_escape(selected_branch) message = _("Your changes can be committed to %{branch_name} because a merge "\ "request is open.") % { branch_name: "<strong>#{branch_name}</strong>" } diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index b33131becd3..392cc0bee03 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -6,6 +6,12 @@ module Emails mail_new_thread(@issue, issue_thread_options(@issue.author_id, recipient_id, reason)) end + def issue_due_email(recipient_id, issue_id, reason = nil) + setup_issue_mail(issue_id, recipient_id) + + mail_new_thread(@issue, issue_thread_options(@issue.author_id, recipient_id, reason)) + end + def new_mention_in_issue_email(recipient_id, issue_id, updated_by_user_id, reason = nil) setup_issue_mail(issue_id, recipient_id) mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason)) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 4aa65bf4273..b0c02cdeec7 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -20,7 +20,7 @@ module Ci has_one :last_deployment, -> { order('deployments.id DESC') }, as: :deployable, class_name: 'Deployment' has_many :trace_sections, class_name: 'Ci::BuildTraceSection' - has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent has_one :job_artifacts_archive, -> { where(file_type: Ci::JobArtifact.file_types[:archive]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id has_one :job_artifacts_metadata, -> { where(file_type: Ci::JobArtifact.file_types[:metadata]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id has_one :job_artifacts_trace, -> { where(file_type: Ci::JobArtifact.file_types[:trace]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id @@ -95,8 +95,8 @@ module Ci run_after_commit { BuildHooksWorker.perform_async(build.id) } end - after_commit :update_project_statistics_after_save, on: [:create, :update] - after_commit :update_project_statistics, on: :destroy + after_save :update_project_statistics_after_save, if: :artifacts_size_changed? + after_destroy :update_project_statistics_after_destroy, unless: :project_destroyed? class << self # This is needed for url_for to work, @@ -162,7 +162,7 @@ module Ci build.validates_dependencies! unless Feature.enabled?('ci_disable_validates_dependencies') end - before_transition pending: :running do |build| + after_transition pending: :running do |build| build.ensure_metadata.update_timeout_state end end @@ -479,7 +479,7 @@ module Ci def user_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| - return variables if user.blank? + break variables if user.blank? variables.append(key: 'GITLAB_USER_ID', value: user.id.to_s) variables.append(key: 'GITLAB_USER_EMAIL', value: user.email) @@ -594,7 +594,7 @@ module Ci def persisted_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| - return variables unless persisted? + break variables unless persisted? variables .append(key: 'CI_JOB_ID', value: id.to_s) @@ -611,7 +611,7 @@ module Ci Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'CI', value: 'true') variables.append(key: 'GITLAB_CI', value: 'true') - variables.append(key: 'GITLAB_FEATURES', value: project.namespace.features.join(',')) + variables.append(key: 'GITLAB_FEATURES', value: project.licensed_features.join(',')) variables.append(key: 'CI_SERVER_NAME', value: 'GitLab') variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION) variables.append(key: 'CI_SERVER_REVISION', value: Gitlab::REVISION) @@ -643,7 +643,7 @@ module Ci def persisted_environment_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| - return variables unless persisted? && persisted_environment.present? + break variables unless persisted? && persisted_environment.present? variables.concat(persisted_environment.predefined_variables) @@ -664,16 +664,20 @@ module Ci pipeline.config_processor.build_attributes(name) end - def update_project_statistics - return unless project + def update_project_statistics_after_save + update_project_statistics(read_attribute(:artifacts_size).to_i - artifacts_size_was.to_i) + end - ProjectCacheWorker.perform_async(project_id, [], [:build_artifacts_size]) + def update_project_statistics_after_destroy + update_project_statistics(-artifacts_size) end - def update_project_statistics_after_save - if previous_changes.include?('artifacts_size') - update_project_statistics - end + def update_project_statistics(difference) + ProjectStatistics.increment_statistic(project_id, :build_artifacts_size, difference) + end + + def project_destroyed? + project.pending_delete? end end end diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb index 62d768cc6cf..44cb583e1bd 100644 --- a/app/models/ci/group_variable.rb +++ b/app/models/ci/group_variable.rb @@ -4,7 +4,7 @@ module Ci include HasVariable include Presentable - belongs_to :group + belongs_to :group, class_name: "::Group" alias_attribute :secret_value, :value diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index fbb95fe16df..39676efa08c 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -7,12 +7,15 @@ module Ci belongs_to :project belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id - before_save :update_file_store + mount_uploader :file, JobArtifactUploader + before_save :set_size, if: :file_changed? + after_save :update_project_statistics_after_save, if: :size_changed? + after_destroy :update_project_statistics_after_destroy, unless: :project_destroyed? - scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) } + after_save :update_file_store - mount_uploader :file, JobArtifactUploader + scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) } delegate :exists?, :open, to: :file @@ -23,7 +26,9 @@ module Ci } def update_file_store - self.file_store = file.object_store + # The file.object_store is set during `uploader.store!` + # which happens after object is inserted/updated + self.update_column(:file_store, file.object_store) end def self.artifacts_size_for(project) @@ -34,10 +39,6 @@ module Ci [nil, ::JobArtifactUploader::Store::LOCAL].include?(self.file_store) end - def set_size - self.size = file.size - end - def expire_in expire_at - Time.now if expire_at end @@ -48,5 +49,28 @@ module Ci ChronicDuration.parse(value)&.seconds&.from_now end end + + private + + def set_size + self.size = file.size + end + + def update_project_statistics_after_save + update_project_statistics(size.to_i - size_was.to_i) + end + + def update_project_statistics_after_destroy + update_project_statistics(-self.size) + end + + def update_project_statistics(difference) + ProjectStatistics.increment_statistic(project_id, :build_artifacts_size, difference) + end + + def project_destroyed? + # Use job.project to avoid extra DB query for project + job.project.pending_delete? + end end end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index ee0d8df8eb7..5a4c56ec0dc 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -13,7 +13,7 @@ module Ci has_many :builds has_many :runner_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :projects, -> { auto_include(false) }, through: :runner_projects + has_many :projects, through: :runner_projects has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index e4a06f3f976..77947d515c1 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -15,7 +15,7 @@ module Clusters belongs_to :user has_many :cluster_projects, class_name: 'Clusters::Project' - has_many :projects, -> { auto_include(false) }, through: :cluster_projects, class_name: '::Project' + has_many :projects, through: :cluster_projects, class_name: '::Project' # we force autosave to happen when we save `Cluster` model has_one :provider_gcp, class_name: 'Clusters::Providers::Gcp', autosave: true diff --git a/app/models/commit.rb b/app/models/commit.rb index de860df4b9c..9750e9298ec 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -248,7 +248,7 @@ class Commit end def notes_with_associations - notes.includes(:author) + notes.includes(:author, :award_emoji) end def merge_requests diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 3469d5d795c..b6276c2fb50 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -87,7 +87,7 @@ class CommitStatus < ActiveRecord::Base transition [:created, :pending, :running, :manual] => :canceled end - before_transition created: [:pending, :running] do |commit_status| + before_transition [:created, :skipped, :manual] => :pending do |commit_status| commit_status.queued_at = Time.now end diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index 4b66725a3e6..22f516a172f 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -27,8 +27,9 @@ module AtomicInternalId module ClassMethods def has_internal_id(column, scope:, init:) # rubocop:disable Naming/PredicateName before_validation(on: :create) do - if read_attribute(column).blank? - scope_attrs = { scope => association(scope).reader } + scope_value = association(scope).reader + if read_attribute(column).blank? && scope_value + scope_attrs = { scope_value.class.table_name.singularize.to_sym => scope_value } usage = self.class.table_name.to_sym new_iid = InternalId.generate_next(self, scope_attrs, usage, init) diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 4ae5dd8c677..db8cf322ef7 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -11,7 +11,9 @@ module CacheMarkdownField extend ActiveSupport::Concern # Increment this number every time the renderer changes its output - CACHE_VERSION = 3 + CACHE_REDCARPET_VERSION = 3 + CACHE_COMMONMARK_VERSION_START = 10 + CACHE_COMMONMARK_VERSION = 11 # changes to these attributes cause the cache to be invalidates INVALIDATED_BY = %w[author project].freeze @@ -49,12 +51,14 @@ module CacheMarkdownField # Always include a project key, or Banzai complains project = self.project if self.respond_to?(:project) - group = self.group if self.respond_to?(:group) + group = self.group if self.respond_to?(:group) context = cached_markdown_fields[field].merge(project: project, group: group) # Banzai is less strict about authors, so don't always have an author key context[:author] = self.author if self.respond_to?(:author) + context[:markdown_engine] = markdown_engine + context end @@ -69,7 +73,7 @@ module CacheMarkdownField Banzai::Renderer.cacheless_render_field(self, markdown_field, options) ] end.to_h - updates['cached_markdown_version'] = CacheMarkdownField::CACHE_VERSION + updates['cached_markdown_version'] = latest_cached_markdown_version updates.each {|html_field, data| write_attribute(html_field, data) } end @@ -90,7 +94,7 @@ module CacheMarkdownField markdown_changed = attribute_changed?(markdown_field) || false html_changed = attribute_changed?(html_field) || false - CacheMarkdownField::CACHE_VERSION == cached_markdown_version && + latest_cached_markdown_version == cached_markdown_version && (html_changed || markdown_changed == html_changed) end @@ -109,6 +113,24 @@ module CacheMarkdownField __send__(cached_markdown_fields.html_field(markdown_field)) # rubocop:disable GitlabSecurity/PublicSend end + def latest_cached_markdown_version + return CacheMarkdownField::CACHE_REDCARPET_VERSION unless cached_markdown_version + + if cached_markdown_version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START + CacheMarkdownField::CACHE_REDCARPET_VERSION + else + CacheMarkdownField::CACHE_COMMONMARK_VERSION + end + end + + def markdown_engine + if latest_cached_markdown_version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START + :redcarpet + else + :common_mark + end + end + included do cattr_reader :cached_markdown_fields do FieldData.new diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb index 01957da0bf3..261ace57a17 100644 --- a/app/models/concerns/group_descendant.rb +++ b/app/models/concerns/group_descendant.rb @@ -37,7 +37,20 @@ module GroupDescendant parent ||= preloaded.detect { |possible_parent| possible_parent.is_a?(Group) && possible_parent.id == child.parent_id } if parent.nil? && !child.parent_id.nil? - raise ArgumentError.new('parent was not preloaded') + parent = child.parent + + exception = ArgumentError.new <<~MSG + parent: [GroupDescendant: #{parent.inspect}] was not preloaded for [#{child.inspect}]") + This error is not user facing, but causes a +1 query. + MSG + extras = { + parent: parent, + child: child, + preloaded: preloaded.map(&:full_path) + } + issue_url = 'https://gitlab.com/gitlab-org/gitlab-ce/issues/40785' + + Gitlab::Sentry.track_exception(exception, issue_url: issue_url, extra: extras) end if parent.nil? && hierarchy_top.present? diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index d9416352f9c..b45395343cc 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -48,7 +48,7 @@ module Issuable end has_many :label_links, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :labels, -> { auto_include(false) }, through: :label_links + has_many :labels, through: :label_links has_many :todos, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_one :metrics diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index 5130ecec472..967fd9c5eea 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -102,14 +102,14 @@ module Milestoneish Gitlab::TimeTrackingFormatter.output(total_issue_time_estimate) end - private - def count_issues_by_state(user) memoize_per_user(user, :count_issues_by_state) do issues_visible_to_user(user).reorder(nil).group(:state).count end end + private + def memoize_per_user(user, method_name) memoized_users[method_name][user&.id] ||= yield end diff --git a/app/models/concerns/nonatomic_internal_id.rb b/app/models/concerns/nonatomic_internal_id.rb deleted file mode 100644 index 9d0c9b8512f..00000000000 --- a/app/models/concerns/nonatomic_internal_id.rb +++ /dev/null @@ -1,22 +0,0 @@ -module NonatomicInternalId - extend ActiveSupport::Concern - - included do - validate :set_iid, on: :create - validates :iid, presence: true, numericality: true - end - - def set_iid - if iid.blank? - parent = project || group - records = parent.public_send(self.class.name.tableize) # rubocop:disable GitlabSecurity/PublicSend - max_iid = records.maximum(:iid) - - self.iid = max_iid.to_i + 1 - end - end - - def to_param - iid.to_s - end -end diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb index 399abb67c9d..7c236369793 100644 --- a/app/models/concerns/resolvable_discussion.rb +++ b/app/models/concerns/resolvable_discussion.rb @@ -102,7 +102,7 @@ module ResolvableDiscussion yield(notes_relation) # Set the notes array to the updated notes - @notes = notes_relation.fresh.auto_include(false).to_a # rubocop:disable Gitlab/ModuleWithInstanceVariables + @notes = notes_relation.fresh.to_a # rubocop:disable Gitlab/ModuleWithInstanceVariables self.class.memoized_values.each do |name| clear_memoization(name) diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index dfd7d94450b..915ad6959be 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -102,7 +102,7 @@ module Routable # the route. Caching this per request ensures that even if we have multiple instances, # we will not have to duplicate work, avoiding N+1 queries in some cases. def full_path - return uncached_full_path unless RequestStore.active? + return uncached_full_path unless RequestStore.active? && persisted? RequestStore[full_path_key] ||= uncached_full_path end @@ -124,6 +124,11 @@ module Routable end end + # Group would override this to check from association + def owned_by?(user) + owner == user + end + private def set_path_errors diff --git a/app/models/concerns/uniquify.rb b/app/models/concerns/uniquify.rb index a7fe5951b6e..549a76da20e 100644 --- a/app/models/concerns/uniquify.rb +++ b/app/models/concerns/uniquify.rb @@ -1,13 +1,21 @@ +# Uniquify +# +# Return a version of the given 'base' string that is unique +# by appending a counter to it. Uniqueness is determined by +# repeated calls to the passed block. +# +# You can pass an initial value for the counter, if not given +# counting starts from 1. +# +# If `base` is a function/proc, we expect that calling it with a +# candidate counter returns a string to test/return. class Uniquify - # Return a version of the given 'base' string that is unique - # by appending a counter to it. Uniqueness is determined by - # repeated calls to the passed block. - # - # If `base` is a function/proc, we expect that calling it with a - # candidate counter returns a string to test/return. + def initialize(counter = nil) + @counter = counter + end + def string(base) @base = base - @counter = nil increment_counter! while yield(base_string) base_string diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb index 858b7ef533e..89a74b7dcb1 100644 --- a/app/models/deploy_key.rb +++ b/app/models/deploy_key.rb @@ -2,7 +2,7 @@ class DeployKey < Key include IgnorableColumn has_many :deploy_keys_projects, inverse_of: :deploy_key, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :projects, -> { auto_include(false) }, through: :deploy_keys_projects + has_many :projects, through: :deploy_keys_projects scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where('deploy_keys_projects.project_id in (?)', projects) } scope :are_public, -> { where(public: true) } diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb index 8dae821a10e..979e9232fda 100644 --- a/app/models/deploy_token.rb +++ b/app/models/deploy_token.rb @@ -8,7 +8,7 @@ class DeployToken < ActiveRecord::Base default_value_for(:expires_at) { Forever.date } has_many :project_deploy_tokens, inverse_of: :deploy_token - has_many :projects, -> { auto_include(false) }, through: :project_deploy_tokens + has_many :projects, through: :project_deploy_tokens validate :ensure_at_least_one_scope before_save :ensure_token diff --git a/app/models/deployment.rb b/app/models/deployment.rb index e18ea8bfea4..254764eefde 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -1,11 +1,13 @@ class Deployment < ActiveRecord::Base - include NonatomicInternalId + include AtomicInternalId belongs_to :project, required: true belongs_to :environment, required: true belongs_to :user belongs_to :deployable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations + has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.deployments&.maximum(:iid) } + validates :sha, presence: true validates :ref, presence: true diff --git a/app/models/fork_network.rb b/app/models/fork_network.rb index aad3509b895..7f1728e8c77 100644 --- a/app/models/fork_network.rb +++ b/app/models/fork_network.rb @@ -1,7 +1,7 @@ class ForkNetwork < ActiveRecord::Base belongs_to :root_project, class_name: 'Project' has_many :fork_network_members - has_many :projects, -> { auto_include(false) }, through: :fork_network_members + has_many :projects, through: :fork_network_members after_create :add_root_as_member, if: :root_project diff --git a/app/models/group.rb b/app/models/group.rb index 202988d743d..9b42bbf99be 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -12,9 +12,9 @@ class Group < Namespace has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent alias_method :members, :group_members - has_many :users, -> { auto_include(false) }, through: :group_members + has_many :users, through: :group_members has_many :owners, - -> { where(members: { access_level: Gitlab::Access::OWNER }).auto_include(false) }, + -> { where(members: { access_level: Gitlab::Access::OWNER }) }, through: :group_members, source: :user @@ -23,7 +23,7 @@ class Group < Namespace has_many :milestones has_many :project_group_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :shared_projects, -> { auto_include(false) }, through: :project_group_links, source: :project + has_many :shared_projects, through: :project_group_links, source: :project has_many :notification_settings, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent has_many :labels, class_name: 'GroupLabel' has_many :variables, class_name: 'Ci::GroupVariable' @@ -125,6 +125,10 @@ class Group < Namespace self[:lfs_enabled] end + def owned_by?(user) + owners.include?(user) + end + def add_users(users, access_level, current_user: nil, expires_at: nil) GroupMember.add_users( self, diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb index cbec735c2dd..189942c5ad8 100644 --- a/app/models/internal_id.rb +++ b/app/models/internal_id.rb @@ -12,8 +12,9 @@ # * (Optionally) add columns to `internal_ids` if needed for scope. class InternalId < ActiveRecord::Base belongs_to :project + belongs_to :namespace - enum usage: { issues: 0 } + enum usage: { issues: 0, merge_requests: 1, deployments: 2, milestones: 3, epics: 4 } validates :usage, presence: true @@ -23,9 +24,12 @@ class InternalId < ActiveRecord::Base # # The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL). # As such, the increment is atomic and safe to be called concurrently. - def increment_and_save! + # + # If a `maximum_iid` is passed in, this overrides the incremented value if it's + # greater than that. This can be used to correct the increment value if necessary. + def increment_and_save!(maximum_iid) lock! - self.last_value = (last_value || 0) + 1 + self.last_value = [(last_value || 0) + 1, (maximum_iid || 0) + 1].max save! last_value end @@ -89,7 +93,16 @@ class InternalId < ActiveRecord::Base # and increment its last value # # Note this will acquire a ROW SHARE lock on the InternalId record - (lookup || create_record).increment_and_save! + + # Note we always calculate the maximum iid present here and + # pass it in to correct the InternalId entry if it's last_value is off. + # + # This can happen in a transition phase where both `AtomicInternalId` and + # `NonatomicInternalId` code runs (e.g. during a deploy). + # + # This is subject to be cleaned up with the 10.8 release: + # https://gitlab.com/gitlab-org/gitlab-ce/issues/45389. + (lookup || create_record).increment_and_save!(maximum_iid) end end @@ -115,11 +128,15 @@ class InternalId < ActiveRecord::Base InternalId.create!( **scope, usage: usage_value, - last_value: init.call(subject) || 0 + last_value: maximum_iid ) end rescue ActiveRecord::RecordNotUnique lookup end + + def maximum_iid + @maximum_iid ||= init.call(subject) || 0 + end end end diff --git a/app/models/issue.rb b/app/models/issue.rb index c1ffe6512ea..0332bfa9371 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -34,7 +34,7 @@ class Issue < ActiveRecord::Base dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :issue_assignees - has_many :assignees, -> { auto_include(false) }, class_name: "User", through: :issue_assignees + has_many :assignees, class_name: "User", through: :issue_assignees validates :project, presence: true @@ -49,6 +49,7 @@ class Issue < ActiveRecord::Base scope :without_due_date, -> { where(due_date: nil) } scope :due_before, ->(date) { where('issues.due_date < ?', date) } scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) } + scope :due_tomorrow, -> { where(due_date: Date.tomorrow) } scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') } scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') } @@ -193,6 +194,15 @@ class Issue < ActiveRecord::Base branches_with_iid - branches_with_merge_request end + def suggested_branch_name + return to_branch_name unless project.repository.branch_exists?(to_branch_name) + + start_counting_from = 2 + Uniquify.new(start_counting_from).string(-> (counter) { "#{to_branch_name}-#{counter}" }) do |suggested_branch_name| + project.repository.branch_exists?(suggested_branch_name) + end + end + # Returns boolean if a related branch exists for the current issue # ignores merge requests branchs def has_related_branch? @@ -247,11 +257,8 @@ class Issue < ActiveRecord::Base end end - def can_be_worked_on?(current_user) - !self.closed? && - !self.project.forked? && - self.related_branches(current_user).empty? && - self.closed_by_merge_requests(current_user).empty? + def can_be_worked_on? + !self.closed? && !self.project.forked? end # Returns `true` if the current issue can be viewed by either a logged in User diff --git a/app/models/label.rb b/app/models/label.rb index f3496884cff..de7f1d56c64 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -18,8 +18,8 @@ class Label < ActiveRecord::Base has_many :lists, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :priorities, class_name: 'LabelPriority' has_many :label_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :issues, -> { auto_include(false) }, through: :label_links, source: :target, source_type: 'Issue' - has_many :merge_requests, -> { auto_include(false) }, through: :label_links, source: :target, source_type: 'MergeRequest' + has_many :issues, through: :label_links, source: :target, source_type: 'Issue' + has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest' before_validation :strip_whitespace_from_title_and_color diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index ed95613ee59..6b7f280fb70 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -3,7 +3,7 @@ class LfsObject < ActiveRecord::Base include ObjectStorage::BackgroundMove has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :projects, -> { auto_include(false) }, through: :lfs_objects_projects + has_many :projects, through: :lfs_objects_projects scope :with_files_stored_locally, -> { where(file_store: [nil, LfsObjectUploader::Store::LOCAL]) } @@ -11,10 +11,12 @@ class LfsObject < ActiveRecord::Base mount_uploader :file, LfsObjectUploader - before_save :update_file_store + after_save :update_file_store def update_file_store - self.file_store = file.object_store + # The file.object_store is set during `uploader.store!` + # which happens after object is inserted/updated + self.update_column(:file_store, file.object_store) end def project_allowed_access?(project) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 91d8be5559b..8f964a488aa 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1,5 +1,5 @@ class MergeRequest < ActiveRecord::Base - include NonatomicInternalId + include AtomicInternalId include Issuable include Noteable include Referable @@ -18,6 +18,8 @@ class MergeRequest < ActiveRecord::Base belongs_to :source_project, class_name: "Project" belongs_to :merge_user, class_name: "User" + has_internal_id :iid, scope: :target_project, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) } + has_many :merge_request_diffs has_one :merge_request_diff, diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 8e33bab81c1..d14e3a4ded5 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -8,7 +8,7 @@ class Milestone < ActiveRecord::Base Started = MilestoneStruct.new('Started', '#started', -3) include CacheMarkdownField - include NonatomicInternalId + include AtomicInternalId include Sortable include Referable include StripAttribute @@ -21,8 +21,11 @@ class Milestone < ActiveRecord::Base belongs_to :project belongs_to :group + has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.milestones&.maximum(:iid) } + has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.milestones&.maximum(:iid) } + has_many :issues - has_many :labels, -> { distinct.reorder('labels.title').auto_include(false) }, through: :issues + has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :merge_requests has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 2b63aa33222..c29a53e5ce7 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -248,10 +248,6 @@ class Namespace < ActiveRecord::Base all_projects.with_storage_feature(:repository).find_each(&:remove_exports) end - def features - [] - end - def refresh_project_authorizations owner.refresh_authorized_projects end diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index b3ffad00a07..2c3580bbdc6 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -83,14 +83,14 @@ class NotificationRecipient def has_access? DeclarativePolicy.subject_scope do - return false unless user.can?(:receive_notifications) - return true if @skip_read_ability + break false unless user.can?(:receive_notifications) + break true if @skip_read_ability - return false if @target && !user.can?(:read_cross_project) - return false if @project && !user.can?(:read_project, @project) + break false if @target && !user.can?(:read_cross_project) + break false if @project && !user.can?(:read_project, @project) - return true unless read_ability - return true unless DeclarativePolicy.has_policy?(@target) + break true unless read_ability + break true unless DeclarativePolicy.has_policy?(@target) user.can?(read_ability, @target) end diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index f6d9b0215fc..9195408551f 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -47,7 +47,8 @@ class NotificationSetting < ActiveRecord::Base ].freeze EXCLUDED_WATCHER_EVENTS = [ - :push_to_merge_request + :push_to_merge_request, + :issue_due ].push(*EXCLUDED_PARTICIPATING_EVENTS).freeze def self.find_or_create_for(source) diff --git a/app/models/project.rb b/app/models/project.rb index 5412b1d49c6..140543bc20a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -143,11 +143,11 @@ class Project < ActiveRecord::Base has_one :packagist_service # TODO: replace these relations with the fork network versions - has_one :forked_project_link, foreign_key: "forked_to_project_id" - has_one :forked_from_project, -> { auto_include(false) }, through: :forked_project_link + has_one :forked_project_link, foreign_key: "forked_to_project_id" + has_one :forked_from_project, through: :forked_project_link has_many :forked_project_links, foreign_key: "forked_from_project_id" - has_many :forks, -> { auto_include(false) }, through: :forked_project_links, source: :forked_to_project + has_many :forks, through: :forked_project_links, source: :forked_to_project # TODO: replace these relations with the fork network versions has_one :root_of_fork_network, @@ -155,7 +155,7 @@ class Project < ActiveRecord::Base inverse_of: :root_project, class_name: 'ForkNetwork' has_one :fork_network_member - has_one :fork_network, -> { auto_include(false) }, through: :fork_network_member + has_one :fork_network, through: :fork_network_member # Merge Requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id' @@ -172,27 +172,27 @@ class Project < ActiveRecord::Base has_many :protected_tags has_many :project_authorizations - has_many :authorized_users, -> { auto_include(false) }, through: :project_authorizations, source: :user, class_name: 'User' + has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User' has_many :project_members, -> { where(requested_at: nil) }, as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent alias_method :members, :project_members - has_many :users, -> { auto_include(false) }, through: :project_members + has_many :users, through: :project_members has_many :requesters, -> { where.not(requested_at: nil) }, as: :source, class_name: 'ProjectMember', dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :members_and_requesters, as: :source, class_name: 'ProjectMember' has_many :deploy_keys_projects - has_many :deploy_keys, -> { auto_include(false) }, through: :deploy_keys_projects + has_many :deploy_keys, through: :deploy_keys_projects has_many :users_star_projects - has_many :starrers, -> { auto_include(false) }, through: :users_star_projects, source: :user + has_many :starrers, through: :users_star_projects, source: :user has_many :releases has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :lfs_objects, -> { auto_include(false) }, through: :lfs_objects_projects + has_many :lfs_objects, through: :lfs_objects_projects has_many :lfs_file_locks has_many :project_group_links - has_many :invited_groups, -> { auto_include(false) }, through: :project_group_links, source: :group + has_many :invited_groups, through: :project_group_links, source: :group has_many :pages_domains has_many :todos has_many :notification_settings, as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent @@ -204,7 +204,7 @@ class Project < ActiveRecord::Base has_one :statistics, class_name: 'ProjectStatistics' has_one :cluster_project, class_name: 'Clusters::Project' - has_many :clusters, -> { auto_include(false) }, through: :cluster_project, class_name: 'Clusters::Cluster' + has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster' # Container repositories need to remove data from the container registry, # which is not managed by the DB. Hence we're still using dependent: :destroy @@ -221,16 +221,16 @@ class Project < ActiveRecord::Base has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName' has_many :runner_projects, class_name: 'Ci::RunnerProject' - has_many :runners, -> { auto_include(false) }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' + has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' has_many :variables, class_name: 'Ci::Variable' has_many :triggers, class_name: 'Ci::Trigger' has_many :environments has_many :deployments has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule' has_many :project_deploy_tokens - has_many :deploy_tokens, -> { auto_include(false) }, through: :project_deploy_tokens + has_many :deploy_tokens, through: :project_deploy_tokens - has_many :active_runners, -> { active.auto_include(false) }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' + has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' has_one :auto_devops, class_name: 'ProjectAutoDevops' has_many :custom_attributes, class_name: 'ProjectCustomAttribute' @@ -1643,7 +1643,7 @@ class Project < ActiveRecord::Base def container_registry_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| - return variables unless Gitlab.config.registry.enabled + break variables unless Gitlab.config.registry.enabled variables.append(key: 'CI_REGISTRY', value: Gitlab.config.registry.host_port) @@ -1881,6 +1881,10 @@ class Project < ActiveRecord::Base memoized_results[cache_key] end + def licensed_features + [] + end + private def storage diff --git a/app/models/project_services/flowdock_service.rb b/app/models/project_services/flowdock_service.rb index 4d23a17a545..da01ac1b7cf 100644 --- a/app/models/project_services/flowdock_service.rb +++ b/app/models/project_services/flowdock_service.rb @@ -1,5 +1,51 @@ require "flowdock-git-hook" +# Flow dock depends on Grit to compute the number of commits between two given +# commits. To make this depend on Gitaly, a monkey patch is applied +module Flowdock + class Git + # pass down a Repository all the way down + def repo + @options[:repo] + end + + def config + {} + end + + def messages + Git::Builder.new(repo: repo, + ref: @ref, + before: @from, + after: @to, + commit_url: @commit_url, + branch_url: @branch_url, + diff_url: @diff_url, + repo_url: @repo_url, + repo_name: @repo_name, + permanent_refs: @permanent_refs, + tags: tags + ).to_hashes + end + + class Builder + def commits + @repo.commits_between(@before, @after).map do |commit| + { + url: @opts[:commit_url] ? @opts[:commit_url] % [commit.sha] : nil, + id: commit.sha, + message: commit.message, + author: { + name: commit.author_name, + email: commit.author_email + } + } + end + end + end + end +end + class FlowdockService < Service prop_accessor :token validates :token, presence: true, if: :activated? @@ -34,7 +80,7 @@ class FlowdockService < Service data[:before], data[:after], token: token, - repo: project.repository.path_to_repo, + repo: project.repository, repo_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}", commit_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/commit/%s", diff_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/compare/%s...%s" diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 87a4350f022..5d4e3c34b39 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -4,15 +4,15 @@ class ProjectStatistics < ActiveRecord::Base before_save :update_storage_size - STORAGE_COLUMNS = [:repository_size, :lfs_objects_size, :build_artifacts_size].freeze - STATISTICS_COLUMNS = [:commit_count] + STORAGE_COLUMNS + COLUMNS_TO_REFRESH = [:repository_size, :lfs_objects_size, :commit_count].freeze + INCREMENTABLE_COLUMNS = [:build_artifacts_size].freeze def total_repository_size repository_size + lfs_objects_size end def refresh!(only: nil) - STATISTICS_COLUMNS.each do |column, generator| + COLUMNS_TO_REFRESH.each do |column, generator| if only.blank? || only.include?(column) public_send("update_#{column}") # rubocop:disable GitlabSecurity/PublicSend end @@ -34,13 +34,15 @@ class ProjectStatistics < ActiveRecord::Base self.lfs_objects_size = project.lfs_objects.sum(:size) end - def update_build_artifacts_size - self.build_artifacts_size = - project.builds.sum(:artifacts_size) + - Ci::JobArtifact.artifacts_size_for(self.project) + def update_storage_size + self.storage_size = repository_size + lfs_objects_size + build_artifacts_size end - def update_storage_size - self.storage_size = STORAGE_COLUMNS.sum(&method(:read_attribute)) + def self.increment_statistic(project_id, key, amount) + raise ArgumentError, "Cannot increment attribute: #{key}" unless key.in?(INCREMENTABLE_COLUMNS) + return if amount == 0 + + where(project_id: project_id) + .update_all(["#{key} = COALESCE(#{key}, 0) + (?)", amount]) end end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 52e067cb44c..b7e38ceb502 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -179,7 +179,11 @@ class ProjectWiki def commit_details(action, message = nil, title = nil) commit_message = message || default_message(action, title) - Gitlab::Git::Wiki::CommitDetails.new(@user.name, @user.email, commit_message) + Gitlab::Git::Wiki::CommitDetails.new(@user.id, + @user.username, + @user.name, + @user.email, + commit_message) end def default_message(action, title) diff --git a/app/models/repository.rb b/app/models/repository.rb index fd1afafe4df..5bdaa7f0720 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -331,6 +331,7 @@ class Repository return unless empty? expire_method_caches(%i(has_visible_content?)) + raw_repository.expire_has_local_branches_cache end def lookup_cache diff --git a/app/models/todo.rb b/app/models/todo.rb index aad2c1dac4e..a2ab405fdbe 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -22,7 +22,7 @@ class Todo < ActiveRecord::Base belongs_to :author, class_name: "User" belongs_to :note belongs_to :project - belongs_to :target, -> { auto_include(false) }, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations + belongs_to :target, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :user delegate :name, :email, to: :author, prefix: true, allow_nil: true diff --git a/app/models/user.rb b/app/models/user.rb index d5c5c0964c5..b0668148972 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -96,23 +96,23 @@ class User < ActiveRecord::Base # Groups has_many :members has_many :group_members, -> { where(requested_at: nil) }, source: 'GroupMember' - has_many :groups, -> { auto_include(false) }, through: :group_members - has_many :owned_groups, -> { where(members: { access_level: Gitlab::Access::OWNER }).auto_include(false) }, through: :group_members, source: :group - has_many :masters_groups, -> { where(members: { access_level: Gitlab::Access::MASTER }).auto_include(false) }, through: :group_members, source: :group + has_many :groups, through: :group_members + has_many :owned_groups, -> { where(members: { access_level: Gitlab::Access::OWNER }) }, through: :group_members, source: :group + has_many :masters_groups, -> { where(members: { access_level: Gitlab::Access::MASTER }) }, through: :group_members, source: :group # Projects - has_many :groups_projects, -> { auto_include(false) }, through: :groups, source: :projects - has_many :personal_projects, -> { auto_include(false) }, through: :namespace, source: :projects + has_many :groups_projects, through: :groups, source: :projects + has_many :personal_projects, through: :namespace, source: :projects has_many :project_members, -> { where(requested_at: nil) } - has_many :projects, -> { auto_include(false) }, through: :project_members - has_many :created_projects, foreign_key: :creator_id, class_name: 'Project' + has_many :projects, through: :project_members + has_many :created_projects, foreign_key: :creator_id, class_name: 'Project' has_many :users_star_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :starred_projects, -> { auto_include(false) }, through: :users_star_projects, source: :project + has_many :starred_projects, through: :users_star_projects, source: :project has_many :project_authorizations - has_many :authorized_projects, -> { auto_include(false) }, through: :project_authorizations, source: :project + has_many :authorized_projects, through: :project_authorizations, source: :project has_many :user_interacted_projects - has_many :project_interactions, -> { auto_include(false) }, through: :user_interacted_projects, source: :project, class_name: 'Project' + has_many :project_interactions, through: :user_interacted_projects, source: :project, class_name: 'Project' has_many :snippets, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent has_many :notes, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent @@ -132,7 +132,7 @@ class User < ActiveRecord::Base has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id # rubocop:disable Cop/ActiveRecordDependent has_many :issue_assignees - has_many :assigned_issues, -> { auto_include(false) }, class_name: "Issue", through: :issue_assignees, source: :issue + has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" # rubocop:disable Cop/ActiveRecordDependent has_many :custom_attributes, class_name: 'UserCustomAttribute' @@ -947,10 +947,13 @@ class User < ActiveRecord::Base end def manageable_groups - union = Gitlab::SQL::Union.new([owned_groups.select(:id), - masters_groups.select(:id)]) - arel_union = Arel::Nodes::SqlLiteral.new(union.to_sql) - owned_and_master_groups = Group.where(Group.arel_table[:id].in(arel_union)) + union_sql = Gitlab::SQL::Union.new([owned_groups.select(:id), masters_groups.select(:id)]).to_sql + + # Update this line to not use raw SQL when migrated to Rails 5.2. + # Either ActiveRecord or Arel constructions are fine. + # This was replaced with the raw SQL construction because of bugs in the arel gem. + # Bugs were fixed in arel 9.0.0 (Rails 5.2). + owned_and_master_groups = Group.where("namespaces.id IN (#{union_sql})") # rubocop:disable GitlabSecurity/SqlInjection Gitlab::GroupHierarchy.new(owned_and_master_groups).base_and_descendants end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 0f5536415f7..cde79b95062 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -265,6 +265,15 @@ class WikiPage title.present? && self.class.unhyphenize(@page.url_path) != title end + # Updates the current @attributes hash by merging a hash of params + def update_attributes(attrs) + attrs[:title] = process_title(attrs[:title]) if attrs[:title].present? + + attrs.slice!(:content, :format, :message, :title) + + @attributes.merge!(attrs) + end + private # Process and format the title based on the user input. @@ -290,15 +299,6 @@ class WikiPage File.join(components) end - # Updates the current @attributes hash by merging a hash of params - def update_attributes(attrs) - attrs[:title] = process_title(attrs[:title]) if attrs[:title].present? - - attrs.slice!(:content, :format, :message, :title) - - @attributes.merge!(attrs) - end - def set_attributes attributes[:slug] = @page.url_path attributes[:title] = @page.title diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb index 9afebda19be..4873d7ce662 100644 --- a/app/presenters/ci/build_presenter.rb +++ b/app/presenters/ci/build_presenter.rb @@ -1,5 +1,14 @@ module Ci class BuildPresenter < Gitlab::View::Presenter::Delegated + CALLOUT_FAILURE_MESSAGES = { + unknown_failure: 'There is an unknown failure, please try again', + script_failure: 'There has been a script failure. Check the job log for more information', + api_failure: 'There has been an API failure, please try again', + stuck_or_timeout_failure: 'There has been a timeout failure or the job got stuck. Check your timeout limits or try again', + runner_system_failure: 'There has been a runner system failure, please try again', + missing_dependency_failure: 'There has been a missing dependency failure, check the job log for more information' + }.freeze + presents :build def erased_by_user? @@ -35,6 +44,14 @@ module Ci "#{subject.name} - #{detailed_status.status_tooltip}" end + def callout_failure_message + CALLOUT_FAILURE_MESSAGES[failure_reason.to_sym] + end + + def recoverable? + failed? && !unrecoverable? + end + private def tooltip_for_badge @@ -44,5 +61,9 @@ module Ci def detailed_status @detailed_status ||= subject.detailed_status(user) end + + def unrecoverable? + script_failure? || missing_dependency_failure? + end end end diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 484ac64580d..63ead5538cb 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -260,7 +260,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated if current_user && can?(current_user, :admin_pipeline, project) && repository.gitlab_ci_yml.blank? && !show_auto_devops_callout OpenStruct.new(enabled: auto_devops_enabled?, label: auto_devops_enabled? ? _('Auto DevOps enabled') : _('Enable Auto DevOps'), - link: project_settings_ci_cd_path(project, anchor: 'js-general-pipeline-settings')) + link: project_settings_ci_cd_path(project, anchor: 'autodevops-settings')) elsif auto_devops_enabled? OpenStruct.new(enabled: true, label: _('Auto DevOps enabled'), diff --git a/app/serializers/entity_date_helper.rb b/app/serializers/entity_date_helper.rb index 71d9a65fb58..464217123b4 100644 --- a/app/serializers/entity_date_helper.rb +++ b/app/serializers/entity_date_helper.rb @@ -1,5 +1,6 @@ module EntityDateHelper include ActionView::Helpers::DateHelper + include ActionView::Helpers::TagHelper def interval_in_words(diff) return 'Not started' unless diff @@ -34,4 +35,30 @@ module EntityDateHelper duration_hash end + + # Generates an HTML-formatted string for remaining dates based on start_date and due_date + # + # It returns "Past due" for expired entities + # It returns "Upcoming" for upcoming entities + # If due date is provided, it returns "# days|weeks|months remaining|ago" + # If start date is provided and elapsed, with no due date, it returns "# days elapsed" + def remaining_days_in_words(entity) + if entity.try(:expired?) + content_tag(:strong, 'Past due') + elsif entity.try(:upcoming?) + content_tag(:strong, 'Upcoming') + elsif entity.due_date + is_upcoming = (entity.due_date - Date.today).to_i > 0 + time_ago = time_ago_in_words(entity.due_date) + content = time_ago.gsub(/\d+/) { |match| "<strong>#{match}</strong>" } + content.slice!("about ") + content << " " + (is_upcoming ? _("remaining") : _("ago")) + content.html_safe + elsif entity.start_date && entity.start_date.past? + days = entity.elapsed_days + content = content_tag(:strong, days) + content << " #{'day'.pluralize(days)} elapsed" + content.html_safe + end + end end diff --git a/app/serializers/job_entity.rb b/app/serializers/job_entity.rb index 523b522d449..3076fed1674 100644 --- a/app/serializers/job_entity.rb +++ b/app/serializers/job_entity.rb @@ -26,6 +26,8 @@ class JobEntity < Grape::Entity expose :created_at expose :updated_at expose :detailed_status, as: :status, with: StatusEntity + expose :callout_message, if: -> (*) { failed? } + expose :recoverable, if: -> (*) { failed? } private @@ -50,4 +52,20 @@ class JobEntity < Grape::Entity def path_to(route, build) send("#{route}_path", build.project.namespace, build.project, build) # rubocop:disable GitlabSecurity/PublicSend end + + def failed? + build.failed? + end + + def callout_message + build_presenter.callout_failure_message + end + + def recoverable + build_presenter.recoverable? + end + + def build_presenter + @build_presenter ||= build.present + end end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index e09b445636f..0b087ad73da 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -4,6 +4,9 @@ module Ci class RegisterJobService attr_reader :runner + JOB_QUEUE_DURATION_SECONDS_BUCKETS = [1, 3, 10, 30].freeze + JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET = 5.freeze + Result = Struct.new(:build, :valid?) def initialize(runner) @@ -41,7 +44,7 @@ module Ci build.run! register_success(build) - return Result.new(build, true) + return Result.new(build, true) # rubocop:disable Cop/AvoidReturnFromBlocks rescue Ci::Build::MissingDependenciesError build.drop!(:missing_dependency_failure) end @@ -104,10 +107,22 @@ module Ci end def register_success(job) - job_queue_duration_seconds.observe({ shared_runner: @runner.shared? }, Time.now - job.created_at) + labels = { shared_runner: runner.shared?, + jobs_running_for_project: jobs_running_for_project(job) } + + job_queue_duration_seconds.observe(labels, Time.now - job.queued_at) unless job.queued_at.nil? attempt_counter.increment end + def jobs_running_for_project(job) + return '+Inf' unless runner.shared? + + # excluding currently started job + running_jobs_count = job.project.builds.running.where(runner: Ci::Runner.shared) + .limit(JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET + 1).count - 1 + running_jobs_count < JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET ? running_jobs_count : "#{JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET}+" + end + def failed_attempt_counter @failed_attempt_counter ||= Gitlab::Metrics.counter(:job_register_attempts_failed_total, "Counts the times a runner tries to register a job") end @@ -117,7 +132,7 @@ module Ci end def job_queue_duration_seconds - @job_queue_duration_seconds ||= Gitlab::Metrics.histogram(:job_queue_duration_seconds, 'Request handling execution time') + @job_queue_duration_seconds ||= Gitlab::Metrics.histogram(:job_queue_duration_seconds, 'Request handling execution time', {}, JOB_QUEUE_DURATION_SECONDS_BUCKETS) end end end diff --git a/app/services/clusters/gcp/finalize_creation_service.rb b/app/services/clusters/gcp/finalize_creation_service.rb index 15ab2d54404..84944e95542 100644 --- a/app/services/clusters/gcp/finalize_creation_service.rb +++ b/app/services/clusters/gcp/finalize_creation_service.rb @@ -13,7 +13,7 @@ module Clusters rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e provider.make_errored!("Failed to request to CloudPlatform; #{e.message}") rescue ActiveRecord::RecordInvalid => e - provider.make_errored!("Failed to configure GKE Cluster: #{e.message}") + provider.make_errored!("Failed to configure Google Kubernetes Engine Cluster: #{e.message}") end private diff --git a/app/services/clusters/gcp/verify_provision_status_service.rb b/app/services/clusters/gcp/verify_provision_status_service.rb index f994aacd086..7cc4324677e 100644 --- a/app/services/clusters/gcp/verify_provision_status_service.rb +++ b/app/services/clusters/gcp/verify_provision_status_service.rb @@ -17,7 +17,7 @@ module Clusters when 'DONE' finalize_creation else - return provider.make_errored!("Unexpected operation status; #{operation.status} #{operation.status_message}") + provider.make_errored!("Unexpected operation status; #{operation.status} #{operation.status_message}") end end end diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb index 88dfb7a4a90..7e5a77fb056 100644 --- a/app/services/create_deployment_service.rb +++ b/app/services/create_deployment_service.rb @@ -19,8 +19,8 @@ class CreateDeploymentService environment.fire_state_event(action) - return unless environment.save - return if environment.stopped? + break unless environment.save + break if environment.stopped? deploy.tap(&:update_merge_request_metrics!) end diff --git a/app/services/import_export_clean_up_service.rb b/app/services/import_export_clean_up_service.rb index 6442406d77e..74088b970c9 100644 --- a/app/services/import_export_clean_up_service.rb +++ b/app/services/import_export_clean_up_service.rb @@ -10,7 +10,7 @@ class ImportExportCleanUpService def execute Gitlab::Metrics.measure(:import_export_clean_up) do - return unless File.directory?(path) + next unless File.directory?(path) clean_up_export_files end diff --git a/app/services/labels/transfer_service.rb b/app/services/labels/transfer_service.rb index 775efed48eb..9b7486cf53b 100644 --- a/app/services/labels/transfer_service.rb +++ b/app/services/labels/transfer_service.rb @@ -64,9 +64,14 @@ module Labels end def update_label_links(labels, old_label_id:, new_label_id:) - LabelLink.joins(:label) - .merge(labels) - .where(label_id: old_label_id) + # use 'labels' relation to get label_link ids only of issues/MRs + # in the project being transferred. + # IDs are fetched in a separate query because MySQL doesn't + # allow referring of 'label_links' table in UPDATE query: + # https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/62435068 + link_ids = labels.pluck('label_links.id') + + LabelLink.where(id: link_ids, label_id: old_label_id) .update_all(label_id: new_label_id) end diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index b82d9c64296..83e59a649b6 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -203,10 +203,11 @@ module NotificationRecipientService attr_reader :action attr_reader :previous_assignee attr_reader :skip_current_user - def initialize(target, current_user, action:, previous_assignee: nil, skip_current_user: true) + def initialize(target, current_user, action:, custom_action: nil, previous_assignee: nil, skip_current_user: true) @target = target @current_user = current_user @action = action + @custom_action = custom_action @previous_assignee = previous_assignee @skip_current_user = skip_current_user end @@ -236,7 +237,13 @@ module NotificationRecipientService add_mentions(current_user, target: target) # Add the assigned users, if any - assignees = custom_action == :new_issue ? target.assignees : target.assignee + assignees = case custom_action + when :new_issue + target.assignees + else + target.assignee + end + # We use the `:participating` notification level in order to match existing legacy behavior as captured # in existing specs (notification_service_spec.rb ~ line 507) add_recipients(assignees, :participating, NotificationReason::ASSIGNED) if assignees diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index f94c76cf3ac..274161df946 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -373,6 +373,20 @@ class NotificationService end end + def issue_due(issue) + recipients = NotificationRecipientService.build_recipients( + issue, + issue.author, + action: 'due', + custom_action: :issue_due, + skip_current_user: false + ) + + recipients.each do |recipient| + mailer.send(:issue_due_email, recipient.user.id, issue.id, recipient.reason).deliver_later + end + end + protected def new_resource_email(target, method) diff --git a/app/services/projects/create_from_template_service.rb b/app/services/projects/create_from_template_service.rb index a549cfbabea..29b133cc466 100644 --- a/app/services/projects/create_from_template_service.rb +++ b/app/services/projects/create_from_template_service.rb @@ -8,9 +8,10 @@ module Projects template_name = params.delete(:template_name) file = Gitlab::ProjectTemplate.find(template_name).file + override_params = params.dup params[:file] = file - GitlabProjectsImportService.new(current_user, params).execute + GitlabProjectsImportService.new(current_user, params, override_params).execute ensure file&.close diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index aa14206db3b..44e869851ca 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -137,7 +137,7 @@ module Projects return true unless Gitlab.config.registry.enabled ContainerRepository.build_root_repository(project).tap do |repository| - return repository.has_tags? ? repository.delete_tags! : true + break repository.has_tags? ? repository.delete_tags! : true end end diff --git a/app/services/repository_archive_clean_up_service.rb b/app/services/repository_archive_clean_up_service.rb index aa84d36a206..9a88459b511 100644 --- a/app/services/repository_archive_clean_up_service.rb +++ b/app/services/repository_archive_clean_up_service.rb @@ -10,7 +10,7 @@ class RepositoryArchiveCleanUpService def execute Gitlab::Metrics.measure(:repository_archive_clean_up) do - return unless File.directory?(path) + next unless File.directory?(path) clean_up_old_archives clean_up_empty_directories diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 00bf5434b7f..958ef065012 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -159,7 +159,7 @@ module SystemNoteService body = if noteable.time_estimate == 0 "removed time estimate" else - "changed time estimate to #{parsed_time}" + "changed time estimate to #{parsed_time}," end create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking')) diff --git a/app/services/test_hooks/base_service.rb b/app/services/test_hooks/base_service.rb index e9aefb1fb75..aadc1ea644b 100644 --- a/app/services/test_hooks/base_service.rb +++ b/app/services/test_hooks/base_service.rb @@ -19,7 +19,7 @@ module TestHooks error_message = catch(:validation_error) do sample_data = self.__send__(trigger_data_method) # rubocop:disable GitlabSecurity/PublicSend - return hook.execute(sample_data, trigger_key) + return hook.execute(sample_data, trigger_key) # rubocop:disable Cop/AvoidReturnFromBlocks end error(error_message) diff --git a/app/uploaders/job_artifact_uploader.rb b/app/uploaders/job_artifact_uploader.rb index dd86753479d..2a5a830ce4f 100644 --- a/app/uploaders/job_artifact_uploader.rb +++ b/app/uploaders/job_artifact_uploader.rb @@ -6,10 +6,10 @@ class JobArtifactUploader < GitlabUploader storage_options Gitlab.config.artifacts - def size - return super if model.size.nil? + def cached_size + return model.size if model.size.present? && !model.file_changed? - model.size + size end def store_dir @@ -20,7 +20,7 @@ class JobArtifactUploader < GitlabUploader if file_storage? File.open(path, "rb") if path else - ::Gitlab::Ci::Trace::HttpIO.new(url, size) if url + ::Gitlab::Ci::Trace::HttpIO.new(url, cached_size) if url end end diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb index bd258e04d3f..a3549cada95 100644 --- a/app/uploaders/object_storage.rb +++ b/app/uploaders/object_storage.rb @@ -183,14 +183,6 @@ module ObjectStorage StoreURL: connection.put_object_url(remote_store_path, upload_path, expire_at, options) } end - - def default_object_store - if self.object_store_enabled? && self.direct_upload_enabled? - Store::REMOTE - else - Store::LOCAL - end - end end # allow to configure and overwrite the filename @@ -211,12 +203,13 @@ module ObjectStorage end def object_store - @object_store ||= model.try(store_serialization_column) || self.class.default_object_store + # We use Store::LOCAL as null value indicates the local storage + @object_store ||= model.try(store_serialization_column) || Store::LOCAL end # rubocop:disable Gitlab/ModuleWithInstanceVariables def object_store=(value) - @object_store = value || self.class.default_object_store + @object_store = value || Store::LOCAL @storage = storage_for(object_store) end # rubocop:enable Gitlab/ModuleWithInstanceVariables @@ -302,6 +295,15 @@ module ObjectStorage super end + def store!(new_file = nil) + # when direct upload is enabled, always store on remote storage + if self.class.object_store_enabled? && self.class.direct_upload_enabled? + self.object_store = Store::REMOTE + end + + super + end + private def schedule_background_upload? diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml index ac31977e1a9..4eebb59110a 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -21,7 +21,7 @@ .help-block Manage repository storage paths. Learn more in the = succeed "." do - = link_to "repository storages documentation", help_page_path("administration/repository_storages") + = link_to "repository storages documentation", help_page_path("administration/repository_storage_paths") .sub-section %h4 Circuit breaker .form-group diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 05c41082882..bbf0e0fb95c 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -126,6 +126,7 @@ GitLab %span.pull-right = Gitlab::VERSION + = "(#{Gitlab::REVISION})" %p GitLab Shell %span.pull-right diff --git a/app/views/dashboard/issues.atom.builder b/app/views/dashboard/issues.atom.builder index 70ec6bc6257..d7b6fb9a4a1 100644 --- a/app/views/dashboard/issues.atom.builder +++ b/app/views/dashboard/issues.atom.builder @@ -1,5 +1,5 @@ xml.title "#{current_user.name} issues" -xml.link href: url_for(params), rel: "self", type: "application/atom+xml" +xml.link href: url_for(safe_params), rel: "self", type: "application/atom+xml" xml.link href: issues_dashboard_url, rel: "alternate", type: "text/html" xml.id issues_dashboard_url xml.updated @issues.first.updated_at.xmlschema if @issues.reorder(nil).any? diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml index bb472b4c900..4bf04dadf01 100644 --- a/app/views/dashboard/issues.html.haml +++ b/app/views/dashboard/issues.html.haml @@ -2,12 +2,12 @@ - page_title _("Issues") - @breadcrumb_link = issues_dashboard_path(assignee_id: current_user.id) = content_for :meta_tags do - = auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{current_user.name} issues") + = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{current_user.name} issues") .top-area = render 'shared/issuable/nav', type: :issues, display_count: !@no_filters_set .nav-controls - = link_to params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe' do + = link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe' do = icon('rss') = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", with_feature_enabled: 'issues', type: :issues diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index 8680ec2e298..646e89e9bd1 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -7,7 +7,7 @@ - unless expanded - diff_data = { lines_path: project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion) } -.diff-file.file-holder{ class: diff_file_class, data: diff_data } +.diff-file.file-holder.js-lazy-load-discussion{ class: diff_file_class, data: diff_data } .js-file-title.file-title.file-title-flex-parent .file-header-content = render "projects/diffs/file_header", diff_file: diff_file, url: discussion_path(discussion), show_toggle: false @@ -28,8 +28,11 @@ %tr.line_holder.line-holder-placeholder %td.old_line.diff-line-num %td.new_line.diff-line-num - %td.line_content + %td.line_content.js-success-lazy-load .js-code-placeholder + %td.js-error-lazy-load-diff.hidden.diff-loading-error-block + - button = button_tag(_("Try again"), class: "btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button") + = _("Unable to load the diff. %{button_try_again}").html_safe % { button_try_again: button} = render "discussions/diff_discussion", discussions: [discussion], expanded: true - else - partial = (diff_file.new_file? || diff_file.deleted_file?) ? 'single_image_diff' : 'replaced_image_diff' diff --git a/app/views/groups/issues.atom.builder b/app/views/groups/issues.atom.builder index a239ea8caf0..2a385b661e5 100644 --- a/app/views/groups/issues.atom.builder +++ b/app/views/groups/issues.atom.builder @@ -1,5 +1,5 @@ xml.title "#{@group.name} issues" -xml.link href: url_for(params), rel: "self", type: "application/atom+xml" +xml.link href: url_for(safe_params), rel: "self", type: "application/atom+xml" xml.link href: issues_group_url, rel: "alternate", type: "text/html" xml.id issues_group_url xml.updated @issues.first.updated_at.xmlschema if @issues.reorder(nil).any? diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index 36df03302e8..bbfbea4ac7a 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -1,6 +1,6 @@ - page_title "Issues" = content_for :meta_tags do - = auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@group.name} issues") + = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues") - if group_issues_count(state: 'all').zero? = render 'shared/empty_states/issues', project_select_button: true diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb index 198f30a1dc4..8e20c4a4b2a 100644 --- a/app/views/layouts/mailer.text.erb +++ b/app/views/layouts/mailer.text.erb @@ -1,4 +1,4 @@ <%= yield -%> ---- +-- <%# signature marker %> You're receiving this email because of your account on <%= Gitlab.config.gitlab.host %>. diff --git a/app/views/layouts/notify.text.erb b/app/views/layouts/notify.text.erb index de48f548a1b..9dc490efa9a 100644 --- a/app/views/layouts/notify.text.erb +++ b/app/views/layouts/notify.text.erb @@ -1,6 +1,6 @@ <%= yield -%> ---- +-- <%# signature marker %> <% if @target_url -%> <% if @reply_by_email -%> <%= "Reply to this email directly or view it on GitLab: #{@target_url}" -%> diff --git a/app/views/notify/issue_due_email.html.haml b/app/views/notify/issue_due_email.html.haml new file mode 100644 index 00000000000..e81144b8fcb --- /dev/null +++ b/app/views/notify/issue_due_email.html.haml @@ -0,0 +1,12 @@ +%p.details + #{link_to @issue.author_name, user_url(@issue.author)}'s issue is due soon. + +- if @issue.assignees.any? + %p + Assignee: #{@issue.assignee_list} +%p + This issue is due on: #{@issue.due_date.to_s(:medium)} + +- if @issue.description + %div + = markdown(@issue.description, pipeline: :email, author: @issue.author) diff --git a/app/views/notify/issue_due_email.text.erb b/app/views/notify/issue_due_email.text.erb new file mode 100644 index 00000000000..3c7a57a8a2e --- /dev/null +++ b/app/views/notify/issue_due_email.text.erb @@ -0,0 +1,7 @@ +The following issue is due on <%= @issue.due_date %>: + +Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %> +Author: <%= @issue.author_name %> +Assignee: <%= @issue.assignee_list %> + +<%= @issue.description %> diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 1bd10018b40..d1eae05c46c 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -20,7 +20,7 @@ - else %p Download the Google Authenticator application from App Store or Google Play Store and scan this code. - More information is available in the #{link_to('documentation', help_page_path('profile/two_factor_authentication'))}. + More information is available in the #{link_to('documentation', help_page_path('user/profile/account/two_factor_authentication'))}. .row.append-bottom-10 .col-md-4 = raw @qr_code diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml index 9c760c81527..b9663bbba15 100644 --- a/app/views/projects/blob/_viewer.html.haml +++ b/app/views/projects/blob/_viewer.html.haml @@ -4,7 +4,7 @@ - load_async = local_assigns.fetch(:load_async, viewer.load_async? && render_error.nil?) - external_embed = local_assigns.fetch(:external_embed, false) -- viewer_url = local_assigns.fetch(:viewer_url) { url_for(params.merge(viewer: viewer.type, format: :json)) } if load_async +- viewer_url = local_assigns.fetch(:viewer_url) { url_for(safe_params.merge(viewer: viewer.type, format: :json)) } if load_async .blob-viewer{ data: { type: viewer.type, rich_type: rich_type, url: viewer_url }, class: ('hidden' if hidden) } - if render_error = render 'projects/blob/render_error', viewer: viewer diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 71176acd12d..d0c01f95cb7 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -29,7 +29,7 @@ = s_('Branches|Cant find HEAD commit for this branch') - if branch.name != @repository.root_ref - .divergence-graph{ title: s_('%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead') % { number_commits_behind: diverging_count_label(number_commits_behind), + .divergence-graph.hidden-xs{ title: s_('%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead') % { number_commits_behind: diverging_count_label(number_commits_behind), default_branch: @repository.root_ref, number_commits_ahead: diverging_count_label(number_commits_ahead) } } .graph-side diff --git a/app/views/projects/clusters/new.html.haml b/app/views/projects/clusters/new.html.haml index ebb7d247125..e004966bdcc 100644 --- a/app/views/projects/clusters/new.html.haml +++ b/app/views/projects/clusters/new.html.haml @@ -8,6 +8,6 @@ %h4.prepend-top-0= s_('ClusterIntegration|Choose how to set up Kubernetes cluster integration') %p= s_('ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab') - = link_to s_('ClusterIntegration|Create on GKE'), gcp_new_namespace_project_clusters_path(@project.namespace, @project), class: 'btn append-bottom-20' + = link_to s_('ClusterIntegration|Create on Google Kubernetes Engine'), gcp_new_namespace_project_clusters_path(@project.namespace, @project), class: 'btn append-bottom-20' %p= s_('ClusterIntegration|Enter the details for an existing Kubernetes cluster') = link_to s_('ClusterIntegration|Add an existing Kubernetes cluster'), user_new_namespace_project_clusters_path(@project.namespace, @project), class: 'btn append-bottom-20' diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 289bfdd69bc..3fd0fa348b3 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -22,7 +22,7 @@ = author_avatar(commit, size: 36) .commit-detail.flex-list - .commit-content + .commit-content.qa-commit-content - if view_details && merge_request = link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: "commit-row-message item-title" - else diff --git a/app/views/projects/diffs/_collapsed.html.haml b/app/views/projects/diffs/_collapsed.html.haml index 8772bd4705f..5762f4d86d7 100644 --- a/app/views/projects/diffs/_collapsed.html.haml +++ b/app/views/projects/diffs/_collapsed.html.haml @@ -1,5 +1,5 @@ - diff_file = viewer.diff_file -- url = url_for(params.merge(action: :diff_for_path, old_path: diff_file.old_path, new_path: diff_file.new_path, file_identifier: diff_file.file_identifier)) +- url = url_for(safe_params.merge(action: :diff_for_path, old_path: diff_file.old_path, new_path: diff_file.new_path, file_identifier: diff_file.file_identifier)) .nothing-here-block.diff-collapsed{ data: { diff_for_path: url } } This diff is collapsed. %a.click-to-expand Click to expand it. diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index b15fe514a08..2f69da593cd 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -22,7 +22,7 @@ %hr %p - - link_to_auto_devops_settings = link_to(s_('AutoDevOps|enable Auto DevOps (Beta)'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings')) + - link_to_auto_devops_settings = link_to(s_('AutoDevOps|enable Auto DevOps (Beta)'), project_settings_ci_cd_path(@project, anchor: 'autodevops-settings')) - link_to_add_kubernetes_cluster = link_to(s_('AutoDevOps|add a Kubernetes cluster'), new_project_cluster_path(@project)) = s_('AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}.').html_safe % { link_to_auto_devops_settings: link_to_auto_devops_settings, link_to_add_kubernetes_cluster: link_to_add_kubernetes_cluster } diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 0c58dd60e2c..e27f5658e87 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -26,7 +26,7 @@ - if issue.milestone %span.issuable-milestone.hidden-xs - = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 1, toggle: 'tooltip', title: issuable_milestone_tooltip_title(issue) } do + = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 1, toggle: 'tooltip', title: milestone_tooltip_due_date(issue.milestone) } do = icon('clock-o') = issue.milestone.title - if issue.due_date diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml index dd1a836fa20..297b928f020 100644 --- a/app/views/projects/issues/_nav_btns.html.haml +++ b/app/views/projects/issues/_nav_btns.html.haml @@ -1,4 +1,4 @@ -= link_to params.merge(rss_url_options), class: 'btn btn-default append-right-10 has-tooltip', title: 'Subscribe' do += link_to safe_params.merge(rss_url_options), class: 'btn btn-default append-right-10 has-tooltip', title: 'Subscribe' do = icon('rss') - if @can_bulk_update = button_tag "Edit issues", class: "btn btn-default append-right-10 js-bulk-update-toggle" diff --git a/app/views/projects/issues/index.atom.builder b/app/views/projects/issues/index.atom.builder index 4029926f373..6330245954e 100644 --- a/app/views/projects/issues/index.atom.builder +++ b/app/views/projects/issues/index.atom.builder @@ -1,5 +1,5 @@ xml.title "#{@project.name} issues" -xml.link href: url_for(params), rel: "self", type: "application/atom+xml" +xml.link href: url_for(safe_params), rel: "self", type: "application/atom+xml" xml.link href: project_issues_url(@project), rel: "alternate", type: "text/html" xml.id project_issues_url(@project) xml.updated @issues.first.updated_at.xmlschema if @issues.reorder(nil).any? diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index c427a9eedc2..1e7737aeb97 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -5,7 +5,7 @@ - new_issue_email = @project.new_issuable_address(current_user, 'issue') = content_for :meta_tags do - = auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@project.name} issues") + = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} issues") - if project_issues(@project).exists? %div{ class: (container_class) } diff --git a/app/views/projects/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml index 0b57ebedebd..7f0bef5ede0 100644 --- a/app/views/projects/jobs/_sidebar.html.haml +++ b/app/views/projects/jobs/_sidebar.html.haml @@ -1,15 +1,8 @@ %aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } } .sidebar-container .blocks-container - .block - %strong.inline.prepend-top-8 - = @build.name - - if can?(current_user, :update_build, @build) && @build.retryable? - = link_to "Retry", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'js-retry-button pull-right btn btn-inverted-secondary btn-retry visible-md-block visible-lg-block', method: :post - %a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-build-toggle{ href: "#", 'aria-label': 'Toggle Sidebar', role: 'button' } - = icon('angle-double-right') - #js-details-block-vue + #js-details-block-vue{ data: { can_user_retry: can?(current_user, :update_build, @build) && @build.retryable? } } - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?) .block diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index a94267deeb2..027a9ff1416 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -23,7 +23,7 @@ - if merge_request.milestone %span.issuable-milestone.hidden-xs - = link_to project_merge_requests_path(merge_request.project, milestone_title: merge_request.milestone.title), data: { html: 1, toggle: 'tooltip', title: issuable_milestone_tooltip_title(merge_request) } do + = link_to project_merge_requests_path(merge_request.project, milestone_title: merge_request.milestone.title), data: { html: 1, toggle: 'tooltip', title: milestone_tooltip_due_date(merge_request.milestone) } do = icon('clock-o') = merge_request.milestone.title - if merge_request.target_project.default_branch != merge_request.target_branch diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml index 9d5cebdda53..f81db9b4e28 100644 --- a/app/views/projects/merge_requests/creations/_new_compare.html.haml +++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml @@ -3,7 +3,7 @@ = form_for [@project.namespace.becomes(Namespace), @project, @merge_request], url: project_new_merge_request_path(@project), method: :get, html: { class: "merge-request-form form-inline js-requires-input" } do |f| .hide.alert.alert-danger.mr-compare-errors - .merge-request-branches.js-merge-request-new-compare.row{ 'data-target-project-url': project_new_merge_request_update_branches_path(@source_project), 'data-source-branch-url': project_new_merge_request_branch_from_path(@source_project), 'data-target-branch-url': project_new_merge_request_branch_to_path(@source_project) } + .js-merge-request-new-compare.row{ 'data-target-project-url': project_new_merge_request_update_branches_path(@source_project), 'data-source-branch-url': project_new_merge_request_branch_from_path(@source_project), 'data-target-branch-url': project_new_merge_request_branch_to_path(@source_project) } .col-md-6 .panel.panel-default.panel-new-merge-request .panel-heading diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml index 376ac377562..68780cedeb1 100644 --- a/app/views/projects/merge_requests/creations/_new_submit.html.haml +++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml @@ -26,16 +26,16 @@ - else %ul.merge-request-tabs.nav-links.no-top.no-bottom %li.commits-tab.active - = link_to url_for(params), data: {target: 'div#commits', action: 'new', toggle: 'tab'} do + = link_to url_for(safe_params), data: {target: 'div#commits', action: 'new', toggle: 'tab'} do Commits %span.badge= @commits.size - if @pipelines.any? %li.builds-tab - = link_to url_for(params.merge(action: 'pipelines')), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tab'} do + = link_to url_for(safe_params.merge(action: 'pipelines')), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tab'} do Pipelines %span.badge= @pipelines.size %li.diffs-tab - = link_to url_for(params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tab'} do + = link_to url_for(safe_params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tab'} do Changes %span.badge= @merge_request.diff_size @@ -46,7 +46,7 @@ -# This tab is always loaded via AJAX - if @pipelines.any? #pipelines.pipelines.tab-pane - = render 'projects/merge_requests/pipelines', endpoint: url_for(params.merge(action: 'pipelines', format: :json)), disable_initialization: true + = render 'projects/merge_requests/pipelines', endpoint: url_for(safe_params.merge(action: 'pipelines', format: :json)), disable_initialization: true .mr-loading-status = spinner diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index 877101b05ca..8f2142af2ce 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -1,24 +1,25 @@ - breadcrumb_title "Pipelines" -- page_title "New Pipeline" +- page_title = s_("Pipeline|Run Pipeline") %h3.page-title - New Pipeline + = s_("Pipeline|Run Pipeline") %hr = form_for @pipeline, as: :pipeline, url: project_pipelines_path(@project), html: { id: "new-pipeline-form", class: "form-horizontal js-new-pipeline-form js-requires-input" } do |f| = form_errors(@pipeline) .form-group - = f.label :ref, 'Create for', class: 'control-label' + = f.label :ref, s_('Pipeline|Run on'), class: 'control-label' .col-sm-10 = hidden_field_tag 'pipeline[ref]', params[:ref] || @project.default_branch = dropdown_tag(params[:ref] || @project.default_branch, options: { toggle_class: 'js-branch-select wide git-revision-dropdown-toggle', - filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: "Search branches", + filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: s_("Pipeline|Search branches"), data: { selected: params[:ref] || @project.default_branch, field_name: 'pipeline[ref]' } }) - .help-block Existing branch name, tag + .help-block + = s_("Pipeline|Existing branch name, tag") .form-actions - = f.submit 'Create pipeline', class: 'btn btn-create', tabindex: 3 - = link_to 'Cancel', project_pipelines_path(@project), class: 'btn btn-cancel' + = f.submit s_('Pipeline|Run pipeline'), class: 'btn btn-success', tabindex: 3 + = link_to 'Cancel', project_pipelines_path(@project), class: 'btn btn-default pull-right' -# haml-lint:disable InlineJavaScript %script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml index 98d56a3e5c5..12ccae10260 100644 --- a/app/views/projects/protected_branches/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml @@ -7,8 +7,8 @@ - content_for :push_access_levels do .push_access_levels-container = dropdown_tag('Select', - options: { toggle_class: 'js-allowed-to-push wide', - dropdown_class: 'dropdown-menu-selectable capitalize-header', + options: { toggle_class: 'js-allowed-to-push qa-allowed-to-push-select wide', + dropdown_class: 'dropdown-menu-selectable qa-allowed-to-push-dropdown capitalize-header', data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }}) = render 'projects/protected_branches/shared/create_protected_branch' diff --git a/app/views/projects/protected_branches/_update_protected_branch.html.haml b/app/views/projects/protected_branches/_update_protected_branch.html.haml index c61b2951e1e..98363f2018a 100644 --- a/app/views/projects/protected_branches/_update_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_update_protected_branch.html.haml @@ -6,5 +6,5 @@ %td = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level = dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header', + options: { toggle_class: 'js-allowed-to-push qa-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header', data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }}) diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml index 300055a4207..d1ed438eb21 100644 --- a/app/views/projects/protected_branches/shared/_branches_list.html.haml +++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml @@ -1,4 +1,4 @@ -.protected-branches-list.js-protected-branches-list +.protected-branches-list.js-protected-branches-list.qa-protected-branches-list - if @protected_branches.empty? .panel-heading %h3.panel-title diff --git a/app/views/projects/protected_branches/shared/_dropdown.html.haml b/app/views/projects/protected_branches/shared/_dropdown.html.haml index 74435236808..b3d6068039a 100644 --- a/app/views/projects/protected_branches/shared/_dropdown.html.haml +++ b/app/views/projects/protected_branches/shared/_dropdown.html.haml @@ -1,8 +1,8 @@ = f.hidden_field(:name) = dropdown_tag('Select branch or create wildcard', - options: { toggle_class: 'js-protected-branch-select js-filter-submit wide git-revision-dropdown-toggle', - filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: "Search protected branches", + options: { toggle_class: 'js-protected-branch-select js-filter-submit wide git-revision-dropdown-toggle qa-protected-branch-select', + filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown qa-protected-branch-dropdown", placeholder: "Search protected branches", footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_branch_name], diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml index 55d87c35a80..fd5c1aa342a 100644 --- a/app/views/projects/protected_branches/shared/_index.html.haml +++ b/app/views/projects/protected_branches/shared/_index.html.haml @@ -4,7 +4,7 @@ .settings-header %h4 Protected Branches - %button.btn.js-settings-toggle{ type: 'button' } + %button.btn.js-settings-toggle.qa-expand-protected-branches{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Keep stable branches secure and force developers to use merge requests. diff --git a/app/views/projects/protected_branches/shared/_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_protected_branch.html.haml index 10b81e42572..f5b21f0e887 100644 --- a/app/views/projects/protected_branches/shared/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_protected_branch.html.haml @@ -2,7 +2,7 @@ %tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } } %td - %span.ref-name= protected_branch.name + %span.ref-name.qa-protected-branch-name= protected_branch.name - if @project.root_ref?(protected_branch.name) %span.label.label-info.prepend-left-5 default diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml new file mode 100644 index 00000000000..7b410101c05 --- /dev/null +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -0,0 +1,40 @@ +.row.prepend-top-default + .col-lg-12 + = form_for @project, url: project_settings_ci_cd_path(@project) do |f| + = form_errors(@project) + %fieldset.builds-feature + .form-group + - message = auto_devops_warning_message(@project) + - ci_file_formatted = '<code>.gitlab-ci.yml</code>'.html_safe + - if message + %p.settings-message.text-center + = message.html_safe + = f.fields_for :auto_devops_attributes, @auto_devops do |form| + .radio + = form.label :enabled_true do + = form.radio_button :enabled, 'true' + %strong= s_('CICD|Enable Auto DevOps') + %br + = s_('CICD|The Auto DevOps pipeline configuration will be used when there is no %{ci_file} in the project.').html_safe % { ci_file: ci_file_formatted } + + .radio + = form.label :enabled_false do + = form.radio_button :enabled, 'false' + %strong= s_('CICD|Disable Auto DevOps') + %br + = s_('CICD|An explicit %{ci_file} needs to be specified before you can begin using Continuous Integration and Delivery.').html_safe % { ci_file: ci_file_formatted } + + .radio + = form.label :enabled_ do + = form.radio_button :enabled, '' + %strong= s_('CICD|Instance default (%{state})') % { state: "#{Gitlab::CurrentSettings.auto_devops_enabled? ? _('enabled') : _('disabled')}" } + %br + = s_('CICD|Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific %{ci_file}.').html_safe % { ci_file: ci_file_formatted } + + = form.label :domain, class:"prepend-top-10" do + = _('Domain') + = form.text_field :domain, class: 'form-control', placeholder: 'domain.com' + .help-block + = s_('CICD|You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages.') + + = f.submit 'Save changes', class: "btn btn-success prepend-top-15" diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index 20868f9ba5d..80c226ad273 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -3,44 +3,6 @@ = form_for @project, url: project_settings_ci_cd_path(@project) do |f| = form_errors(@project) %fieldset.builds-feature - .form-group - %h5 Auto DevOps (Beta) - %p - Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration. - = link_to 'Learn more about Auto DevOps', help_page_path('topics/autodevops/index.md') - - message = auto_devops_warning_message(@project) - - if message - %p.settings-message.text-center - = message.html_safe - = f.fields_for :auto_devops_attributes, @auto_devops do |form| - .radio - = form.label :enabled_true do - = form.radio_button :enabled, 'true' - %strong Enable Auto DevOps - %br - %span.descr - The Auto DevOps pipeline configuration will be used when there is no <code>.gitlab-ci.yml</code> in the project. - - .radio - = form.label :enabled_false do - = form.radio_button :enabled, 'false' - %strong Disable Auto DevOps - %br - %span.descr - An explicit <code>.gitlab-ci.yml</code> needs to be specified before you can begin using Continuous Integration and Delivery. - - .radio - = form.label :enabled_ do - = form.radio_button :enabled, '' - %strong Instance default (#{Gitlab::CurrentSettings.auto_devops_enabled? ? 'enabled' : 'disabled'}) - %br - %span.descr - Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific <code>.gitlab-ci.yml</code>. - %p - You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages. - = form.text_field :domain, class: 'form-control', placeholder: 'domain.com' - - %hr .form-group.append-bottom-default.js-secret-runner-token = f.label :runners_token, "Runner token", class: 'label-light' .form-control.js-secret-value-placeholder diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 09268c9943b..5f596a019f7 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -12,10 +12,22 @@ %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p - Update your CI/CD configuration, like job timeout or Auto DevOps. + Access your runner token, customize your pipeline configuration, and view your pipeline status and coverage report. .settings-content = render 'form' +%section.settings#autodevops-settings.no-animate{ class: ('expanded' if expanded) } + .settings-header + %h4 + = s_('CICD|Auto DevOps (Beta)') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = s_('CICD|Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration.') + = link_to s_('CICD|Learn more about Auto DevOps'), help_page_path('topics/autodevops/index.md') + .settings-content + = render 'autodevops_form' + %section.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml index e9ac192f5f7..d3fa324e460 100644 --- a/app/views/shared/_auto_devops_callout.html.haml +++ b/app/views/shared/_auto_devops_callout.html.haml @@ -9,7 +9,7 @@ - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer') = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link } .banner-buttons - = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'), class: 'btn js-close-callout' + = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'), class: 'btn js-close-callout' %button.btn-transparent.banner-close.close.js-close-callout{ type: 'button', 'aria-label' => 'Dismiss Auto DevOps box' } diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml index 4c8c92d722a..f1c39b9e923 100644 --- a/app/views/shared/_ref_switcher.html.haml +++ b/app/views/shared/_ref_switcher.html.haml @@ -8,8 +8,8 @@ - @options && @options.each do |key, value| = hidden_field_tag key, value, id: nil .dropdown - = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_project_path(@project, sort: 'updated_desc'), field_name: 'ref', submit_form_on_click: true, visit: true }, { toggle_class: "js-project-refs-dropdown" } - .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown.dropdown-menu-paging{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) } + = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_project_path(@project, sort: 'updated_desc'), field_name: 'ref', submit_form_on_click: true, visit: true }, { toggle_class: "js-project-refs-dropdown qa-branches-select" } + .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown.dropdown-menu-paging.qa-branches-dropdown{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) } .dropdown-page-one = dropdown_title _("Switch branch/tag") = dropdown_filter _("Search branches and tags") diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml index 87e6b52f46e..1c73534c642 100644 --- a/app/views/shared/boards/components/sidebar/_labels.html.haml +++ b/app/views/shared/boards/components/sidebar/_labels.html.haml @@ -4,7 +4,7 @@ - if can_admin_issue? = icon("spinner spin", class: "block-loading") = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right" - .value.issuable-show-labels + .value.issuable-show-labels.dont-hide %span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" } None %a{ href: "#", diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 975b9cb4729..093033775a9 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -7,7 +7,7 @@ - if current_user %span.issuable-header-text.hide-collapsed.pull-left = _('Todo') - %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" } + %a.gutter-toggle.pull-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => "Toggle sidebar", title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left' } } = sidebar_gutter_toggle_icon - if current_user = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable @@ -19,12 +19,11 @@ .block.assignee = render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable, signed_in: current_user.present? .block.milestone - .sidebar-collapsed-icon + .sidebar-collapsed-icon.has-tooltip{ title: milestone_tooltip_title(issuable.milestone), data: { container: 'body', html: 1, placement: 'left' } } = icon('clock-o', 'aria-hidden': 'true') %span.milestone-title - if issuable.milestone - %span.has-tooltip{ title: "#{issuable.milestone.title}<br>#{milestone_tooltip_title(issuable.milestone)}", data: { container: 'body', html: 1, placement: 'left' } } - = issuable.milestone.title + = issuable.milestone.title - else = _('None') .title.hide-collapsed @@ -34,7 +33,7 @@ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' .value.hide-collapsed - if issuable.milestone - = link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_tooltip_title(issuable.milestone), data: { container: "body", html: 1 } + = link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_tooltip_due_date(issuable.milestone), data: { container: "body", html: 1 } - else %span.no-value = _('None') @@ -50,7 +49,7 @@ = icon('spinner spin', 'aria-hidden': 'true') - if issuable.has_attribute?(:due_date) .block.due_date - .sidebar-collapsed-icon + .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 1 }, title: sidebar_due_date_tooltip_label(issuable) } = icon('calendar', 'aria-hidden': 'true') %span.js-due-date-sidebar-value = issuable.due_date.try(:to_s, :medium) || 'None' @@ -96,7 +95,7 @@ = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' - .value.issuable-show-labels.hide-collapsed{ class: ("has-labels" if selected_labels.any?) } + .value.issuable-show-labels.dont-hide.hide-collapsed{ class: ("has-labels" if selected_labels.any?) } - if selected_labels.any? - selected_labels.each do |label| = link_to_label(label, subject: issuable.project, type: issuable.to_ability_name) diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index 304df38a096..21006a76b28 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -4,7 +4,7 @@ = _('Assignee') = icon('spinner spin') - else - .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: sidebar_assignee_tooltip_label(issuable) } - if issuable.assignee = link_to_member(@project, issuable.assignee, size: 24) - else diff --git a/app/views/shared/issuable/_sidebar_todo.html.haml b/app/views/shared/issuable/_sidebar_todo.html.haml index b77e104c072..74327fb1ba8 100644 --- a/app/views/shared/issuable/_sidebar_todo.html.haml +++ b/app/views/shared/issuable/_sidebar_todo.html.haml @@ -1,11 +1,11 @@ - is_collapsed = local_assigns.fetch(:is_collapsed, false) -- mark_content = is_collapsed ? icon('check-square', class: 'todo-undone') : _('Mark done') +- mark_content = is_collapsed ? icon('check-square', class: 'todo-undone') : _('Mark todo as done') - todo_content = is_collapsed ? icon('plus-square') : _('Add todo') %button.issuable-todo-btn.js-issuable-todo{ type: 'button', class: (is_collapsed ? 'btn-blank sidebar-collapsed-icon dont-change-state has-tooltip' : 'btn btn-default issuable-header-btn pull-right'), - title: (todo.nil? ? _('Add todo') : _('Mark done')), - 'aria-label' => (todo.nil? ? _('Add todo') : _('Mark done')), + title: (todo.nil? ? _('Add todo') : _('Mark todo as done')), + 'aria-label' => (todo.nil? ? _('Add todo') : _('Mark todo as done')), data: issuable_todo_button_data(issuable, todo, is_collapsed) } %span.issuable-todo-inner.js-issuable-todo-inner< - if todo diff --git a/app/views/shared/issuable/form/_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_merge_request_assignee.html.haml index bf8613b0f0d..d7740eddcca 100644 --- a/app/views/shared/issuable/form/_merge_request_assignee.html.haml +++ b/app/views/shared/issuable/form/_merge_request_assignee.html.haml @@ -1,6 +1,6 @@ - merge_request = issuable .block.assignee - .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (merge_request.assignee.name if merge_request.assignee) } + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: sidebar_assignee_tooltip_label(issuable) } - if merge_request.assignee = link_to_member(@project, merge_request.assignee, size: 24) - else diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index ee134480705..8e9a1b56bb8 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -4,12 +4,8 @@ %aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix", "always-show-toggle" => true }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } .issuable-sidebar.milestone-sidebar .block.milestone-progress.issuable-sidebar-header - %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" } + %a.gutter-toggle.pull-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => "Toggle sidebar", title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left' } } = sidebar_gutter_toggle_icon - - .sidebar-collapsed-icon - %span== #{milestone.percent_complete(current_user)}% - = milestone_progress_bar(milestone) .title.hide-collapsed %strong.bold== #{milestone.percent_complete(current_user)}% %span.hide-collapsed @@ -17,6 +13,11 @@ .value.hide-collapsed = milestone_progress_bar(milestone) + .block.milestone-progress.hide-expanded + .sidebar-collapsed-icon.has-tooltip{ title: milestone_progress_tooltip_text(milestone), data: { container: 'body', html: 1, placement: 'left' } } + %span== #{milestone.percent_complete(current_user)}% + = milestone_progress_bar(milestone) + .block.start_date.hide-collapsed .title Start date @@ -35,19 +36,25 @@ %span.collapsed-milestone-date - if milestone.start_date && milestone.due_date - if milestone.start_date.year == milestone.due_date.year - .milestone-date= milestone.start_date.strftime('%b %-d') + .milestone-date.has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 1, placement: 'left' } } + = milestone.start_date.strftime('%b %-d') - else - .milestone-date= milestone.start_date.strftime('%b %-d %Y') + .milestone-date.has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 1, placement: 'left' } } + = milestone.start_date.strftime('%b %-d %Y') .date-separator - - .due_date= milestone.due_date.strftime('%b %-d %Y') + .due_date.has-tooltip{ title: milestone_time_for(milestone.due_date, :end), data: { container: 'body', html: 1, placement: 'left' } } + = milestone.due_date.strftime('%b %-d %Y') - elsif milestone.start_date From - .milestone-date= milestone.start_date.strftime('%b %-d %Y') + .milestone-date.has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 1, placement: 'left' } } + = milestone.start_date.strftime('%b %-d %Y') - elsif milestone.due_date Until - .milestone-date= milestone.due_date.strftime('%b %-d %Y') + .milestone-date.has-tooltip{ title: milestone_time_for(milestone.due_date, :end), data: { container: 'body', html: 1, placement: 'left' } } + = milestone.due_date.strftime('%b %-d %Y') - else - None + .has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 1, placement: 'left' } } + None .title.hide-collapsed Due date - if @project && can?(current_user, :admin_milestone, @project) @@ -58,14 +65,14 @@ %span.bold= milestone.due_date.to_s(:medium) - else %span.no-value No due date - - remaining_days = milestone_remaining_days(milestone) + - remaining_days = remaining_days_in_words(milestone) - if remaining_days.present? = surround '(', ')' do %span.remaining-days= remaining_days - if !project || can?(current_user, :read_issue, project) .block.issues - .sidebar-collapsed-icon + .sidebar-collapsed-icon.has-tooltip{ title: milestone_issues_tooltip_text(milestone), data: { container: 'body', html: 1, placement: 'left' } } %strong = custom_icon('issues') %span= milestone.issues_visible_to_user(current_user).count @@ -93,7 +100,7 @@ = icon('spinner spin') .block.merge-requests - .sidebar-collapsed-icon + .sidebar-collapsed-icon.has-tooltip{ title: milestone_merge_requests_tooltip_text(milestone), data: { container: 'body', html: 1, placement: 'left' } } %strong = custom_icon('mr_bold') %span= milestone.merge_requests.count diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 9a11cdb121e..9aea3bad27b 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -18,6 +18,7 @@ - cronjob:stuck_import_jobs - cronjob:stuck_merge_jobs - cronjob:trending_projects +- cronjob:issue_due_scheduler - gcp_cluster:cluster_install_app - gcp_cluster:cluster_provision @@ -39,6 +40,8 @@ - github_importer:github_import_stage_import_pull_requests - github_importer:github_import_stage_import_repository +- mail_scheduler:mail_scheduler_issue_due + - object_storage_upload - object_storage:object_storage_background_move - object_storage:object_storage_migrate_uploads diff --git a/app/workers/concerns/mail_scheduler_queue.rb b/app/workers/concerns/mail_scheduler_queue.rb new file mode 100644 index 00000000000..9df55ad9522 --- /dev/null +++ b/app/workers/concerns/mail_scheduler_queue.rb @@ -0,0 +1,7 @@ +module MailSchedulerQueue + extend ActiveSupport::Concern + + included do + queue_namespace :mail_scheduler + end +end diff --git a/app/workers/issue_due_scheduler_worker.rb b/app/workers/issue_due_scheduler_worker.rb new file mode 100644 index 00000000000..16ab5d069e0 --- /dev/null +++ b/app/workers/issue_due_scheduler_worker.rb @@ -0,0 +1,10 @@ +class IssueDueSchedulerWorker + include ApplicationWorker + include CronjobQueue + + def perform + project_ids = Issue.opened.due_tomorrow.group(:project_id).pluck(:project_id).map { |id| [id] } + + MailScheduler::IssueDueWorker.bulk_perform_async(project_ids) + end +end diff --git a/app/workers/mail_scheduler/issue_due_worker.rb b/app/workers/mail_scheduler/issue_due_worker.rb new file mode 100644 index 00000000000..b06079d68ca --- /dev/null +++ b/app/workers/mail_scheduler/issue_due_worker.rb @@ -0,0 +1,14 @@ +module MailScheduler + class IssueDueWorker + include ApplicationWorker + include MailSchedulerQueue + + def perform(project_id) + notification_service = NotificationService.new + + Issue.opened.due_tomorrow.in_projects(project_id).preload(:project).find_each do |issue| + notification_service.issue_due(issue) + end + end + end +end diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 3909dbf7d7f..f88b3fdbfb1 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -33,7 +33,7 @@ class PostReceive unless @user log("Triggered hook for non-existing user \"#{post_received.identifier}\"") - return false + return false # rubocop:disable Cop/AvoidReturnFromBlocks end if Gitlab::Git.tag_ref?(ref) diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb index fb26fa4c515..7ebf69bdc39 100644 --- a/app/workers/stuck_ci_jobs_worker.rb +++ b/app/workers/stuck_ci_jobs_worker.rb @@ -38,7 +38,7 @@ class StuckCiJobsWorker def drop_stuck(status, timeout) search(status, timeout) do |build| - return unless build.stuck? + break unless build.stuck? drop_build :stuck, build, status, timeout end |