diff options
author | Martin Hanzel <mhanzel@gitlab.com> | 2019-06-05 10:18:12 +0200 |
---|---|---|
committer | Martin Hanzel <mhanzel@gitlab.com> | 2019-06-05 10:18:12 +0200 |
commit | 03c72e998dd8016ccda0a6b9515dd3fc0302978a (patch) | |
tree | 1f7e1623637de75c2807ef7d40f0feae08c687f3 /app | |
parent | 3aeea7fb0c1d6389df6d8643ef40dd54aa84d1a8 (diff) | |
parent | b560ce1e666733f12c65e8b9f659c89256c1775b (diff) | |
download | gitlab-ce-mh/notes-spec.tar.gz |
Merge branch 'master' into mh/notes-specmh/notes-spec
Diffstat (limited to 'app')
428 files changed, 4633 insertions, 2444 deletions
diff --git a/app/assets/images/favicon-yellow.png b/app/assets/images/favicon-yellow.png Binary files differindex 2d5289818b4..a80827808fc 100644 --- a/app/assets/images/favicon-yellow.png +++ b/app/assets/images/favicon-yellow.png diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index e583a8affd4..7cebb88f3a4 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -12,7 +12,7 @@ const Api = { groupProjectsPath: '/api/:version/groups/:id/projects.json', projectsPath: '/api/:version/projects.json', projectPath: '/api/:version/projects/:id', - projectLabelsPath: '/:namespace_path/:project_path/labels', + projectLabelsPath: '/:namespace_path/:project_path/-/labels', projectMergeRequestsPath: '/api/:version/projects/:id/merge_requests', projectMergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js index 96ee9f62ba4..3bbbaa86b51 100644 --- a/app/assets/javascripts/batch_comments/mixins/resolved_status.js +++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js @@ -1,10 +1,12 @@ +import { sprintf, __ } from '~/locale'; + export default { computed: { resolveButtonTitle() { - let title = 'Mark comment as resolved'; + let title = __('Mark comment as resolved'); if (this.resolvedBy) { - title = `Resolved by ${this.resolvedBy.name}`; + title = sprintf(__('Resolved by %{name}'), { name: this.resolvedBy.name }); } return title; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 670f66b005e..c8eb96a625c 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -37,7 +37,7 @@ export default class ShortcutsIssuable extends Shortcuts { } // Sanity check: Make sure the selected text comes from a discussion : it can either contain a message... - let foundMessage = !!documentFragment.querySelector('.md'); + let foundMessage = Boolean(documentFragment.querySelector('.md')); // ... Or come from a message if (!foundMessage) { diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue index 47a46502bff..1cbd31729cd 100644 --- a/app/assets/javascripts/boards/components/board_blank_state.vue +++ b/app/assets/javascripts/boards/components/board_blank_state.vue @@ -1,6 +1,5 @@ <script> /* global ListLabel */ -import _ from 'underscore'; import Cookies from 'js-cookie'; import boardsStore from '../stores/boards_store'; @@ -29,8 +28,6 @@ export default { }); }); - boardsStore.state.lists = _.sortBy(boardsStore.state.lists, 'position'); - // Save the labels gl.boardService .generateDefaultLists() diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index c9972d051aa..b1a8b13f3ac 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -142,8 +142,10 @@ export default { const card = this.$refs.issue[e.oldIndex]; card.showDetail = false; - boardsStore.moving.list = card.list; - boardsStore.moving.issue = boardsStore.moving.list.findIssue(+e.item.dataset.issueId); + + const { list } = card; + const issue = list.findIssue(Number(e.item.dataset.issueId)); + boardsStore.startMoving(list, issue); sortableStart(); }, diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index dc1bdc23b5e..63dc99db086 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -72,7 +72,7 @@ export default { // Need this because our jQuery very kindly disables buttons on ALL form submissions $(this.$refs.submitButton).enable(); - boardsStore.detail.issue = issue; + boardsStore.setIssueDetail(issue); boardsStore.detail.list = this.list; }) .catch(() => { diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 17de7b2cf1e..a8516f178fc 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -6,7 +6,6 @@ import Icon from '~/vue_shared/components/icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; -import eventHub from '../eventhub'; import IssueDueDate from './issue_due_date.vue'; import IssueTimeEstimate from './issue_time_estimate.vue'; import boardsStore from '../stores/boards_store'; @@ -136,23 +135,7 @@ export default { const labelTitle = encodeURIComponent(label.title); const filter = `label_name[]=${labelTitle}`; - this.applyFilter(filter); - }, - applyFilter(filter) { - const filterPath = boardsStore.filter.path.split('&'); - const filterIndex = filterPath.indexOf(filter); - - if (filterIndex === -1) { - filterPath.push(filter); - } else { - filterPath.splice(filterIndex, 1); - } - - boardsStore.filter.path = filterPath.join('&'); - - boardsStore.updateFiltersUrl(); - - eventHub.$emit('updateTokens'); + boardsStore.toggleFilter(filter); }, labelStyle(label) { return { diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue index 8e09e265cfb..defa1f75ba2 100644 --- a/app/assets/javascripts/boards/components/modal/index.vue +++ b/app/assets/javascripts/boards/components/modal/index.vue @@ -124,7 +124,7 @@ export default { data.issues.forEach(issueObj => { const issue = new ListIssue(issueObj); const foundSelectedIssue = ModalStore.findSelectedIssue(issue); - issue.selected = !!foundSelectedIssue; + issue.selected = Boolean(foundSelectedIssue); this.issues.push(issue); }); diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue index a2b8a0af236..4ab2b17301f 100644 --- a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue +++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue @@ -48,7 +48,7 @@ export default Vue.extend({ list.removeIssue(issue); }); - boardsStore.detail.issue = {}; + boardsStore.clearDetailIssue(); }, /** * Build the default patch request. diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 4995a8d9367..e9cab3e3bba 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,5 +1,4 @@ import $ from 'jquery'; -import _ from 'underscore'; import Vue from 'vue'; import Flash from '~/flash'; @@ -106,18 +105,23 @@ export default () => { gl.boardService .all() .then(res => res.data) - .then(data => { - data.forEach(board => { - const list = boardsStore.addList(board, this.defaultAvatar); - - if (list.type === 'closed') { - list.position = Infinity; - } else if (list.type === 'backlog') { - list.position = -1; + .then(lists => { + lists.forEach(listObj => { + let { position } = listObj; + if (listObj.list_type === 'closed') { + position = Infinity; + } else if (listObj.list_type === 'backlog') { + position = -1; } - }); - this.state.lists = _.sortBy(this.state.lists, 'position'); + boardsStore.addList( + { + ...listObj, + position, + }, + this.defaultAvatar, + ); + }); boardsStore.addBlankState(); this.loading = false; @@ -164,10 +168,10 @@ export default () => { }); } - boardsStore.detail.issue = newIssue; + boardsStore.setIssueDetail(newIssue); }, clearDetailIssue() { - boardsStore.detail.issue = {}; + boardsStore.clearDetailIssue(); }, toggleSubscription(id) { const { issue } = boardsStore.detail; diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 7e5d0e0f888..08aecfab8a4 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -37,8 +37,8 @@ class List { this.type = obj.list_type; const typeInfo = this.getTypeInfo(this.type); - this.preset = !!typeInfo.isPreset; - this.isExpandable = !!typeInfo.isExpandable; + this.preset = Boolean(typeInfo.isPreset); + this.isExpandable = Boolean(typeInfo.isExpandable); this.isExpanded = true; this.page = 1; this.loading = true; diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 70861fbf2b3..f72ab189015 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -8,6 +8,7 @@ import Cookies from 'js-cookie'; import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee'; import { getUrlParamsArray, parseBoolean } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; +import eventHub from '../eventhub'; const boardsStore = { disabled: false, @@ -45,7 +46,7 @@ const boardsStore = { }, addList(listObj, defaultAvatar) { const list = new List(listObj, defaultAvatar); - this.state.lists.push(list); + this.state.lists = _.sortBy([...this.state.lists, list], 'position'); return list; }, @@ -82,8 +83,6 @@ const boardsStore = { title: __('Welcome to your Issue Board!'), position: 0, }); - - this.state.lists = _.sortBy(this.state.lists, 'position'); }, removeBlankState() { this.removeList('blank'); @@ -111,6 +110,11 @@ const boardsStore = { }); listFrom.update(); }, + + startMoving(list, issue) { + Object.assign(this.moving, { list, issue }); + }, + moveIssueToList(listFrom, listTo, issue, newIndex) { const issueTo = listTo.findIssue(issue.id); const issueLists = issue.getLists(); @@ -185,9 +189,35 @@ const boardsStore = { findListByLabelId(id) { return this.state.lists.find(list => list.type === 'label' && list.label.id === id); }, + + toggleFilter(filter) { + const filterPath = this.filter.path.split('&'); + const filterIndex = filterPath.indexOf(filter); + + if (filterIndex === -1) { + filterPath.push(filter); + } else { + filterPath.splice(filterIndex, 1); + } + + this.filter.path = filterPath.join('&'); + + this.updateFiltersUrl(); + + eventHub.$emit('updateTokens'); + }, + updateFiltersUrl() { window.history.pushState(null, null, `?${this.filter.path}`); }, + + clearDetailIssue() { + this.setIssueDetail({}); + }, + + setIssueDetail(issueDetail) { + this.detail.issue = issueDetail; + }, }; BoardsStoreEE.initEESpecific(boardsStore); diff --git a/app/assets/javascripts/branches/branches_delete_modal.js b/app/assets/javascripts/branches/branches_delete_modal.js index f34496f84c6..f4c3fa185d8 100644 --- a/app/assets/javascripts/branches/branches_delete_modal.js +++ b/app/assets/javascripts/branches/branches_delete_modal.js @@ -23,7 +23,7 @@ class DeleteModal { const branchData = e.currentTarget.dataset; this.branchName = branchData.branchName || ''; this.deletePath = branchData.deletePath || ''; - this.isMerged = !!branchData.isMerged; + this.isMerged = Boolean(branchData.isMerged); this.updateModal(); } diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 561b6bdd9f1..bc2e71b99f2 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -1,5 +1,6 @@ import Visibility from 'visibilityjs'; import Vue from 'vue'; +import AccessorUtilities from '~/lib/utils/accessor'; import { GlToast } from '@gitlab/ui'; import PersistentUserCallout from '../persistent_user_callout'; import { s__, sprintf } from '../locale'; @@ -43,8 +44,10 @@ export default class Clusters { helpPath, ingressHelpPath, ingressDnsHelpPath, + clusterId, } = document.querySelector('.js-edit-cluster-form').dataset; + this.clusterId = clusterId; this.store = new ClustersStore(); this.store.setHelpPaths(helpPath, ingressHelpPath, ingressDnsHelpPath); this.store.setManagePrometheusPath(managePrometheusPath); @@ -69,6 +72,10 @@ export default class Clusters { this.errorContainer = document.querySelector('.js-cluster-error'); this.successContainer = document.querySelector('.js-cluster-success'); this.creatingContainer = document.querySelector('.js-cluster-creating'); + this.unreachableContainer = document.querySelector('.js-cluster-api-unreachable'); + this.authenticationFailureContainer = document.querySelector( + '.js-cluster-authentication-failure', + ); this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason'); this.successApplicationContainer = document.querySelector('.js-cluster-application-notice'); this.showTokenButton = document.querySelector('.js-show-cluster-token'); @@ -125,6 +132,13 @@ export default class Clusters { PersistentUserCallout.factory(callout); } + addBannerCloseHandler(el, status) { + el.querySelector('.js-close-banner').addEventListener('click', () => { + el.classList.add('hidden'); + this.setBannerDismissedState(status, true); + }); + } + addListeners() { if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken); eventHub.$on('installApplication', this.installApplication); @@ -133,6 +147,9 @@ export default class Clusters { eventHub.$on('saveKnativeDomain', data => this.saveKnativeDomain(data)); eventHub.$on('setKnativeHostname', data => this.setKnativeHostname(data)); eventHub.$on('uninstallApplication', data => this.uninstallApplication(data)); + // Add event listener to all the banner close buttons + this.addBannerCloseHandler(this.unreachableContainer, 'unreachable'); + this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure'); } removeListeners() { @@ -205,6 +222,8 @@ export default class Clusters { this.errorContainer.classList.add('hidden'); this.successContainer.classList.add('hidden'); this.creatingContainer.classList.add('hidden'); + this.unreachableContainer.classList.add('hidden'); + this.authenticationFailureContainer.classList.add('hidden'); } checkForNewInstalls(prevApplicationMap, newApplicationMap) { @@ -228,9 +247,32 @@ export default class Clusters { } } + setBannerDismissedState(status, isDismissed) { + if (AccessorUtilities.isLocalStorageAccessSafe()) { + window.localStorage.setItem( + `cluster_${this.clusterId}_banner_dismissed`, + `${status}_${isDismissed}`, + ); + } + } + + isBannerDismissed(status) { + let bannerState; + if (AccessorUtilities.isLocalStorageAccessSafe()) { + bannerState = window.localStorage.getItem(`cluster_${this.clusterId}_banner_dismissed`); + } + + return bannerState === `${status}_true`; + } + updateContainer(prevStatus, status, error) { this.hideAll(); + if (this.isBannerDismissed(status)) { + return; + } + this.setBannerDismissedState(status, false); + // We poll all the time but only want the `created` banner to show when newly created if (this.store.state.status !== 'created' || prevStatus !== this.store.state.status) { switch (status) { @@ -241,6 +283,12 @@ export default class Clusters { this.errorContainer.classList.remove('hidden'); this.errorReasonContainer.textContent = error; break; + case 'unreachable': + this.unreachableContainer.classList.remove('hidden'); + break; + case 'authentication_failure': + this.authenticationFailureContainer.classList.remove('hidden'); + break; case 'scheduled': case 'creating': this.creatingContainer.classList.remove('hidden'); @@ -305,8 +353,10 @@ export default class Clusters { saveKnativeDomain(data) { const appId = data.id; - this.store.updateAppProperty(appId, 'status', APPLICATION_STATUS.UPDATING); - this.service.updateApplication(appId, data.params); + this.store.updateApplication(appId); + this.service.updateApplication(appId, data.params).catch(() => { + this.store.notifyUpdateFailure(appId); + }); } setKnativeHostname(data) { diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 5f7675bb432..7b173be599a 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -89,6 +89,10 @@ export default { type: Boolean, required: false, }, + updateable: { + type: Boolean, + default: true, + }, updateSuccessful: { type: Boolean, required: false, @@ -138,7 +142,7 @@ export default { ); }, hasLogo() { - return !!this.logoUrl; + return Boolean(this.logoUrl); }, identiconId() { // generate a deterministic integer id for the identicon background @@ -326,36 +330,38 @@ export default { </ul> </div> - <div - v-if="shouldShowUpgradeDetails" - class="form-text text-muted label p-0 js-cluster-application-upgrade-details" - > - {{ versionLabel }} - <span v-if="updateSuccessful">to</span> - - <gl-link - v-if="updateSuccessful" - :href="chartRepo" - target="_blank" - class="js-cluster-application-upgrade-version" - >chart v{{ version }}</gl-link + <div v-if="updateable"> + <div + v-if="shouldShowUpgradeDetails" + class="form-text text-muted label p-0 js-cluster-application-upgrade-details" > - </div> + {{ versionLabel }} + <span v-if="updateSuccessful">to</span> - <div - v-if="updateFailed && !isUpgrading" - class="bs-callout bs-callout-danger cluster-application-banner mt-2 mb-0 js-cluster-application-upgrade-failure-message" - > - {{ upgradeFailureDescription }} + <gl-link + v-if="updateSuccessful" + :href="chartRepo" + target="_blank" + class="js-cluster-application-upgrade-version" + >chart v{{ version }}</gl-link + > + </div> + + <div + v-if="updateFailed && !isUpgrading" + class="bs-callout bs-callout-danger cluster-application-banner mt-2 mb-0 js-cluster-application-upgrade-failure-message" + > + {{ upgradeFailureDescription }} + </div> + <loading-button + v-if="upgradeAvailable || updateFailed || isUpgrading" + class="btn btn-primary js-cluster-application-upgrade-button mt-2" + :loading="isUpgrading" + :disabled="isUpgrading" + :label="upgradeButtonLabel" + @click="upgradeClicked" + /> </div> - <loading-button - v-if="upgradeAvailable || updateFailed || isUpgrading" - class="btn btn-primary js-cluster-application-upgrade-button mt-2" - :loading="isUpgrading" - :disabled="isUpgrading" - :label="upgradeButtonLabel" - @click="upgradeClicked" - /> </div> <div :class="{ 'section-25': showManageButton, 'section-15': !showManageButton }" diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 73760da9b98..2d129245d37 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -15,6 +15,7 @@ import prometheusLogo from 'images/cluster_app_logos/prometheus.png'; import { s__, sprintf } from '../../locale'; import applicationRow from './application_row.vue'; import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; +import KnativeDomainEditor from './knative_domain_editor.vue'; import { CLUSTER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; import eventHub from '~/clusters/event_hub'; @@ -25,6 +26,7 @@ export default { clipboardButton, LoadingButton, GlLoadingIcon, + KnativeDomainEditor, }, props: { type: { @@ -154,64 +156,21 @@ export default { knative() { return this.applications.knative; }, - knativeInstalled() { - return ( - this.knative.status === APPLICATION_STATUS.INSTALLED || - this.knativeUpgrading || - this.knativeUpgradeFailed || - this.knative.status === APPLICATION_STATUS.UPDATED - ); - }, - knativeUpgrading() { - return ( - this.knative.status === APPLICATION_STATUS.UPDATING || - this.knative.status === APPLICATION_STATUS.SCHEDULED - ); - }, - knativeUpgradeFailed() { - return this.knative.status === APPLICATION_STATUS.UPDATE_ERRORED; - }, - knativeExternalEndpoint() { - return this.knative.externalIp || this.knative.externalHostname; - }, - knativeDescription() { - return sprintf( - _.escape( - s__( - `ClusterIntegration|Installing Knative may incur additional costs. Learn more about %{pricingLink}.`, - ), - ), - { - pricingLink: `<strong><a href="https://cloud.google.com/compute/pricing#lb" - target="_blank" rel="noopener noreferrer"> - ${_.escape(s__('ClusterIntegration|pricing'))}</a></strong>`, - }, - false, - ); - }, - canUpdateKnativeEndpoint() { - return this.knativeExternalEndpoint && !this.knativeUpgradeFailed && !this.knativeUpgrading; - }, - knativeHostname: { - get() { - return this.knative.hostname; - }, - set(hostname) { - eventHub.$emit('setKnativeHostname', { - id: 'knative', - hostname, - }); - }, - }, }, created() { this.helmInstallIllustration = helmInstallIllustration; }, methods: { - saveKnativeDomain() { + saveKnativeDomain(hostname) { eventHub.$emit('saveKnativeDomain', { id: 'knative', - params: { hostname: this.knative.hostname }, + params: { hostname }, + }); + }, + setKnativeHostname(hostname) { + eventHub.$emit('setKnativeHostname', { + id: 'knative', + hostname, }); }, }, @@ -318,9 +277,9 @@ export default { generated endpoint in order to access your application after it has been deployed.`) }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{ - __('More information') - }}</a> + <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> + {{ __('More information') }} + </a> </p> </div> @@ -330,9 +289,9 @@ export default { the process of being assigned. Please check your Kubernetes cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{ - __('More information') - }}</a> + <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> + {{ __('More information') }} + </a> </p> </template> <template v-if="!ingressInstalled"> @@ -361,9 +320,9 @@ export default { <div slot="description"> <p v-html="certManagerDescription"></p> <div class="form-group"> - <label for="cert-manager-issuer-email">{{ - s__('ClusterIntegration|Issuer Email') - }}</label> + <label for="cert-manager-issuer-email"> + {{ s__('ClusterIntegration|Issuer Email') }} + </label> <div class="input-group"> <input v-model="applications.cert_manager.email" @@ -491,9 +450,9 @@ export default { s__(`ClusterIntegration|Replace this with your own hostname if you want. If you do so, point hostname to Ingress IP Address from above.`) }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{ - __('More information') - }}</a> + <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> + {{ __('More information') }} + </a> </p> </div> </template> @@ -514,6 +473,7 @@ export default { :uninstallable="applications.knative.uninstallable" :uninstall-successful="applications.knative.uninstallSuccessful" :uninstall-failed="applications.knative.uninstallFailed" + :updateable="false" :disabled="!helmInstalled" v-bind="applications.knative" title-link="https://github.com/knative/docs" @@ -525,9 +485,9 @@ export default { s__(`ClusterIntegration|You must have an RBAC-enabled cluster to install Knative.`) }} - <a :href="helpPath" target="_blank" rel="noopener noreferrer">{{ - __('More information') - }}</a> + <a :href="helpPath" target="_blank" rel="noopener noreferrer"> + {{ __('More information') }} + </a> </p> <br /> </span> @@ -540,83 +500,13 @@ export default { }} </p> - <div class="row"> - <template v-if="knativeInstalled || (helmInstalled && rbac)"> - <div - :class="{ 'col-md-6': knativeInstalled, 'col-12': helmInstalled && rbac }" - class="form-group col-sm-12 mb-0" - > - <label for="knative-domainname"> - <strong>{{ s__('ClusterIntegration|Knative Domain Name:') }}</strong> - </label> - <input - id="knative-domainname" - v-model="knativeHostname" - type="text" - class="form-control js-knative-domainname" - /> - </div> - </template> - <template v-if="knativeInstalled"> - <div class="form-group col-sm-12 col-md-6 pl-md-0 mb-0 mt-3 mt-md-0"> - <label for="knative-endpoint"> - <strong>{{ s__('ClusterIntegration|Knative Endpoint:') }}</strong> - </label> - <div v-if="knativeExternalEndpoint" class="input-group"> - <input - id="knative-endpoint" - :value="knativeExternalEndpoint" - type="text" - class="form-control js-knative-endpoint" - readonly - /> - <span class="input-group-append"> - <clipboard-button - :text="knativeExternalEndpoint" - :title="s__('ClusterIntegration|Copy Knative Endpoint to clipboard')" - class="input-group-text js-knative-endpoint-clipboard-btn" - /> - </span> - </div> - <div v-else class="input-group"> - <input type="text" class="form-control js-endpoint" readonly /> - <gl-loading-icon - class="position-absolute align-self-center ml-2 js-knative-ip-loading-icon" - /> - </div> - </div> - - <p class="form-text text-muted col-12"> - {{ - s__( - `ClusterIntegration|To access your application after deployment, point a wildcard DNS to the Knative Endpoint.`, - ) - }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{ - __('More information') - }}</a> - </p> - - <p - v-if="!knativeExternalEndpoint" - class="settings-message js-no-knative-endpoint-message mt-2 mr-3 mb-0 ml-3" - > - {{ - s__(`ClusterIntegration|The endpoint is in - the process of being assigned. Please check your Kubernetes - cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) - }} - </p> - - <button - v-if="canUpdateKnativeEndpoint" - class="btn btn-success js-knative-save-domain-button mt-3 ml-3" - @click="saveKnativeDomain" - > - {{ s__('ClusterIntegration|Save changes') }} - </button> - </template> - </div> + <knative-domain-editor + v-if="knative.installed || (helmInstalled && rbac)" + :knative="knative" + :ingress-dns-help-path="ingressDnsHelpPath" + @save="saveKnativeDomain" + @set="setKnativeHostname" + /> </div> </application-row> </div> diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue new file mode 100644 index 00000000000..480228619a5 --- /dev/null +++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue @@ -0,0 +1,150 @@ +<script> +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +import { APPLICATION_STATUS } from '~/clusters/constants'; + +const { UPDATING, UNINSTALLING } = APPLICATION_STATUS; + +export default { + components: { + LoadingButton, + ClipboardButton, + GlLoadingIcon, + }, + props: { + knative: { + type: Object, + required: true, + }, + ingressDnsHelpPath: { + type: String, + default: '', + }, + }, + computed: { + saveButtonDisabled() { + return [UNINSTALLING, UPDATING].includes(this.knative.status); + }, + saving() { + return [UPDATING].includes(this.knative.status); + }, + saveButtonLabel() { + return this.saving ? this.__('Saving') : this.__('Save changes'); + }, + knativeInstalled() { + return this.knative.installed; + }, + knativeExternalEndpoint() { + return this.knative.externalIp || this.knative.externalHostname; + }, + knativeUpdateSuccessful() { + return this.knative.updateSuccessful; + }, + knativeHostname: { + get() { + return this.knative.hostname; + }, + set(hostname) { + this.$emit('set', hostname); + }, + }, + }, + watch: { + knativeUpdateSuccessful(updateSuccessful) { + if (updateSuccessful) { + this.$toast.show(s__('ClusterIntegration|Knative domain name was updated successfully.')); + } + }, + }, +}; +</script> + +<template> + <div class="row"> + <div + v-if="knative.updateFailed" + class="bs-callout bs-callout-danger cluster-application-banner col-12 mt-2 mb-2 js-cluster-knative-domain-name-failure-message" + > + {{ s__('ClusterIntegration|Something went wrong while updating Knative domain name.') }} + </div> + + <template> + <div + :class="{ 'col-md-6': knativeInstalled, 'col-12': !knativeInstalled }" + class="form-group col-sm-12 mb-0" + > + <label for="knative-domainname"> + <strong>{{ s__('ClusterIntegration|Knative Domain Name:') }}</strong> + </label> + <input + id="knative-domainname" + v-model="knativeHostname" + type="text" + class="form-control js-knative-domainname" + /> + </div> + </template> + <template v-if="knativeInstalled"> + <div class="form-group col-sm-12 col-md-6 pl-md-0 mb-0 mt-3 mt-md-0"> + <label for="knative-endpoint"> + <strong>{{ s__('ClusterIntegration|Knative Endpoint:') }}</strong> + </label> + <div v-if="knativeExternalEndpoint" class="input-group"> + <input + id="knative-endpoint" + :value="knativeExternalEndpoint" + type="text" + class="form-control js-knative-endpoint" + readonly + /> + <span class="input-group-append"> + <clipboard-button + :text="knativeExternalEndpoint" + :title="s__('ClusterIntegration|Copy Knative Endpoint to clipboard')" + class="input-group-text js-knative-endpoint-clipboard-btn" + /> + </span> + </div> + <div v-else class="input-group"> + <input type="text" class="form-control js-endpoint" readonly /> + <gl-loading-icon + class="position-absolute align-self-center ml-2 js-knative-ip-loading-icon" + /> + </div> + </div> + + <p class="form-text text-muted col-12"> + {{ + s__( + `ClusterIntegration|To access your application after deployment, point a wildcard DNS to the Knative Endpoint.`, + ) + }} + <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> + {{ __('More information') }} + </a> + </p> + + <p + v-if="!knativeExternalEndpoint" + class="settings-message js-no-knative-endpoint-message mt-2 mr-3 mb-0 ml-3" + > + {{ + s__(`ClusterIntegration|The endpoint is in + the process of being assigned. Please check your Kubernetes + cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) + }} + </p> + + <loading-button + class="btn-success js-knative-save-domain-button mt-3 ml-3" + :loading="saving" + :disabled="saveButtonDisabled" + :label="saveButtonLabel" + @click="$emit('save', knativeHostname)" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 1b4d7e8372c..89e61c10a46 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -77,6 +77,8 @@ export default class ClusterStore { isEditingHostName: false, externalIp: null, externalHostname: null, + updateSuccessful: false, + updateFailed: false, }, }, }; diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js index a0ca44caa07..d0cc4897aeb 100644 --- a/app/assets/javascripts/commons/polyfills.js +++ b/app/assets/javascripts/commons/polyfills.js @@ -1,19 +1,21 @@ // ECMAScript polyfills -import 'core-js/fn/array/fill'; -import 'core-js/fn/array/find'; -import 'core-js/fn/array/find-index'; -import 'core-js/fn/array/from'; -import 'core-js/fn/array/includes'; -import 'core-js/fn/object/assign'; -import 'core-js/fn/object/values'; -import 'core-js/fn/promise'; -import 'core-js/fn/promise/finally'; -import 'core-js/fn/string/code-point-at'; -import 'core-js/fn/string/from-code-point'; -import 'core-js/fn/string/includes'; -import 'core-js/fn/symbol'; -import 'core-js/es6/map'; -import 'core-js/es6/weak-map'; +import 'core-js/es/array/fill'; +import 'core-js/es/array/find'; +import 'core-js/es/array/find-index'; +import 'core-js/es/array/from'; +import 'core-js/es/array/includes'; +import 'core-js/es/object/assign'; +import 'core-js/es/object/values'; +import 'core-js/es/object/entries'; +import 'core-js/es/promise'; +import 'core-js/es/promise/finally'; +import 'core-js/es/string/code-point-at'; +import 'core-js/es/string/from-code-point'; +import 'core-js/es/string/includes'; +import 'core-js/es/symbol'; +import 'core-js/es/map'; +import 'core-js/es/weak-map'; +import 'core-js/modules/web.url'; // Browser polyfills import 'formdata-polyfill'; diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index 37a3ceb5341..5bfe158ceda 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -40,7 +40,7 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = ( }, selectable: true, filterable: true, - filterRemote: !!$dropdown.data('refsUrl'), + filterRemote: Boolean($dropdown.data('refsUrl')), fieldName: $dropdown.data('fieldName'), filterInput: 'input[type="search"]', renderRow: function(ref) { diff --git a/app/assets/javascripts/create_item_dropdown.js b/app/assets/javascripts/create_item_dropdown.js index 916b190f469..fa0f04c7d82 100644 --- a/app/assets/javascripts/create_item_dropdown.js +++ b/app/assets/javascripts/create_item_dropdown.js @@ -12,7 +12,7 @@ export default class CreateItemDropdown { this.fieldName = options.fieldName; this.onSelect = options.onSelect || (() => {}); this.getDataOption = options.getData; - this.getDataRemote = !!options.filterRemote; + this.getDataRemote = Boolean(options.filterRemote); this.createNewItemFromValueOption = options.createNewItemFromValue; this.$dropdown = options.$dropdown; this.$dropdownContainer = this.$dropdown.parent(); diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index a767379d662..bd7259ce3ee 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -69,7 +69,7 @@ export default { :link-href="authorUrl" :img-src="authorAvatar" :img-alt="authorName" - :img-size="36" + :img-size="40" class="avatar-cell d-none d-sm-block" /> <div class="commit-detail flex-list"> diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 392de1c9f23..eb9f1465945 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -240,7 +240,7 @@ export default { css-class="btn-default btn-transparent btn-clipboard" /> - <small v-if="isModeChanged" ref="fileMode"> + <small v-if="isModeChanged" ref="fileMode" class="mr-1"> {{ diffFile.a_mode }} → {{ diffFile.b_mode }} </small> @@ -254,16 +254,17 @@ export default { <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" /> <div class="btn-group" role="group"> <template v-if="diffFile.blob && diffFile.blob.readable_text"> - <button - :disabled="!diffHasDiscussions(diffFile)" - :class="{ active: hasExpandedDiscussions }" - :title="s__('MergeRequests|Toggle comments for this file')" - class="js-btn-vue-toggle-comments btn" - type="button" - @click="handleToggleDiscussions" - > - <icon name="comment" /> - </button> + <span v-gl-tooltip.hover :title="s__('MergeRequests|Toggle comments for this file')"> + <gl-button + :disabled="!diffHasDiscussions(diffFile)" + :class="{ active: hasExpandedDiscussions }" + class="js-btn-vue-toggle-comments btn" + type="button" + @click="handleToggleDiscussions" + > + <icon name="comment" /> + </gl-button> + </span> <edit-button v-if="!diffFile.deleted_file" diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue index 0c0a0faa59d..7cf3d90d468 100644 --- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue +++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue @@ -86,7 +86,6 @@ export default { :key="note.id" :img-src="note.author.avatar_url" :tooltip-text="getTooltipText(note)" - :size="19" class="diff-comment-avatar js-diff-comment-avatar" @click.native="toggleDiscussions" /> diff --git a/app/assets/javascripts/diffs/components/edit_button.vue b/app/assets/javascripts/diffs/components/edit_button.vue index f0cc5de4b33..dcb79cd5e16 100644 --- a/app/assets/javascripts/diffs/components/edit_button.vue +++ b/app/assets/javascripts/diffs/components/edit_button.vue @@ -38,7 +38,7 @@ export default { <template> <gl-button - v-gl-tooltip.bottom + v-gl-tooltip.top :href="editPath" :title="__('Edit file')" class="js-edit-blob" diff --git a/app/assets/javascripts/error_tracking_settings/store/getters.js b/app/assets/javascripts/error_tracking_settings/store/getters.js index a008b181907..d77e5f15469 100644 --- a/app/assets/javascripts/error_tracking_settings/store/getters.js +++ b/app/assets/javascripts/error_tracking_settings/store/getters.js @@ -2,10 +2,10 @@ import _ from 'underscore'; import { __, s__, sprintf } from '~/locale'; import { getDisplayName } from '../utils'; -export const hasProjects = state => !!state.projects && state.projects.length > 0; +export const hasProjects = state => Boolean(state.projects) && state.projects.length > 0; export const isProjectInvalid = (state, getters) => - !!state.selectedProject && + Boolean(state.selectedProject) && getters.hasProjects && !state.projects.some(project => _.isMatch(state.selectedProject, project)); diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js index f1e7be6bde1..a65c0012b4d 100644 --- a/app/assets/javascripts/filtered_search/dropdown_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_user.js @@ -18,6 +18,7 @@ export default class DropdownUser extends DropdownAjaxFilter { group_id: this.getGroupId(), project_id: this.getProjectId(), current_user: true, + ...this.projectOrGroupId(), }, onLoadingFinished: () => { this.hideCurrentUser(); @@ -36,4 +37,17 @@ export default class DropdownUser extends DropdownAjaxFilter { getProjectId() { return this.input.getAttribute('data-project-id'); } + + projectOrGroupId() { + const projectId = this.getProjectId(); + const groupId = this.getGroupId(); + if (groupId) { + return { + group_id: groupId, + }; + } + return { + project_id: projectId, + }; + } } diff --git a/app/assets/javascripts/frequent_items/store/actions.js b/app/assets/javascripts/frequent_items/store/actions.js index 3dd89a82a42..ba62ab67e50 100644 --- a/app/assets/javascripts/frequent_items/store/actions.js +++ b/app/assets/javascripts/frequent_items/store/actions.js @@ -51,7 +51,7 @@ export const fetchSearchedItems = ({ state, dispatch }, searchQuery) => { const params = { simple: true, per_page: 20, - membership: !!gon.current_user_id, + membership: Boolean(gon.current_user_id), }; if (state.namespace === 'projects') { diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index f437954881c..0af9aabd8cf 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import 'at.js'; import _ from 'underscore'; import glRegexp from './lib/utils/regexp'; import AjaxCache from './lib/utils/ajax_cache'; diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 1c6b18c0e03..18fa6265108 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -307,8 +307,8 @@ GitLabDropdown = (function() { // Set Defaults this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT); this.noFilterInput = this.options.noFilterInput || this.getElement(NO_FILTER_INPUT); - this.highlight = !!this.options.highlight; - this.icon = !!this.options.icon; + this.highlight = Boolean(this.options.highlight); + this.icon = Boolean(this.options.icon); this.filterInputBlur = this.options.filterInputBlur != null ? this.options.filterInputBlur : true; // If no input is passed create a default one @@ -335,6 +335,10 @@ GitLabDropdown = (function() { _this.fullData = data; _this.parseData(_this.fullData); _this.focusTextInput(); + + // Update dropdown position since remote data may have changed dropdown size + _this.dropdown.find('.dropdown-menu-toggle').dropdown('update'); + if ( _this.options.filterable && _this.filter && @@ -702,6 +706,10 @@ GitLabDropdown = (function() { } html = document.createElement('li'); + if (rowHidden) { + html.style.display = 'none'; + } + if (data === 'divider' || data === 'separator') { html.className = data; return html; diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 5a6d44ef838..a66555838ba 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -13,7 +13,7 @@ export default class GLForm { const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {}; Object.keys(this.enableGFM).forEach(item => { if (item !== 'emojis') { - this.enableGFM[item] = !!dataSources[item]; + this.enableGFM[item] = Boolean(dataSources[item]); } }); // Before we start, we should clean up any previous data for this form diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 9894ebb0624..e41b1530226 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,6 +1,7 @@ <script> import Vue from 'vue'; import { mapActions, mapState, mapGetters } from 'vuex'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import FindFile from '~/vue_shared/components/file_finder/index.vue'; import NewModal from './new_dropdown/modal.vue'; @@ -22,6 +23,8 @@ export default { FindFile, ErrorMessage, CommitEditorHeader, + GlButton, + GlLoadingIcon, }, props: { rightPaneComponent: { @@ -47,13 +50,15 @@ export default { 'someUncommittedChanges', 'isCommitModeActive', 'allBlobs', + 'emptyRepo', + 'currentTree', ]), }, mounted() { window.onbeforeunload = e => this.onBeforeUnload(e); }, methods: { - ...mapActions(['toggleFileFinder']), + ...mapActions(['toggleFileFinder', 'openNewEntryModal']), onBeforeUnload(e = {}) { const returnValue = __('Are you sure you want to lose unsaved changes?'); @@ -98,17 +103,40 @@ export default { <repo-editor :file="activeFile" class="multi-file-edit-pane-content" /> </template> <template v-else> - <div v-once class="ide-empty-state"> + <div class="ide-empty-state"> <div class="row js-empty-state"> <div class="col-12"> <div class="svg-content svg-250"><img :src="emptyStateSvgPath" /></div> </div> <div class="col-12"> <div class="text-content text-center"> - <h4>Welcome to the GitLab IDE</h4> - <p> - Select a file from the left sidebar to begin editing. Afterwards, you'll be able - to commit your changes. + <h4> + {{ __('Make and review changes in the browser with the Web IDE') }} + </h4> + <template v-if="emptyRepo"> + <p> + {{ + __( + "Create a new file as there are no files yet. Afterwards, you'll be able to commit your changes.", + ) + }} + </p> + <gl-button + variant="success" + :title="__('New file')" + :aria-label="__('New file')" + @click="openNewEntryModal({ type: 'blob' })" + > + {{ __('New file') }} + </gl-button> + </template> + <gl-loading-icon v-else-if="!currentTree || currentTree.loading" size="md" /> + <p v-else> + {{ + __( + "Select a file from the left sidebar to begin editing. Afterwards, you'll be able to commit your changes.", + ) + }} </p> </div> </div> diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index 81374f26645..95782b2c88a 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -54,14 +54,17 @@ export default { <slot name="header"></slot> </header> <div class="ide-tree-body h-100"> - <file-row - v-for="file in currentTree.tree" - :key="file.key" - :file="file" - :level="0" - :extra-component="$options.FileRowExtra" - @toggleTreeOpen="toggleTreeOpen" - /> + <template v-if="currentTree.tree.length"> + <file-row + v-for="file in currentTree.tree" + :key="file.key" + :file="file" + :level="0" + :extra-component="$options.FileRowExtra" + @toggleTreeOpen="toggleTreeOpen" + /> + </template> + <div v-else class="file-row">{{ __('No files') }}</div> </div> </template> </div> diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue index c98dda00817..6999746f115 100644 --- a/app/assets/javascripts/ide/components/preview/clientside.vue +++ b/app/assets/javascripts/ide/components/preview/clientside.vue @@ -105,7 +105,7 @@ export default { .then(() => { this.initManager('#ide-preview', this.sandboxOpts, { fileResolver: { - isFile: p => Promise.resolve(!!this.entries[createPathWithExt(p)]), + isFile: p => Promise.resolve(Boolean(this.entries[createPathWithExt(p)])), readFile: p => this.loadFileContent(createPathWithExt(p)).then(content => content), }, }); diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 99f1d4a573d..5201c33b1b4 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -30,7 +30,7 @@ export default { ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommittedChanges', 'activeFile']), ...mapGetters('commit', ['discardDraftButtonDisabled']), showStageUnstageArea() { - return !!(this.someUncommittedChanges || this.lastCommitMsg || !this.unusedSeal); + return Boolean(this.someUncommittedChanges || this.lastCommitMsg || !this.unusedSeal); }, activeFileKey() { return this.activeFile ? this.activeFile.key : null; diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js index e35595ab1fd..dac2a8e8b51 100644 --- a/app/assets/javascripts/ide/lib/editor_options.js +++ b/app/assets/javascripts/ide/lib/editor_options.js @@ -11,7 +11,7 @@ export const defaultEditorOptions = { export default [ { - readOnly: model => !!model.file.file_lock, + readOnly: model => Boolean(model.file.file_lock), quickSuggestions: model => !(model.language === 'markdown'), }, ]; diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index fd678e6e10c..dc8ca732879 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -1,12 +1,15 @@ import $ from 'jquery'; import Vue from 'vue'; +import { __, sprintf } from '~/locale'; import { visitUrl } from '~/lib/utils/url_utility'; import flash from '~/flash'; +import _ from 'underscore'; import * as types from './mutation_types'; import { decorateFiles } from '../lib/files'; import { stageKeys } from '../constants'; +import service from '../services'; -export const redirectToUrl = (_, url) => visitUrl(url); +export const redirectToUrl = (self, url) => visitUrl(url); export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); @@ -239,6 +242,53 @@ export const renameEntry = ( } }; +export const getBranchData = ({ commit, state }, { projectId, branchId, force = false } = {}) => + new Promise((resolve, reject) => { + const currentProject = state.projects[projectId]; + if (!currentProject || !currentProject.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(e => { + if (e.response.status === 404) { + reject(e); + } else { + flash( + __('Error loading branch data. Please try again.'), + 'alert', + document, + null, + false, + true, + ); + + reject( + new Error( + sprintf( + __('Branch not loaded - %{branchId}'), + { + branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`, + }, + false, + ), + ), + ); + } + }); + } else { + resolve(currentProject.branches[branchId]); + } + }); + export * from './actions/tree'; export * from './actions/file'; export * from './actions/project'; diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index 4b10d148ebf..dd8f17e4f3a 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -35,48 +35,6 @@ export const getProjectData = ({ commit, state }, { namespace, projectId, force } }); -export const getBranchData = ( - { commit, dispatch, state }, - { 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(e => { - if (e.response.status === 404) { - dispatch('showBranchNotFoundError', branchId); - } else { - 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]); - } - }); - export const refreshLastCommitData = ({ commit }, { projectId, branchId } = {}) => service .getBranchData(projectId, branchId) @@ -125,40 +83,66 @@ export const showBranchNotFoundError = ({ dispatch }, branchId) => { }); }; -export const openBranch = ({ dispatch, state }, { projectId, branchId, basePath }) => { - dispatch('setCurrentBranchId', branchId); - - dispatch('getBranchData', { - projectId, - branchId, +export const showEmptyState = ({ commit, state }, { projectId, branchId }) => { + const treePath = `${projectId}/${branchId}`; + commit(types.CREATE_TREE, { treePath }); + commit(types.TOGGLE_LOADING, { + entry: state.trees[treePath], + forceValue: false, }); +}; - return dispatch('getFiles', { +export const openBranch = ({ dispatch, state, getters }, { projectId, branchId, basePath }) => { + dispatch('setCurrentBranchId', branchId); + + if (getters.emptyRepo) { + return dispatch('showEmptyState', { projectId, branchId }); + } + return dispatch('getBranchData', { projectId, branchId, }) .then(() => { - if (basePath) { - const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath; - const treeEntryKey = Object.keys(state.entries).find( - key => key === path && !state.entries[key].pending, - ); - const treeEntry = state.entries[treeEntryKey]; - - if (treeEntry) { - dispatch('handleTreeEntryAction', treeEntry); - } else { - dispatch('createTempEntry', { - name: path, - type: 'blob', - }); - } - } - }) - .then(() => { dispatch('getMergeRequestsForBranch', { projectId, branchId, }); + dispatch('getFiles', { + projectId, + branchId, + }) + .then(() => { + if (basePath) { + const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath; + const treeEntryKey = Object.keys(state.entries).find( + key => key === path && !state.entries[key].pending, + ); + const treeEntry = state.entries[treeEntryKey]; + + if (treeEntry) { + dispatch('handleTreeEntryAction', treeEntry); + } else { + dispatch('createTempEntry', { + name: path, + type: 'blob', + }); + } + } + }) + .catch( + () => + new Error( + sprintf( + __('An error occurred whilst getting files for - %{branchId}'), + { + branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`, + }, + false, + ), + ), + ); + }) + .catch(() => { + dispatch('showBranchNotFoundError', branchId); }); }; diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index 3d83e4a9ba5..75511574d3e 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -74,17 +74,13 @@ export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = resolve(); }) .catch(e => { - if (e.response.status === 404) { - dispatch('showBranchNotFoundError', branchId); - } else { - dispatch('setErrorMessage', { - text: __('An error occurred whilst loading all the files.'), - action: payload => - dispatch('getFiles', payload).then(() => dispatch('setErrorMessage', null)), - actionText: __('Please try again'), - actionPayload: { projectId, branchId }, - }); - } + dispatch('setErrorMessage', { + text: __('An error occurred whilst loading all the files.'), + action: payload => + dispatch('getFiles', payload).then(() => dispatch('setErrorMessage', null)), + actionText: __('Please try again'), + actionPayload: { projectId, branchId }, + }); reject(e); }); } else { diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 490658a4543..5a736805fdc 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -36,12 +36,16 @@ export const currentMergeRequest = state => { export const currentProject = state => state.projects[state.currentProjectId]; +export const emptyRepo = state => + state.projects[state.currentProjectId] && state.projects[state.currentProjectId].empty_repo; + export const currentTree = state => state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; -export const hasChanges = state => !!state.changedFiles.length || !!state.stagedFiles.length; +export const hasChanges = state => + Boolean(state.changedFiles.length) || Boolean(state.stagedFiles.length); -export const hasMergeRequest = state => !!state.currentMergeRequestId; +export const hasMergeRequest = state => Boolean(state.currentMergeRequestId); export const allBlobs = state => Object.keys(state.entries) @@ -67,7 +71,7 @@ export const isCommitModeActive = state => state.currentActivityView === activit export const isReviewModeActive = state => state.currentActivityView === activityBarViews.review; export const someUncommittedChanges = state => - !!(state.changedFiles.length || state.stagedFiles.length); + Boolean(state.changedFiles.length || state.stagedFiles.length); export const getChangesInFolder = state => path => { const changedFilesCount = state.changedFiles.filter(f => filePathMatches(f.path, path)).length; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index c2760eb1554..77ea2084877 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -102,7 +102,7 @@ export const updateFilesAfterCommit = ({ commit, dispatch, rootState, rootGetter eventHub.$emit(`editor.update.model.content.${file.key}`, { content: file.content, - changed: !!changedFile, + changed: Boolean(changedFile), }); }); }; @@ -135,6 +135,17 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo return null; } + if (!data.parent_ids.length) { + commit( + rootTypes.TOGGLE_EMPTY_STATE, + { + projectPath: rootState.currentProjectId, + value: false, + }, + { root: true }, + ); + } + dispatch('setLastCommitMessage', data); dispatch('updateCommitMessage', ''); return dispatch('updateFilesAfterCommit', { diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/getters.js b/app/assets/javascripts/ide/stores/modules/pipelines/getters.js index ef7cd4ff8e8..1d127d915d7 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/getters.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/getters.js @@ -1,6 +1,6 @@ import { states } from './constants'; -export const hasLatestPipeline = state => !state.isLoadingPipeline && !!state.latestPipeline; +export const hasLatestPipeline = state => !state.isLoadingPipeline && Boolean(state.latestPipeline); export const pipelineFailed = state => state.latestPipeline && state.latestPipeline.details.status.text === states.failed; diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index a5f8098dc17..86ab76136df 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -12,6 +12,7 @@ export const SET_LINKS = 'SET_LINKS'; export const SET_PROJECT = 'SET_PROJECT'; export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT'; export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN'; +export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; // Merge Request Mutation Types export const SET_MERGE_REQUEST = 'SET_MERGE_REQUEST'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index 344b189decf..ae42b87c9a7 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -142,7 +142,7 @@ export default { Object.assign(state.entries[file.path], { raw: file.content, - changed: !!changedFile, + changed: Boolean(changedFile), staged: false, prevPath: '', moved: false, diff --git a/app/assets/javascripts/ide/stores/mutations/branch.js b/app/assets/javascripts/ide/stores/mutations/branch.js index e09f88878f4..6afd8de2aa4 100644 --- a/app/assets/javascripts/ide/stores/mutations/branch.js +++ b/app/assets/javascripts/ide/stores/mutations/branch.js @@ -19,6 +19,12 @@ export default { }); }, [types.SET_BRANCH_WORKING_REFERENCE](state, { projectId, branchId, reference }) { + if (!state.projects[projectId].branches[branchId]) { + Object.assign(state.projects[projectId].branches, { + [branchId]: {}, + }); + } + Object.assign(state.projects[projectId].branches[branchId], { workingReference: reference, }); diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js index 284b39a2c72..9230f3839c1 100644 --- a/app/assets/javascripts/ide/stores/mutations/project.js +++ b/app/assets/javascripts/ide/stores/mutations/project.js @@ -21,4 +21,9 @@ export default { }), }); }, + [types.TOGGLE_EMPTY_STATE](state, { projectPath, value }) { + Object.assign(state.projects[projectPath], { + empty_repo: value, + }); + }, }; diff --git a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js index 05000c73052..7051a968dac 100644 --- a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js +++ b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js @@ -14,7 +14,7 @@ export function addCommentIndicator(containerEl, { x, y }) { export function removeCommentIndicator(imageFrameEl) { const commentIndicatorEl = imageFrameEl.querySelector('.comment-indicator'); const imageEl = imageFrameEl.querySelector('img'); - const willRemove = !!commentIndicatorEl; + const willRemove = Boolean(commentIndicatorEl); let meta = {}; if (willRemove) { diff --git a/app/assets/javascripts/image_diff/image_diff.js b/app/assets/javascripts/image_diff/image_diff.js index 3587f073a00..26c1b0ec7be 100644 --- a/app/assets/javascripts/image_diff/image_diff.js +++ b/app/assets/javascripts/image_diff/image_diff.js @@ -6,8 +6,8 @@ import { isImageLoaded } from '../lib/utils/image_utility'; export default class ImageDiff { constructor(el, options) { this.el = el; - this.canCreateNote = !!(options && options.canCreateNote); - this.renderCommentBadge = !!(options && options.renderCommentBadge); + this.canCreateNote = Boolean(options && options.canCreateNote); + this.renderCommentBadge = Boolean(options && options.renderCommentBadge); this.$noteContainer = $('.note-container', this.el); this.imageBadges = []; } diff --git a/app/assets/javascripts/image_diff/view_types.js b/app/assets/javascripts/image_diff/view_types.js index ab0a595571f..1a5123de220 100644 --- a/app/assets/javascripts/image_diff/view_types.js +++ b/app/assets/javascripts/image_diff/view_types.js @@ -5,5 +5,5 @@ export const viewTypes = { }; export function isValidViewType(validate) { - return !!Object.getOwnPropertyNames(viewTypes).find(viewType => viewType === validate); + return Boolean(Object.getOwnPropertyNames(viewTypes).find(viewType => viewType === validate)); } diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js index f51c7a2f990..16f88cddce3 100644 --- a/app/assets/javascripts/issuable_index.js +++ b/app/assets/javascripts/issuable_index.js @@ -12,7 +12,7 @@ export default class IssuableIndex { } initBulkUpdate(pagePrefix) { const userCanBulkUpdate = $('.issues-bulk-update').length > 0; - const alreadyInitialized = !!this.bulkUpdateSidebar; + const alreadyInitialized = Boolean(this.bulkUpdateSidebar); if (userCanBulkUpdate && !alreadyInitialized) { IssuableBulkUpdateActions.init({ diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index ab0b4231255..e88ca4747c5 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -156,7 +156,7 @@ export default { return this.store.formState; }, hasUpdated() { - return !!this.state.updatedAt; + return Boolean(this.state.updatedAt); }, issueChanged() { const { diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue index d2f33dc31a7..1e1dce5f4fc 100644 --- a/app/assets/javascripts/issue_show/components/title.vue +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -71,7 +71,7 @@ export default { 'issue-realtime-pre-pulse': preAnimation, 'issue-realtime-trigger-pulse': pulseAnimation, }" - class="title" + class="title qa-title" dir="auto" v-html="titleHtml" ></h2> diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index 7594edfac27..79fb67d38cd 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -86,6 +86,7 @@ export default { 'isScrollTopDisabled', 'isScrolledToBottomBeforeReceivingTrace', 'hasError', + 'selectedStage', ]), ...mapGetters([ 'headerTime', @@ -121,7 +122,13 @@ export default { // fetch the stages for the dropdown on the sidebar job(newVal, oldVal) { if (_.isEmpty(oldVal) && !_.isEmpty(newVal.pipeline)) { - this.fetchStages(); + const stages = this.job.pipeline.details.stages || []; + + const defaultStage = stages.find(stage => stage && stage.name === this.selectedStage); + + if (defaultStage) { + this.fetchJobsForStage(defaultStage); + } } if (newVal.archived) { @@ -160,7 +167,7 @@ export default { 'setJobEndpoint', 'setTraceOptions', 'fetchJob', - 'fetchStages', + 'fetchJobsForStage', 'hideSidebar', 'showSidebar', 'toggleSidebar', @@ -269,7 +276,6 @@ export default { :class="{ 'sticky-top border-bottom-0': hasTrace }" > <icon name="lock" class="align-text-bottom" /> - {{ __('This job is archived. Only the complete pipeline can be retried.') }} </div> <!-- job log --> diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index 1691ac62100..24276c06486 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -34,7 +34,7 @@ export default { }, }, computed: { - ...mapState(['job', 'stages', 'jobs', 'selectedStage', 'isLoadingStages']), + ...mapState(['job', 'stages', 'jobs', 'selectedStage']), coverage() { return `${this.job.coverage}%`; }, @@ -208,7 +208,6 @@ export default { /> <stages-dropdown - v-if="!isLoadingStages" :stages="stages" :pipeline="job.pipeline" :selected-stage="selectedStage" diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue index 6e92b599b0a..cb073a9b04d 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue @@ -2,6 +2,7 @@ import _ from 'underscore'; import { GlLink } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import PipelineLink from '~/vue_shared/components/ci_pipeline_link.vue'; import Icon from '~/vue_shared/components/icon.vue'; export default { @@ -9,6 +10,7 @@ export default { CiIcon, Icon, GlLink, + PipelineLink, }, props: { pipeline: { @@ -48,9 +50,12 @@ export default { <ci-icon :status="pipeline.details.status" class="vertical-align-middle" /> <span class="font-weight-bold">{{ s__('Job|Pipeline') }}</span> - <gl-link :href="pipeline.path" class="js-pipeline-path link-commit qa-pipeline-path" - >#{{ pipeline.id }}</gl-link - > + <pipeline-link + :href="pipeline.path" + :pipeline-id="pipeline.id" + :pipeline-iid="pipeline.iid" + class="js-pipeline-path link-commit qa-pipeline-path" + /> <template v-if="hasRef"> {{ s__('Job|for') }} diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index 8045f6dc3ff..12d67a43599 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -179,37 +179,13 @@ export const receiveTraceError = ({ commit }) => { }; /** - * Stages dropdown on sidebar - */ -export const requestStages = ({ commit }) => commit(types.REQUEST_STAGES); -export const fetchStages = ({ state, dispatch }) => { - dispatch('requestStages'); - - axios - .get(`${state.job.pipeline.path}.json`) - .then(({ data }) => { - // Set selected stage - dispatch('receiveStagesSuccess', data.details.stages); - const selectedStage = data.details.stages.find(stage => stage.name === state.selectedStage); - dispatch('fetchJobsForStage', selectedStage); - }) - .catch(() => dispatch('receiveStagesError')); -}; -export const receiveStagesSuccess = ({ commit }, data) => - commit(types.RECEIVE_STAGES_SUCCESS, data); -export const receiveStagesError = ({ commit }) => { - commit(types.RECEIVE_STAGES_ERROR); - flash(__('An error occurred while fetching stages.')); -}; - -/** * Jobs list on sidebar - depend on stages dropdown */ export const requestJobsForStage = ({ commit }, stage) => commit(types.REQUEST_JOBS_FOR_STAGE, stage); // On stage click, set selected stage + fetch job -export const fetchJobsForStage = ({ dispatch }, stage) => { +export const fetchJobsForStage = ({ dispatch }, stage = {}) => { dispatch('requestJobsForStage', stage); axios diff --git a/app/assets/javascripts/jobs/store/mutation_types.js b/app/assets/javascripts/jobs/store/mutation_types.js index fd098f13e90..39146b2eefd 100644 --- a/app/assets/javascripts/jobs/store/mutation_types.js +++ b/app/assets/javascripts/jobs/store/mutation_types.js @@ -24,10 +24,6 @@ export const STOP_POLLING_TRACE = 'STOP_POLLING_TRACE'; export const RECEIVE_TRACE_SUCCESS = 'RECEIVE_TRACE_SUCCESS'; export const RECEIVE_TRACE_ERROR = 'RECEIVE_TRACE_ERROR'; -export const REQUEST_STAGES = 'REQUEST_STAGES'; -export const RECEIVE_STAGES_SUCCESS = 'RECEIVE_STAGES_SUCCESS'; -export const RECEIVE_STAGES_ERROR = 'RECEIVE_STAGES_ERROR'; - export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE'; export const REQUEST_JOBS_FOR_STAGE = 'REQUEST_JOBS_FOR_STAGE'; export const RECEIVE_JOBS_FOR_STAGE_SUCCESS = 'RECEIVE_JOBS_FOR_STAGE_SUCCESS'; diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js index cd440d21c1f..ad08f27b147 100644 --- a/app/assets/javascripts/jobs/store/mutations.js +++ b/app/assets/javascripts/jobs/store/mutations.js @@ -65,6 +65,11 @@ export default { state.isLoading = false; state.job = job; + state.stages = + job.pipeline && job.pipeline.details && job.pipeline.details.stages + ? job.pipeline.details.stages + : []; + /** * We only update it on the first request * The dropdown can be changed by the user @@ -101,19 +106,7 @@ export default { state.isScrolledToBottomBeforeReceivingTrace = toggle; }, - [types.REQUEST_STAGES](state) { - state.isLoadingStages = true; - }, - [types.RECEIVE_STAGES_SUCCESS](state, stages) { - state.isLoadingStages = false; - state.stages = stages; - }, - [types.RECEIVE_STAGES_ERROR](state) { - state.isLoadingStages = false; - state.stages = []; - }, - - [types.REQUEST_JOBS_FOR_STAGE](state, stage) { + [types.REQUEST_JOBS_FOR_STAGE](state, stage = {}) { state.isLoadingJobs = true; state.selectedStage = stage.name; }, diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js index 04825187c99..6019214e62c 100644 --- a/app/assets/javascripts/jobs/store/state.js +++ b/app/assets/javascripts/jobs/store/state.js @@ -25,7 +25,6 @@ export default () => ({ traceState: null, // sidebar dropdown & list of jobs - isLoadingStages: false, isLoadingJobs: false, selectedStage: '', stages: [], diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js index c6dd21cd2d4..7064731a5ea 100644 --- a/app/assets/javascripts/label_manager.js +++ b/app/assets/javascripts/label_manager.js @@ -53,7 +53,7 @@ export default class LabelManager { toggleEmptyState($label, $btn, action) { this.emptyState.classList.toggle( 'hidden', - !!this.prioritizedLabels[0].querySelector(':scope > li'), + Boolean(this.prioritizedLabels[0].querySelector(':scope > li')), ); } diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index 498c2348ca2..5857f9e22ae 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -1,24 +1,32 @@ import { ApolloClient } from 'apollo-client'; import { InMemoryCache } from 'apollo-cache-inmemory'; import { createUploadLink } from 'apollo-upload-client'; +import { ApolloLink } from 'apollo-link'; +import { BatchHttpLink } from 'apollo-link-batch-http'; import csrf from '~/lib/utils/csrf'; -export default (resolvers = {}, baseUrl = '') => { +export default (resolvers = {}, config = {}) => { let uri = `${gon.relative_url_root}/api/graphql`; - if (baseUrl) { + if (config.baseUrl) { // Prepend baseUrl and ensure that `///` are replaced with `/` - uri = `${baseUrl}${uri}`.replace(/\/{3,}/g, '/'); + uri = `${config.baseUrl}${uri}`.replace(/\/{3,}/g, '/'); } + const httpOptions = { + uri, + headers: { + [csrf.headerKey]: csrf.token, + }, + }; + return new ApolloClient({ - link: createUploadLink({ - uri, - headers: { - [csrf.headerKey]: csrf.token, - }, - }), - cache: new InMemoryCache(), + link: ApolloLink.split( + operation => operation.getContext().hasUpload, + createUploadLink(httpOptions), + new BatchHttpLink(httpOptions), + ), + cache: new InMemoryCache(config.cacheConfig), resolvers, }); }; diff --git a/app/assets/javascripts/lib/utils/accessor.js b/app/assets/javascripts/lib/utils/accessor.js index 1d18992af63..39cffedcac6 100644 --- a/app/assets/javascripts/lib/utils/accessor.js +++ b/app/assets/javascripts/lib/utils/accessor.js @@ -2,7 +2,7 @@ function isPropertyAccessSafe(base, property) { let safe; try { - safe = !!base[property]; + safe = Boolean(base[property]); } catch (error) { safe = false; } diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index b236daff1e0..cc5e12aa467 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -94,6 +94,8 @@ export const handleLocationHash = () => { const fixedNav = document.querySelector('.navbar-gitlab'); const performanceBar = document.querySelector('#js-peek'); const topPadding = 8; + const diffFileHeader = document.querySelector('.js-file-title'); + const versionMenusContainer = document.querySelector('.mr-version-menus-container'); let adjustment = 0; if (fixedNav) adjustment -= fixedNav.offsetHeight; @@ -114,6 +116,14 @@ export const handleLocationHash = () => { adjustment -= performanceBar.offsetHeight; } + if (diffFileHeader) { + adjustment -= diffFileHeader.offsetHeight; + } + + if (versionMenusContainer) { + adjustment -= versionMenusContainer.offsetHeight; + } + if (isInMRPage()) { adjustment -= topPadding; } diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 4d6327840db..d3e6851496b 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -3,7 +3,7 @@ import _ from 'underscore'; import timeago from 'timeago.js'; import dateFormat from 'dateformat'; import { pluralize } from './text_utility'; -import { languageCode, s__ } from '../../locale'; +import { languageCode, s__, __ } from '../../locale'; window.timeago = timeago; @@ -63,7 +63,15 @@ export const pad = (val, len = 2) => `0${val}`.slice(-len); * @returns {String} */ export const getDayName = date => - ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][date.getDay()]; + [ + __('Sunday'), + __('Monday'), + __('Tuesday'), + __('Wednesday'), + __('Thursday'), + __('Friday'), + __('Saturday'), + ][date.getDay()]; /** * @example @@ -71,7 +79,12 @@ export const getDayName = date => * @param {date} datetime * @returns {String} */ -export const formatDate = datetime => dateFormat(datetime, 'mmm d, yyyy h:MMtt Z'); +export const formatDate = datetime => { + if (_.isString(datetime) && datetime.match(/\d+-\d+\d+ /)) { + throw new Error('Invalid date'); + } + return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z'); +}; /** * Timeago uses underscores instead of dashes to separate language from country code. @@ -320,13 +333,13 @@ export const getSundays = date => { } const daysToSunday = [ - 'Saturday', - 'Friday', - 'Thursday', - 'Wednesday', - 'Tuesday', - 'Monday', - 'Sunday', + __('Saturday'), + __('Friday'), + __('Thursday'), + __('Wednesday'), + __('Tuesday'), + __('Monday'), + __('Sunday'), ]; const month = date.getMonth(); @@ -336,7 +349,7 @@ export const getSundays = date => { while (dateOfMonth.getMonth() === month) { const dayName = getDayName(dateOfMonth); - if (dayName === 'Sunday') { + if (dayName === __('Sunday')) { sundays.push(new Date(dateOfMonth.getTime())); } @@ -500,7 +513,7 @@ export const stringifyTime = (timeObject, fullNameFormat = false) => { const reducedTime = _.reduce( timeObject, (memo, unitValue, unitName) => { - const isNonZero = !!unitValue; + const isNonZero = Boolean(unitValue); if (fullNameFormat && isNonZero) { // Remove traling 's' if unit value is singular diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index 19c4de6083d..9ddfb4bca11 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -1,4 +1,5 @@ import { BYTES_IN_KIB } from './constants'; +import { sprintf, __ } from '~/locale'; /** * Function that allows a number with an X amount of decimals @@ -72,13 +73,13 @@ export function bytesToGiB(number) { */ export function numberToHumanSize(size) { if (size < BYTES_IN_KIB) { - return `${size} bytes`; + return sprintf(__('%{size} bytes'), { size }); } else if (size < BYTES_IN_KIB * BYTES_IN_KIB) { - return `${bytesToKiB(size).toFixed(2)} KiB`; + return sprintf(__('%{size} KiB'), { size: bytesToKiB(size).toFixed(2) }); } else if (size < BYTES_IN_KIB * BYTES_IN_KIB * BYTES_IN_KIB) { - return `${bytesToMiB(size).toFixed(2)} MiB`; + return sprintf(__('%{size} MiB'), { size: bytesToMiB(size).toFixed(2) }); } - return `${bytesToGiB(size).toFixed(2)} GiB`; + return sprintf(__('%{size} GiB'), { size: bytesToGiB(size).toFixed(2) }); } /** diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 84a617acb42..b7922e29bb0 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -223,9 +223,9 @@ export function insertMarkdownText({ return tag.replace(textPlaceholder, val); } if (val.indexOf(tag) === 0) { - return '' + val.replace(tag, ''); + return String(val.replace(tag, '')); } else { - return '' + tag + val; + return String(tag) + val; } }) .join('\n'); @@ -233,7 +233,7 @@ export function insertMarkdownText({ } else if (tag.indexOf(textPlaceholder) > -1) { textToInsert = tag.replace(textPlaceholder, selected); } else { - textToInsert = '' + startChar + tag + selected + (wrap ? tag : ' '); + textToInsert = String(startChar) + tag + selected + (wrap ? tag : ' '); } if (removedFirstNewLine) { diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index bdfd06fc250..b5474fc5c71 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -121,4 +121,40 @@ export function webIDEUrl(route = undefined) { return returnUrl; } +/** + * Returns current base URL + */ +export function getBaseURL() { + const { protocol, host } = window.location; + return `${protocol}//${host}`; +} + +/** + * Returns true if url is an absolute or root-relative URL + * + * @param {String} url + */ +export function isAbsoluteOrRootRelative(url) { + return /^(https?:)?\//.test(url); +} + +/** + * Checks if the provided URL is a safe URL (absolute http(s) or root-relative URL) + * + * @param {String} url that will be checked + * @returns {Boolean} + */ +export function isSafeURL(url) { + if (!isAbsoluteOrRootRelative(url)) { + return false; + } + + try { + const parsedUrl = new URL(url, getBaseURL()); + return ['http:', 'https:'].includes(parsedUrl.protocol); + } catch (e) { + return false; + } +} + export { join as joinPaths } from 'path'; diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index afe8d87a8d6..c43791f2426 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -125,17 +125,17 @@ export default { }, earliestDatapoint() { return this.chartData.reduce((acc, series) => { - if (!series.data.length) { + const { data } = series; + const { length } = data; + if (!length) { return acc; } - const [[timestamp]] = series.data.sort(([a], [b]) => { - if (a < b) { - return -1; - } - return a > b ? 1 : 0; - }); - return timestamp < acc || acc === null ? timestamp : acc; + const [first] = data[0]; + const [last] = data[length - 1]; + const seriesEarliest = first < last ? first : last; + + return seriesEarliest < acc || acc === null ? seriesEarliest : acc; }, null); }, isMultiSeries() { diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index ff1e1805948..def810e879a 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -8,16 +8,14 @@ import { GlLink, } from '@gitlab/ui'; import _ from 'underscore'; +import { mapActions, mapState } from 'vuex'; import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import '~/vue_shared/mixins/is_ee'; import { getParameterValues } from '~/lib/utils/url_utility'; -import Flash from '../../flash'; -import MonitoringService from '../services/monitoring_service'; import MonitorAreaChart from './charts/area.vue'; import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; -import MonitoringStore from '../stores/monitoring_store'; import { timeWindows, timeWindowsKeyNames } from '../constants'; import { getTimeDiff } from '../utils'; @@ -40,7 +38,7 @@ export default { GlModalDirective, }, props: { - externalDashboardPath: { + externalDashboardUrl: { type: String, required: false, default: '', @@ -128,9 +126,7 @@ export default { }, data() { return { - store: new MonitoringStore(), state: 'gettingStarted', - showEmptyState: true, elWidth: 0, selectedTimeWindow: '', selectedTimeWindowKey: '', @@ -141,13 +137,21 @@ export default { canAddMetrics() { return this.customMetricsAvailable && this.customMetricsPath.length; }, + ...mapState('monitoringDashboard', [ + 'groups', + 'emptyState', + 'showEmptyState', + 'environments', + 'deploymentData', + ]), }, created() { - this.service = new MonitoringService({ + this.setEndpoints({ metricsEndpoint: this.metricsEndpoint, - deploymentEndpoint: this.deploymentEndpoint, environmentsEndpoint: this.environmentsEndpoint, + deploymentsEndpoint: this.deploymentEndpoint, }); + this.timeWindows = timeWindows; this.selectedTimeWindowKey = _.escape(getParameterValues('time_window')[0]) || timeWindowsKeyNames.eightHours; @@ -165,31 +169,11 @@ export default { } }, mounted() { - const startEndWindow = getTimeDiff(this.timeWindows[this.selectedTimeWindowKey]); - this.servicePromises = [ - this.service - .getGraphsData(startEndWindow) - .then(data => this.store.storeMetrics(data)) - .catch(() => Flash(s__('Metrics|There was an error while retrieving metrics'))), - this.service - .getDeploymentData() - .then(data => this.store.storeDeploymentData(data)) - .catch(() => Flash(s__('Metrics|There was an error getting deployment information.'))), - ]; if (!this.hasMetrics) { - this.state = 'gettingStarted'; + this.setGettingStartedEmptyState(); } else { - if (this.environmentsEndpoint) { - this.servicePromises.push( - this.service - .getEnvironmentsData() - .then(data => this.store.storeEnvironmentsData(data)) - .catch(() => - Flash(s__('Metrics|There was an error getting environments information.')), - ), - ); - } - this.getGraphsData(); + this.fetchData(getTimeDiff(this.timeWindows.eightHours)); + sidebarMutationObserver = new MutationObserver(this.onSidebarMutation); sidebarMutationObserver.observe(document.querySelector('.layout-page'), { attributes: true, @@ -199,6 +183,11 @@ export default { } }, methods: { + ...mapActions('monitoringDashboard', [ + 'fetchData', + 'setGettingStartedEmptyState', + 'setEndpoints', + ]), getGraphAlerts(queries) { if (!this.allAlerts) return {}; const metricIdsForChart = queries.map(q => q.metricId); @@ -207,21 +196,6 @@ export default { getGraphAlertValues(queries) { return Object.values(this.getGraphAlerts(queries)); }, - getGraphsData() { - this.state = 'loading'; - Promise.all(this.servicePromises) - .then(() => { - if (this.store.groups.length < 1) { - this.state = 'noData'; - return; - } - - this.showEmptyState = false; - }) - .catch(() => { - this.state = 'unableToConnect'; - }); - }, hideAddMetricModal() { this.$refs.addMetricModal.hide(); }, @@ -263,10 +237,10 @@ export default { class="prepend-left-10 js-environments-dropdown" toggle-class="dropdown-menu-toggle" :text="currentEnvironmentName" - :disabled="store.environmentsData.length === 0" + :disabled="environments.length === 0" > <gl-dropdown-item - v-for="environment in store.environmentsData" + v-for="environment in environments" :key="environment.id" :active="environment.name === currentEnvironmentName" active-class="is-active" @@ -274,7 +248,7 @@ export default { > </gl-dropdown> </div> - <div v-if="showTimeWindowDropdown" class="d-flex align-items-center"> + <div v-if="showTimeWindowDropdown" class="d-flex align-items-center prepend-left-8"> <strong>{{ s__('Metrics|Show last') }}</strong> <gl-dropdown class="prepend-left-10 js-time-window-dropdown" @@ -325,10 +299,11 @@ export default { </gl-modal> </div> <gl-button - v-if="externalDashboardPath.length" + v-if="externalDashboardUrl.length" class="js-external-dashboard-link prepend-left-8" variant="primary" - :href="externalDashboardPath" + :href="externalDashboardUrl" + target="_blank" > {{ __('View full dashboard') }} <icon name="external-link" /> @@ -336,7 +311,7 @@ export default { </div> </div> <graph-group - v-for="(groupData, index) in store.groups" + v-for="(groupData, index) in groups" :key="index" :name="groupData.group" :show-panels="showPanels" @@ -345,7 +320,7 @@ export default { v-for="(graphData, graphIndex) in groupData.metrics" :key="graphIndex" :graph-data="graphData" - :deployment-data="store.deploymentData" + :deployment-data="deploymentData" :thresholds="getGraphAlertValues(graphData.queries)" :container-width="elWidth" group-id="monitor-area-chart" @@ -362,7 +337,7 @@ export default { </div> <empty-state v-else - :selected-state="state" + :selected-state="emptyState" :documentation-path="documentationPath" :settings-path="settingsPath" :clusters-path="clustersPath" diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index 08dc57d545c..57771ccf4d9 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue'; +import store from './stores'; export default (props = {}) => { const el = document.getElementById('prometheus-graphs'); @@ -9,6 +10,7 @@ export default (props = {}) => { // eslint-disable-next-line no-new new Vue({ el, + store, render(createElement) { return createElement(Dashboard, { props: { diff --git a/app/assets/javascripts/monitoring/services/monitoring_service.js b/app/assets/javascripts/monitoring/services/monitoring_service.js deleted file mode 100644 index 1efa5189996..00000000000 --- a/app/assets/javascripts/monitoring/services/monitoring_service.js +++ /dev/null @@ -1,75 +0,0 @@ -import axios from '../../lib/utils/axios_utils'; -import statusCodes from '../../lib/utils/http_status'; -import { backOff } from '../../lib/utils/common_utils'; -import { s__, __ } from '../../locale'; - -const MAX_REQUESTS = 3; - -function backOffRequest(makeRequestCallback) { - let requestCounter = 0; - return backOff((next, stop) => { - makeRequestCallback() - .then(resp => { - if (resp.status === statusCodes.NO_CONTENT) { - requestCounter += 1; - if (requestCounter < MAX_REQUESTS) { - next(); - } else { - stop(new Error(__('Failed to connect to the prometheus server'))); - } - } else { - stop(resp); - } - }) - .catch(stop); - }); -} - -export default class MonitoringService { - constructor({ metricsEndpoint, deploymentEndpoint, environmentsEndpoint }) { - this.metricsEndpoint = metricsEndpoint; - this.deploymentEndpoint = deploymentEndpoint; - this.environmentsEndpoint = environmentsEndpoint; - } - - getGraphsData(params = {}) { - return backOffRequest(() => axios.get(this.metricsEndpoint, { params })) - .then(resp => resp.data) - .then(response => { - if (!response || !response.data || !response.success) { - throw new Error(s__('Metrics|Unexpected metrics data response from prometheus endpoint')); - } - return response.data; - }); - } - - getDeploymentData() { - if (!this.deploymentEndpoint) { - return Promise.resolve([]); - } - return backOffRequest(() => axios.get(this.deploymentEndpoint)) - .then(resp => resp.data) - .then(response => { - if (!response || !response.deployments) { - throw new Error( - s__('Metrics|Unexpected deployment data response from prometheus endpoint'), - ); - } - return response.deployments; - }); - } - - getEnvironmentsData() { - return axios - .get(this.environmentsEndpoint) - .then(resp => resp.data) - .then(response => { - if (!response || !response.environments) { - throw new Error( - s__('Metrics|There was an error fetching the environments data, please try again'), - ); - } - return response.environments; - }); - } -} diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js new file mode 100644 index 00000000000..63c23e8449d --- /dev/null +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -0,0 +1,117 @@ +import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; +import statusCodes from '../../lib/utils/http_status'; +import { backOff } from '../../lib/utils/common_utils'; +import { s__, __ } from '../../locale'; + +const MAX_REQUESTS = 3; + +function backOffRequest(makeRequestCallback) { + let requestCounter = 0; + return backOff((next, stop) => { + makeRequestCallback() + .then(resp => { + if (resp.status === statusCodes.NO_CONTENT) { + requestCounter += 1; + if (requestCounter < MAX_REQUESTS) { + next(); + } else { + stop(new Error(__('Failed to connect to the prometheus server'))); + } + } else { + stop(resp); + } + }) + .catch(stop); + }); +} + +export const setGettingStartedEmptyState = ({ commit }) => { + commit(types.SET_GETTING_STARTED_EMPTY_STATE); +}; + +export const setEndpoints = ({ commit }, endpoints) => { + commit(types.SET_ENDPOINTS, endpoints); +}; + +export const requestMetricsData = ({ commit }) => commit(types.REQUEST_METRICS_DATA); +export const receiveMetricsDataSuccess = ({ commit }, data) => + commit(types.RECEIVE_METRICS_DATA_SUCCESS, data); +export const receiveMetricsDataFailure = ({ commit }, error) => + commit(types.RECEIVE_METRICS_DATA_FAILURE, error); +export const receiveDeploymentsDataSuccess = ({ commit }, data) => + commit(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, data); +export const receiveDeploymentsDataFailure = ({ commit }) => + commit(types.RECEIVE_DEPLOYMENTS_DATA_FAILURE); +export const receiveEnvironmentsDataSuccess = ({ commit }, data) => + commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data); +export const receiveEnvironmentsDataFailure = ({ commit }) => + commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE); + +export const fetchData = ({ dispatch }, params) => { + dispatch('fetchMetricsData', params); + dispatch('fetchDeploymentsData'); + dispatch('fetchEnvironmentsData'); +}; + +export const fetchMetricsData = ({ state, dispatch }, params) => { + dispatch('requestMetricsData'); + + return backOffRequest(() => axios.get(state.metricsEndpoint, { params })) + .then(resp => resp.data) + .then(response => { + if (!response || !response.data || !response.success) { + dispatch('receiveMetricsDataFailure', null); + createFlash(s__('Metrics|Unexpected metrics data response from prometheus endpoint')); + } + dispatch('receiveMetricsDataSuccess', response.data); + }) + .catch(error => { + dispatch('receiveMetricsDataFailure', error); + createFlash(s__('Metrics|There was an error while retrieving metrics')); + }); +}; + +export const fetchDeploymentsData = ({ state, dispatch }) => { + if (!state.deploymentEndpoint) { + return Promise.resolve([]); + } + return backOffRequest(() => axios.get(state.deploymentEndpoint)) + .then(resp => resp.data) + .then(response => { + if (!response || !response.deployments) { + createFlash(s__('Metrics|Unexpected deployment data response from prometheus endpoint')); + } + + dispatch('receiveDeploymentsDataSuccess', response.deployments); + }) + .catch(() => { + dispatch('receiveDeploymentsDataFailure'); + createFlash(s__('Metrics|There was an error getting deployment information.')); + }); +}; + +export const fetchEnvironmentsData = ({ state, dispatch }) => { + if (!state.environmentsEndpoint) { + return Promise.resolve([]); + } + return axios + .get(state.environmentsEndpoint) + .then(resp => resp.data) + .then(response => { + if (!response || !response.environments) { + createFlash( + s__('Metrics|There was an error fetching the environments data, please try again'), + ); + } + dispatch('receiveEnvironmentsDataSuccess', response.environments); + }) + .catch(() => { + dispatch('receiveEnvironmentsDataFailure'); + createFlash(s__('Metrics|There was an error getting environments information.')); + }); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/index.js b/app/assets/javascripts/monitoring/stores/index.js new file mode 100644 index 00000000000..d58398c54ae --- /dev/null +++ b/app/assets/javascripts/monitoring/stores/index.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + modules: { + monitoringDashboard: { + namespaced: true, + actions, + mutations, + state, + }, + }, + }); + +export default createStore(); diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js new file mode 100644 index 00000000000..3fd9e07fa8b --- /dev/null +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -0,0 +1,12 @@ +export const REQUEST_METRICS_DATA = 'REQUEST_METRICS_DATA'; +export const RECEIVE_METRICS_DATA_SUCCESS = 'RECEIVE_METRICS_DATA_SUCCESS'; +export const RECEIVE_METRICS_DATA_FAILURE = 'RECEIVE_METRICS_DATA_FAILURE'; +export const REQUEST_DEPLOYMENTS_DATA = 'REQUEST_DEPLOYMENTS_DATA'; +export const RECEIVE_DEPLOYMENTS_DATA_SUCCESS = 'RECEIVE_DEPLOYMENTS_DATA_SUCCESS'; +export const RECEIVE_DEPLOYMENTS_DATA_FAILURE = 'RECEIVE_DEPLOYMENTS_DATA_FAILURE'; +export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA'; +export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS'; +export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE'; +export const SET_TIME_WINDOW = 'SET_TIME_WINDOW'; +export const SET_ENDPOINTS = 'SET_ENDPOINTS'; +export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js new file mode 100644 index 00000000000..c1779333d75 --- /dev/null +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -0,0 +1,45 @@ +import * as types from './mutation_types'; +import { normalizeMetrics, sortMetrics } from './utils'; + +export default { + [types.REQUEST_METRICS_DATA](state) { + state.emptyState = 'loading'; + state.showEmptyState = true; + }, + [types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) { + state.groups = groupData.map(group => ({ + ...group, + metrics: normalizeMetrics(sortMetrics(group.metrics)), + })); + + if (!state.groups.length) { + state.emptyState = 'noData'; + } else { + state.showEmptyState = false; + } + }, + [types.RECEIVE_METRICS_DATA_FAILURE](state, error) { + state.emptyState = error ? 'unableToConnect' : 'noData'; + state.showEmptyState = true; + }, + [types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](state, deployments) { + state.deploymentData = deployments; + }, + [types.RECEIVE_DEPLOYMENTS_DATA_FAILURE](state) { + state.deploymentData = []; + }, + [types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, environments) { + state.environments = environments; + }, + [types.RECEIVE_ENVIRONMENTS_DATA_FAILURE](state) { + state.environments = []; + }, + [types.SET_ENDPOINTS](state, endpoints) { + state.metricsEndpoint = endpoints.metricsEndpoint; + state.environmentsEndpoint = endpoints.environmentsEndpoint; + state.deploymentsEndpoint = endpoints.deploymentsEndpoint; + }, + [types.SET_GETTING_STARTED_EMPTY_STATE](state) { + state.emptyState = 'gettingStarted'; + }, +}; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js new file mode 100644 index 00000000000..5103122612a --- /dev/null +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -0,0 +1,12 @@ +export default () => ({ + hasMetrics: false, + showPanels: true, + metricsEndpoint: null, + environmentsEndpoint: null, + deploymentsEndpoint: null, + emptyState: 'gettingStarted', + showEmptyState: true, + groups: [], + deploymentData: [], + environments: [], +}); diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/utils.js index 013fb0d4540..9216554ecbf 100644 --- a/app/assets/javascripts/monitoring/stores/monitoring_store.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -1,12 +1,5 @@ import _ from 'underscore'; -function sortMetrics(metrics) { - return _.chain(metrics) - .sortBy('title') - .sortBy('weight') - .value(); -} - function checkQueryEmptyData(query) { return { ...query, @@ -59,7 +52,13 @@ function groupQueriesByChartInfo(metrics) { return Object.values(metricsByChart); } -function normalizeMetrics(metrics) { +export const sortMetrics = metrics => + _.chain(metrics) + .sortBy('title') + .sortBy('weight') + .value(); + +export const normalizeMetrics = metrics => { const groupedMetrics = groupQueriesByChartInfo(metrics); return groupedMetrics.map(metric => { @@ -81,31 +80,4 @@ function normalizeMetrics(metrics) { queries: removeTimeSeriesNoData(queries), }; }); -} - -export default class MonitoringStore { - constructor() { - this.groups = []; - this.deploymentData = []; - this.environmentsData = []; - } - - storeMetrics(groups = []) { - this.groups = groups.map(group => ({ - ...group, - metrics: normalizeMetrics(sortMetrics(group.metrics)), - })); - } - - storeDeploymentData(deploymentData = []) { - this.deploymentData = deploymentData; - } - - storeEnvironmentsData(environmentsData = []) { - this.environmentsData = environmentsData.filter(environment => !!environment.last_deployment); - } - - getMetricsCount() { - return this.groups.reduce((count, group) => count + group.metrics.length, 0); - } -} +}; diff --git a/app/assets/javascripts/mr_notes/stores/getters.js b/app/assets/javascripts/mr_notes/stores/getters.js index b10e9f9f9f1..e48cfcd9564 100644 --- a/app/assets/javascripts/mr_notes/stores/getters.js +++ b/app/assets/javascripts/mr_notes/stores/getters.js @@ -1,5 +1,5 @@ export default { isLoggedIn(state, getters) { - return !!getters.getUserData.id; + return Boolean(getters.getUserData.id); }, }; diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index c7cfc0f0f3b..307e56708e0 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -57,7 +57,7 @@ export default { class="line-resolve-btn is-disabled" type="button" > - <icon name="check-circle" /> + <icon :name="allResolved ? 'check-circle-filled' : 'check-circle'" /> </span> <span class="line-resolve-text"> {{ resolvedDiscussionsCount }}/{{ resolvableDiscussionsCount }} diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 5b6163a6214..228bb652597 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -49,7 +49,7 @@ export default { computed: { ...mapGetters(['userCanReply']), hasReplies() { - return !!this.replies.length; + return Boolean(this.replies.length); }, replies() { return this.discussion.notes.slice(1); diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 7e8f23d6a96..c9c40cb6acf 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -135,7 +135,7 @@ export default { @click="onResolve" > <template v-if="!isResolving"> - <icon name="check-circle" /> + <icon :name="isResolved ? 'check-circle-filled' : 'check-circle'" /> </template> <gl-loading-icon v-else inline /> </button> @@ -147,13 +147,11 @@ export default { class="note-action-button note-emoji-button js-add-award js-note-emoji" href="#" title="Add reaction" + data-position="right" > - <icon - css-classes="link-highlight award-control-icon-neutral" - name="emoji_slightly_smiling_face" - /> - <icon css-classes="link-highlight award-control-icon-positive" name="emoji_smiley" /> - <icon css-classes="link-highlight award-control-icon-super-positive" name="emoji_smiley" /> + <icon css-classes="link-highlight award-control-icon-neutral" name="slight-smile" /> + <icon css-classes="link-highlight award-control-icon-positive" name="smiley" /> + <icon css-classes="link-highlight award-control-icon-super-positive" name="smiley" /> </a> </div> <reply-button diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 17e5fcab5b7..941b6d5cab3 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -189,13 +189,13 @@ export default { type="button" > <span class="award-control-icon award-control-icon-neutral"> - <icon name="emoji_slightly_smiling_face" /> + <icon name="slight-smile" /> </span> <span class="award-control-icon award-control-icon-positive"> - <icon name="emoji_smiley" /> + <icon name="smiley" /> </span> <span class="award-control-icon award-control-icon-super-positive"> - <icon name="emoji_smiley" /> + <icon name="smiley" /> </span> <i aria-hidden="true" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 47d74c2f892..aa80e25a3e0 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -10,7 +10,7 @@ import Flash from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import noteHeader from './note_header.vue'; import noteActions from './note_actions.vue'; -import noteBody from './note_body.vue'; +import NoteBody from './note_body.vue'; import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; @@ -21,7 +21,7 @@ export default { userAvatarLink, noteHeader, noteActions, - noteBody, + NoteBody, TimelineEntryItem, }, mixins: [noteable, resolvable, draftMixin], @@ -75,7 +75,7 @@ export default { }; }, canReportAsAbuse() { - return !!this.note.report_abuse_path && this.author.id !== this.getUserData.id; + return Boolean(this.note.report_abuse_path) && this.author.id !== this.getUserData.id; }, noteAnchorId() { return `note_${this.note.id}`; @@ -209,7 +209,10 @@ export default { // we need to do this to prevent noteForm inconsistent content warning // this is something we intentionally do so we need to recover the content this.note.note = noteText; - this.$refs.noteBody.note.note = noteText; + const { noteBody } = this.$refs; + if (noteBody) { + noteBody.note.note = noteText; + } }, }, }; diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 0f1976db37d..4d00e957973 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -127,6 +127,9 @@ export default { initUserPopovers(this.$el.querySelectorAll('.js-user-link')); }); }, + beforeDestroy() { + this.stopPolling(); + }, methods: { ...mapActions([ 'setLoadingState', @@ -144,6 +147,7 @@ export default { 'expandDiscussion', 'startTaskList', 'convertToDiscussion', + 'stopPolling', ]), fetchNotes() { if (this.isFetching) return null; diff --git a/app/assets/javascripts/notes/mixins/issuable_state.js b/app/assets/javascripts/notes/mixins/issuable_state.js index 97f3ea0d5de..ded0ac3cfa9 100644 --- a/app/assets/javascripts/notes/mixins/issuable_state.js +++ b/app/assets/javascripts/notes/mixins/issuable_state.js @@ -1,11 +1,11 @@ export default { methods: { isConfidential(issue) { - return !!issue.confidential; + return Boolean(issue.confidential); }, isLocked(issue) { - return !!issue.discussion_locked; + return Boolean(issue.discussion_locked); }, hasWarning(issue) { diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 2d150e64ef7..d7982be3e4b 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -20,7 +20,7 @@ export const getNoteableData = state => state.noteableData; export const getNoteableDataByProp = state => prop => state.noteableData[prop]; -export const userCanReply = state => !!state.noteableData.current_user.can_create_note; +export const userCanReply = state => Boolean(state.noteableData.current_user.can_create_note); export const openState = state => state.noteableData.state; diff --git a/app/assets/javascripts/operation_settings/components/external_dashboard.vue b/app/assets/javascripts/operation_settings/components/external_dashboard.vue index 0a87d193b72..ed518611d0b 100644 --- a/app/assets/javascripts/operation_settings/components/external_dashboard.vue +++ b/app/assets/javascripts/operation_settings/components/external_dashboard.vue @@ -1,4 +1,5 @@ <script> +import { mapState, mapActions } from 'vuex'; import { GlButton, GlFormGroup, GlFormInput, GlLink } from '@gitlab/ui'; export default { @@ -8,26 +9,34 @@ export default { GlFormInput, GlLink, }, - props: { - externalDashboardPath: { - type: String, - required: false, - default: '', - }, - externalDashboardHelpPagePath: { - type: String, - required: true, + computed: { + ...mapState([ + 'externalDashboardHelpPagePath', + 'externalDashboardUrl', + 'operationsSettingsEndpoint', + ]), + userDashboardUrl: { + get() { + return this.externalDashboardUrl; + }, + set(url) { + this.setExternalDashboardUrl(url); + }, }, }, + methods: { + ...mapActions(['setExternalDashboardUrl', 'updateExternalDashboardUrl']), + }, }; </script> <template> - <section class="settings expanded"> + <section class="settings no-animate"> <div class="settings-header"> <h4 class="js-section-header"> {{ s__('ExternalMetrics|External Dashboard') }} </h4> + <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> <p class="js-section-sub-header"> {{ s__( @@ -44,11 +53,12 @@ export default { :description="s__('ExternalMetrics|Enter the URL of the dashboard you want to link to')" > <gl-form-input - :value="externalDashboardPath" + v-model="userDashboardUrl" placeholder="https://my-org.gitlab.io/my-dashboards" + @keydown.enter.native.prevent="updateExternalDashboardUrl" /> </gl-form-group> - <gl-button variant="success"> + <gl-button variant="success" @click="updateExternalDashboardUrl"> {{ __('Save Changes') }} </gl-button> </form> diff --git a/app/assets/javascripts/operation_settings/index.js b/app/assets/javascripts/operation_settings/index.js index 1171f3ece9f..6946578e6d2 100644 --- a/app/assets/javascripts/operation_settings/index.js +++ b/app/assets/javascripts/operation_settings/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import store from './store'; import ExternalDashboardForm from './components/external_dashboard.vue'; export default () => { @@ -14,13 +15,9 @@ export default () => { return new Vue({ el, + store: store(el.dataset), render(createElement) { - return createElement(ExternalDashboardForm, { - props: { - ...el.dataset, - expanded: false, - }, - }); + return createElement(ExternalDashboardForm); }, }); }; diff --git a/app/assets/javascripts/operation_settings/store/actions.js b/app/assets/javascripts/operation_settings/store/actions.js new file mode 100644 index 00000000000..ec05b0c76cf --- /dev/null +++ b/app/assets/javascripts/operation_settings/store/actions.js @@ -0,0 +1,38 @@ +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import * as mutationTypes from './mutation_types'; + +export const setExternalDashboardUrl = ({ commit }, url) => + commit(mutationTypes.SET_EXTERNAL_DASHBOARD_URL, url); + +export const updateExternalDashboardUrl = ({ state, dispatch }) => + axios + .patch(state.operationsSettingsEndpoint, { + project: { + metrics_setting_attributes: { + external_dashboard_url: state.externalDashboardUrl, + }, + }, + }) + .then(() => dispatch('receiveExternalDashboardUpdateSuccess')) + .catch(error => dispatch('receiveExternalDashboardUpdateError', error)); + +export const receiveExternalDashboardUpdateSuccess = () => { + /** + * The operations_controller currently handles successful requests + * by creating a flash banner messsage to notify the user. + */ + refreshCurrentPage(); +}; + +export const receiveExternalDashboardUpdateError = (_, error) => { + const { response } = error; + const message = response.data && response.data.message ? response.data.message : ''; + + createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert'); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/operation_settings/store/index.js b/app/assets/javascripts/operation_settings/store/index.js new file mode 100644 index 00000000000..e96bb1e8aad --- /dev/null +++ b/app/assets/javascripts/operation_settings/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export const createStore = initialState => + new Vuex.Store({ + state: createState(initialState), + actions, + mutations, + }); + +export default createStore; diff --git a/app/assets/javascripts/operation_settings/store/mutation_types.js b/app/assets/javascripts/operation_settings/store/mutation_types.js new file mode 100644 index 00000000000..237d2b6122f --- /dev/null +++ b/app/assets/javascripts/operation_settings/store/mutation_types.js @@ -0,0 +1,3 @@ +/* eslint-disable import/prefer-default-export */ + +export const SET_EXTERNAL_DASHBOARD_URL = 'SET_EXTERNAL_DASHBOARD_URL'; diff --git a/app/assets/javascripts/operation_settings/store/mutations.js b/app/assets/javascripts/operation_settings/store/mutations.js new file mode 100644 index 00000000000..64bb33bb89f --- /dev/null +++ b/app/assets/javascripts/operation_settings/store/mutations.js @@ -0,0 +1,7 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_EXTERNAL_DASHBOARD_URL](state, url) { + state.externalDashboardUrl = url; + }, +}; diff --git a/app/assets/javascripts/operation_settings/store/state.js b/app/assets/javascripts/operation_settings/store/state.js new file mode 100644 index 00000000000..72167141c48 --- /dev/null +++ b/app/assets/javascripts/operation_settings/store/state.js @@ -0,0 +1,5 @@ +export default (initialState = {}) => ({ + externalDashboardUrl: initialState.externalDashboardUrl || '', + operationsSettingsEndpoint: initialState.operationsSettingsEndpoint, + externalDashboardHelpPagePath: initialState.externalDashboardHelpPagePath, +}); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index bd4309e47ad..bb490919a9a 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue @@ -29,7 +29,7 @@ export default { // The text input is editable when there's a custom interval, or when it's // a preset interval and the user clicks the 'custom' radio button isEditable() { - return !!(this.customInputEnabled || !this.intervalIsPreset); + return Boolean(this.customInputEnabled || !this.intervalIsPreset); }, }, watch: { diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js index 5270a7924ec..98e19705976 100644 --- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js @@ -1,7 +1,9 @@ import mountErrorTrackingForm from '~/error_tracking_settings'; import mountOperationSettings from '~/operation_settings'; +import initSettingsPanels from '~/settings_panels'; document.addEventListener('DOMContentLoaded', () => { mountErrorTrackingForm(); mountOperationSettings(); + initSettingsPanels(); }); diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue index 482898b80c4..ebd7a17040a 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -69,7 +69,9 @@ export default { > <ci-icon :status="group.status" /> - <span class="ci-status-text"> {{ group.name }} </span> + <span class="ci-status-text text-truncate mw-70p gl-pl-1 d-inline-block align-bottom"> + {{ group.name }} + </span> <span class="dropdown-counter-badge"> {{ group.size }} </span> </button> diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index b2e365e5cde..f3a71ee434c 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -83,6 +83,8 @@ export default { v-if="shouldRenderContent" :status="status" :item-id="pipeline.id" + :item-iid="pipeline.iid" + :item-id-tooltip="__('Pipeline ID (IID)')" :time="pipeline.created_at" :user="pipeline.user" :actions="actions" diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index c41ecab1294..00c02e15562 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -2,6 +2,7 @@ import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import _ from 'underscore'; import { __, sprintf } from '~/locale'; +import PipelineLink from '~/vue_shared/components/ci_pipeline_link.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import popover from '~/vue_shared/directives/popover'; @@ -19,6 +20,7 @@ export default { components: { UserAvatarLink, GlLink, + PipelineLink, }, directives: { GlTooltip: GlTooltipDirective, @@ -59,10 +61,13 @@ export default { }; </script> <template> - <div class="table-section section-10 d-none d-sm-none d-md-block pipeline-tags"> - <gl-link :href="pipeline.path" class="js-pipeline-url-link"> - <span class="pipeline-id">#{{ pipeline.id }}</span> - </gl-link> + <div class="table-section section-10 d-none d-sm-none d-md-block pipeline-tags section-wrap"> + <pipeline-link + :href="pipeline.path" + :pipeline-id="pipeline.id" + :pipeline-iid="pipeline.iid" + class="js-pipeline-url-link" + /> <div class="label-container"> <span v-if="pipeline.flags.latest" diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js index 59c13e1a042..f0d9642a2b2 100644 --- a/app/assets/javascripts/profile/account/index.js +++ b/app/assets/javascripts/profile/account/index.js @@ -35,7 +35,7 @@ export default () => { return createElement('delete-account-modal', { props: { actionUrl: deleteAccountModalEl.dataset.actionUrl, - confirmWithPassword: !!deleteAccountModalEl.dataset.confirmWithPassword, + confirmWithPassword: Boolean(deleteAccountModalEl.dataset.confirmWithPassword), username: deleteAccountModalEl.dataset.username, }, }); diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index 6e3800021b4..8dd37aee7e1 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -39,6 +39,7 @@ export default class Profile { bindEvents() { $('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm); + $('.js-group-notification-email').on('change', this.submitForm); $('#user_notification_email').on('change', this.submitForm); $('#user_notified_of_own_activity').on('change', this.submitForm); this.form.on('submit', this.onSubmitForm); diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js index 4834a856271..f05ad7773a2 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js @@ -57,7 +57,7 @@ export const validateProjectBilling = ({ dispatch, commit, state }) => resp => { const { billingEnabled } = resp.result; - commit(types.SET_PROJECT_BILLING_STATUS, !!billingEnabled); + commit(types.SET_PROJECT_BILLING_STATUS, Boolean(billingEnabled)); dispatch('setIsValidatingProjectBilling', false); resolve(); }, diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js index e39f02d0894..f9e2e2f74fb 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js @@ -1,3 +1,3 @@ -export const hasProject = state => !!state.selectedProject.projectId; -export const hasZone = state => !!state.selectedZone; -export const hasMachineType = state => !!state.selectedMachineType; +export const hasProject = state => Boolean(state.selectedProject.projectId); +export const hasZone = state => Boolean(state.selectedZone); +export const hasMachineType = state => Boolean(state.selectedMachineType); diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js index 1ac699c538f..8ace6657ad1 100644 --- a/app/assets/javascripts/registry/stores/mutations.js +++ b/app/assets/javascripts/registry/stores/mutations.js @@ -9,7 +9,7 @@ export default { [types.SET_REPOS_LIST](state, list) { Object.assign(state, { repos: list.map(el => ({ - canDelete: !!el.destroy_path, + canDelete: Boolean(el.destroy_path), destroyPath: el.destroy_path, id: el.id, isLoading: false, @@ -42,7 +42,7 @@ export default { location: element.location, createdAt: element.created_at, destroyPath: element.destroy_path, - canDelete: !!element.destroy_path, + canDelete: Boolean(element.destroy_path), })); }, diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue new file mode 100644 index 00000000000..6eca015036f --- /dev/null +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -0,0 +1,61 @@ +<script> +import getRefMixin from '../mixins/get_ref'; +import getProjectShortPath from '../queries/getProjectShortPath.graphql'; + +export default { + apollo: { + projectShortPath: { + query: getProjectShortPath, + }, + }, + mixins: [getRefMixin], + props: { + currentPath: { + type: String, + required: false, + default: '/', + }, + }, + data() { + return { + projectShortPath: '', + }; + }, + computed: { + pathLinks() { + return this.currentPath + .split('/') + .filter(p => p !== '') + .reduce( + (acc, name, i) => { + const path = `${i > 0 ? acc[i].path : ''}/${name}`; + + return acc.concat({ + name, + path, + to: `/tree/${this.ref}${path}`, + }); + }, + [{ name: this.projectShortPath, path: '/', to: `/tree/${this.ref}` }], + ); + }, + }, + methods: { + isLast(i) { + return i === this.pathLinks.length - 1; + }, + }, +}; +</script> + +<template> + <nav :aria-label="__('Files breadcrumb')"> + <ol class="breadcrumb repo-breadcrumb"> + <li v-for="(link, i) in pathLinks" :key="i" class="breadcrumb-item"> + <router-link :to="link.to" :aria-current="isLast(i) ? 'page' : null"> + {{ link.name }} + </router-link> + </li> + </ol> + </nav> +</template> diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 7119861c7b3..cccde1bb278 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -1,25 +1,27 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; +import createFlash from '~/flash'; import { sprintf, __ } from '../../../locale'; import getRefMixin from '../../mixins/get_ref'; import getFiles from '../../queries/getFiles.graphql'; +import getProjectPath from '../../queries/getProjectPath.graphql'; import TableHeader from './header.vue'; +import TableRow from './row.vue'; +import ParentRow from './parent_row.vue'; + +const PAGE_SIZE = 100; export default { components: { GlLoadingIcon, TableHeader, + TableRow, + ParentRow, }, mixins: [getRefMixin], apollo: { - files: { - query: getFiles, - variables() { - return { - ref: this.ref, - path: this.path, - }; - }, + projectPath: { + query: getProjectPath, }, }, props: { @@ -30,7 +32,14 @@ export default { }, data() { return { - files: [], + projectPath: '', + nextPageCursor: '', + entries: { + trees: [], + submodules: [], + blobs: [], + }, + isLoadingFiles: false, }; }, computed: { @@ -40,8 +49,66 @@ export default { { path: this.path, ref: this.ref }, ); }, - isLoadingFiles() { - return this.$apollo.queries.files.loading; + showParentRow() { + return !this.isLoadingFiles && ['', '/'].indexOf(this.path) === -1; + }, + }, + watch: { + $route: function routeChange() { + this.entries.trees = []; + this.entries.submodules = []; + this.entries.blobs = []; + this.nextPageCursor = ''; + this.fetchFiles(); + }, + }, + mounted() { + // We need to wait for `ref` and `projectPath` to be set + this.$nextTick(() => this.fetchFiles()); + }, + methods: { + fetchFiles() { + this.isLoadingFiles = true; + + return this.$apollo + .query({ + query: getFiles, + variables: { + projectPath: this.projectPath, + ref: this.ref, + path: this.path, + nextPageCursor: this.nextPageCursor, + pageSize: PAGE_SIZE, + }, + }) + .then(({ data }) => { + if (!data) return; + + const pageInfo = this.hasNextPage(data.project.repository.tree); + + this.isLoadingFiles = false; + this.entries = Object.keys(this.entries).reduce( + (acc, key) => ({ + ...acc, + [key]: this.normalizeData(key, data.project.repository.tree[key].edges), + }), + {}, + ); + + if (pageInfo && pageInfo.hasNextPage) { + this.nextPageCursor = pageInfo.endCursor; + this.fetchFiles(); + } + }) + .catch(() => createFlash(__('An error occurred while fetching folder content.'))); + }, + normalizeData(key, data) { + return this.entries[key].concat(data.map(({ node }) => node)); + }, + hasNextPage(data) { + return [] + .concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo) + .find(({ hasNextPage }) => hasNextPage); }, }, }; @@ -56,10 +123,22 @@ export default { tableCaption }} </caption> - <table-header /> - <tbody></tbody> + <table-header v-once /> + <tbody> + <parent-row v-show="showParentRow" :commit-ref="ref" :path="path" /> + <template v-for="val in entries"> + <table-row + v-for="entry in val" + :id="entry.id" + :key="`${entry.flatPath}-${entry.id}`" + :current-path="path" + :path="entry.flatPath" + :type="entry.type" + /> + </template> + </tbody> </table> - <gl-loading-icon v-if="isLoadingFiles" class="my-3" size="md" /> + <gl-loading-icon v-show="isLoadingFiles" class="my-3" size="md" /> </div> </div> </template> diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue new file mode 100644 index 00000000000..3c39f404226 --- /dev/null +++ b/app/assets/javascripts/repository/components/table/parent_row.vue @@ -0,0 +1,37 @@ +<script> +export default { + props: { + commitRef: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + }, + computed: { + parentRoute() { + const splitArray = this.path.split('/'); + splitArray.pop(); + + return { path: `/tree/${this.commitRef}/${splitArray.join('/')}` }; + }, + }, + methods: { + clickRow() { + this.$router.push(this.parentRoute); + }, + }, +}; +</script> + +<template> + <tr class="tree-item"> + <td colspan="3" class="tree-item-file-name" @click.self="clickRow"> + <router-link :to="parentRoute" :aria-label="__('Go to parent')"> + .. + </router-link> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue new file mode 100644 index 00000000000..9a264bef87e --- /dev/null +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -0,0 +1,72 @@ +<script> +import { getIconName } from '../../utils/icon'; +import getRefMixin from '../../mixins/get_ref'; + +export default { + mixins: [getRefMixin], + props: { + id: { + type: String, + required: true, + }, + currentPath: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + type: { + type: String, + required: true, + }, + }, + computed: { + routerLinkTo() { + return this.isFolder ? { path: `/tree/${this.ref}/${this.path}` } : null; + }, + iconName() { + return `fa-${getIconName(this.type, this.path)}`; + }, + isFolder() { + return this.type === 'tree'; + }, + isSubmodule() { + return this.type === 'commit'; + }, + linkComponent() { + return this.isFolder ? 'router-link' : 'a'; + }, + fullPath() { + return this.path.replace(new RegExp(`^${this.currentPath}/`), ''); + }, + shortSha() { + return this.id.slice(0, 8); + }, + }, + methods: { + openRow() { + if (this.isFolder) { + this.$router.push(this.routerLinkTo); + } + }, + }, +}; +</script> + +<template> + <tr v-once :class="`file_${id}`" class="tree-item" @click="openRow"> + <td class="tree-item-file-name"> + <i :aria-label="type" role="img" :class="iconName" class="fa fa-fw"></i> + <component :is="linkComponent" :to="routerLinkTo" class="str-truncated"> + {{ fullPath }} + </component> + <template v-if="isSubmodule"> + @ <a href="#" class="commit-sha">{{ shortSha }}</a> + </template> + </td> + <td class="d-none d-sm-table-cell tree-commit"></td> + <td class="tree-time-ago text-right"></td> + </tr> +</template> diff --git a/app/assets/javascripts/repository/fragmentTypes.json b/app/assets/javascripts/repository/fragmentTypes.json new file mode 100644 index 00000000000..949ebca432b --- /dev/null +++ b/app/assets/javascripts/repository/fragmentTypes.json @@ -0,0 +1 @@ +{"__schema":{"types":[{"kind":"INTERFACE","name":"Entry","possibleTypes":[{"name":"Blob"},{"name":"Submodule"},{"name":"TreeEntry"}]}]}} diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js index 0aedc73fc12..c64d16ef02a 100644 --- a/app/assets/javascripts/repository/graphql.js +++ b/app/assets/javascripts/repository/graphql.js @@ -1,16 +1,42 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import createDefaultClient from '~/lib/graphql'; +import introspectionQueryResultData from './fragmentTypes.json'; Vue.use(VueApollo); -const defaultClient = createDefaultClient({ - Query: { - files() { - return []; +// We create a fragment matcher so that we can create a fragment from an interface +// Without this, Apollo throws a heuristic fragment matcher warning +const fragmentMatcher = new IntrospectionFragmentMatcher({ + introspectionQueryResultData, +}); + +const defaultClient = createDefaultClient( + {}, + { + cacheConfig: { + fragmentMatcher, + dataIdFromObject: obj => { + // eslint-disable-next-line no-underscore-dangle + switch (obj.__typename) { + // We need to create a dynamic ID for each entry + // Each entry can have the same ID as the ID is a commit ID + // So we create a unique cache ID with the path and the ID + case 'TreeEntry': + case 'Submodule': + case 'Blob': + return `${obj.flatPath}-${obj.id}`; + default: + // If the type doesn't match any of the above we fallback + // to using the default Apollo ID + // eslint-disable-next-line no-underscore-dangle + return obj.id || obj._id; + } + }, }, }, -}); +); export default new VueApollo({ defaultClient, diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 00b69362312..52f53be045b 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -1,22 +1,56 @@ import Vue from 'vue'; import createRouter from './router'; import App from './components/app.vue'; +import Breadcrumbs from './components/breadcrumbs.vue'; import apolloProvider from './graphql'; +import { setTitle } from './utils/title'; export default function setupVueRepositoryList() { const el = document.getElementById('js-tree-list'); - const { projectPath, ref } = el.dataset; + const { projectPath, projectShortPath, ref, fullName } = el.dataset; + const router = createRouter(projectPath, ref); apolloProvider.clients.defaultClient.cache.writeData({ data: { projectPath, + projectShortPath, ref, }, }); + router.afterEach(({ params: { pathMatch } }) => { + const isRoot = pathMatch === undefined || pathMatch === '/'; + + setTitle(pathMatch, ref, fullName); + + if (!isRoot) { + document + .querySelectorAll('.js-keep-hidden-on-navigation') + .forEach(elem => elem.classList.add('hidden')); + } + + document + .querySelectorAll('.js-hide-on-navigation') + .forEach(elem => elem.classList.toggle('hidden', !isRoot)); + }); + + // eslint-disable-next-line no-new + new Vue({ + el: document.getElementById('js-repo-breadcrumb'), + router, + apolloProvider, + render(h) { + return h(Breadcrumbs, { + props: { + currentPath: this.$route.params.pathMatch, + }, + }); + }, + }); + return new Vue({ el, - router: createRouter(projectPath, ref), + router, apolloProvider, render(h) { return h(App); diff --git a/app/assets/javascripts/repository/pages/tree.vue b/app/assets/javascripts/repository/pages/tree.vue index 413102b2cd3..3b898d1aa91 100644 --- a/app/assets/javascripts/repository/pages/tree.vue +++ b/app/assets/javascripts/repository/pages/tree.vue @@ -2,7 +2,7 @@ import FileTable from '../components/table/index.vue'; export default { - component: { + components: { FileTable, }, props: { diff --git a/app/assets/javascripts/repository/queries/getFiles.graphql b/app/assets/javascripts/repository/queries/getFiles.graphql index 5ceaf67ea82..a9b61d28560 100644 --- a/app/assets/javascripts/repository/queries/getFiles.graphql +++ b/app/assets/javascripts/repository/queries/getFiles.graphql @@ -1,8 +1,55 @@ -query getFiles($path: String!, $ref: String!) { - files(path: $path, ref: $ref) @client { - id - name - fullPath - type +fragment TreeEntry on Entry { + id + flatPath + type +} + +fragment PageInfo on PageInfo { + hasNextPage + endCursor +} + +query getFiles( + $projectPath: ID! + $path: String + $ref: String! + $pageSize: Int! + $nextPageCursor: String +) { + project(fullPath: $projectPath) { + repository { + tree(path: $path, ref: $ref) { + trees(first: $pageSize, after: $nextPageCursor) { + edges { + node { + ...TreeEntry + } + } + pageInfo { + ...PageInfo + } + } + submodules(first: $pageSize, after: $nextPageCursor) { + edges { + node { + ...TreeEntry + } + } + pageInfo { + ...PageInfo + } + } + blobs(first: $pageSize, after: $nextPageCursor) { + edges { + node { + ...TreeEntry + } + } + pageInfo { + ...PageInfo + } + } + } + } } } diff --git a/app/assets/javascripts/repository/queries/getProjectPath.graphql b/app/assets/javascripts/repository/queries/getProjectPath.graphql new file mode 100644 index 00000000000..74e73e07577 --- /dev/null +++ b/app/assets/javascripts/repository/queries/getProjectPath.graphql @@ -0,0 +1,3 @@ +query getProjectPath { + projectPath +} diff --git a/app/assets/javascripts/repository/queries/getProjectShortPath.graphql b/app/assets/javascripts/repository/queries/getProjectShortPath.graphql new file mode 100644 index 00000000000..34eb26598c2 --- /dev/null +++ b/app/assets/javascripts/repository/queries/getProjectShortPath.graphql @@ -0,0 +1,3 @@ +query getProjectShortPath { + projectShortPath @client +} diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js index b42a96a4ee2..9322c81ab97 100644 --- a/app/assets/javascripts/repository/router.js +++ b/app/assets/javascripts/repository/router.js @@ -12,24 +12,17 @@ export default function createRouter(base, baseRef) { base: joinPaths(gon.relative_url_root || '', base), routes: [ { - path: '/', - name: 'projectRoot', - component: IndexPage, - }, - { path: `/tree/${baseRef}(/.*)?`, name: 'treePath', component: TreePage, props: route => ({ - path: route.params.pathMatch, + path: route.params.pathMatch && route.params.pathMatch.replace(/^\//, ''), }), - beforeEnter(to, from, next) { - document - .querySelectorAll('.js-hide-on-navigation') - .forEach(el => el.classList.add('hidden')); - - next(); - }, + }, + { + path: '/', + name: 'projectRoot', + component: IndexPage, }, ], }); diff --git a/app/assets/javascripts/repository/utils/icon.js b/app/assets/javascripts/repository/utils/icon.js new file mode 100644 index 00000000000..661ebb6edfc --- /dev/null +++ b/app/assets/javascripts/repository/utils/icon.js @@ -0,0 +1,99 @@ +const entryTypeIcons = { + tree: 'folder', + commit: 'archive', +}; + +const fileTypeIcons = [ + { extensions: ['pdf'], name: 'file-pdf-o' }, + { + extensions: [ + 'jpg', + 'jpeg', + 'jif', + 'jfif', + 'jp2', + 'jpx', + 'j2k', + 'j2c', + 'png', + 'gif', + 'tif', + 'tiff', + 'svg', + 'ico', + 'bmp', + ], + name: 'file-image-o', + }, + { + extensions: ['zip', 'zipx', 'tar', 'gz', 'bz', 'bzip', 'xz', 'rar', '7z'], + name: 'file-archive-o', + }, + { extensions: ['mp3', 'wma', 'ogg', 'oga', 'wav', 'flac', 'aac'], name: 'file-audio-o' }, + { + extensions: [ + 'mp4', + 'm4p', + 'm4v', + 'mpg', + 'mp2', + 'mpeg', + 'mpe', + 'mpv', + 'm2v', + 'avi', + 'mkv', + 'flv', + 'ogv', + 'mov', + '3gp', + '3g2', + ], + name: 'file-video-o', + }, + { extensions: ['doc', 'dot', 'docx', 'docm', 'dotx', 'dotm', 'docb'], name: 'file-word-o' }, + { + extensions: [ + 'xls', + 'xlt', + 'xlm', + 'xlsx', + 'xlsm', + 'xltx', + 'xltm', + 'xlsb', + 'xla', + 'xlam', + 'xll', + 'xlw', + ], + name: 'file-excel-o', + }, + { + extensions: [ + 'ppt', + 'pot', + 'pps', + 'pptx', + 'pptm', + 'potx', + 'potm', + 'ppam', + 'ppsx', + 'ppsm', + 'sldx', + 'sldm', + ], + name: 'file-powerpoint-o', + }, +]; + +// eslint-disable-next-line import/prefer-default-export +export const getIconName = (type, path) => { + if (entryTypeIcons[type]) return entryTypeIcons[type]; + + const extension = path.split('.').pop(); + const file = fileTypeIcons.find(t => t.extensions.some(ext => ext === extension)); + + return file ? file.name : 'file-text-o'; +}; diff --git a/app/assets/javascripts/repository/utils/title.js b/app/assets/javascripts/repository/utils/title.js new file mode 100644 index 00000000000..4e194640e92 --- /dev/null +++ b/app/assets/javascripts/repository/utils/title.js @@ -0,0 +1,9 @@ +// eslint-disable-next-line import/prefer-default-export +export const setTitle = (pathMatch, ref, project) => { + if (!pathMatch) return; + + const path = pathMatch.replace(/^\//, ''); + const isEmpty = path === ''; + + document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project}`; +}; diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 72e061df573..930c0d5e958 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -82,9 +82,9 @@ Sidebar.prototype.toggleTodo = function(e) { ajaxType = $this.data('deletePath') ? 'delete' : 'post'; if ($this.data('deletePath')) { - url = '' + $this.data('deletePath'); + url = String($this.data('deletePath')); } else { - url = '' + $this.data('createPath'); + url = String($this.data('createPath')); } $this.tooltip('hide'); diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 0a4583b5861..6aca4067ba7 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import { escape, throttle } from 'underscore'; -import { s__, sprintf } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper'; import axios from './lib/utils/axios_utils'; import DropdownUtils from './filtered_search/dropdown_utils'; @@ -379,7 +379,7 @@ export class SearchAutocomplete { } } } - this.wrap.toggleClass('has-value', !!e.target.value); + this.wrap.toggleClass('has-value', Boolean(e.target.value)); } onSearchInputFocus() { @@ -396,7 +396,7 @@ export class SearchAutocomplete { onClearInputClick(e) { e.preventDefault(); - this.wrap.toggleClass('has-value', !!e.target.value); + this.wrap.toggleClass('has-value', Boolean(e.target.value)); return this.searchInput.val('').focus(); } @@ -405,8 +405,9 @@ export class SearchAutocomplete { this.wrap.removeClass('search-active'); // If input is blank then restore state if (this.searchInput.val() === '') { - return this.restoreOriginalState(); + this.restoreOriginalState(); } + this.dropdownMenu.removeClass('show'); } restoreOriginalState() { @@ -439,7 +440,7 @@ export class SearchAutocomplete { restoreMenu() { var html; - html = '<ul><li class="dropdown-menu-empty-item"><a>Loading...</a></li></ul>'; + html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`; return this.dropdownContent.html(html); } diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index f9b4e789563..94341050b86 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -4,6 +4,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import FunctionRow from './function_row.vue'; import EnvironmentRow from './environment_row.vue'; import EmptyState from './empty_state.vue'; +import { CHECKING_INSTALLED } from '../constants'; export default { components: { @@ -13,10 +14,6 @@ export default { GlLoadingIcon, }, props: { - installed: { - type: Boolean, - required: true, - }, clustersPath: { type: String, required: true, @@ -31,8 +28,15 @@ export default { }, }, computed: { - ...mapState(['isLoading', 'hasFunctionData']), + ...mapState(['installed', 'isLoading', 'hasFunctionData']), ...mapGetters(['getFunctions']), + + checkingInstalled() { + return this.installed === CHECKING_INSTALLED; + }, + isInstalled() { + return this.installed === true; + }, }, created() { this.fetchFunctions({ @@ -47,15 +51,16 @@ export default { <template> <section id="serverless-functions"> - <div v-if="installed"> + <gl-loading-icon + v-if="checkingInstalled" + :size="2" + class="prepend-top-default append-bottom-default" + /> + + <div v-else-if="isInstalled"> <div v-if="hasFunctionData"> - <gl-loading-icon - v-if="isLoading" - :size="2" - class="prepend-top-default append-bottom-default" - /> - <template v-else> - <div class="groups-list-tree-container"> + <template> + <div class="groups-list-tree-container js-functions-wrapper"> <ul class="content-list group-list-tree"> <environment-row v-for="(env, index) in getFunctions" @@ -66,6 +71,11 @@ export default { </ul> </div> </template> + <gl-loading-icon + v-if="isLoading" + :size="2" + class="prepend-top-default append-bottom-default js-functions-loader" + /> </div> <div v-else class="empty-state js-empty-state"> <div class="text-content"> diff --git a/app/assets/javascripts/serverless/constants.js b/app/assets/javascripts/serverless/constants.js index 35f77205f2c..2fa15e56ccb 100644 --- a/app/assets/javascripts/serverless/constants.js +++ b/app/assets/javascripts/serverless/constants.js @@ -1,3 +1,7 @@ export const MAX_REQUESTS = 3; // max number of times to retry export const X_INTERVAL = 5; // Reflects the number of verticle bars on the x-axis + +export const CHECKING_INSTALLED = 'checking'; // The backend is still determining whether or not Knative is installed + +export const TIMEOUT = 'timeout'; diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js index 2d3f086ffee..ed3b633d766 100644 --- a/app/assets/javascripts/serverless/serverless_bundle.js +++ b/app/assets/javascripts/serverless/serverless_bundle.js @@ -45,7 +45,7 @@ export default class Serverless { }, }); } else { - const { statusPath, clustersPath, helpPath, installed } = document.querySelector( + const { statusPath, clustersPath, helpPath } = document.querySelector( '.js-serverless-functions-page', ).dataset; @@ -56,7 +56,6 @@ export default class Serverless { render(createElement) { return createElement(Functions, { props: { - installed: installed !== undefined, clustersPath, helpPath, statusPath, diff --git a/app/assets/javascripts/serverless/store/actions.js b/app/assets/javascripts/serverless/store/actions.js index 826501c9022..a0a9fdf7ace 100644 --- a/app/assets/javascripts/serverless/store/actions.js +++ b/app/assets/javascripts/serverless/store/actions.js @@ -3,13 +3,18 @@ import axios from '~/lib/utils/axios_utils'; import statusCodes from '~/lib/utils/http_status'; import { backOff } from '~/lib/utils/common_utils'; import createFlash from '~/flash'; -import { MAX_REQUESTS } from '../constants'; +import { __ } from '~/locale'; +import { MAX_REQUESTS, CHECKING_INSTALLED, TIMEOUT } from '../constants'; export const requestFunctionsLoading = ({ commit }) => commit(types.REQUEST_FUNCTIONS_LOADING); export const receiveFunctionsSuccess = ({ commit }, data) => commit(types.RECEIVE_FUNCTIONS_SUCCESS, data); -export const receiveFunctionsNoDataSuccess = ({ commit }) => - commit(types.RECEIVE_FUNCTIONS_NODATA_SUCCESS); +export const receiveFunctionsPartial = ({ commit }, data) => + commit(types.RECEIVE_FUNCTIONS_PARTIAL, data); +export const receiveFunctionsTimeout = ({ commit }, data) => + commit(types.RECEIVE_FUNCTIONS_TIMEOUT, data); +export const receiveFunctionsNoDataSuccess = ({ commit }, data) => + commit(types.RECEIVE_FUNCTIONS_NODATA_SUCCESS, data); export const receiveFunctionsError = ({ commit }, error) => commit(types.RECEIVE_FUNCTIONS_ERROR, error); @@ -25,18 +30,25 @@ export const receiveMetricsError = ({ commit }, error) => export const fetchFunctions = ({ dispatch }, { functionsPath }) => { let retryCount = 0; + const functionsPartiallyFetched = data => { + if (data.functions !== null && data.functions.length) { + dispatch('receiveFunctionsPartial', data); + } + }; + dispatch('requestFunctionsLoading'); backOff((next, stop) => { axios .get(functionsPath) .then(response => { - if (response.status === statusCodes.NO_CONTENT) { + if (response.data.knative_installed === CHECKING_INSTALLED) { retryCount += 1; if (retryCount < MAX_REQUESTS) { + functionsPartiallyFetched(response.data); next(); } else { - stop(null); + stop(TIMEOUT); } } else { stop(response.data); @@ -45,10 +57,13 @@ export const fetchFunctions = ({ dispatch }, { functionsPath }) => { .catch(stop); }) .then(data => { - if (data !== null) { + if (data === TIMEOUT) { + dispatch('receiveFunctionsTimeout'); + createFlash(__('Loading functions timed out. Please reload the page to try again.')); + } else if (data.functions !== null && data.functions.length) { dispatch('receiveFunctionsSuccess', data); } else { - dispatch('receiveFunctionsNoDataSuccess'); + dispatch('receiveFunctionsNoDataSuccess', data); } }) .catch(error => { diff --git a/app/assets/javascripts/serverless/store/mutation_types.js b/app/assets/javascripts/serverless/store/mutation_types.js index 25b2f7ac38a..b8fa9ea1a01 100644 --- a/app/assets/javascripts/serverless/store/mutation_types.js +++ b/app/assets/javascripts/serverless/store/mutation_types.js @@ -1,5 +1,7 @@ export const REQUEST_FUNCTIONS_LOADING = 'REQUEST_FUNCTIONS_LOADING'; export const RECEIVE_FUNCTIONS_SUCCESS = 'RECEIVE_FUNCTIONS_SUCCESS'; +export const RECEIVE_FUNCTIONS_PARTIAL = 'RECEIVE_FUNCTIONS_PARTIAL'; +export const RECEIVE_FUNCTIONS_TIMEOUT = 'RECEIVE_FUNCTIONS_TIMEOUT'; export const RECEIVE_FUNCTIONS_NODATA_SUCCESS = 'RECEIVE_FUNCTIONS_NODATA_SUCCESS'; export const RECEIVE_FUNCTIONS_ERROR = 'RECEIVE_FUNCTIONS_ERROR'; diff --git a/app/assets/javascripts/serverless/store/mutations.js b/app/assets/javascripts/serverless/store/mutations.js index 991f32a275d..2685a5b11ff 100644 --- a/app/assets/javascripts/serverless/store/mutations.js +++ b/app/assets/javascripts/serverless/store/mutations.js @@ -5,12 +5,23 @@ export default { state.isLoading = true; }, [types.RECEIVE_FUNCTIONS_SUCCESS](state, data) { - state.functions = data; + state.functions = data.functions; + state.installed = data.knative_installed; state.isLoading = false; state.hasFunctionData = true; }, - [types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state) { + [types.RECEIVE_FUNCTIONS_PARTIAL](state, data) { + state.functions = data.functions; + state.installed = true; + state.isLoading = true; + state.hasFunctionData = true; + }, + [types.RECEIVE_FUNCTIONS_TIMEOUT](state) { + state.isLoading = false; + }, + [types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state, data) { state.isLoading = false; + state.installed = data.knative_installed; state.hasFunctionData = false; }, [types.RECEIVE_FUNCTIONS_ERROR](state, error) { diff --git a/app/assets/javascripts/serverless/store/state.js b/app/assets/javascripts/serverless/store/state.js index afc3f37d7ba..fdd29299749 100644 --- a/app/assets/javascripts/serverless/store/state.js +++ b/app/assets/javascripts/serverless/store/state.js @@ -1,5 +1,6 @@ export default () => ({ error: null, + installed: 'checking', isLoading: true, // functions diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index 10e2c8453e2..35eba266625 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -194,9 +194,9 @@ export default { v-show="noEmoji" class="js-no-emoji-placeholder no-emoji-placeholder position-relative" > - <icon name="emoji_slightly_smiling_face" css-classes="award-control-icon-neutral" /> - <icon name="emoji_smiley" css-classes="award-control-icon-positive" /> - <icon name="emoji_smile" css-classes="award-control-icon-super-positive" /> + <icon name="slight-smile" css-classes="award-control-icon-neutral" /> + <icon name="smiley" css-classes="award-control-icon-positive" /> + <icon name="smile" css-classes="award-control-icon-super-positive" /> </span> </button> </span> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index c03b2a68c78..d84d5344935 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -49,10 +49,10 @@ export default { }, computed: { hasTimeSpent() { - return !!this.timeSpent; + return Boolean(this.timeSpent); }, hasTimeEstimate() { - return !!this.timeEstimate; + return Boolean(this.timeEstimate); }, showComparisonState() { return this.hasTimeEstimate && this.hasTimeSpent; @@ -67,7 +67,7 @@ export default { return !this.hasTimeEstimate && !this.hasTimeSpent; }, showHelpState() { - return !!this.showHelp; + return Boolean(this.showHelp); }, }, created() { diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/star.js index 7404dfbf22a..70f89152f70 100644 --- a/app/assets/javascripts/star.js +++ b/app/assets/javascripts/star.js @@ -31,7 +31,7 @@ export default class Star { $this.prepend(spriteIcon('star', iconClasses)); } }) - .catch(() => Flash('Star toggle failed. Try again later.')); + .catch(() => Flash(__('Star toggle failed. Try again later.'))); }); } } diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/subscription_select.js index ebe1c6dd02d..7206bbd7109 100644 --- a/app/assets/javascripts/subscription_select.js +++ b/app/assets/javascripts/subscription_select.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { __ } from './locale'; export default function subscriptionSelect() { $('.js-subscription-event').each((i, element) => { @@ -8,7 +9,7 @@ export default function subscriptionSelect() { selectable: true, fieldName, toggleLabel(selected, el, instance) { - let label = 'Subscription'; + let label = __('Subscription'); const $item = instance.dropdown.find('.is-active'); if ($item.length) { label = $item.text(); diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js index a55a338eea8..1e75ee60671 100644 --- a/app/assets/javascripts/test_utils/index.js +++ b/app/assets/javascripts/test_utils/index.js @@ -1,5 +1,5 @@ -import 'core-js/es6/map'; -import 'core-js/es6/set'; +import 'core-js/es/map'; +import 'core-js/es/set'; import simulateDrag from './simulate_drag'; import simulateInput from './simulate_input'; diff --git a/app/assets/javascripts/usage_ping_consent.js b/app/assets/javascripts/usage_ping_consent.js index d3d745a3c11..1e7a5fb19c2 100644 --- a/app/assets/javascripts/usage_ping_consent.js +++ b/app/assets/javascripts/usage_ping_consent.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; import Flash, { hideFlash } from './flash'; import { parseBoolean } from './lib/utils/common_utils'; +import { __ } from './locale'; export default () => { $('body').on('click', '.js-usage-consent-action', e => { @@ -25,7 +26,7 @@ export default () => { }) .catch(() => { hideConsentMessage(); - Flash('Something went wrong. Try again later.'); + Flash(__('Something went wrong. Try again later.')); }); }); }; diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 8c71615dff2..7e6f02b10af 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -5,7 +5,7 @@ import $ from 'jquery'; import _ from 'underscore'; import axios from './lib/utils/axios_utils'; -import { __ } from './locale'; +import { s__, __, sprintf } from './locale'; import ModalStore from './boards/stores/modal_store'; // TODO: remove eventHub hack after code splitting refactor @@ -157,14 +157,20 @@ function UsersSelect(currentUser, els, options = {}) { .get(0); if (selectedUsers.length === 0) { - return 'Unassigned'; + return s__('UsersSelect|Unassigned'); } else if (selectedUsers.length === 1) { return firstUser.name; } else if (isSelected) { const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); - return `${selectedUser.name} + ${otherSelected.length} more`; + return sprintf(s__('UsersSelect|%{name} + %{length} more'), { + name: selectedUser.name, + length: otherSelected.length, + }); } else { - return `${firstUser.name} + ${selectedUsers.length - 1} more`; + return sprintf(s__('UsersSelect|%{name} + %{length} more'), { + name: firstUser.name, + length: selectedUsers.length - 1, + }); } }; @@ -218,11 +224,11 @@ function UsersSelect(currentUser, els, options = {}) { tooltipTitle = _.escape(user.name); } else { user = { - name: 'Unassigned', + name: s__('UsersSelect|Unassigned'), username: '', avatar: '', }; - tooltipTitle = __('Assignee'); + tooltipTitle = s__('UsersSelect|Assignee'); } $value.html(assigneeTemplate(user)); $collapsedSidebar.attr('title', tooltipTitle).tooltip('_fixTitle'); @@ -233,7 +239,11 @@ function UsersSelect(currentUser, els, options = {}) { '<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>', ); assigneeTemplate = _.template( - '<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>', + `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> + ${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), { + openingTag: '<a href="#" class="js-assign-yourself">', + closingTag: '</a>', + })}</span> <% } %>`, ); return $dropdown.glDropdown({ showMenuAbove: showMenuAbove, @@ -302,7 +312,7 @@ function UsersSelect(currentUser, els, options = {}) { showDivider += 1; users.unshift({ beforeDivider: true, - name: 'Unassigned', + name: s__('UsersSelect|Unassigned'), id: 0, }); } @@ -310,7 +320,7 @@ function UsersSelect(currentUser, els, options = {}) { showDivider += 1; name = showAnyUser; if (name === true) { - name = 'Any User'; + name = s__('UsersSelect|Any User'); } anyUser = { beforeDivider: true, @@ -596,7 +606,7 @@ function UsersSelect(currentUser, els, options = {}) { showEmailUser = $(select).data('emailUser'); firstUser = $(select).data('firstUser'); return $(select).select2({ - placeholder: 'Search for a user', + placeholder: __('Search for a user'), multiple: $(select).hasClass('multiselect'), minimumInputLength: 0, query: function(query) { @@ -621,7 +631,7 @@ function UsersSelect(currentUser, els, options = {}) { } if (showNullUser) { nullUser = { - name: 'Unassigned', + name: s__('UsersSelect|Unassigned'), id: 0, }; data.results.unshift(nullUser); @@ -629,7 +639,7 @@ function UsersSelect(currentUser, els, options = {}) { if (showAnyUser) { name = showAnyUser; if (name === true) { - name = 'Any User'; + name = s__('UsersSelect|Any User'); } anyUser = { name: name, @@ -645,7 +655,7 @@ function UsersSelect(currentUser, els, options = {}) { ) { var trimmed = query.term.trim(); emailUser = { - name: 'Invite "' + trimmed + '" by email', + name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }), username: trimmed, id: trimmed, invite: true, @@ -688,7 +698,7 @@ UsersSelect.prototype.initSelection = function(element, callback) { id = $(element).val(); if (id === '0') { nullUser = { - name: 'Unassigned', + name: s__('UsersSelect|Unassigned'), }; return callback(nullUser); } else if (id !== '') { diff --git a/app/assets/javascripts/visual_review_toolbar/index.js b/app/assets/javascripts/visual_review_toolbar/index.js new file mode 100644 index 00000000000..91d0382feac --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/index.js @@ -0,0 +1,2 @@ +import './styles/toolbar.css'; +import 'vendor/visual_review_toolbar'; diff --git a/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css b/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css new file mode 100644 index 00000000000..342b3599a44 --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css @@ -0,0 +1,149 @@ +/* + As a standalone script, the toolbar has its own css + */ + +#gitlab-collapse > * { + pointer-events: none; +} + +#gitlab-form-wrapper { + display: flex; + flex-direction: column; + width: 100% +} + +#gitlab-review-container { + max-width: 22rem; + max-height: 22rem; + overflow: scroll; + position: fixed; + bottom: 1rem; + right: 1rem; + display: flex; + flex-direction: row-reverse; + padding: 1rem; + background-color: #fff; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, + 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; + font-size: .8rem; + font-weight: 400; + color: #2e2e2e; +} + +.gitlab-open-wrapper { + max-width: 22rem; + max-height: 22rem; +} + +.gitlab-closed-wrapper { + max-width: 3.4rem; + max-height: 3.4rem; +} + +.gitlab-button { + cursor: pointer; + transition: background-color 100ms linear, border-color 100ms linear, color 100ms linear, box-shadow 100ms linear; +} + +.gitlab-button-secondary { + background: none #fff; + margin: 0 .5rem; + border: 1px solid #e3e3e3; +} + +.gitlab-button-secondary:hover { + background-color: #f0f0f0; + border-color: #e3e3e3; + color: #2e2e2e; +} + +.gitlab-button-secondary:active { + color: #2e2e2e; + background-color: #e1e1e1; + border-color: #dadada; +} + +.gitlab-button-success:hover { + color: #fff; + background-color: #137e3f; + border-color: #127339; +} + +.gitlab-button-success:active { + background-color: #168f48; + border-color: #12753a; + color: #fff; +} + +.gitlab-button-success { + background-color: #1aaa55; + border: 1px solid #168f48; + color: #fff; +} + +.gitlab-button-wide { + width: 100%; +} + +.gitlab-button-wrapper { + margin-top: 1rem; + display: flex; + align-items: baseline; + justify-content: flex-end; +} + +.gitlab-collapse { + width: 2.4rem; + height: 2.2rem; + margin-left: 1rem; + padding: .5rem; +} + +.gitlab-collapse-closed { + align-self: center; +} + +.gitlab-checkbox-label { + padding: 0 .2rem; +} + +.gitlab-checkbox-wrapper { + display: flex; + align-items: baseline; +} + +.gitlab-label { + font-weight: 600; + display: inline-block; + width: 100%; +} + +.gitlab-link { + color: #1b69b6; + text-decoration: none; + background-color: transparent; + background-image: none; +} + +.gitlab-message { + padding: .25rem 0; + margin: 0; + line-height: 1.2rem; +} + +.gitlab-metadata-note { + font-size: .7rem; + line-height: 1rem; + color: #666; + margin-bottom: 0; +} + +.gitlab-input { + width: 100%; + border: 1px solid #dfdfdf; + border-radius: 4px; + padding: .1rem .2rem; + min-height: 2rem; + max-width: 17rem; +} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue index 8f4cae8ae58..abe5bdd2901 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -49,7 +49,7 @@ export default { required: false, default: () => ({ sourceProjectId: '', - issueId: '', + mergeRequestId: '', appUrl: '', }), }, @@ -77,16 +77,16 @@ export default { return this.deployment.external_url; }, hasExternalUrls() { - return !!(this.deployment.external_url && this.deployment.external_url_formatted); + return Boolean(this.deployment.external_url && this.deployment.external_url_formatted); }, hasDeploymentTime() { - return !!(this.deployment.deployed_at && this.deployment.deployed_at_formatted); + return Boolean(this.deployment.deployed_at && this.deployment.deployed_at_formatted); }, hasDeploymentMeta() { - return !!(this.deployment.url && this.deployment.name); + return Boolean(this.deployment.url && this.deployment.name); }, hasMetrics() { - return !!this.deployment.metrics_url; + return Boolean(this.deployment.metrics_url); }, deployedText() { return this.$options.deployedTextMap[this.deployment.status]; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue index 040315b3c66..19a222462b3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue @@ -37,7 +37,7 @@ export default { </script> <template> - <div class="m-3 ml-5" :class="messageClass"> + <div class="m-3 ml-7" :class="messageClass"> <slot></slot> <gl-link v-if="helpPath" :href="helpPath" target="_blank"> <icon :size="16" name="question-o" class="align-middle" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index f5fa68308bc..c377c16fb13 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -5,6 +5,7 @@ import { sprintf, __ } from '~/locale'; import PipelineStage from '~/pipelines/components/stage.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import Icon from '~/vue_shared/components/icon.vue'; +import PipelineLink from '~/vue_shared/components/ci_pipeline_link.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline'; @@ -16,6 +17,7 @@ export default { Icon, TooltipOnTruncate, GlLink, + PipelineLink, LinkedPipelinesMiniList: () => import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), }, @@ -112,9 +114,12 @@ export default { <div class="media-body"> <div class="font-weight-bold js-pipeline-info-container"> {{ s__('Pipeline|Pipeline') }} - <gl-link :href="pipeline.path" class="pipeline-id font-weight-normal pipeline-number" - >#{{ pipeline.id }}</gl-link - > + <pipeline-link + :href="pipeline.path" + :pipeline-id="pipeline.id" + :pipeline-iid="pipeline.iid" + class="pipeline-id pipeline-iid font-weight-normal" + /> {{ pipeline.details.status.label }} <template v-if="hasCommitInfo"> {{ s__('Pipeline|for') }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index b9f5f602117..03a15ba81ed 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -48,7 +48,7 @@ export default { visualReviewAppMeta() { return { appUrl: this.mr.appUrl, - issueId: this.mr.iid, + mergeRequestId: this.mr.iid, sourceProjectId: this.mr.sourceProjectId, }; }, @@ -56,7 +56,7 @@ export default { return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline; }, showVisualReviewAppLink() { - return !!(this.mr.visualReviewFF && this.mr.visualReviewAppAvailable); + return Boolean(this.mr.visualReviewFF && this.mr.visualReviewAppAvailable); }, }, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue index 780ecdcdac4..6aad2a26a53 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue @@ -14,7 +14,7 @@ export default { </script> <template> - <p v-once class="mr-info-list mr-links source-branch-removal-status append-bottom-0"> + <p v-once class="mr-info-list mr-links append-bottom-0"> <span class="status-text" v-html="removesBranchText"> </span> <i v-tooltip :title="tooltipTitle" :aria-label="tooltipTitle" class="fa fa-question-circle"> </i> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue index a3a44dd8e99..83e7d6db9fa 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue @@ -35,9 +35,7 @@ export default { <status-icon status="warning" /> <div class="media-body space-children"> <span class="bold"> - <template v-if="mr.mergeError" - >{{ mr.mergeError }}.</template - > + <template v-if="mr.mergeError">{{ mr.mergeError }}</template> {{ s__('mrWidget|This merge request failed to be merged automatically') }} </span> <button diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue index 1b3af2fccf2..88e1ccbaf35 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue @@ -57,7 +57,7 @@ export default { removeSourceBranch() { const options = { sha: this.mr.sha, - merge_when_pipeline_succeeds: true, + auto_merge_strategy: 'merge_when_pipeline_succeeds', should_remove_source_branch: true, }; @@ -85,7 +85,7 @@ export default { <h4 class="d-flex align-items-start"> <span class="append-right-10"> {{ s__('mrWidget|Set by') }} - <mr-widget-author :author="mr.setToMWPSBy" /> + <mr-widget-author :author="mr.setToAutoMergeBy" /> {{ s__('mrWidget|to be merged automatically when the pipeline succeeds') }} </span> <a diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 851939d5d4e..615d59a7b8e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -31,7 +31,7 @@ export default { return { removeSourceBranch: this.mr.shouldRemoveSourceBranch, mergeWhenBuildSucceeds: false, - setToMergeWhenPipelineSucceeds: false, + autoMergeStrategy: undefined, isMakingRequest: false, isMergingImmediately: false, commitMessage: this.mr.commitMessage, @@ -42,7 +42,7 @@ export default { }; }, computed: { - shouldShowMergeWhenPipelineSucceedsText() { + shouldShowAutoMergeText() { return this.mr.isPipelineActive; }, status() { @@ -87,7 +87,7 @@ export default { mergeButtonText() { if (this.isMergingImmediately) { return __('Merge in progress'); - } else if (this.shouldShowMergeWhenPipelineSucceedsText) { + } else if (this.shouldShowAutoMergeText) { return __('Merge when pipeline succeeds'); } @@ -104,7 +104,7 @@ export default { return enableSquashBeforeMerge && commitsCount > 1; }, shouldShowMergeControls() { - return this.mr.isMergeAllowed || this.shouldShowMergeWhenPipelineSucceedsText; + return this.mr.isMergeAllowed || this.shouldShowAutoMergeText; }, shouldShowSquashEdit() { return this.squashBeforeMerge && this.shouldShowSquashBeforeMerge; @@ -126,12 +126,12 @@ export default { this.isMergingImmediately = true; } - this.setToMergeWhenPipelineSucceeds = mergeWhenBuildSucceeds === true; + this.autoMergeStrategy = mergeWhenBuildSucceeds ? 'merge_when_pipeline_succeeds' : undefined; const options = { sha: this.mr.sha, commit_message: this.commitMessage, - merge_when_pipeline_succeeds: this.setToMergeWhenPipelineSucceeds, + auto_merge_strategy: this.autoMergeStrategy, should_remove_source_branch: this.removeSourceBranch === true, squash: this.squashBeforeMerge, squash_commit_message: this.squashCommitMessage, @@ -330,6 +330,7 @@ export default { :commits-count="mr.commitsCount" :target-branch="mr.targetBranch" :is-fast-forward-enabled="mr.ffOnlyEnabled" + :class="{ 'border-bottom': mr.mergeError }" > <ul class="border-top content-list commits-list flex-list"> <commit-edit diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue index b1f5655a15a..accb9d9fef1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue @@ -29,8 +29,8 @@ export default { </script> <template> - <div class="accept-control inline"> - <label class="merge-param-checkbox"> + <div class="inline"> + <label> <input :checked="value" :disabled="isDisabled" diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 705ee05e29f..d02bb2f341d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -1,6 +1,6 @@ <script> import _ from 'underscore'; -import { __ } from '~/locale'; +import { sprintf, s__, __ } from '~/locale'; import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; @@ -97,7 +97,7 @@ export default { return this.mr.hasCI; }, shouldRenderRelatedLinks() { - return !!this.mr.relatedLinks && !this.mr.isNothingToMergeState; + return Boolean(this.mr.relatedLinks) && !this.mr.isNothingToMergeState; }, shouldRenderSourceBranchRemovalStatus() { return ( @@ -125,6 +125,11 @@ export default { this.mr.pipeline.target_sha !== this.mr.targetBranchSha, ); }, + mergeError() { + return sprintf(s__('mrWidget|Merge failed: %{mergeError}. Please try again.'), { + mergeError: this.mr.mergeError, + }); + }, }, watch: { state(newVal, oldVal) { @@ -333,41 +338,49 @@ export default { <div class="mr-widget-section"> <component :is="componentName" :mr="mr" :service="service" /> - <section v-if="shouldRenderCollaborationStatus" class="mr-info-list mr-links"> - {{ s__('mrWidget|Allows commits from members who can merge to the target branch') }} - </section> + <div class="mr-widget-info"> + <section v-if="shouldRenderCollaborationStatus" class="mr-info-list mr-links"> + <p> + {{ s__('mrWidget|Allows commits from members who can merge to the target branch') }} + </p> + </section> + + <mr-widget-related-links + v-if="shouldRenderRelatedLinks" + :state="mr.state" + :related-links="mr.relatedLinks" + /> - <mr-widget-related-links - v-if="shouldRenderRelatedLinks" - :state="mr.state" - :related-links="mr.relatedLinks" - /> + <mr-widget-alert-message + v-if="showMergePipelineForkWarning" + type="warning" + :help-path="mr.mergeRequestPipelinesHelpPath" + > + {{ + s__( + 'mrWidget|Fork merge requests do not create merge request pipelines which validate a post merge result', + ) + }} + </mr-widget-alert-message> - <mr-widget-alert-message - v-if="showMergePipelineForkWarning" - type="warning" - :help-path="mr.mergeRequestPipelinesHelpPath" - > - {{ - s__( - 'mrWidget|Fork merge requests do not create merge request pipelines which validate a post merge result', - ) - }} - </mr-widget-alert-message> + <mr-widget-alert-message + v-if="showTargetBranchAdvancedError" + type="danger" + :help-path="mr.mergeRequestPipelinesHelpPath" + > + {{ + s__( + 'mrWidget|The target branch has advanced, which invalidates the merge request pipeline. Please update the source branch and retry merging', + ) + }} + </mr-widget-alert-message> - <mr-widget-alert-message - v-if="showTargetBranchAdvancedError" - type="danger" - :help-path="mr.mergeRequestPipelinesHelpPath" - > - {{ - s__( - 'mrWidget|The target branch has advanced, which invalidates the merge request pipeline. Please update the source branch and retry merging', - ) - }} - </mr-widget-alert-message> + <mr-widget-alert-message v-if="mr.mergeError" type="danger"> + {{ mergeError }} + </mr-widget-alert-message> - <source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" /> + <source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" /> + </div> </div> <div v-if="shouldRenderMergeHelp" class="mr-widget-footer"><mr-widget-merge-help /></div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index 0cc4fd59f5e..3ab229567f6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -23,8 +23,8 @@ export default function deviseState(data) { return stateKey.pipelineBlocked; } else if (this.isSHAMismatch) { return stateKey.shaMismatch; - } else if (this.mergeWhenPipelineSucceeds) { - return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds; + } else if (this.autoMergeEnabled) { + return this.mergeError ? stateKey.autoMergeFailed : stateKey.autoMergeEnabled; } else if (!this.canMerge) { return stateKey.notAllowedToMerge; } else if (this.canBeMerged) { diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 45708d78886..32badb0fb08 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -61,7 +61,7 @@ export default class MergeRequestStore { this.updatedAt = data.updated_at; this.metrics = MergeRequestStore.buildMetrics(data.metrics); - this.setToMWPSBy = MergeRequestStore.formatUserObject(data.merge_user || {}); + this.setToAutoMergeBy = MergeRequestStore.formatUserObject(data.merge_user || {}); this.mergeUserId = data.merge_user_id; this.currentUserId = gon.current_user_id; this.sourceBranchPath = data.source_branch_path; @@ -70,15 +70,16 @@ export default class MergeRequestStore { this.targetBranchPath = data.target_branch_commits_path; this.targetBranchTreePath = data.target_branch_tree_path; this.conflictResolutionPath = data.conflict_resolution_path; - this.cancelAutoMergePath = data.cancel_merge_when_pipeline_succeeds_path; + this.cancelAutoMergePath = data.cancel_auto_merge_path; this.removeWIPPath = data.remove_wip_path; this.sourceBranchRemoved = !data.source_branch_exists; this.shouldRemoveSourceBranch = data.remove_source_branch || false; this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false; - this.mergeWhenPipelineSucceeds = data.merge_when_pipeline_succeeds || false; + this.autoMergeEnabled = Boolean(data.auto_merge_enabled); + this.autoMergeStrategy = data.auto_merge_strategy; this.mergePath = data.merge_path; this.ffOnlyEnabled = data.ff_only_enabled; - this.shouldBeRebased = !!data.should_be_rebased; + this.shouldBeRebased = Boolean(data.should_be_rebased); this.statusPath = data.status_path; this.emailPatchesPath = data.email_patches_path; this.plainDiffPath = data.plain_diff_path; @@ -91,9 +92,9 @@ export default class MergeRequestStore { this.isOpen = data.state === 'opened'; this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false; this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false; - this.canMerge = !!data.merge_path; + this.canMerge = Boolean(data.merge_path); this.canCreateIssue = currentUser.can_create_issue || false; - this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path; + this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path); this.isSHAMismatch = this.sha !== data.diff_head_sha; this.canBeMerged = data.can_be_merged || false; this.isMergeAllowed = data.mergeable || false; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js index e080ce5c229..48bc6a867f4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -13,7 +13,7 @@ const stateToComponentMap = { unresolvedDiscussions: 'mr-widget-unresolved-discussions', pipelineBlocked: 'mr-widget-pipeline-blocked', pipelineFailed: 'mr-widget-pipeline-failed', - mergeWhenPipelineSucceeds: 'mr-widget-merge-when-pipeline-succeeds', + autoMergeEnabled: 'mr-widget-merge-when-pipeline-succeeds', failedToMerge: 'mr-widget-failed-to-merge', autoMergeFailed: 'mr-widget-auto-merge-failed', shaMismatch: 'sha-mismatch', @@ -45,7 +45,7 @@ export const stateKey = { pipelineBlocked: 'pipelineBlocked', shaMismatch: 'shaMismatch', autoMergeFailed: 'autoMergeFailed', - mergeWhenPipelineSucceeds: 'mergeWhenPipelineSucceeds', + autoMergeEnabled: 'autoMergeEnabled', notAllowedToMerge: 'notAllowedToMerge', readyToMerge: 'readyToMerge', rebase: 'rebase', diff --git a/app/assets/javascripts/vue_shared/components/ci_pipeline_link.vue b/app/assets/javascripts/vue_shared/components/ci_pipeline_link.vue new file mode 100644 index 00000000000..eae4c06467c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/ci_pipeline_link.vue @@ -0,0 +1,32 @@ +<script> +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; + +export default { + components: { + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + href: { + type: String, + required: true, + }, + pipelineId: { + type: Number, + required: true, + }, + pipelineIid: { + type: Number, + required: true, + }, + }, +}; +</script> +<template> + <gl-link v-gl-tooltip :href="href" :title="__('Pipeline ID (IID)')"> + <span class="pipeline-id">#{{ pipelineId }}</span> + <span class="pipeline-iid">(#{{ pipelineIid }})</span> + </gl-link> +</template> 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 3f45dc7853b..0bac63b1062 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -37,6 +37,16 @@ export default { type: Number, required: true, }, + itemIid: { + type: Number, + required: false, + default: null, + }, + itemIdTooltip: { + type: String, + required: false, + default: '', + }, time: { type: String, required: true, @@ -85,7 +95,12 @@ export default { <section class="header-main-content"> <ci-icon-badge :status="status" /> - <strong> {{ itemName }} #{{ itemId }} </strong> + <strong v-gl-tooltip :title="itemIdTooltip"> + {{ itemName }} #{{ itemId }} + <template v-if="itemIid" + >(#{{ itemIid }})</template + > + </strong> <template v-if="shouldRenderTriggeredLabel"> triggered @@ -96,9 +111,8 @@ export default { <timeago-tooltip :time="time" /> - by - <template v-if="user"> + by <gl-link v-gl-tooltip :href="user.path" diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 3b57b5e8da4..d6c398c8946 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -33,37 +33,36 @@ export default { <div class="comment-toolbar clearfix"> <div class="toolbar-text"> <template v-if="!hasQuickActionsDocsPath && markdownDocsPath"> - <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1"> - Markdown is supported - </gl-link> + <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1" + >Markdown is supported</gl-link + > </template> <template v-if="hasQuickActionsDocsPath && markdownDocsPath"> - <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1"> Markdown </gl-link> - and - <gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1"> - quick actions - </gl-link> + <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">Markdown</gl-link> and + <gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1">quick actions</gl-link> are supported </template> </div> <span v-if="canAttachFile" class="uploading-container"> <span class="uploading-progress-container hide"> - <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"> </i> - <span class="attaching-file-message"></span> <span class="uploading-progress">0%</span> + <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i> + <span class="attaching-file-message"></span> + <span class="uploading-progress">0%</span> <span class="uploading-spinner"> - <i class="fa fa-spinner fa-spin toolbar-button-icon" aria-hidden="true"> </i> + <i class="fa fa-spinner fa-spin toolbar-button-icon" aria-hidden="true"></i> </span> </span> <span class="uploading-error-container hide"> <span class="uploading-error-icon"> - <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"> </i> + <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i> </span> <span class="uploading-error-message"></span> <button class="retry-uploading-link" type="button">Try again</button> or <button class="attach-new-file markdown-selector" type="button">attach a new file</button> </span> - <button class="markdown-selector button-attach-file" tabindex="-1" type="button"> - <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"> </i> Attach a file + <button class="markdown-selector button-attach-file btn-link" tabindex="-1" type="button"> + <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i + ><span class="text-attach-file">Attach a file</span> </button> <button class="btn btn-default btn-sm hide button-cancel-uploading-files" type="button"> Cancel diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue index fa502b9beb9..8104d919bf6 100644 --- a/app/assets/javascripts/vue_shared/components/pikaday.vue +++ b/app/assets/javascripts/vue_shared/components/pikaday.vue @@ -34,7 +34,7 @@ export default { format: 'yyyy-mm-dd', container: this.$el, defaultDate: this.selectedDate, - setDefaultDate: !!this.selectedDate, + setDefaultDate: Boolean(this.selectedDate), minDate: this.minDate, maxDate: this.maxDate, parse: dateString => parsePikadayDate(dateString), diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.vue b/app/assets/javascripts/vue_shared/components/table_pagination.vue index 8e0b08032f7..9cce9a4e542 100644 --- a/app/assets/javascripts/vue_shared/components/table_pagination.vue +++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue @@ -121,7 +121,7 @@ export default { this.change(1); break; default: - this.change(+text); + this.change(Number(text)); break; } }, diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 4dbfd1ba6f4..a60d5eb491e 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -71,11 +71,15 @@ export default { </div> <div class="text-secondary"> <div v-if="user.bio" class="js-bio d-flex mb-1"> - <icon name="profile" css-classes="category-icon" /> + <icon name="profile" css-classes="category-icon flex-shrink-0" /> <span class="ml-1">{{ user.bio }}</span> </div> <div v-if="user.organization" class="js-organization d-flex mb-1"> - <icon v-show="!jobInfoIsLoading" name="work" css-classes="category-icon" /> + <icon + v-show="!jobInfoIsLoading" + name="work" + css-classes="category-icon flex-shrink-0" + /> <span class="ml-1">{{ user.organization }}</span> </div> <gl-skeleton-loading @@ -88,7 +92,7 @@ export default { <icon v-show="!locationIsLoading && user.location" name="location" - css-classes="category-icon" + css-classes="category-icon flex-shrink-0" /> <span class="ml-1">{{ user.location }}</span> <gl-skeleton-loading diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index 93377b8dd91..7f6384f4eea 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -22,7 +22,9 @@ body, .form-control, .search form { // Override default font size used in non-csslab UI - font-size: 14px; + // Use rem to keep default font-size at 14px on body so 1rem still + // fits 8px grid, but also allow users to change browser font size + font-size: .875rem; } legend { diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss new file mode 100644 index 00000000000..1afa5ed90f4 --- /dev/null +++ b/app/assets/stylesheets/components/avatar.scss @@ -0,0 +1,202 @@ +$avatar-sizes: ( + 16: ( + font-size: 10px, + line-height: 16px, + border-radius: $border-radius-small + ), + 18: ( + border-radius: $border-radius-small + ), + 20: ( + border-radius: $border-radius-small + ), + 24: ( + font-size: 12px, + line-height: 24px, + border-radius: $border-radius-default + ), + 26: ( + font-size: 20px, + line-height: 1.33, + border-radius: $border-radius-default + ), + 32: ( + font-size: 14px, + line-height: 32px, + border-radius: $border-radius-default + ), + 40: ( + font-size: 16px, + line-height: 38px, + border-radius: $border-radius-default + ), + 48: ( + font-size: 20px, + line-height: 48px, + border-radius: $border-radius-large + ), + 60: ( + font-size: 32px, + line-height: 58px, + border-radius: $border-radius-large + ), + 64: ( + font-size: 28px, + line-height: 64px, + border-radius: $border-radius-large + ), + 90: ( + font-size: 36px, + line-height: 88px, + border-radius: $border-radius-large + ), + 100: ( + font-size: 36px, + line-height: 98px, + border-radius: $border-radius-large + ), + 160: ( + font-size: 96px, + line-height: 158px, + border-radius: $border-radius-large + ) +); + +$identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $identicon-blue, $identicon-teal, + $identicon-orange, $gray-darker; + +.avatar-circle { + float: left; + margin-right: $gl-padding; + border-radius: $avatar-radius; + border: 1px solid $gray-normal; + + @each $size, $size-config in $avatar-sizes { + &.s#{$size} { + @include avatar-size(#{$size}px, if($size < 48, 8px, 16px)); + } + } +} + +.avatar { + @extend .avatar-circle; + transition-property: none; + + width: 40px; + height: 40px; + padding: 0; + background: $gray-lightest; + overflow: hidden; + border-color: rgba($black, $gl-avatar-border-opacity); + + &.avatar-inline { + float: none; + display: inline-block; + margin-left: 2px; + flex-shrink: 0; + + &.s16 { + margin-right: 4px; + } + + &.s24 { + margin-right: 4px; + } + } + + &.center { + font-size: 14px; + line-height: 1.8em; + text-align: center; + } + + &.avatar-tile { + border-radius: 0; + border: 0; + } + + &.avatar-placeholder { + border: 0; + } +} + +.identicon { + text-align: center; + vertical-align: top; + color: $gray-800; + background-color: $gray-darker; + + // Sizes + @each $size, $size-config in $avatar-sizes { + $keys: map-keys($size-config); + + &.s#{$size} { + @each $key in $keys { + // We don't want `border-radius` to be included here. + @if ($key != 'border-radius') { + #{$key}: map-get($size-config, #{$key}); + } + } + } + } + + // Background colors + @for $i from 1 through length($identicon-backgrounds) { + &.bg#{$i} { + background-color: nth($identicon-backgrounds, $i); + } + } +} + +.avatar-container { + @extend .avatar-circle; + overflow: hidden; + display: flex; + + a { + width: 100%; + height: 100%; + display: flex; + text-decoration: none; + } + + .avatar { + border-radius: 0; + border: 0; + height: auto; + width: 100%; + margin: 0; + align-self: center; + } + + &.s40 { + min-width: 40px; + min-height: 40px; + } + + &.s64 { + min-width: 64px; + min-height: 64px; + } +} + +.rect-avatar { + border-radius: $border-radius-small; + + @each $size, $size-config in $avatar-sizes { + &.s#{$size} { + border-radius: map-get($size-config, 'border-radius'); + } + } +} + +.avatar-counter { + background-color: $gray-darkest; + color: $white-light; + border: 1px solid $gray-normal; + border-radius: 1em; + font-family: $regular-font; + font-size: 9px; + line-height: 16px; + text-align: center; +} diff --git a/app/assets/stylesheets/errors.scss b/app/assets/stylesheets/errors.scss index 658e0ff638e..8c32b6c8985 100644 --- a/app/assets/stylesheets/errors.scss +++ b/app/assets/stylesheets/errors.scss @@ -17,7 +17,7 @@ body { text-align: center; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; margin: auto; - font-size: 14px; + font-size: .875rem; } h1 { diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index ab9047c54e4..9b0d19b0ef0 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -8,7 +8,6 @@ @import 'framework/animations'; @import 'framework/vue_transitions'; -@import 'framework/avatar'; @import 'framework/asciidoctor'; @import 'framework/banner'; @import 'framework/blocks'; diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss deleted file mode 100644 index 37a729c7a63..00000000000 --- a/app/assets/stylesheets/framework/avatar.scss +++ /dev/null @@ -1,194 +0,0 @@ -@mixin avatar-size($size, $margin-right) { - width: $size; - height: $size; - margin-right: $margin-right; -} - -.avatar-circle { - float: left; - margin-right: 15px; - border-radius: $avatar-radius; - border: 1px solid $gray-normal; - &.s16 { @include avatar-size(16px, 6px); } - &.s18 { @include avatar-size(18px, 6px); } - &.s19 { @include avatar-size(19px, 6px); } - &.s20 { @include avatar-size(20px, 7px); } - &.s24 { @include avatar-size(24px, 8px); } - &.s26 { @include avatar-size(26px, 8px); } - &.s32 { @include avatar-size(32px, 10px); } - &.s36 { @include avatar-size(36px, 10px); } - &.s40 { @include avatar-size(40px, 10px); } - &.s46 { @include avatar-size(46px, 15px); } - &.s48 { @include avatar-size(48px, 10px); } - &.s60 { @include avatar-size(60px, 12px); } - &.s64 { @include avatar-size(64px, 14px); } - &.s70 { @include avatar-size(70px, 14px); } - &.s90 { @include avatar-size(90px, 15px); } - &.s100 { @include avatar-size(100px, 15px); } - &.s110 { @include avatar-size(110px, 15px); } - &.s140 { @include avatar-size(140px, 15px); } - &.s160 { @include avatar-size(160px, 20px); } -} - -.avatar { - @extend .avatar-circle; - transition-property: none; - - width: 40px; - height: 40px; - padding: 0; - background: $gray-lightest; - overflow: hidden; - - &.avatar-inline { - float: none; - display: inline-block; - margin-left: 2px; - flex-shrink: 0; - - &.s16 { margin-right: 4px; } - &.s24 { margin-right: 4px; } - } - - &.center { - font-size: 14px; - line-height: 1.8em; - text-align: center; - } - - &.avatar-tile { - border-radius: 0; - border: 0; - } - - &.avatar-placeholder { - border: 0; - } - - &:not([href]):hover { - border-color: darken($gray-normal, 10%); - } -} - -.identicon { - text-align: center; - vertical-align: top; - color: $gl-gray-700; - background-color: $gray-darker; - - // Sizes - &.s16 { font-size: 12px; - line-height: 1.33; } - - &.s24 { font-size: 13px; - line-height: 1.8; } - - &.s26 { font-size: 20px; - line-height: 1.33; } - - &.s32 { font-size: 20px; - line-height: 30px; } - - &.s40 { font-size: 16px; - line-height: 38px; } - - &.s48 { font-size: 20px; - line-height: 46px; } - - &.s60 { font-size: 32px; - line-height: 58px; } - - &.s64 { font-size: 32px; - line-height: 64px; } - - &.s70 { font-size: 34px; - line-height: 70px; } - - &.s90 { font-size: 36px; - line-height: 88px; } - - &.s100 { font-size: 36px; - line-height: 98px; } - - &.s110 { font-size: 40px; - line-height: 108px; - font-weight: $gl-font-weight-normal; } - - &.s140 { font-size: 72px; - line-height: 138px; } - - &.s160 { font-size: 96px; - line-height: 158px; } - - // Background colors - &.bg1 { background-color: $identicon-red; } - &.bg2 { background-color: $identicon-purple; } - &.bg3 { background-color: $identicon-indigo; } - &.bg4 { background-color: $identicon-blue; } - &.bg5 { background-color: $identicon-teal; } - &.bg6 { background-color: $identicon-orange; } - &.bg7 { background-color: $gray-darker; } -} - -.avatar-container { - @extend .avatar-circle; - overflow: hidden; - display: flex; - - a { - width: 100%; - height: 100%; - display: flex; - text-decoration: none; - } - - .avatar { - border-radius: 0; - border: 0; - height: auto; - width: 100%; - margin: 0; - align-self: center; - } - - &.s40 { min-width: 40px; - min-height: 40px; } - - &.s64 { min-width: 64px; - min-height: 64px; } -} - -.rect-avatar { - border-radius: $border-radius-small; - &.s16 { border-radius: $border-radius-small; } - &.s18 { border-radius: $border-radius-small; } - &.s19 { border-radius: $border-radius-small; } - &.s20 { border-radius: $border-radius-small; } - &.s24 { border-radius: $border-radius-default; } - &.s26 { border-radius: $border-radius-default; } - &.s32 { border-radius: $border-radius-default; } - &.s36 { border-radius: $border-radius-default; } - &.s40 { border-radius: $border-radius-default; } - &.s46 { border-radius: $border-radius-default; } - &.s48 { border-radius: $border-radius-large; } - &.s60 { border-radius: $border-radius-large; } - &.s64 { border-radius: $border-radius-large; } - &.s70 { border-radius: $border-radius-large; } - &.s90 { border-radius: $border-radius-large; } - &.s96 { border-radius: $border-radius-large; } - &.s100 { border-radius: $border-radius-large; } - &.s110 { border-radius: $border-radius-large; } - &.s140 { border-radius: $border-radius-large; } - &.s160 { border-radius: $border-radius-large; } -} - -.avatar-counter { - background-color: $gray-darkest; - color: $white-light; - border: 1px solid $gray-normal; - border-radius: 1em; - font-family: $regular-font; - font-size: 9px; - line-height: 16px; - text-align: center; -} diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index 648e1944388..7760c48cb92 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -151,8 +151,7 @@ outline: 0; .award-control-icon svg { - background: $award-emoji-positive-add-bg; - fill: $award-emoji-positive-add-lines; + fill: $blue-500; } .award-control-icon-neutral { @@ -233,10 +232,7 @@ height: $default-icon-size; width: $default-icon-size; border-radius: 50%; - } - - path { - fill: $border-gray-normal; + fill: $gray-700; } } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index ab8f397f3a0..97a763671ba 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -1,12 +1,12 @@ @mixin btn-comment-icon { border-radius: 50%; background: $white-light; - padding: 1px 5px; + padding: 1px; font-size: 12px; color: $blue-500; + border: 1px solid $blue-500; width: 24px; height: 24px; - border: 1px solid $blue-500; &:hover, &.inverted { @@ -21,7 +21,7 @@ } @mixin btn-default { - border-radius: 3px; + border-radius: $border-radius-default; font-size: $gl-font-size; font-weight: $gl-font-weight-normal; padding: $gl-vert-padding $gl-btn-padding; @@ -37,7 +37,7 @@ @include btn-default; } -@mixin btn-outline($background, $text, $border, $hover-background, $hover-text, $hover-border, $active-background, $active-border) { +@mixin btn-outline($background, $text, $border, $hover-background, $hover-text, $hover-border, $active-background, $active-border, $active-text) { background-color: $background; color: $text; border-color: $border; @@ -61,13 +61,22 @@ } } + &:focus { + box-shadow: 0 0 4px 1px $blue-300; + } + &:active { background-color: $active-background; border-color: $active-border; - color: $hover-text; + box-shadow: inset 0 2px 4px 0 rgba($black, 0.2); + color: $active-text; > .icon { - color: $hover-text; + color: $active-text; + } + + &:focus { + box-shadow: inset 0 2px 4px 0 rgba($black, 0.2); } } } @@ -164,21 +173,21 @@ &.btn-inverted { &.btn-success { - @include btn-outline($white-light, $green-600, $green-500, $green-500, $white-light, $green-600, $green-600, $green-700); + @include btn-outline($white-light, $green-600, $green-500, $green-100, $green-700, $green-500, $green-200, $green-600, $green-800); } &.btn-remove, &.btn-danger { - @include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700); + @include btn-outline($white-light, $red-500, $red-500, $red-100, $red-700, $red-500, $red-200, $red-600, $red-800); } &.btn-warning { - @include btn-outline($white-light, $orange-500, $orange-500, $orange-500, $white-light, $orange-600, $orange-600, $orange-700); + @include btn-outline($white-light, $orange-500, $orange-500, $orange-100, $orange-700, $orange-500, $orange-200, $orange-600, $orange-800); } &.btn-primary, &.btn-info { - @include btn-outline($white-light, $blue-500, $blue-500, $blue-500, $white-light, $blue-600, $blue-600, $blue-700); + @include btn-outline($white-light, $blue-500, $blue-500, $blue-100, $blue-700, $blue-500, $blue-200, $blue-600, $blue-800); } } @@ -193,11 +202,11 @@ &.btn-close, &.btn-close-color { - @include btn-outline($white-light, $orange-600, $orange-500, $orange-500, $white-light, $orange-600, $orange-600, $orange-700); + @include btn-outline($white-light, $orange-600, $orange-500, $orange-100, $orange-700, $orange-500, $orange-200, $orange-600, $orange-800); } &.btn-spam { - @include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700); + @include btn-outline($white-light, $red-500, $red-500, $red-100, $red-700, $red-500, $red-200, $red-600, $red-800); } &.btn-danger, @@ -330,6 +339,8 @@ svg { top: auto; + width: 16px; + height: 16px; } } @@ -402,7 +413,7 @@ .btn-inverted { &-secondary { - @include btn-outline($white-light, $blue-500, $blue-500, $blue-500, $white-light, $blue-600, $blue-600, $blue-700); + @include btn-outline($white-light, $blue-500, $blue-500, $blue-100, $blue-700, $blue-500, $blue-200, $blue-600, $blue-800); } } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 8fb4027bf97..cd951f67293 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -570,10 +570,10 @@ } .dropdown-menu-close { - right: 5px; + top: $gl-padding-4; + right: $gl-padding-8; width: 20px; height: 20px; - top: -1px; } .dropdown-menu-close-icon { diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 53d3645cd63..ef6f0633150 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -241,6 +241,7 @@ */ &.code { padding: 0; + border-radius: 0 0 $border-radius-default $border-radius-default; } .list-inline.previews { @@ -328,7 +329,7 @@ span.idiff { background-color: $gray-light; border-bottom: 1px solid $border-color; border-top: 1px solid $border-color; - padding: 5px $gl-padding; + padding: $gl-padding-8 $gl-padding; margin: 0; border-radius: $border-radius-default $border-radius-default 0 0; @@ -365,10 +366,6 @@ span.idiff { color: $gl-text-color; } - small { - margin: 0 10px 0 0; - } - .file-actions .btn { padding: 0 10px; font-size: 13px; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 5bcfd5d1322..26cbb7f5c13 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -218,7 +218,7 @@ min-width: 200px; padding-right: 25px; padding-left: 0; - height: $input-height; + height: $input-height - 2; line-height: inherit; border-color: transparent; diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index d0f99c2df7e..2a601afff53 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -27,10 +27,16 @@ input[type='text'].danger { } label { + font-weight: $gl-font-weight-bold; + &.inline-label { margin: 0; } + &.form-check-label { + font-weight: $gl-font-weight-normal; + } + &.label-bold { font-weight: $gl-font-weight-bold; } @@ -41,14 +47,6 @@ label { margin: 0; } -.form-label { - @extend label; -} - -.form-control-label { - @extend .col-md-2; -} - .inline-input-group { width: 250px; } @@ -81,44 +79,14 @@ label { margin-left: 0; margin-right: 0; - .form-control-label { - font-weight: $gl-font-weight-bold; - padding-top: 4px; - } - .form-control { height: 29px; background: $white-light; font-family: $monospace-font; } - .input-group-prepend .btn, - .input-group-append .btn { - padding: 3px $gl-btn-padding; - background-color: $gray-light; - border: 1px solid $border-color; - } - - .text-block { - line-height: 0.8; - padding-top: 9px; - - code { - line-height: 1.8; - } - - img { - margin-right: $gl-padding; - } - } - @include media-breakpoint-down(xs) { padding: 0 $gl-padding; - - .form-control-label, - .text-block { - padding-left: 0; - } } } @@ -140,19 +108,6 @@ label { } } -.select-wrapper { - position: relative; - - .fa-chevron-down { - position: absolute; - font-size: 10px; - right: 10px; - top: 12px; - color: $gray-darkest; - pointer-events: none; - } -} - .select-control { padding-left: 10px; padding-right: 10px; @@ -175,12 +130,6 @@ label { margin-top: 35px; } -.form-group .form-control-label, -.form-group .form-control-label-full-width { - font-weight: $gl-font-weight-normal; -} - - .form-control::placeholder { color: $gl-text-color-tertiary; } @@ -224,7 +173,8 @@ label { border: 1px solid $green-600; &:focus { - box-shadow: 0 0 0 1px $green-600 inset, 0 1px 1px $gl-field-focus-shadow inset, 0 0 4px 0 $green-600; + box-shadow: 0 0 0 1px $green-600 inset, 0 1px 1px $gl-field-focus-shadow inset, + 0 0 4px 0 $green-600; border: 0 none; } } @@ -233,7 +183,8 @@ label { border: 1px solid $red-500; &:focus { - box-shadow: 0 0 0 1px $red-500 inset, 0 1px 1px $gl-field-focus-shadow inset, 0 0 4px 0 $gl-field-focus-shadow-error; + box-shadow: 0 0 0 1px $red-500 inset, 0 1px 1px $gl-field-focus-shadow inset, + 0 0 4px 0 $gl-field-focus-shadow-error; border: 0 none; } } @@ -259,16 +210,26 @@ label { } } -.input-icon-wrapper { +.input-icon-wrapper, +.select-wrapper { position: relative; +} - .input-icon-right { - position: absolute; - right: 0.8em; - top: 50%; - transform: translateY(-50%); - color: $gray-600; - } +.select-wrapper > .fa-chevron-down { + position: absolute; + font-size: 10px; + right: 10px; + top: 12px; + color: $gray-darkest; + pointer-events: none; +} + +.input-icon-wrapper > .input-icon-right { + position: absolute; + right: 0.8em; + top: 50%; + transform: translateY(-50%); + color: $gray-600; } .input-md { @@ -280,3 +241,21 @@ label { max-width: $input-lg-width; width: 100%; } + +.input-group-text { + max-height: $input-height; +} + +.gl-form-checkbox { + align-items: baseline; + + &.form-check-inline .form-check-input { + align-self: flex-start; + margin-right: $gl-padding-8; + height: 1.5 * $gl-font-size; + } + + .help-text { + margin-bottom: 0; + } +} diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 946f575ac13..741f92110c3 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -8,7 +8,7 @@ pre { padding: 10px 0; border: 0; - border-radius: 0; + border-radius: 0 0 $border-radius-default $border-radius-default; font-family: $monospace-font; font-size: $code-font-size; line-height: 19px; @@ -42,6 +42,7 @@ padding: 10px; text-align: right; float: left; + border-bottom-left-radius: $border-radius-default; a { font-family: $monospace-font; diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 18eb10c1f23..df40149f0a6 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -325,8 +325,8 @@ line-height: 1; padding: 0; min-width: 16px; - color: $gray-darkest; - fill: $gray-darkest; + color: $gray-600; + fill: $gray-600; .fa { position: relative; @@ -376,3 +376,17 @@ } } } + +/* +* Mixin that handles the size and right margin of avatars. +*/ +@mixin avatar-size($size, $margin-right) { + width: $size; + height: $size; + margin-right: $margin-right; +} + +@mixin code-icon-size() { + width: $gl-font-size * $code-line-height * 0.9; + height: $gl-font-size * $code-line-height * 0.9; +} diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss index 36ab38f1c9d..3ab83f4c8e6 100644 --- a/app/assets/stylesheets/framework/snippets.scss +++ b/app/assets/stylesheets/framework/snippets.scss @@ -22,6 +22,10 @@ .snippet-file-content { border-radius: 3px; + + .file-title-flex-parent .btn-clipboard { + line-height: 28px; + } } .snippet-header { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 1cf122102cc..28768bdf88f 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -589,6 +589,7 @@ $issue-board-list-difference-md: $issue-board-list-difference-sm + $issue-boards */ $avatar-radius: 50%; $gl-avatar-size: 40px; +$gl-avatar-border-opacity: 0.1; /* * Blame diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss index fb4d3f23cd9..ea96381a098 100644 --- a/app/assets/stylesheets/framework/variables_overrides.scss +++ b/app/assets/stylesheets/framework/variables_overrides.scss @@ -7,6 +7,7 @@ $secondary: $gray-light; $input-disabled-bg: $gray-light; $input-border-color: $gray-200; $input-color: $gl-text-color; +$input-font-size: $gl-font-size; $font-family-sans-serif: $regular-font; $font-family-monospace: $monospace-font; $btn-line-height: 20px; diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index 809ba6d4953..255383d89c8 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -69,6 +69,8 @@ align-self: flex-start; font-weight: 500; font-size: 20px; + color: $orange-900; + opacity: 1; margin: $gl-padding-8 14px 0 0; } diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 66cd113db84..77a36e59b03 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -154,8 +154,6 @@ } .avatar-cell { - width: 46px; - img { margin-right: 0; } diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss index cb5f1a84005..c386493231c 100644 --- a/app/assets/stylesheets/pages/detail_page.scss +++ b/app/assets/stylesheets/pages/detail_page.scss @@ -21,7 +21,6 @@ .detail-page-header-body { position: relative; - line-height: 35px; display: flex; flex: 1 1; min-width: 0; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index b2b3720fdde..5e5d298f8f2 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -15,7 +15,6 @@ position: sticky; top: $mr-file-header-top; z-index: 102; - height: $mr-version-controls-height; &::before { content: ''; @@ -494,6 +493,12 @@ table.code { } } + .line_holder:last-of-type { + td:first-child { + border-bottom-left-radius: $border-radius-default; + } + } + &.left-side-selected { td.line_content.parallel.right-side { user-select: none; @@ -609,10 +614,9 @@ table.code { .diff-comment-avatar-holders { position: absolute; - height: 19px; - width: 19px; - margin-left: -15px; + margin-left: -$gl-padding; z-index: 100; + @include code-icon-size(); &:hover { .diff-comment-avatar, @@ -646,26 +650,28 @@ table.code { .diff-comments-more-count { position: absolute; left: 0; - width: 19px; - height: 19px; margin-right: 0; border-color: $white-light; cursor: pointer; transition: all 0.1s ease-out; + @include code-icon-size(); @for $i from 1 through 4 { &:nth-child(#{$i}) { z-index: (4 - $i); } } + + .avatar { + @include code-icon-size(); + } } .diff-comments-more-count { - width: 19px; - min-width: 19px; padding-left: 0; padding-right: 0; overflow: hidden; + @include code-icon-size(); } .diff-comments-more-count, @@ -674,12 +680,15 @@ table.code { } .diff-notes-collapse { - width: 24px; - height: 24px; + border: 0; border-radius: 50%; padding: 0; transition: transform 0.1s ease-out; z-index: 100; + display: flex; + justify-content: center; + align-items: center; + @include code-icon-size(); .collapse-icon { height: 50%; diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index 618f23d81b1..e34628002ac 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -156,6 +156,10 @@ &:hover { background: none; } + + a { + color: $blue-600; + } } } } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 04c66006027..4ba74d34664 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -218,7 +218,7 @@ .title { color: $gl-text-color; - margin-bottom: 10px; + margin-bottom: $gl-padding-8; line-height: 1; .avatar { @@ -604,7 +604,6 @@ .participants-list { display: flex; flex-wrap: wrap; - margin: -7px; } .user-list { @@ -614,7 +613,7 @@ .participants-author { display: inline-block; - padding: 7px; + padding: 0 $gl-padding-8 $gl-padding-8 0; &:nth-of-type(7n) { padding-right: 0; @@ -641,7 +640,6 @@ .participants-more, .user-list-more { - margin-top: 5px; margin-left: 5px; a, diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 13288d8bad1..11e8a32389f 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -456,8 +456,9 @@ // Don't hide the overflow in system messages .system-note-message, -.issuable-detail, +.issuable-details, .md-preview-holder, +.referenced-commands, .note-body { .scoped-label-wrapper { .badge { diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index 22a515cbdaa..297f642681b 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -21,13 +21,6 @@ color: $login-brand-holder-color; } - h1:first-child { - font-weight: $gl-font-weight-normal; - margin-bottom: 0.68em; - margin-top: 0; - font-size: 34px; - } - h3 { font-size: 22px; } @@ -49,8 +42,8 @@ .login-box, .omniauth-container { box-shadow: 0 0 0 1px $border-color; - border-bottom-right-radius: $border-radius-small; - border-bottom-left-radius: $border-radius-small; + border-bottom-right-radius: $border-radius; + border-bottom-left-radius: $border-radius; padding: 15px; .login-heading h3 { @@ -95,7 +88,7 @@ } .omniauth-container { - border-radius: $border-radius-small; + border-radius: $border-radius; font-size: 13px; p { diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 44b558dd5ff..77b40fe2d30 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -87,6 +87,11 @@ padding: $gl-padding; } +.mr-widget-info { + padding-left: $gl-padding-50 - $gl-padding-32; + padding-right: $gl-padding; +} + .mr-state-widget { color: $gl-text-color; @@ -180,46 +185,6 @@ } } } - - .accept-control { - display: inline-block; - float: left; - margin: 0; - margin-left: 20px; - padding: 5px; - padding-top: 8px; - line-height: 20px; - - &.right { - float: right; - padding-right: 0; - } - - .modify-merge-commit-link { - padding: 0; - background-color: transparent; - border: 0; - color: $gl-text-color; - - &:hover, - &:focus { - text-decoration: underline; - } - } - - .merge-param-checkbox { - margin: 0; - } - - a .fa-question-circle { - color: $gl-text-color-secondary; - - &:hover, - &:focus { - color: $link-hover-color; - } - } - } } .ci-widget { @@ -402,12 +367,6 @@ width: 100%; text-align: center; } - - .accept-control { - width: 100%; - text-align: center; - margin: 0; - } } .commit-message-editor { @@ -560,6 +519,10 @@ .mr-links { padding-left: $status-icon-size + $gl-btn-padding; + + &:last-child { + padding-bottom: $gl-padding; + } } .mr-info-list { @@ -1030,11 +993,6 @@ background: $black-transparent; } -.source-branch-removal-status { - padding-left: 50px; - padding-bottom: $gl-padding; -} - .mr-compare { .diff-file .file-title-flex-parent { top: $header-height + 51px; diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 3343b55d24b..8c7b124dd33 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -334,7 +334,7 @@ table { .toolbar-button-icon { position: relative; top: 1px; - margin-right: 3px; + margin-right: $gl-padding-4; color: inherit; font-size: 16px; } @@ -461,6 +461,15 @@ table { border: 0; font-size: 14px; line-height: 16px; + + &:hover, + &:focus { + text-decoration: none; + + .text-attach-file { + text-decoration: underline; + } + } } .markdown-selector { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index fcb57db590a..32477c20db6 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -437,7 +437,9 @@ $note-form-margin-left: 72px; .diff-file { .is-over { .add-diff-note { - display: inline-block; + display: inline-flex; + justify-content: center; + align-items: center; } } @@ -645,7 +647,7 @@ $note-form-margin-left: 72px; display: inline-flex; align-items: center; margin-left: 10px; - color: $gray-darkest; + color: $gray-600; @include notes-media('max', map-get($grid-breakpoints, sm) - 1) { float: none; @@ -741,7 +743,7 @@ $note-form-margin-left: 72px; .add-diff-note { @include btn-comment-icon; opacity: 0; - margin-left: -50px; + margin-left: -52px; position: absolute; top: 50%; transform: translateY(-50%); @@ -822,6 +824,7 @@ $note-form-margin-left: 72px; .line-resolve-btn { margin-right: 5px; + color: $gray-darkest; svg { vertical-align: middle; @@ -836,7 +839,6 @@ $note-form-margin-left: 72px; background-color: transparent; border: 0; outline: 0; - color: $gray-darkest; transition: color $general-hover-transition-duration $general-hover-transition-curve; &.is-disabled { @@ -900,10 +902,6 @@ $note-form-margin-left: 72px; .diff-comment-form { display: block; } - - .add-diff-note svg { - margin-top: 4px; - } } .discussion-filter-container { diff --git a/app/assets/stylesheets/pages/notifications.scss b/app/assets/stylesheets/pages/notifications.scss index e98cb444f0a..e1cbf0e6654 100644 --- a/app/assets/stylesheets/pages/notifications.scss +++ b/app/assets/stylesheets/pages/notifications.scss @@ -4,6 +4,34 @@ .dropdown-menu { @extend .dropdown-menu-right; } + + @include media-breakpoint-down(sm) { + .notification-dropdown { + width: 100%; + } + + .notification-form { + display: block; + } + + .notifications-btn, + .btn-group { + width: 100%; + } + + .table-section { + border-top: 0; + min-height: unset; + + &:not(:first-child) { + padding-top: 0; + } + } + + .update-notifications { + width: 100%; + } + } } .notification { diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 37071a57bb3..dbf600df9d6 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -261,3 +261,13 @@ input[type='checkbox']:hover { color: $blue-600; } } + +// Disable webkit input icons, link to solution: https://stackoverflow.com/questions/9421551/how-do-i-remove-all-default-webkit-search-field-styling +/* stylelint-disable property-no-vendor-prefix */ +input[type='search']::-webkit-search-decoration, +input[type='search']::-webkit-search-cancel-button, +input[type='search']::-webkit-search-results-button, +input[type='search']::-webkit-search-results-decoration { + -webkit-appearance: none; +} +/* stylelint-enable */ diff --git a/app/controllers/admin/logs_controller.rb b/app/controllers/admin/logs_controller.rb index 06b0e6a15a3..704e727b1da 100644 --- a/app/controllers/admin/logs_controller.rb +++ b/app/controllers/admin/logs_controller.rb @@ -15,7 +15,8 @@ class Admin::LogsController < Admin::ApplicationController Gitlab::EnvironmentLogger, Gitlab::SidekiqLogger, Gitlab::RepositoryCheckLogger, - Gitlab::ProjectServiceLogger + Gitlab::ProjectServiceLogger, + Gitlab::Kubernetes::Logger ] end end diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb index aeff0c96b64..70db15916b9 100644 --- a/app/controllers/admin/projects_controller.rb +++ b/app/controllers/admin/projects_controller.rb @@ -3,7 +3,7 @@ class Admin::ProjectsController < Admin::ApplicationController include MembersPresentation - before_action :project, only: [:show, :transfer, :repository_check] + before_action :project, only: [:show, :transfer, :repository_check, :destroy] before_action :group, only: [:show, :transfer] def index @@ -35,6 +35,15 @@ class Admin::ProjectsController < Admin::ApplicationController end # rubocop: enable CodeReuse/ActiveRecord + def destroy + ::Projects::DestroyService.new(@project, current_user, {}).async_execute + flash[:notice] = _("Project '%{project_name}' is in the process of being deleted.") % { project_name: @project.full_name } + + redirect_to admin_projects_path, status: :found + rescue Projects::DestroyService::DestroyError => ex + redirect_to admin_projects_path, status: 302, alert: ex.message + end + # rubocop: disable CodeReuse/ActiveRecord def transfer namespace = Namespace.find_by(id: params[:new_namespace_id]) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4cbab6811bc..6e98d66d712 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -42,7 +42,7 @@ class ApplicationController < ActionController::Base :bitbucket_server_import_enabled?, :google_code_import_enabled?, :fogbugz_import_enabled?, :git_import_enabled?, :gitlab_project_import_enabled?, - :manifest_import_enabled? + :manifest_import_enabled?, :phabricator_import_enabled? # Adds `no-store` to the DEFAULT_CACHE_CONTROL, to prevent security # concerns due to caching private data. @@ -424,6 +424,10 @@ class ApplicationController < ActionController::Base Group.supports_nested_objects? && Gitlab::CurrentSettings.import_sources.include?('manifest') end + def phabricator_import_enabled? + Gitlab::PhabricatorImport.available? + end + # U2F (universal 2nd factor) devices need a unique identifier for the application # to perform authentication. # https://developers.yubico.com/U2F/App_ID.html diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb index 71bdef8ce03..0fddf15d197 100644 --- a/app/controllers/concerns/enforces_two_factor_authentication.rb +++ b/app/controllers/concerns/enforces_two_factor_authentication.rb @@ -16,7 +16,7 @@ module EnforcesTwoFactorAuthentication end def check_two_factor_requirement - if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor? + if two_factor_authentication_required? && current_user && !current_user.temp_oauth_email? && !current_user.two_factor_enabled? && !skip_two_factor? redirect_to profile_two_factor_auth_path end end diff --git a/app/controllers/concerns/import_url_params.rb b/app/controllers/concerns/import_url_params.rb new file mode 100644 index 00000000000..e51e4157f50 --- /dev/null +++ b/app/controllers/concerns/import_url_params.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module ImportUrlParams + def import_url_params + return {} unless params.dig(:project, :import_url).present? + + { import_url: import_params_to_full_url(params[:project]) } + end + + def import_params_to_full_url(params) + Gitlab::UrlSanitizer.new( + params[:import_url], + credentials: { + user: params[:import_url_user], + password: params[:import_url_password] + } + ).full_url + end +end diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 91e875dca54..9cf25915e92 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -41,6 +41,7 @@ module IssuableCollections return if pagination_disabled? @issuables = @issuables.page(params[:page]) + @issuables = per_page_for_relative_position if params[:sort] == 'relative_position' @issuable_meta_data = issuable_meta_data(@issuables, collection_type) @total_pages = issuable_page_count end @@ -80,6 +81,11 @@ module IssuableCollections (row_count.to_f / limit).ceil end + # manual / relative_position sorting allows for 100 items on the page + def per_page_for_relative_position + @issuables.per(100) # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + def issuable_finder_for(finder_class) finder_class.new(current_user, finder_options) end diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb index cfff154c3dd..8b8b7db72f8 100644 --- a/app/controllers/concerns/milestone_actions.rb +++ b/app/controllers/concerns/milestone_actions.rb @@ -26,16 +26,22 @@ module MilestoneActions end end + # rubocop:disable Gitlab/ModuleWithInstanceVariables def labels respond_to do |format| format.html { redirect_to milestone_redirect_path } format.json do + milestone_labels = @milestone.issue_labels_visible_by_user(current_user) + render json: tabs_json("shared/milestones/_labels_tab", { - labels: @milestone.labels.map { |label| label.present(issuable_subject: @milestone.parent) } # rubocop:disable Gitlab/ModuleWithInstanceVariables + labels: milestone_labels.map do |label| + label.present(issuable_subject: @milestone.parent) + end }) end end end + # rubocop:enable Gitlab/ModuleWithInstanceVariables private diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 70811f5ea59..65d14781d92 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -6,18 +6,14 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } before_action :set_non_archived_param + before_action :projects, only: [:index] before_action :default_sorting skip_cross_project_access_check :index, :starred def index - @projects = load_projects(params.merge(non_public: true)) - respond_to do |format| format.html do - # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/40260 - Gitlab::GitalyClient.allow_n_plus_1_calls do - render - end + render_projects end format.atom do load_events @@ -51,6 +47,17 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController private + def projects + @projects ||= load_projects(params.merge(non_public: true)) + end + + def render_projects + # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/40260 + Gitlab::GitalyClient.allow_n_plus_1_calls do + render + end + end + def default_sorting params[:sort] ||= 'latest_activity_desc' @sort = params[:sort] diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 7b5dc22815c..1ce0afac83b 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -16,13 +16,8 @@ class GraphqlController < ApplicationController before_action(only: [:execute]) { authenticate_sessionless_user!(:api) } def execute - variables = Gitlab::Graphql::Variables.new(params[:variables]).to_h - query = params[:query] - operation_name = params[:operationName] - context = { - current_user: current_user - } - result = GitlabSchema.execute(query, variables: variables, context: context, operation_name: operation_name) + result = multiplex? ? execute_multiplex : execute_query + render json: result end @@ -38,6 +33,44 @@ class GraphqlController < ApplicationController private + def execute_multiplex + GitlabSchema.multiplex(multiplex_queries, context: context) + end + + def execute_query + variables = build_variables(params[:variables]) + operation_name = params[:operationName] + + GitlabSchema.execute(query, variables: variables, context: context, operation_name: operation_name) + end + + def query + params[:query] + end + + def multiplex_queries + params[:_json].map do |single_query_info| + { + query: single_query_info[:query], + variables: build_variables(single_query_info[:variables]), + operation_name: single_query_info[:operationName], + context: context + } + end + end + + def context + @context ||= { current_user: current_user } + end + + def build_variables(variable_info) + Gitlab::Graphql::Variables.new(variable_info).to_h + end + + def multiplex? + params[:_json].present? + end + def authorize_access_api! access_denied!("API not accessible for user.") unless can?(current_user, :access_api) end diff --git a/app/controllers/import/phabricator_controller.rb b/app/controllers/import/phabricator_controller.rb new file mode 100644 index 00000000000..d1c04817689 --- /dev/null +++ b/app/controllers/import/phabricator_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class Import::PhabricatorController < Import::BaseController + include ImportHelper + + before_action :verify_import_enabled + + def new + end + + def create + @project = Gitlab::PhabricatorImport::ProjectCreator + .new(current_user, import_params).execute + + if @project&.persisted? + redirect_to @project + else + @name = params[:name] + @path = params[:path] + @errors = @project&.errors&.full_messages || [_("Invalid import params")] + + render :new + end + end + + def verify_import_enabled + render_404 unless phabricator_import_enabled? + end + + private + + def import_params + params.permit(:path, :phabricator_server_url, :api_token, :name, :namespace_id) + end +end diff --git a/app/controllers/profiles/accounts_controller.rb b/app/controllers/profiles/accounts_controller.rb index 0d2a6145d0e..b03f4b7435f 100644 --- a/app/controllers/profiles/accounts_controller.rb +++ b/app/controllers/profiles/accounts_controller.rb @@ -17,7 +17,7 @@ class Profiles::AccountsController < Profiles::ApplicationController if unlink_provider_allowed?(provider) identity.destroy else - flash[:alert] = "You are not allowed to unlink your primary login account" + flash[:alert] = _("You are not allowed to unlink your primary login account") end redirect_to profile_account_path diff --git a/app/controllers/profiles/groups_controller.rb b/app/controllers/profiles/groups_controller.rb new file mode 100644 index 00000000000..c755bcb718a --- /dev/null +++ b/app/controllers/profiles/groups_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Profiles::GroupsController < Profiles::ApplicationController + include RoutableActions + + def update + group = find_routable!(Group, params[:id]) + notification_setting = current_user.notification_settings.find_by(source: group) # rubocop: disable CodeReuse/ActiveRecord + + if notification_setting.update(update_params) + flash[:notice] = "Notification settings for #{group.name} saved" + else + flash[:alert] = "Failed to save new settings for #{group.name}" + end + + redirect_back_or_default(default: profile_notifications_path) + end + + private + + def update_params + params.require(:notification_setting).permit(:notification_email) + end +end diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb index b719b70c56e..617e5bb7cb3 100644 --- a/app/controllers/profiles/notifications_controller.rb +++ b/app/controllers/profiles/notifications_controller.rb @@ -14,9 +14,9 @@ class Profiles::NotificationsController < Profiles::ApplicationController result = Users::UpdateService.new(current_user, user_params.merge(user: current_user)).execute if result[:status] == :success - flash[:notice] = "Notification settings saved" + flash[:notice] = _("Notification settings saved") else - flash[:alert] = "Failed to save new settings" + flash[:alert] = _("Failed to save new settings") end redirect_back_or_default(default: profile_notifications_path) diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb index 7038447581c..d2787c2e450 100644 --- a/app/controllers/profiles/passwords_controller.rb +++ b/app/controllers/profiles/passwords_controller.rb @@ -14,7 +14,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController def create unless @user.password_automatically_set || @user.valid_password?(user_params[:current_password]) - redirect_to new_profile_password_path, alert: 'You must provide a valid current password' + redirect_to new_profile_password_path, alert: _('You must provide a valid current password') return end @@ -29,7 +29,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController if result[:status] == :success Users::UpdateService.new(current_user, user: @user, password_expires_at: nil).execute - redirect_to root_path, notice: 'Password successfully changed' + redirect_to root_path, notice: _('Password successfully changed') else render :new end @@ -45,14 +45,14 @@ class Profiles::PasswordsController < Profiles::ApplicationController password_attributes[:password_automatically_set] = false unless @user.password_automatically_set || @user.valid_password?(user_params[:current_password]) - redirect_to edit_profile_password_path, alert: 'You must provide a valid current password' + redirect_to edit_profile_password_path, alert: _('You must provide a valid current password') return end result = Users::UpdateService.new(current_user, password_attributes.merge(user: @user)).execute if result[:status] == :success - flash[:notice] = "Password was successfully updated. Please login with it" + flash[:notice] = _('Password was successfully updated. Please login with it') redirect_to new_user_session_path else @user.reset @@ -62,7 +62,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController def reset current_user.send_reset_password_instructions - redirect_to edit_profile_password_path, notice: 'We sent you an email with reset password instructions' + redirect_to edit_profile_password_path, notice: _('We sent you an email with reset password instructions') end private diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index 83e14275a8b..95b9344c551 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -18,7 +18,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController two_factor_authentication_reason( global: lambda do flash.now[:alert] = - s_('The global settings require you to enable Two-Factor Authentication for your account.') + _('The global settings require you to enable Two-Factor Authentication for your account.') end, group: lambda do |groups| flash.now[:alert] = groups_notification(groups) @@ -27,7 +27,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController unless two_factor_grace_period_expired? grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours - flash.now[:alert] = flash.now[:alert] + s_(" You need to do this before %{grace_period_deadline}.") % { grace_period_deadline: l(grace_period_deadline) } + flash.now[:alert] = flash.now[:alert] + _(" You need to do this before %{grace_period_deadline}.") % { grace_period_deadline: l(grace_period_deadline) } end end @@ -44,7 +44,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController render 'create' else - @error = s_('Invalid pin code') + @error = _('Invalid pin code') @qr_code = build_qr_code setup_u2f_registration render 'show' diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb index 4640be015de..afbf9fd7720 100644 --- a/app/controllers/projects/imports_controller.rb +++ b/app/controllers/projects/imports_controller.rb @@ -2,6 +2,7 @@ class Projects::ImportsController < Projects::ApplicationController include ContinueParams + include ImportUrlParams # Authorize before_action :authorize_admin_project! @@ -67,10 +68,12 @@ class Projects::ImportsController < Projects::ApplicationController end def import_params_attributes - [:import_url] + [] end def import_params - params.require(:project).permit(import_params_attributes) + params.require(:project) + .permit(import_params_attributes) + .merge(import_url_params) end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 8f177895b08..135117926be 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -33,7 +33,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo def show close_merge_request_if_no_source_project - mark_merge_request_mergeable + @merge_request.check_mergeability respond_to do |format| format.html do @@ -145,14 +145,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo render partial: 'projects/merge_requests/widget/commit_change_content', layout: false end - def cancel_merge_when_pipeline_succeeds - unless @merge_request.can_cancel_merge_when_pipeline_succeeds?(current_user) + def cancel_auto_merge + unless @merge_request.can_cancel_auto_merge?(current_user) return access_denied! end - ::MergeRequests::MergeWhenPipelineSucceedsService - .new(@project, current_user) - .cancel(@merge_request) + AutoMergeService.new(project, current_user).cancel(@merge_request) render json: serialize_widget(@merge_request) end @@ -229,12 +227,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def merge_params_attributes - [:should_remove_source_branch, :commit_message, :squash_commit_message, :squash] + [:should_remove_source_branch, :commit_message, :squash_commit_message, :squash, :auto_merge_strategy] end - def merge_when_pipeline_succeeds_active? - params[:merge_when_pipeline_succeeds].present? && - @merge_request.head_pipeline && @merge_request.head_pipeline.active? + def auto_merge_requested? + # Support params[:merge_when_pipeline_succeeds] during the transition period + params[:auto_merge_strategy].present? || params[:merge_when_pipeline_succeeds].present? end def close_merge_request_if_no_source_project @@ -253,14 +251,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @merge_request.has_no_commits? && !@merge_request.target_branch_exists? end - def mark_merge_request_mergeable - @merge_request.check_if_can_be_merged - end - def merge! - # Disable the CI check if merge_when_pipeline_succeeds is enabled since we have + # Disable the CI check if auto_merge_strategy is specified since we have # to wait until CI completes to know - unless @merge_request.mergeable?(skip_ci_check: merge_when_pipeline_succeeds_active?) + unless @merge_request.mergeable?(skip_ci_check: auto_merge_requested?) return :failed end @@ -274,24 +268,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @merge_request.update(merge_error: nil, squash: merge_params.fetch(:squash, false)) - if params[:merge_when_pipeline_succeeds].present? - return :failed unless @merge_request.actual_head_pipeline - - if @merge_request.actual_head_pipeline.active? - ::MergeRequests::MergeWhenPipelineSucceedsService - .new(@project, current_user, merge_params) - .execute(@merge_request) - - :merge_when_pipeline_succeeds - elsif @merge_request.actual_head_pipeline.success? - # This can be triggered when a user clicks the auto merge button while - # the tests finish at about the same time - @merge_request.merge_async(current_user.id, merge_params) - - :success - else - :failed - end + if auto_merge_requested? + AutoMergeService.new(project, current_user, merge_params) + .execute(merge_request, + params[:auto_merge_strategy] || AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) else @merge_request.merge_async(current_user.id, merge_params) diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb index 79030da64d3..4b0d001fca6 100644 --- a/app/controllers/projects/serverless/functions_controller.rb +++ b/app/controllers/projects/serverless/functions_controller.rb @@ -10,15 +10,13 @@ module Projects format.json do functions = finder.execute - if functions.any? - render json: serialize_function(functions) - else - head :no_content - end + render json: { + knative_installed: finder.knative_installed, + functions: serialize_function(functions) + }.to_json end format.html do - @installed = finder.installed? render end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index e88c46144ef..12db493978b 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -7,6 +7,7 @@ class ProjectsController < Projects::ApplicationController include PreviewMarkdown include SendFileUpload include RecordUserLastActivity + include ImportUrlParams prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } @@ -333,6 +334,7 @@ class ProjectsController < Projects::ApplicationController def project_params(attributes: []) params.require(:project) .permit(project_params_attributes + attributes) + .merge(import_url_params) end def project_params_attributes diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 6fea61cf45d..a841859621e 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -18,6 +18,7 @@ class SessionsController < Devise::SessionsController prepend_before_action :store_redirect_uri, only: [:new] prepend_before_action :ldap_servers, only: [:new, :create] prepend_before_action :require_no_authentication_without_flash, only: [:new, :create] + prepend_before_action :ensure_password_authentication_enabled!, if: :password_based_login?, only: [:create] before_action :auto_sign_in_with_provider, only: [:new] before_action :load_recaptcha @@ -138,6 +139,14 @@ class SessionsController < Devise::SessionsController end # rubocop: enable CodeReuse/ActiveRecord + def ensure_password_authentication_enabled! + render_403 unless Gitlab::CurrentSettings.password_authentication_enabled_for_web? + end + + def password_based_login? + user_params[:login].present? || user_params[:password].present? + end + def user_params params.require(:user).permit(:login, :password, :remember_me, :otp_attempt, :device_response) end diff --git a/app/finders/clusters/knative_services_finder.rb b/app/finders/clusters/knative_services_finder.rb new file mode 100644 index 00000000000..7d3b53ef663 --- /dev/null +++ b/app/finders/clusters/knative_services_finder.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true +module Clusters + class KnativeServicesFinder + include ReactiveCaching + include Gitlab::Utils::StrongMemoize + + KNATIVE_STATES = { + 'checking' => 'checking', + 'installed' => 'installed', + 'not_found' => 'not_found' + }.freeze + + self.reactive_cache_key = ->(finder) { finder.model_name } + self.reactive_cache_worker_finder = ->(_id, *cache_args) { from_cache(*cache_args) } + + attr_reader :cluster, :project + + def initialize(cluster, project) + @cluster = cluster + @project = project + end + + def with_reactive_cache_memoized(*cache_args, &block) + strong_memoize(:reactive_cache) do + with_reactive_cache(*cache_args, &block) + end + end + + def clear_cache! + clear_reactive_cache!(*cache_args) + end + + def self.from_cache(cluster_id, project_id) + cluster = Clusters::Cluster.find(cluster_id) + project = ::Project.find(project_id) + + new(cluster, project) + end + + def calculate_reactive_cache(*) + # read_services calls knative_client.discover implicitily. If we stop + # detecting services but still want to detect knative, we'll need to + # explicitily call: knative_client.discover + # + # We didn't create it separately to avoid 2 cluster requests. + ksvc = read_services + pods = knative_client.discovered ? read_pods : [] + { services: ksvc, pods: pods, knative_detected: knative_client.discovered } + end + + def services + return [] unless search_namespace + + cached_data = with_reactive_cache_memoized(*cache_args) { |data| data } + cached_data.to_h.fetch(:services, []) + end + + def cache_args + [cluster.id, project.id] + end + + def service_pod_details(service) + cached_data = with_reactive_cache_memoized(*cache_args) { |data| data } + cached_data.to_h.fetch(:pods, []).select do |pod| + filter_pods(pod, service) + end + end + + def knative_detected + cached_data = with_reactive_cache_memoized(*cache_args) { |data| data } + + knative_state = cached_data.to_h[:knative_detected] + + return KNATIVE_STATES['checking'] if knative_state.nil? + return KNATIVE_STATES['installed'] if knative_state + + KNATIVE_STATES['uninstalled'] + end + + def model_name + self.class.name.underscore.tr('/', '_') + end + + private + + def search_namespace + @search_namespace ||= cluster.kubernetes_namespace_for(project) + end + + def knative_client + cluster.kubeclient.knative_client + end + + def filter_pods(pod, service) + pod["metadata"]["labels"]["serving.knative.dev/service"] == service + end + + def read_services + knative_client.get_services(namespace: search_namespace).as_json + rescue Kubeclient::ResourceNotFoundError + [] + end + + def read_pods + cluster.kubeclient.core_client.get_pods(namespace: search_namespace).as_json + end + + def id + nil + end + end +end diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb index f90a7868102..917de249104 100644 --- a/app/finders/members_finder.rb +++ b/app/finders/members_finder.rb @@ -9,25 +9,18 @@ class MembersFinder @group = project.group end - # rubocop: disable CodeReuse/ActiveRecord - def execute(include_descendants: false) + def execute(include_descendants: false, include_invited_groups_members: false) project_members = project.project_members project_members = project_members.non_invite unless can?(current_user, :admin_project, project) - if group - group_members = GroupMembersFinder.new(group).execute(include_descendants: include_descendants) # rubocop: disable CodeReuse/Finder - group_members = group_members.non_invite + union_members = group_union_members(include_descendants, include_invited_groups_members) - union = Gitlab::SQL::Union.new([project_members, group_members], remove_duplicates: false) # rubocop: disable Gitlab/Union - - sql = distinct_on(union) - - Member.includes(:user).from("(#{sql}) AS #{Member.table_name}") + if union_members.any? + distinct_union_of_members(union_members << project_members) else project_members end end - # rubocop: enable CodeReuse/ActiveRecord def can?(*args) Ability.allowed?(*args) @@ -35,6 +28,34 @@ class MembersFinder private + def group_union_members(include_descendants, include_invited_groups_members) + [].tap do |members| + members << direct_group_members(include_descendants) if group + members << project_invited_groups_members if include_invited_groups_members + end + end + + def direct_group_members(include_descendants) + GroupMembersFinder.new(group).execute(include_descendants: include_descendants).non_invite # rubocop: disable CodeReuse/Finder + end + + def project_invited_groups_members + invited_groups_ids_including_ancestors = Gitlab::ObjectHierarchy + .new(project.invited_groups) + .base_and_ancestors + .public_or_visible_to_user(current_user) + .select(:id) + + GroupMember.with_source_id(invited_groups_ids_including_ancestors) + end + + def distinct_union_of_members(union_members) + union = Gitlab::SQL::Union.new(union_members, remove_duplicates: false) # rubocop: disable Gitlab/Union + sql = distinct_on(union) + + Member.includes(:user).from([Arel.sql("(#{sql}) AS #{Member.table_name}")]) # rubocop: disable CodeReuse/ActiveRecord + end + def distinct_on(union) # We're interested in a list of members without duplicates by user_id. # We prefer project members over group members, project members should go first. diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb index d9802598c64..ebe50806ca1 100644 --- a/app/finders/projects/serverless/functions_finder.rb +++ b/app/finders/projects/serverless/functions_finder.rb @@ -3,6 +3,8 @@ module Projects module Serverless class FunctionsFinder + attr_reader :project + def initialize(project) @clusters = project.clusters @project = project @@ -12,8 +14,16 @@ module Projects knative_services.flatten.compact end - def installed? - clusters_with_knative_installed.exists? + # Possible return values: Clusters::KnativeServicesFinder::KNATIVE_STATE + def knative_installed + states = @clusters.map do |cluster| + cluster.application_knative + cluster.knative_services_finder(project).knative_detected.tap do |state| + return state if state == ::Clusters::KnativeServicesFinder::KNATIVE_STATES['checking'] # rubocop:disable Cop/AvoidReturnFromBlocks + end + end + + states.any? { |state| state == ::Clusters::KnativeServicesFinder::KNATIVE_STATES['installed'] } end def service(environment_scope, name) @@ -23,16 +33,16 @@ module Projects def invocation_metrics(environment_scope, name) return unless prometheus_adapter&.can_query? - cluster = clusters_with_knative_installed.preload_knative.find do |c| + cluster = @clusters.find do |c| environment_scope == c.environment_scope end - func = ::Serverless::Function.new(@project, name, cluster.platform_kubernetes&.actual_namespace) + func = ::Serverless::Function.new(project, name, cluster.kubernetes_namespace_for(project)) prometheus_adapter.query(:knative_invocation, func) end def has_prometheus?(environment_scope) - clusters_with_knative_installed.preload_knative.to_a.any? do |cluster| + @clusters.any? do |cluster| environment_scope == cluster.environment_scope && cluster.application_prometheus_available? end end @@ -40,10 +50,12 @@ module Projects private def knative_service(environment_scope, name) - clusters_with_knative_installed.preload_knative.map do |cluster| + @clusters.map do |cluster| next if environment_scope != cluster.environment_scope - services = cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace) + services = cluster + .knative_services_finder(project) + .services .select { |svc| svc["metadata"]["name"] == name } add_metadata(cluster, services).first unless services.nil? @@ -51,8 +63,11 @@ module Projects end def knative_services - clusters_with_knative_installed.preload_knative.map do |cluster| - services = cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace) + @clusters.map do |cluster| + services = cluster + .knative_services_finder(project) + .services + add_metadata(cluster, services) unless services.nil? end end @@ -63,20 +78,17 @@ module Projects s["cluster_id"] = cluster.id if services.length == 1 - s["podcount"] = cluster.application_knative.service_pod_details( - cluster.platform_kubernetes&.actual_namespace, - s["metadata"]["name"]).length + s["podcount"] = cluster + .knative_services_finder(project) + .service_pod_details(s["metadata"]["name"]) + .length end end end - def clusters_with_knative_installed - @clusters.with_knative_installed - end - # rubocop: disable CodeReuse/ServiceClass def prometheus_adapter - @prometheus_adapter ||= ::Prometheus::AdapterService.new(@project).prometheus_adapter + @prometheus_adapter ||= ::Prometheus::AdapterService.new(project).prometheus_adapter end # rubocop: enable CodeReuse/ServiceClass end diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index 897e12c1b56..f8ad6bee21b 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -7,7 +7,7 @@ class GitlabSchema < GraphQL::Schema AUTHENTICATED_COMPLEXITY = 250 ADMIN_COMPLEXITY = 300 - ANONYMOUS_MAX_DEPTH = 10 + DEFAULT_MAX_DEPTH = 10 AUTHENTICATED_MAX_DEPTH = 15 use BatchLoader::GraphQL @@ -16,17 +16,28 @@ class GitlabSchema < GraphQL::Schema use Gitlab::Graphql::Connections use Gitlab::Graphql::GenericTracing - query_analyzer Gitlab::Graphql::QueryAnalyzers::LogQueryComplexity.analyzer + query_analyzer Gitlab::Graphql::QueryAnalyzers::LoggerAnalyzer.new query(Types::QueryType) default_max_page_size 100 max_complexity DEFAULT_MAX_COMPLEXITY + max_depth DEFAULT_MAX_DEPTH mutation(Types::MutationType) class << self + def multiplex(queries, **kwargs) + kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context]) + + queries.each do |query| + query[:max_depth] = max_query_depth(kwargs[:context]) + end + + super(queries, **kwargs) + end + def execute(query_str = nil, **kwargs) kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context]) kwargs[:max_depth] ||= max_query_depth(kwargs[:context]) @@ -54,7 +65,7 @@ class GitlabSchema < GraphQL::Schema if current_user AUTHENTICATED_MAX_DEPTH else - ANONYMOUS_MAX_DEPTH + DEFAULT_MAX_DEPTH end end end diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 31850c2cadb..5b7eb57841c 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -10,7 +10,7 @@ module Resolvers end end - def self.resolver_complexity(args) + def self.resolver_complexity(args, child_complexity:) complexity = 1 complexity += 1 if args[:sort] complexity += 5 if args[:search] diff --git a/app/graphql/resolvers/concerns/resolves_pipelines.rb b/app/graphql/resolvers/concerns/resolves_pipelines.rb index a166211fc18..a6f82cc8505 100644 --- a/app/graphql/resolvers/concerns/resolves_pipelines.rb +++ b/app/graphql/resolvers/concerns/resolves_pipelines.rb @@ -20,7 +20,7 @@ module ResolvesPipelines end class_methods do - def resolver_complexity(args) + def resolver_complexity(args, child_complexity:) complexity = super complexity += 2 if args[:sha] complexity += 2 if args[:ref] diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index f7e49166ca0..3ee3849f483 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -58,7 +58,7 @@ module Resolvers IssuesFinder.new(context[:current_user], args).execute end - def self.resolver_complexity(args) + def self.resolver_complexity(args, child_complexity:) complexity = super complexity += 2 if args[:labelName] diff --git a/app/graphql/resolvers/namespace_projects_resolver.rb b/app/graphql/resolvers/namespace_projects_resolver.rb new file mode 100644 index 00000000000..677ea808aeb --- /dev/null +++ b/app/graphql/resolvers/namespace_projects_resolver.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Resolvers + class NamespaceProjectsResolver < BaseResolver + argument :include_subgroups, GraphQL::BOOLEAN_TYPE, + required: false, + default_value: false, + description: 'Include also subgroup projects' + + type Types::ProjectType, null: true + + alias_method :namespace, :object + + def resolve(include_subgroups:) + # The namespace could have been loaded in batch by `BatchLoader`. + # At this point we need the `id` or the `full_path` of the namespace + # to query for projects, so make sure it's loaded and not `nil` before continuing. + namespace.sync if namespace.respond_to?(:sync) + return Project.none if namespace.nil? + + if include_subgroups + namespace.all_projects.with_route + else + namespace.projects.with_route + end + end + + def self.resolver_complexity(args, child_complexity:) + complexity = super + complexity + 10 + end + end +end diff --git a/app/graphql/resolvers/namespace_resolver.rb b/app/graphql/resolvers/namespace_resolver.rb new file mode 100644 index 00000000000..17b3800d151 --- /dev/null +++ b/app/graphql/resolvers/namespace_resolver.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Resolvers + class NamespaceResolver < BaseResolver + prepend FullPathResolver + + type Types::NamespaceType, null: true + + def resolve(full_path:) + model_by_full_path(Namespace, full_path) + end + end +end diff --git a/app/graphql/resolvers/tree_resolver.rb b/app/graphql/resolvers/tree_resolver.rb new file mode 100644 index 00000000000..5aad1c71b40 --- /dev/null +++ b/app/graphql/resolvers/tree_resolver.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Resolvers + class TreeResolver < BaseResolver + argument :path, GraphQL::STRING_TYPE, + required: false, + default_value: '', + description: 'The path to get the tree for. Default value is the root of the repository' + argument :ref, GraphQL::STRING_TYPE, + required: false, + default_value: :head, + description: 'The commit ref to get the tree for. Default value is HEAD' + argument :recursive, GraphQL::BOOLEAN_TYPE, + required: false, + default_value: false, + description: 'Used to get a recursive tree. Default is false' + + alias_method :repository, :object + + def resolve(**args) + return unless repository.exists? + + repository.tree(args[:ref], args[:path], recursive: args[:recursive]) + end + end +end diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index 15331129134..a374851e835 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -33,7 +33,7 @@ module Types limit_value = [args[:first], args[:last], page_size].compact.min # Resolvers may add extra complexity depending on used arguments - complexity = child_complexity + self.resolver&.try(:resolver_complexity, args).to_i + complexity = child_complexity + self.resolver&.try(:resolver_complexity, args, child_complexity: child_complexity).to_i # Resolvers may add extra complexity depending on number of items being loaded. multiplier = self.resolver&.try(:complexity_multiplier, args).to_f diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index b21a226d07f..dd5133189dc 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -15,6 +15,10 @@ module Types field :description, GraphQL::STRING_TYPE, null: true field :state, IssueStateEnum, null: false + field :reference, GraphQL::STRING_TYPE, null: false, method: :to_reference do + argument :full, GraphQL::BOOLEAN_TYPE, required: false, default_value: false + end + field :author, Types::UserType, null: false, resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find } @@ -37,7 +41,9 @@ module Types field :upvotes, GraphQL::INT_TYPE, null: false field :downvotes, GraphQL::INT_TYPE, null: false field :user_notes_count, GraphQL::INT_TYPE, null: false + field :web_path, GraphQL::STRING_TYPE, null: false, method: :issue_path field :web_url, GraphQL::STRING_TYPE, null: false + field :relative_position, GraphQL::INT_TYPE, null: true field :closed_at, Types::TimeType, null: true diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index 36d8ee8c878..f6d91320e50 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -15,5 +15,10 @@ module Types field :visibility, GraphQL::STRING_TYPE, null: true field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled? field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true + + field :projects, + Types::ProjectType.connection_type, + null: false, + resolver: ::Resolvers::NamespaceProjectsResolver end end diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb new file mode 100644 index 00000000000..62537361918 --- /dev/null +++ b/app/graphql/types/project_statistics_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + class ProjectStatisticsType < BaseObject + graphql_name 'ProjectStatistics' + + field :commit_count, GraphQL::INT_TYPE, null: false + + field :storage_size, GraphQL::INT_TYPE, null: false + field :repository_size, GraphQL::INT_TYPE, null: false + field :lfs_objects_size, GraphQL::INT_TYPE, null: false + field :build_artifacts_size, GraphQL::INT_TYPE, null: false + field :packages_size, GraphQL::INT_TYPE, null: false + field :wiki_size, GraphQL::INT_TYPE, null: true + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index baea6658e05..2236ffa394d 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -69,6 +69,12 @@ module Types field :namespace, Types::NamespaceType, null: false field :group, Types::GroupType, null: true + field :statistics, Types::ProjectStatisticsType, + null: false, + resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(obj.id).find } + + field :repository, Types::RepositoryType, null: false + field :merge_requests, Types::MergeRequestType.connection_type, null: true, diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 40d7de1a49a..536bdb077ad 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -14,6 +14,11 @@ module Types resolver: Resolvers::GroupResolver, description: "Find a group" + field :namespace, Types::NamespaceType, + null: true, + resolver: Resolvers::NamespaceResolver, + description: "Find a namespace" + field :metadata, Types::MetadataType, null: true, resolver: Resolvers::MetadataResolver, diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb new file mode 100644 index 00000000000..5987467e1ea --- /dev/null +++ b/app/graphql/types/repository_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + class RepositoryType < BaseObject + graphql_name 'Repository' + + authorize :download_code + + field :root_ref, GraphQL::STRING_TYPE, null: true + field :empty, GraphQL::BOOLEAN_TYPE, null: false, method: :empty? + field :exists, GraphQL::BOOLEAN_TYPE, null: false, method: :exists? + field :tree, Types::Tree::TreeType, null: true, resolver: Resolvers::TreeResolver + end +end diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb new file mode 100644 index 00000000000..230624201b0 --- /dev/null +++ b/app/graphql/types/tree/blob_type.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +module Types + module Tree + class BlobType < BaseObject + implements Types::Tree::EntryType + + graphql_name 'Blob' + end + end +end diff --git a/app/graphql/types/tree/entry_type.rb b/app/graphql/types/tree/entry_type.rb new file mode 100644 index 00000000000..d8e8642ddb8 --- /dev/null +++ b/app/graphql/types/tree/entry_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +module Types + module Tree + module EntryType + include Types::BaseInterface + + field :id, GraphQL::ID_TYPE, null: false + field :name, GraphQL::STRING_TYPE, null: false + field :type, Tree::TypeEnum, null: false + field :path, GraphQL::STRING_TYPE, null: false + field :flat_path, GraphQL::STRING_TYPE, null: false + end + end +end diff --git a/app/graphql/types/tree/submodule_type.rb b/app/graphql/types/tree/submodule_type.rb new file mode 100644 index 00000000000..cea76dbfd2a --- /dev/null +++ b/app/graphql/types/tree/submodule_type.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +module Types + module Tree + class SubmoduleType < BaseObject + implements Types::Tree::EntryType + + graphql_name 'Submodule' + end + end +end diff --git a/app/graphql/types/tree/tree_entry_type.rb b/app/graphql/types/tree/tree_entry_type.rb new file mode 100644 index 00000000000..d5cfb898aea --- /dev/null +++ b/app/graphql/types/tree/tree_entry_type.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +module Types + module Tree + class TreeEntryType < BaseObject + implements Types::Tree::EntryType + + graphql_name 'TreeEntry' + description 'Represents a directory' + end + end +end diff --git a/app/graphql/types/tree/tree_type.rb b/app/graphql/types/tree/tree_type.rb new file mode 100644 index 00000000000..1eb6c43972e --- /dev/null +++ b/app/graphql/types/tree/tree_type.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +module Types + module Tree + class TreeType < BaseObject + graphql_name 'Tree' + + field :trees, Types::Tree::TreeEntryType.connection_type, null: false + field :submodules, Types::Tree::SubmoduleType.connection_type, null: false + field :blobs, Types::Tree::BlobType.connection_type, null: false + end + end +end diff --git a/app/graphql/types/tree/type_enum.rb b/app/graphql/types/tree/type_enum.rb new file mode 100644 index 00000000000..6560d91e9e5 --- /dev/null +++ b/app/graphql/types/tree/type_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Tree + class TypeEnum < BaseEnum + graphql_name 'EntryType' + description 'Type of a tree entry' + + value 'tree', value: :tree + value 'blob', value: :blob + value 'commit', value: :commit + end + end +end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 971d1052824..4469118f065 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -160,6 +160,7 @@ module ApplicationSettingsHelper :akismet_api_key, :akismet_enabled, :allow_local_requests_from_hooks_and_services, + :dns_rebinding_protection_enabled, :archive_builds_in_human_readable, :authorized_keys_enabled, :auto_devops_enabled, diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 7e631053b54..0d6a6496993 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -188,7 +188,7 @@ module BlobHelper end def copy_file_path_button(file_path) - clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard') + clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent', title: 'Copy file path to clipboard') end def copy_blob_source_button(blob) diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index dc0e5511fcf..2beb081ab77 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -98,16 +98,17 @@ module EmailsHelper case format when :html - " via merge request #{link_to(merge_request.to_reference, merge_request.web_url)}" + merge_request_link = link_to(merge_request.to_reference, merge_request.web_url) + _("via merge request %{link}").html_safe % { link: merge_request_link } else # If it's not HTML nor text then assume it's text to be safe - " via merge request #{merge_request.to_reference} (#{merge_request.web_url})" + _("via merge request %{link}") % { link: "#{merge_request.to_reference} (#{merge_request.web_url})" } end when String # Technically speaking this should be Commit but per # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/15610#note_163812339 # we can't deserialize Commit without custom serializer for ActiveJob - " via #{closed_via}" + _("via %{closed_via}") % { closed_via: closed_via } else "" end diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 365b94f5a3e..8002eb08ada 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -30,7 +30,8 @@ module EnvironmentsHelper "environments-endpoint": project_environments_path(project, format: :json), "project-path" => project_path(project), "tags-path" => project_tags_path(project), - "has-metrics" => "#{environment.has_metrics?}" + "has-metrics" => "#{environment.has_metrics?}", + "external-dashboard-url" => project.metrics_setting_external_dashboard_url } end end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 7af766c8544..a3f53ca8dd6 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -99,7 +99,7 @@ module GroupsHelper end def remove_group_message(group) - _("You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?") % + _("You are going to remove %{group_name}, this will also remove all of its subgroups and projects. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?") % { group_name: group.name } end diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 76300e791e6..db4f29cd996 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -5,7 +5,7 @@ module LabelsHelper include ActionView::Helpers::TagHelper def show_label_issuables_link?(label, issuables_type, current_user: nil, project: nil) - return true if label.is_a?(GroupLabel) + return true unless label.project_label? return true unless project project.feature_available?(issuables_type, current_user) @@ -76,29 +76,39 @@ module LabelsHelper end def suggested_colors - [ - '#0033CC', - '#428BCA', - '#44AD8E', - '#A8D695', - '#5CB85C', - '#69D100', - '#004E00', - '#34495E', - '#7F8C8D', - '#A295D6', - '#5843AD', - '#8E44AD', - '#FFECDB', - '#AD4363', - '#D10069', - '#CC0033', - '#FF0000', - '#D9534F', - '#D1D100', - '#F0AD4E', - '#AD8D43' - ] + { + '#0033CC' => s_('SuggestedColors|UA blue'), + '#428BCA' => s_('SuggestedColors|Moderate blue'), + '#44AD8E' => s_('SuggestedColors|Lime green'), + '#A8D695' => s_('SuggestedColors|Feijoa'), + '#5CB85C' => s_('SuggestedColors|Slightly desaturated green'), + '#69D100' => s_('SuggestedColors|Bright green'), + '#004E00' => s_('SuggestedColors|Very dark lime green'), + '#34495E' => s_('SuggestedColors|Very dark desaturated blue'), + '#7F8C8D' => s_('SuggestedColors|Dark grayish cyan'), + '#A295D6' => s_('SuggestedColors|Slightly desaturated blue'), + '#5843AD' => s_('SuggestedColors|Dark moderate blue'), + '#8E44AD' => s_('SuggestedColors|Dark moderate violet'), + '#FFECDB' => s_('SuggestedColors|Very pale orange'), + '#AD4363' => s_('SuggestedColors|Dark moderate pink'), + '#D10069' => s_('SuggestedColors|Strong pink'), + '#CC0033' => s_('SuggestedColors|Strong red'), + '#FF0000' => s_('SuggestedColors|Pure red'), + '#D9534F' => s_('SuggestedColors|Soft red'), + '#D1D100' => s_('SuggestedColors|Strong yellow'), + '#F0AD4E' => s_('SuggestedColors|Soft orange'), + '#AD8D43' => s_('SuggestedColors|Dark moderate orange') + } + end + + def render_suggested_colors + colors_html = suggested_colors.map do |color_hex_value, color_name| + link_to('', '#', class: "has-tooltip", style: "background-color: #{color_hex_value}", data: { color: color_hex_value }, title: color_name) + end + + content_tag(:div, class: 'suggest-colors') do + colors_html.join.html_safe + end end def text_color_for_bg(bg_color) @@ -159,13 +169,6 @@ module LabelsHelper label.subscribed?(current_user, project) ? 'Unsubscribe' : 'Subscribe' end - def label_deletion_confirm_text(label) - case label - when GroupLabel then _('Remove this label? This will affect all projects within the group. Are you sure?') - when ProjectLabel then _('Remove this label? Are you sure?') - end - end - def create_label_title(subject) case subject when Group @@ -200,7 +203,7 @@ module LabelsHelper end def label_status_tooltip(label, status) - type = label.is_a?(ProjectLabel) ? 'project' : 'group' + type = label.project_label? ? 'project' : 'group' level = status.unsubscribed? ? type : status.sub('-level', '') action = status.unsubscribed? ? 'Subscribe' : 'Unsubscribe' diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 991ca42c445..2de4e92e33e 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -103,7 +103,7 @@ module MergeRequestsHelper def merge_params(merge_request) { - merge_when_pipeline_succeeds: true, + auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS, should_remove_source_branch: true, sha: merge_request.diff_head_sha, squash: merge_request.squash diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index a7ce7667916..11b9cf22142 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -100,4 +100,8 @@ module NotificationsHelper css_class: "icon notifications-icon js-notifications-icon" ) end + + def show_unsubscribe_title?(noteable) + can?(current_user, "read_#{noteable.to_ability_name}".to_sym, noteable) + end end diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb index 5038dcf9746..ec1d8577f36 100644 --- a/app/helpers/page_layout_helper.rb +++ b/app/helpers/page_layout_helper.rb @@ -1,3 +1,4 @@ +# coding: utf-8 # frozen_string_literal: true module PageLayoutHelper @@ -36,7 +37,7 @@ module PageLayoutHelper if description.present? @page_description = description.squish elsif @page_description.present? - sanitize(@page_description, tags: []).truncate_words(30) + sanitize(@page_description.truncate_words(30), tags: []) end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 91d15e0e4ea..8dee842a22d 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -241,6 +241,7 @@ module ProjectsHelper # TODO: Remove this method when removing the feature flag # https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/11209#note_162234863 + # make sure to remove from the EE specific controller as well: ee/app/controllers/ee/dashboard/projects_controller.rb def show_projects?(projects, params) Feature.enabled?(:project_list_filter_bar) || !!(params[:personal] || params[:name] || any_projects?(projects)) end @@ -319,6 +320,34 @@ module ProjectsHelper Ability.allowed?(current_user, :admin_project_member, @project) end + def project_can_be_shared? + !membership_locked? || @project.allowed_to_share_with_group? + end + + def membership_locked? + false + end + + def share_project_description(project) + share_with_group = project.allowed_to_share_with_group? + share_with_members = !membership_locked? + + description = + if share_with_group && share_with_members + _("You can invite a new member to <strong>%{project_name}</strong> or invite another group.") + elsif share_with_group + _("You can invite another group to <strong>%{project_name}</strong>.") + elsif share_with_members + _("You can invite a new member to <strong>%{project_name}</strong>.") + end + + description.html_safe % { project_name: project.name } + end + + def metrics_external_dashboard_url + @project.metrics_setting_external_dashboard_url + end + private def get_project_nav_tabs(project, current_user) @@ -631,4 +660,8 @@ module ProjectsHelper project.builds_enabled? && !project.repository.gitlab_ci_yml end + + def vue_file_list_enabled? + Gitlab::Graphql.enabled? && Feature.enabled?(:vue_file_list, @project) + end end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index f2d814e6930..26692934456 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -3,29 +3,30 @@ module SortingHelper def sort_options_hash { - sort_value_created_date => sort_title_created_date, - sort_value_downvotes => sort_title_downvotes, - sort_value_due_date => sort_title_due_date, - sort_value_due_date_later => sort_title_due_date_later, - sort_value_due_date_soon => sort_title_due_date_soon, - sort_value_label_priority => sort_title_label_priority, - sort_value_largest_group => sort_title_largest_group, - sort_value_largest_repo => sort_title_largest_repo, - sort_value_milestone => sort_title_milestone, - sort_value_milestone_later => sort_title_milestone_later, - sort_value_milestone_soon => sort_title_milestone_soon, - sort_value_name => sort_title_name, - sort_value_name_desc => sort_title_name_desc, - sort_value_oldest_created => sort_title_oldest_created, - sort_value_oldest_signin => sort_title_oldest_signin, - sort_value_oldest_updated => sort_title_oldest_updated, - sort_value_recently_created => sort_title_recently_created, - sort_value_recently_signin => sort_title_recently_signin, - sort_value_recently_updated => sort_title_recently_updated, - sort_value_popularity => sort_title_popularity, - sort_value_priority => sort_title_priority, - sort_value_upvotes => sort_title_upvotes, - sort_value_contacted_date => sort_title_contacted_date + sort_value_created_date => sort_title_created_date, + sort_value_downvotes => sort_title_downvotes, + sort_value_due_date => sort_title_due_date, + sort_value_due_date_later => sort_title_due_date_later, + sort_value_due_date_soon => sort_title_due_date_soon, + sort_value_label_priority => sort_title_label_priority, + sort_value_largest_group => sort_title_largest_group, + sort_value_largest_repo => sort_title_largest_repo, + sort_value_milestone => sort_title_milestone, + sort_value_milestone_later => sort_title_milestone_later, + sort_value_milestone_soon => sort_title_milestone_soon, + sort_value_name => sort_title_name, + sort_value_name_desc => sort_title_name_desc, + sort_value_oldest_created => sort_title_oldest_created, + sort_value_oldest_signin => sort_title_oldest_signin, + sort_value_oldest_updated => sort_title_oldest_updated, + sort_value_recently_created => sort_title_recently_created, + sort_value_recently_signin => sort_title_recently_signin, + sort_value_recently_updated => sort_title_recently_updated, + sort_value_popularity => sort_title_popularity, + sort_value_priority => sort_title_priority, + sort_value_upvotes => sort_title_upvotes, + sort_value_contacted_date => sort_title_contacted_date, + sort_value_relative_position => sort_title_relative_position } end @@ -397,6 +398,10 @@ module SortingHelper s_('SortOptions|Recent last activity') end + def sort_title_relative_position + s_('SortOptions|Manual') + end + # Values. def sort_value_access_level_asc 'access_level_asc' @@ -545,4 +550,8 @@ module SortingHelper def sort_value_recently_last_activity 'last_activity_on_desc' end + + def sort_value_relative_position + 'relative_position' + end end diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb index e80b3f2b54a..ecf37bae6b3 100644 --- a/app/helpers/storage_helper.rb +++ b/app/helpers/storage_helper.rb @@ -12,10 +12,11 @@ module StorageHelper def storage_counters_details(statistics) counters = { counter_repositories: storage_counter(statistics.repository_size), + counter_wikis: storage_counter(statistics.wiki_size), counter_build_artifacts: storage_counter(statistics.build_artifacts_size), counter_lfs_objects: storage_counter(statistics.lfs_objects_size) } - _("%{counter_repositories} repositories, %{counter_build_artifacts} build artifacts, %{counter_lfs_objects} LFS") % counters + _("%{counter_repositories} repositories, %{counter_wikis} wikis, %{counter_build_artifacts} build artifacts, %{counter_lfs_objects} LFS") % counters end end diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 2b046d17122..f3a3203f7ad 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -83,7 +83,7 @@ module Emails @project = Project.find(project_id) @results = results - mail(to: @user.notification_email, subject: subject('Imported issues')) do |format| + mail(to: recipient(@user.id, @project.group), subject: subject('Imported issues')) do |format| format.html { render layout: 'mailer' } format.text { render layout: 'mailer' } end @@ -103,7 +103,7 @@ module Emails def issue_thread_options(sender_id, recipient_id, reason) { from: sender(sender_id), - to: recipient(recipient_id), + to: recipient(recipient_id, @project.group), subject: subject("#{@issue.title} (##{@issue.iid})"), 'X-GitLab-NotificationReason' => reason } diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 91dfdf58982..2bfa59774d7 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -58,9 +58,8 @@ module Emails @member_source_type = member_source_type @member_source = member_source_class.find(source_id) @invite_email = invite_email - inviter = User.find(created_by_id) - mail(to: inviter.notification_email, + mail(to: recipient(created_by_id, member_source_type == 'Project' ? @member_source.group : @member_source), subject: subject('Invitation declined')) end diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index fc6ed695675..864f9e2975a 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -110,7 +110,7 @@ module Emails def merge_request_thread_options(sender_id, recipient_id, reason = nil) { from: sender(sender_id), - to: recipient(recipient_id), + to: recipient(recipient_id, @project.group), subject: subject("#{@merge_request.title} (#{@merge_request.to_reference})"), 'X-GitLab-NotificationReason' => reason } diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb index 1b3c1f9a8a9..70d296fe3b8 100644 --- a/app/mailers/emails/notes.rb +++ b/app/mailers/emails/notes.rb @@ -51,7 +51,7 @@ module Emails def note_thread_options(recipient_id) { from: sender(@note.author_id), - to: recipient(recipient_id), + to: recipient(recipient_id, @group), subject: subject("#{@note.noteable.title} (#{@note.noteable.reference_link_text})") } end diff --git a/app/mailers/emails/pages_domains.rb b/app/mailers/emails/pages_domains.rb index ce449237ef6..2d390666f65 100644 --- a/app/mailers/emails/pages_domains.rb +++ b/app/mailers/emails/pages_domains.rb @@ -7,7 +7,7 @@ module Emails @project = domain.project mail( - to: recipient.notification_email, + to: recipient(recipient.id, @project.group), subject: subject("GitLab Pages domain '#{domain.domain}' has been enabled") ) end @@ -17,7 +17,7 @@ module Emails @project = domain.project mail( - to: recipient.notification_email, + to: recipient(recipient.id, @project.group), subject: subject("GitLab Pages domain '#{domain.domain}' has been disabled") ) end @@ -27,7 +27,7 @@ module Emails @project = domain.project mail( - to: recipient.notification_email, + to: recipient(recipient.id, @project.group), subject: subject("Verification succeeded for GitLab Pages domain '#{domain.domain}'") ) end @@ -37,7 +37,7 @@ module Emails @project = domain.project mail( - to: recipient.notification_email, + to: recipient(recipient.id, @project.group), subject: subject("ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'") ) end diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 2500622caa7..f81f76f67f7 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -7,20 +7,20 @@ module Emails @project = Project.find project_id @target_url = project_url(@project) @old_path_with_namespace = old_path_with_namespace - mail(to: @user.notification_email, + mail(to: recipient(user_id, @project.group), subject: subject("Project was moved")) end def project_was_exported_email(current_user, project) @project = project - mail(to: current_user.notification_email, + mail(to: recipient(current_user.id, project.group), subject: subject("Project was exported")) end def project_was_not_exported_email(current_user, project, errors) @project = project @errors = errors - mail(to: current_user.notification_email, + mail(to: recipient(current_user.id, @project.group), subject: subject("Project export error")) end @@ -28,7 +28,7 @@ module Emails @project = project @user = user - mail(to: user.notification_email, subject: subject("Project cleanup has completed")) + mail(to: recipient(user.id, project.group), subject: subject("Project cleanup has completed")) end def repository_cleanup_failure_email(project, user, error) @@ -36,7 +36,7 @@ module Emails @user = user @error = error - mail(to: user.notification_email, subject: subject("Project cleanup failure")) + mail(to: recipient(user.id, project.group), subject: subject("Project cleanup failure")) end def repository_push_email(project_id, opts = {}) diff --git a/app/mailers/emails/remote_mirrors.rb b/app/mailers/emails/remote_mirrors.rb index 2018eb7260b..2d8137843ec 100644 --- a/app/mailers/emails/remote_mirrors.rb +++ b/app/mailers/emails/remote_mirrors.rb @@ -6,7 +6,7 @@ module Emails @remote_mirror = RemoteMirrorFinder.new(id: remote_mirror_id).execute @project = @remote_mirror.project - mail(to: recipient(recipient_id), subject: subject('Remote mirror update failed')) + mail(to: recipient(recipient_id, @project.group), subject: subject('Remote mirror update failed')) end end end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 0b740809f30..576caea4c10 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -73,12 +73,22 @@ class Notify < BaseMailer # Look up a User by their ID and return their email address # - # recipient_id - User ID + # recipient_id - User ID + # notification_group - The parent group of the notification # # Returns a String containing the User's email address. - def recipient(recipient_id) + def recipient(recipient_id, notification_group = nil) @current_user = User.find(recipient_id) - @current_user.notification_email + group_notification_email = nil + + if notification_group + notification_settings = notification_group.notification_settings_for(@current_user, hierarchy_order: :asc) + group_notification_email = notification_settings.find { |n| n.notification_email.present? }&.notification_email + end + + # Return group-specific email address if present, otherwise return global + # email address + group_notification_email || @current_user.notification_email end # Formats arguments into a String suitable for use as an email subject diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index fb1e558e46c..bbe2d2e8fd4 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -257,6 +257,12 @@ class ApplicationSetting < ApplicationRecord algorithm: 'aes-256-gcm', encode: true + attr_encrypted :lets_encrypt_private_key, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm', + encode: true + before_validation :ensure_uuid! before_validation :strip_sentry_values diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index e51619b0f9c..904d650ef96 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -21,6 +21,7 @@ module ApplicationSettingImplementation after_sign_up_text: nil, akismet_enabled: false, allow_local_requests_from_hooks_and_services: false, + dns_rebinding_protection_enabled: true, authorized_keys_enabled: true, # TODO default to false if the instance is configured to use AuthorizedKeysCommand container_registry_token_expire_delay: 5, default_artifacts_expire_in: '30 days', diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 1b67a7272bc..aaa326afea5 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -2,7 +2,6 @@ module Ci class Build < CommitStatus - prepend ArtifactMigratable include Ci::Processable include Ci::Metadatable include Ci::Contextable @@ -16,11 +15,15 @@ module Ci include Gitlab::Utils::StrongMemoize include Deployable include HasRef - include UpdateProjectStatistics BuildArchivedError = Class.new(StandardError) ignore_column :commands + ignore_column :artifacts_file + ignore_column :artifacts_metadata + ignore_column :artifacts_file_store + ignore_column :artifacts_metadata_store + ignore_column :artifacts_size belongs_to :project, inverse_of: :builds belongs_to :runner @@ -83,13 +86,7 @@ module Ci scope :unstarted, ->() { where(runner_id: nil) } scope :ignore_failures, ->() { where(allow_failure: false) } scope :with_artifacts_archive, ->() do - if Feature.enabled?(:ci_enable_legacy_artifacts) - where('(artifacts_file IS NOT NULL AND artifacts_file <> ?) OR EXISTS (?)', - '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive) - else - where('EXISTS (?)', - Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive) - end + where('EXISTS (?)', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive) end scope :with_existing_job_artifacts, ->(query) do @@ -111,8 +108,8 @@ module Ci scope :eager_load_job_artifacts, -> { includes(:job_artifacts) } - scope :with_artifacts_stored_locally, -> { with_artifacts_archive.where(artifacts_file_store: [nil, LegacyArtifactUploader::Store::LOCAL]) } - scope :with_archived_trace_stored_locally, -> { with_archived_trace.where(artifacts_file_store: [nil, LegacyArtifactUploader::Store::LOCAL]) } + scope :with_artifacts_stored_locally, -> { with_existing_job_artifacts(Ci::JobArtifact.archive.with_files_stored_locally) } + scope :with_archived_trace_stored_locally, -> { with_existing_job_artifacts(Ci::JobArtifact.trace.with_files_stored_locally) } scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } @@ -142,16 +139,10 @@ module Ci scope :queued_before, ->(time) { where(arel_table[:queued_at].lt(time)) } - ## - # TODO: Remove these mounters when we remove :ci_enable_legacy_artifacts feature flag - mount_uploader :legacy_artifacts_file, LegacyArtifactUploader, mount_on: :artifacts_file - mount_uploader :legacy_artifacts_metadata, LegacyArtifactUploader, mount_on: :artifacts_metadata - acts_as_taggable add_authentication_token_field :token, encrypted: :optional - before_save :update_artifacts_size, if: :artifacts_file_changed? before_save :ensure_token before_destroy { unscoped_project } @@ -159,8 +150,6 @@ module Ci run_after_commit { BuildHooksWorker.perform_async(build.id) } end - update_project_statistics stat: :build_artifacts_size, attribute: :artifacts_size - class << self # This is needed for url_for to work, # as the controller is JobsController @@ -542,6 +531,26 @@ module Ci trace.exist? end + def artifacts_file + job_artifacts_archive&.file + end + + def artifacts_size + job_artifacts_archive&.size + end + + def artifacts_metadata + job_artifacts_metadata&.file + end + + def artifacts? + !artifacts_expired? && artifacts_file&.exists? + end + + def artifacts_metadata? + artifacts? && artifacts_metadata&.exists? + end + def has_job_artifacts? job_artifacts.any? end @@ -610,14 +619,12 @@ module Ci # and use that for `ExpireBuildInstanceArtifactsWorker`? def erase_erasable_artifacts! job_artifacts.erasable.destroy_all # rubocop: disable DestroyAll - erase_old_artifacts! end def erase(opts = {}) return false unless erasable? job_artifacts.destroy_all # rubocop: disable DestroyAll - erase_old_artifacts! erase_trace! update_erased!(opts[:erased_by]) end @@ -655,10 +662,7 @@ module Ci end def artifacts_file_for_type(type) - file = job_artifacts.find_by(file_type: Ci::JobArtifact.file_types[type])&.file - # TODO: to be removed once legacy artifacts is removed - file ||= legacy_artifacts_file if type == :archive - file + job_artifacts.find_by(file_type: Ci::JobArtifact.file_types[type])&.file end def coverage_regex @@ -765,6 +769,10 @@ module Ci end end + def report_artifacts + job_artifacts.with_reports + end + # Virtual deployment status depending on the environment status. def deployment_status return unless starts_environment? @@ -780,13 +788,6 @@ module Ci private - def erase_old_artifacts! - # TODO: To be removed once we get rid of ci_enable_legacy_artifacts feature flag - remove_artifacts_file! - remove_artifacts_metadata! - save - end - def successful_deployment_status if deployment&.last? :last @@ -808,10 +809,6 @@ module Ci job_artifacts.select { |artifact| artifact.file_type.in?(report_types) } end - def update_artifacts_size - self.artifacts_size = legacy_artifacts_file&.size - end - def erase_trace! trace.erase! end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 3beb76ffc2b..f80e98e5bca 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -26,10 +26,13 @@ module Ci metrics: 'metrics.txt' }.freeze - TYPE_AND_FORMAT_PAIRS = { + INTERNAL_TYPES = { archive: :zip, metadata: :gzip, - trace: :raw, + trace: :raw + }.freeze + + REPORT_TYPES = { junit: :gzip, metrics: :gzip, @@ -45,6 +48,8 @@ module Ci performance: :raw }.freeze + TYPE_AND_FORMAT_PAIRS = INTERNAL_TYPES.merge(REPORT_TYPES).freeze + belongs_to :project belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id @@ -54,7 +59,7 @@ module Ci validate :valid_file_format?, unless: :trace?, on: :create before_save :set_size, if: :file_changed? - update_project_statistics stat: :build_artifacts_size + update_project_statistics project_statistics_name: :build_artifacts_size after_save :update_file_store, if: :saved_change_to_file? @@ -66,6 +71,10 @@ module Ci where(file_type: types) end + scope :with_reports, -> do + with_file_types(REPORT_TYPES.keys.map(&:to_s)) + end + scope :test_reports, -> do with_file_types(TEST_REPORT_FILE_TYPES) end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 80401ca0a1e..3727a9861aa 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -166,6 +166,16 @@ module Ci end end + after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline| + pipeline.run_after_commit do + pipeline.all_merge_requests.each do |merge_request| + next unless merge_request.auto_merge_enabled? + + AutoMergeProcessWorker.perform_async(merge_request.id) + end + end + end + after_transition any => [:success, :failed] do |pipeline| pipeline.run_after_commit do PipelineNotificationWorker.perform_async(pipeline.id) diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index c0a0ca9acf6..c40ad39be61 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -27,9 +27,13 @@ module Ci scope :active, -> { where(active: true) } scope :inactive, -> { where(active: false) } + scope :runnable_schedules, -> { active.where("next_run_at < ?", Time.now) } + scope :preloaded, -> { preload(:owner, :project) } accepts_nested_attributes_for :variables, allow_destroy: true + alias_attribute :real_next_run, :next_run_at + def owned_by?(current_user) owner == current_user end @@ -46,8 +50,14 @@ module Ci update_attribute(:active, false) end + ## + # The `next_run_at` column is set to the actual execution date of `PipelineScheduleWorker`. + # This way, a schedule like `*/1 * * * *` won't be triggered in a short interval + # when PipelineScheduleWorker runs irregularly by Sidekiq Memory Killer. def set_next_run_at - self.next_run_at = Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now) + self.next_run_at = Gitlab::Ci::CronParser.new(Settings.cron_jobs['pipeline_schedule_worker']['cron'], + Time.zone.name) + .next_time_from(ideal_next_run_at) end def schedule_next_run! @@ -56,15 +66,14 @@ module Ci update_attribute(:next_run_at, nil) # update without validation end - def real_next_run( - worker_cron: Settings.cron_jobs['pipeline_schedule_worker']['cron'], - worker_time_zone: Time.zone.name) - Gitlab::Ci::CronParser.new(worker_cron, worker_time_zone) - .next_time_from(next_run_at) - end - def job_variables variables&.map(&:to_runner_variable) || [] end + + private + + def ideal_next_run_at + Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now) + end end end diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb index 36c51522089..bd9c453e2a4 100644 --- a/app/models/clusters/applications/jupyter.rb +++ b/app/models/clusters/applications/jupyter.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'securerandom' + module Clusters module Applications class Jupyter < ApplicationRecord @@ -80,6 +82,9 @@ module Clusters "secretToken" => secret_token }, "auth" => { + "state" => { + "cryptoKey" => crypto_key + }, "gitlab" => { "clientId" => oauth_application.uid, "clientSecret" => oauth_application.secret, @@ -95,6 +100,10 @@ module Clusters } end + def crypto_key + @crypto_key ||= SecureRandom.hex(32) + end + def project_id cluster&.project&.id end diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index 9fbf5d8af04..d5a3bd62e3d 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -15,9 +15,6 @@ module Clusters include ::Clusters::Concerns::ApplicationVersion include ::Clusters::Concerns::ApplicationData include AfterCommitQueue - include ReactiveCaching - - self.reactive_cache_key = ->(knative) { [knative.class.model_name.singular, knative.id] } def set_initial_status return unless not_installable? @@ -41,8 +38,6 @@ module Clusters scope :for_cluster, -> (cluster) { where(cluster: cluster) } - after_save :clear_reactive_cache! - def chart 'knative/knative' end @@ -77,55 +72,12 @@ module Clusters ClusterWaitForIngressIpAddressWorker.perform_async(name, id) end - def client - cluster.kubeclient.knative_client - end - - def services - with_reactive_cache do |data| - data[:services] - end - end - - def calculate_reactive_cache - { services: read_services, pods: read_pods } - end - def ingress_service cluster.kubeclient.get_service('istio-ingressgateway', 'istio-system') end - def services_for(ns: namespace) - return [] unless services - return [] unless ns - - services.select do |service| - service.dig('metadata', 'namespace') == ns - end - end - - def service_pod_details(ns, service) - with_reactive_cache do |data| - data[:pods].select { |pod| filter_pods(pod, ns, service) } - end - end - private - def read_pods - cluster.kubeclient.core_client.get_pods.as_json - end - - def filter_pods(pod, namespace, service) - pod["metadata"]["namespace"] == namespace && pod["metadata"]["labels"]["serving.knative.dev/service"] == service - end - - def read_services - client.get_services.as_json - rescue Kubeclient::ResourceNotFoundError - [] - end - def install_knative_metrics ["kubectl apply -f #{METRICS_CONFIG}"] if cluster.application_prometheus_available? end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 01711e477b1..db7fd8524c2 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.5.0'.freeze + VERSION = '0.5.2'.freeze self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 9299e61dad3..e1d6b2a802b 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -5,8 +5,10 @@ module Clusters include Presentable include Gitlab::Utils::StrongMemoize include FromUnion + include ReactiveCaching self.table_name = 'clusters' + self.reactive_cache_key = -> (cluster) { [cluster.class.model_name.singular, cluster.id] } PROJECT_ONLY_APPLICATIONS = { Applications::Jupyter.application_name => Applications::Jupyter, @@ -45,7 +47,6 @@ module Clusters has_one :application_knative, class_name: 'Clusters::Applications::Knative' has_many :kubernetes_namespaces - has_one :kubernetes_namespace, -> { order(id: :desc) }, class_name: 'Clusters::KubernetesNamespace' accepts_nested_attributes_for :provider_gcp, update_only: true accepts_nested_attributes_for :platform_kubernetes, update_only: true @@ -58,6 +59,8 @@ module Clusters validate :no_groups, unless: :group_type? validate :no_projects, unless: :project_type? + after_save :clear_reactive_cache! + delegate :status, to: :provider, allow_nil: true delegate :status_reason, to: :provider, allow_nil: true delegate :on_creation?, to: :provider, allow_nil: true @@ -108,7 +111,7 @@ module Clusters scope :preload_knative, -> { preload( - :kubernetes_namespace, + :kubernetes_namespaces, :platform_kubernetes, :application_knative ) @@ -124,15 +127,19 @@ module Clusters end def status_name - if provider - provider.status_name - else - :created + provider&.status_name || connection_status.presence || :created + end + + def connection_status + with_reactive_cache do |data| + data[:connection_status] end end - def created? - status_name == :created + def calculate_reactive_cache + return unless enabled? + + { connection_status: retrieve_connection_status } end def applications @@ -187,16 +194,16 @@ module Clusters platform_kubernetes.kubeclient if kubernetes? end + def kubernetes_namespace_for(project) + find_or_initialize_kubernetes_namespace_for_project(project).namespace + end + def find_or_initialize_kubernetes_namespace_for_project(project) - if project_type? - kubernetes_namespaces.find_or_initialize_by( - project: project, - cluster_project: cluster_project - ) - else - kubernetes_namespaces.find_or_initialize_by( - project: project - ) + attributes = { project: project } + attributes[:cluster_project] = cluster_project if project_type? + + kubernetes_namespaces.find_or_initialize_by(attributes).tap do |namespace| + namespace.set_defaults end end @@ -205,7 +212,7 @@ module Clusters end def kube_ingress_domain - @kube_ingress_domain ||= domain.presence || instance_domain || legacy_auto_devops_domain + @kube_ingress_domain ||= domain.presence || instance_domain end def predefined_variables @@ -216,12 +223,43 @@ module Clusters end end + def knative_services_finder(project) + @knative_services_finder ||= KnativeServicesFinder.new(self, project) + end + private def instance_domain @instance_domain ||= Gitlab::CurrentSettings.auto_devops_domain end + def retrieve_connection_status + kubeclient.core_client.discover + rescue *Gitlab::Kubernetes::Errors::CONNECTION + :unreachable + rescue *Gitlab::Kubernetes::Errors::AUTHENTICATION + :authentication_failure + rescue Kubeclient::HttpError => e + kubeclient_error_status(e.message) + rescue => e + Gitlab::Sentry.track_acceptable_exception(e, extra: { cluster_id: id }) + + :unknown_failure + else + :connected + end + + # KubeClient uses the same error class + # For connection errors (eg. timeout) and + # for Kubernetes errors. + def kubeclient_error_status(message) + if message&.match?(/timed out|timeout/i) + :unreachable + else + :authentication_failure + end + end + # To keep backward compatibility with AUTO_DEVOPS_DOMAIN # environment variable, we need to ensure KUBE_INGRESS_BASE_DOMAIN # is set if AUTO_DEVOPS_DOMAIN is set on any of the following options: diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 3b7b93e7631..9b951578aee 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -52,11 +52,14 @@ module Clusters alias_attribute :ca_pem, :ca_cert - delegate :project, to: :cluster, allow_nil: true delegate :enabled?, to: :cluster, allow_nil: true delegate :provided_by_user?, to: :cluster, allow_nil: true delegate :allow_user_defined_namespace?, to: :cluster, allow_nil: true - delegate :kubernetes_namespace, to: :cluster + + # This is just to maintain compatibility with KubernetesService, which + # will be removed in https://gitlab.com/gitlab-org/gitlab-ce/issues/39217. + # It can be removed once KubernetesService is gone. + delegate :kubernetes_namespace_for, to: :cluster, allow_nil: true alias_method :active?, :enabled? @@ -68,18 +71,6 @@ module Clusters default_value_for :authorization_type, :rbac - def actual_namespace - if namespace.present? - namespace - else - default_namespace - end - end - - def namespace_for(project) - cluster.find_or_initialize_kubernetes_namespace_for_project(project).namespace - end - def predefined_variables(project:) Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'KUBE_URL', value: api_url) @@ -98,11 +89,13 @@ module Clusters # Once we have marked all project-level clusters that make use of this # behaviour as "unmanaged", we can remove the `cluster.project_type?` # check here. + project_namespace = cluster.kubernetes_namespace_for(project) + variables .append(key: 'KUBE_URL', value: api_url) .append(key: 'KUBE_TOKEN', value: token, public: false, masked: true) - .append(key: 'KUBE_NAMESPACE', value: actual_namespace) - .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true) + .append(key: 'KUBE_NAMESPACE', value: project_namespace) + .append(key: 'KUBECONFIG', value: kubeconfig(project_namespace), public: false, file: true) end variables.concat(cluster.predefined_variables) @@ -115,8 +108,10 @@ module Clusters # short time later def terminals(environment) with_reactive_cache do |data| + project = environment.project + pods = filter_by_project_environment(data[:pods], project.full_path_slug, environment.slug) - terminals = pods.flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) }.compact + terminals = pods.flat_map { |pod| terminals_for_pod(api_url, cluster.kubernetes_namespace_for(project), pod) }.compact terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) } end end @@ -124,7 +119,7 @@ module Clusters # Caches resources in the namespace so other calls don't need to block on # network access def calculate_reactive_cache - return unless enabled? && project && !project.pending_delete? + return unless enabled? # We may want to cache extra things in the future { pods: read_pods } @@ -136,33 +131,16 @@ module Clusters private - def kubeconfig + def kubeconfig(namespace) to_kubeconfig( url: api_url, - namespace: actual_namespace, + namespace: namespace, token: token, ca_pem: ca_pem) end - def default_namespace - kubernetes_namespace&.namespace.presence || fallback_default_namespace - end - - # DEPRECATED - # - # On 11.4 Clusters::KubernetesNamespace was introduced, this model will allow to - # have multiple namespaces per project. This method will be removed after migration - # has been completed. - def fallback_default_namespace - return unless project - - slug = "#{project.path}-#{project.id}".downcase - Gitlab::NamespaceSanitizer.sanitize(slug) - end - def build_kube_client! raise "Incomplete settings" unless api_url - raise "No namespace" if cluster.project_type? && actual_namespace.empty? # can probably remove this line once we remove #actual_namespace unless (username && password) || token raise "Either username/password or token is required to access API" @@ -178,9 +156,13 @@ module Clusters # Returns a hash of all pods in the namespace def read_pods - kubeclient = build_kube_client! + # TODO: The project lookup here should be moved (to environment?), + # which will enable reading pods from the correct namespace for group + # and instance clusters. + # This will be done in https://gitlab.com/gitlab-org/gitlab-ce/issues/61156 + return [] unless cluster.project_type? - kubeclient.get_pods(namespace: actual_namespace).as_json + kubeclient.get_pods(namespace: cluster.kubernetes_namespace_for(cluster.first_project)).as_json rescue Kubeclient::ResourceNotFoundError [] end diff --git a/app/models/clusters/project.rb b/app/models/clusters/project.rb index d2b68b3f117..e0bf60164ba 100644 --- a/app/models/clusters/project.rb +++ b/app/models/clusters/project.rb @@ -8,6 +8,5 @@ module Clusters belongs_to :project, class_name: '::Project' has_many :kubernetes_namespaces, class_name: 'Clusters::KubernetesNamespace', foreign_key: :cluster_project_id - has_one :kubernetes_namespace, -> { order(id: :desc) }, class_name: 'Clusters::KubernetesNamespace', foreign_key: :cluster_project_id end end diff --git a/app/models/concerns/artifact_migratable.rb b/app/models/concerns/artifact_migratable.rb deleted file mode 100644 index 7c9f579b480..00000000000 --- a/app/models/concerns/artifact_migratable.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -# Adapter class to unify the interface between mounted uploaders and the -# Ci::Artifact model -# Meant to be prepended so the interface can stay the same -module ArtifactMigratable - def artifacts_file - job_artifacts_archive&.file || legacy_artifacts_file - end - - def artifacts_metadata - job_artifacts_metadata&.file || legacy_artifacts_metadata - end - - def artifacts? - !artifacts_expired? && artifacts_file&.exists? - end - - def artifacts_metadata? - artifacts? && artifacts_metadata.exists? - end - - def artifacts_file_changed? - job_artifacts_archive&.file_changed? || attribute_changed?(:artifacts_file) - end - - def remove_artifacts_file! - if job_artifacts_archive - job_artifacts_archive.destroy - else - remove_legacy_artifacts_file! - end - end - - def remove_artifacts_metadata! - if job_artifacts_metadata - job_artifacts_metadata.destroy - else - remove_legacy_artifacts_metadata! - end - end - - def artifacts_size - read_attribute(:artifacts_size).to_i + job_artifacts.sum(:size).to_i - end - - def legacy_artifacts_file - return unless Feature.enabled?(:ci_enable_legacy_artifacts) - - super - end - - def legacy_artifacts_metadata - return unless Feature.enabled?(:ci_enable_legacy_artifacts) - - super - end -end diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index e65bbb8ca07..3deb86da6cf 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -1,28 +1,20 @@ # frozen_string_literal: true module Milestoneish - def closed_items_count(user) - memoize_per_user(user, :closed_items_count) do - (count_issues_by_state(user)['closed'] || 0) + merge_requests.closed_and_merged.size - end - end - - def total_items_count(user) - memoize_per_user(user, :total_items_count) do - total_issues_count(user) + merge_requests.size - end - end - def total_issues_count(user) count_issues_by_state(user).values.sum end + def closed_issues_count(user) + count_issues_by_state(user)['closed'].to_i + end + def complete?(user) - total_items_count(user) > 0 && total_items_count(user) == closed_items_count(user) + total_issues_count(user) > 0 && total_issues_count(user) == closed_issues_count(user) end def percent_complete(user) - ((closed_items_count(user) * 100) / total_items_count(user)).abs + closed_issues_count(user) * 100 / total_issues_count(user) rescue ZeroDivisionError 0 end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index bfd0c36942b..4b428b0af83 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -3,14 +3,16 @@ module Noteable extend ActiveSupport::Concern - # `Noteable` class names that support resolvable notes. - RESOLVABLE_TYPES = %w(MergeRequest).freeze - class_methods do # `Noteable` class names that support replying to individual notes. def replyable_types %w(Issue MergeRequest) end + + # `Noteable` class names that support resolvable notes. + def resolvable_types + %w(MergeRequest) + end end # The timestamp of the note (e.g. the :created_at or :updated_at attribute if provided via @@ -36,7 +38,7 @@ module Noteable end def supports_resolvable_notes? - RESOLVABLE_TYPES.include?(base_class_name) + self.class.resolvable_types.include?(base_class_name) end def supports_discussions? @@ -131,3 +133,5 @@ module Noteable ) end end + +Noteable.extend(Noteable::ClassMethods) diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb index 58143a32fdc..4a506146de3 100644 --- a/app/models/concerns/referable.rb +++ b/app/models/concerns/referable.rb @@ -73,6 +73,7 @@ module Referable (?<url> #{Regexp.escape(Gitlab.config.gitlab.url)} \/#{Project.reference_pattern} + (?:\/\-)? \/#{Regexp.escape(route)} \/#{pattern} (?<path> diff --git a/app/models/concerns/resolvable_note.rb b/app/models/concerns/resolvable_note.rb index 16ea330701d..2d2d5fb7168 100644 --- a/app/models/concerns/resolvable_note.rb +++ b/app/models/concerns/resolvable_note.rb @@ -12,7 +12,7 @@ module ResolvableNote validates :resolved_by, presence: true, if: :resolved? # Keep this scope in sync with `#potentially_resolvable?` - scope :potentially_resolvable, -> { where(type: RESOLVABLE_TYPES).where(noteable_type: Noteable::RESOLVABLE_TYPES) } + scope :potentially_resolvable, -> { where(type: RESOLVABLE_TYPES).where(noteable_type: Noteable.resolvable_types) } # Keep this scope in sync with `#resolvable?` scope :resolvable, -> { potentially_resolvable.user } diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb index 67e1f0ec930..1f881249322 100644 --- a/app/models/concerns/update_project_statistics.rb +++ b/app/models/concerns/update_project_statistics.rb @@ -5,43 +5,47 @@ # It deals with `ProjectStatistics.increment_statistic` making sure not to update statistics on a cascade delete from the # project, and keeping track of value deltas on each save. It updates the DB only when a change is needed. # -# How to use -# - Invoke `update_project_statistics stat: :a_project_statistics_column, attribute: :an_attr_to_track` in a model class body. +# Example: # -# Expectation -# - `attribute` must be an ActiveRecord attribute +# module Ci +# class JobArtifact < ApplicationRecord +# include UpdateProjectStatistics +# +# update_project_statistics project_statistics_name: :build_artifacts_size +# end +# end +# +# Expectation: +# +# - `statistic_attribute` must be an ActiveRecord attribute # - The model must implement `project` and `project_id`. i.e. direct Project relationship or delegation +# module UpdateProjectStatistics extend ActiveSupport::Concern class_methods do - attr_reader :statistic_name, :statistic_attribute + attr_reader :project_statistics_name, :statistic_attribute - # Configure the model to update +stat+ on ProjectStatistics when +attribute+ changes + # Configure the model to update `project_statistics_name` on ProjectStatistics, + # when `statistic_attribute` changes + # + # - project_statistics_name: A column of `ProjectStatistics` to update + # - statistic_attribute: An attribute of the current model, default to `size` # - # +stat+:: a column of ProjectStatistics to update - # +attribute+:: an attribute of the current model, default to +:size+ - def update_project_statistics(stat:, attribute: :size) - @statistic_name = stat - @statistic_attribute = attribute + def update_project_statistics(project_statistics_name:, statistic_attribute: :size) + @project_statistics_name = project_statistics_name + @statistic_attribute = statistic_attribute after_save(:update_project_statistics_after_save, if: :update_project_statistics_attribute_changed?) after_destroy(:update_project_statistics_after_destroy, unless: :project_destroyed?) end + private :update_project_statistics end included do private - def project_destroyed? - project.pending_delete? - end - - def update_project_statistics_attribute_changed? - saved_change_to_attribute?(self.class.statistic_attribute) - end - def update_project_statistics_after_save attr = self.class.statistic_attribute delta = read_attribute(attr).to_i - attribute_before_last_save(attr).to_i @@ -49,12 +53,20 @@ module UpdateProjectStatistics update_project_statistics(delta) end + def update_project_statistics_attribute_changed? + saved_change_to_attribute?(self.class.statistic_attribute) + end + def update_project_statistics_after_destroy update_project_statistics(-read_attribute(self.class.statistic_attribute).to_i) end + def project_destroyed? + project.pending_delete? + end + def update_project_statistics(delta) - ProjectStatistics.increment_statistic(project_id, self.class.statistic_name, delta) + ProjectStatistics.increment_statistic(project_id, self.class.project_statistics_name, delta) end end end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index feabea9b8ba..1a87fc47c56 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -15,7 +15,9 @@ class DiffNote < Note validates :original_position, presence: true validates :position, presence: true validates :line_code, presence: true, line_code: true, if: :on_text? - validates :noteable_type, inclusion: { in: noteable_types } + # We need to evaluate the `noteable` types when running the validation since + # EE might have added a type when the module was prepended + validates :noteable_type, inclusion: { in: -> (_note) { noteable_types } } validate :positions_complete validate :verify_supported validate :diff_refs_match_commit, if: :for_commit? @@ -44,7 +46,7 @@ class DiffNote < Note # Returns the diff file from `position` def latest_diff_file strong_memoize(:latest_diff_file) do - position.diff_file(project.repository) + position.diff_file(repository) end end @@ -111,7 +113,7 @@ class DiffNote < Note if note_diff_file diff = Gitlab::Git::Diff.new(note_diff_file.to_hash) Gitlab::Diff::File.new(diff, - repository: project.repository, + repository: repository, diff_refs: original_position.diff_refs) elsif created_at_diff?(noteable.diff_refs) # We're able to use the already persisted diffs (Postgres) if we're @@ -122,7 +124,7 @@ class DiffNote < Note # `Diff::FileCollection::MergeRequestDiff`. noteable.diffs(original_position.diff_options).diff_files.first else - original_position.diff_file(self.project.repository) + original_position.diff_file(repository) end # Since persisted diff files already have its content "unfolded" @@ -137,7 +139,7 @@ class DiffNote < Note end def set_line_code - self.line_code = self.position.line_code(self.project.repository) + self.line_code = self.position.line_code(repository) end def verify_supported @@ -171,6 +173,10 @@ class DiffNote < Note shas << self.position.head_sha end - project.repository.keep_around(*shas) + repository.keep_around(*shas) + end + + def repository + noteable.respond_to?(:repository) ? noteable.repository : project.repository end end diff --git a/app/models/group.rb b/app/models/group.rb index 53331a19776..cdb4e6e87f6 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -126,10 +126,20 @@ class Group < Namespace # Overrides notification_settings has_many association # This allows to apply notification settings from parent groups # to child groups and projects. - def notification_settings + def notification_settings(hierarchy_order: nil) source_type = self.class.base_class.name + settings = NotificationSetting.where(source_type: source_type, source_id: self_and_ancestors_ids) - NotificationSetting.where(source_type: source_type, source_id: self_and_ancestors_ids) + return settings unless hierarchy_order && self_and_ancestors_ids.length > 1 + + settings + .joins("LEFT JOIN (#{self_and_ancestors(hierarchy_order: hierarchy_order).to_sql}) AS ordered_groups ON notification_settings.source_id = ordered_groups.id") + .select('notification_settings.*, ordered_groups.depth AS depth') + .order("ordered_groups.depth #{hierarchy_order}") + end + + def notification_settings_for(user, hierarchy_order: nil) + notification_settings(hierarchy_order: hierarchy_order).where(user: user) end def to_reference(_from = nil, full: nil) diff --git a/app/models/identity.rb b/app/models/identity.rb index 8322b9bf35f..1cbd50205ed 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -13,6 +13,7 @@ class Identity < ApplicationRecord before_save :ensure_normalized_extern_uid, if: :extern_uid_changed? after_destroy :clear_user_synced_attributes, if: :user_synced_attributes_metadata_from_provider? + scope :for_user, ->(user) { where(user: user) } scope :with_provider, ->(provider) { where(provider: provider) } scope :with_extern_uid, ->(provider, extern_uid) do iwhere(extern_uid: normalize_uid(provider, extern_uid)).with_provider(provider) diff --git a/app/models/issue.rb b/app/models/issue.rb index eb5544f2a12..6da6fbe55cb 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -58,6 +58,7 @@ class Issue < ApplicationRecord 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') } scope :order_closest_future_date, -> { reorder('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC') } + scope :order_relative_position_asc, -> { reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')) } scope :preload_associations, -> { preload(:labels, project: :namespace) } scope :with_api_entity_associations, -> { preload(:timelogs, :assignees, :author, :notes, :labels, project: [:route, { namespace: :route }] ) } @@ -130,9 +131,10 @@ class Issue < ApplicationRecord def self.sort_by_attribute(method, excluded_labels: []) case method.to_s when 'closest_future_date' then order_closest_future_date - when 'due_date' then order_due_date_asc - when 'due_date_asc' then order_due_date_asc - when 'due_date_desc' then order_due_date_desc + when 'due_date' then order_due_date_asc + when 'due_date_asc' then order_due_date_asc + when 'due_date_desc' then order_due_date_desc + when 'relative_position' then order_relative_position_asc else super end diff --git a/app/models/key.rb b/app/models/key.rb index b097be8cc89..8aa25924c28 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -59,6 +59,11 @@ class Key < ApplicationRecord "key-#{id}" end + # EE overrides this + def can_delete? + true + end + # rubocop: disable CodeReuse/ServiceClass def update_last_used_at Keys::LastUsedService.new(self).execute diff --git a/app/models/member.rb b/app/models/member.rb index 83b4f5b29c4..c7583434148 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -80,6 +80,8 @@ class Member < ApplicationRecord scope :owners_and_masters, -> { owners_and_maintainers } # @deprecated scope :with_user, -> (user) { where(user: user) } + scope :with_source_id, ->(source_id) { where(source_id: source_id) } + scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) } scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) } scope :order_recent_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'DESC')) } diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index df162e4844c..59416fb4b51 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -165,7 +165,7 @@ class MergeRequest < ApplicationRecord validates :source_branch, presence: true validates :target_project, presence: true validates :target_branch, presence: true - validates :merge_user, presence: true, if: :merge_when_pipeline_succeeds?, unless: :importing? + validates :merge_user, presence: true, if: :auto_merge_enabled?, unless: :importing? validate :validate_branches, unless: [:allow_broken, :importing?, :closed_without_fork?] validate :validate_fork, unless: :closed_without_fork? validate :validate_target_project, on: :create @@ -196,6 +196,7 @@ class MergeRequest < ApplicationRecord alias_attribute :project, :target_project alias_attribute :project_id, :target_project_id + alias_attribute :auto_merge_enabled, :merge_when_pipeline_succeeds def self.reference_prefix '!' @@ -391,7 +392,7 @@ class MergeRequest < ApplicationRecord def merge_participants participants = [author] - if merge_when_pipeline_succeeds? && !participants.include?(merge_user) + if auto_merge_enabled? && !participants.include?(merge_user) participants << merge_user end @@ -581,11 +582,15 @@ class MergeRequest < ApplicationRecord end def validate_branches + return unless target_project && source_project + if target_project == source_project && target_branch == source_branch errors.add :branch_conflict, "You can't use same project/branch for source and target" return end + [:source_branch, :target_branch].each { |attr| validate_branch_name(attr) } + if opened? similar_mrs = target_project .merge_requests @@ -606,6 +611,16 @@ class MergeRequest < ApplicationRecord end end + def validate_branch_name(attr) + return unless changes_include?(attr) + + branch = read_attribute(attr) + + return unless branch + + errors.add(attr) unless Gitlab::GitRefValidator.validate_merge_request_branch(branch) + end + def validate_target_project return true if target_project.merge_requests_enabled? @@ -710,19 +725,16 @@ class MergeRequest < ApplicationRecord MergeRequests::ReloadDiffsService.new(self, current_user).execute end - # rubocop: enable CodeReuse/ServiceClass - - def check_if_can_be_merged - return unless self.class.state_machines[:merge_status].check_state?(merge_status) && Gitlab::Database.read_write? - can_be_merged = - !broken? && project.repository.can_be_merged?(diff_head_sha, target_branch) + def check_mergeability + MergeRequests::MergeabilityCheckService.new(self).execute + end + # rubocop: enable CodeReuse/ServiceClass - if can_be_merged - mark_as_mergeable - else - mark_as_unmergeable - end + # Returns boolean indicating the merge_status should be rechecked in order to + # switch to either can_be_merged or cannot_be_merged. + def recheck_merge_status? + self.class.state_machines[:merge_status].check_state?(merge_status) end def merge_event @@ -748,7 +760,7 @@ class MergeRequest < ApplicationRecord def mergeable?(skip_ci_check: false) return false unless mergeable_state?(skip_ci_check: skip_ci_check) - check_if_can_be_merged + check_mergeability can_be_merged? && !should_be_rebased? end @@ -763,15 +775,6 @@ class MergeRequest < ApplicationRecord true end - def mergeable_to_ref? - return false unless mergeable_state?(skip_ci_check: true, skip_discussions_check: true) - - # Given the `merge_ref_path` will have the same - # state the `target_branch` would have. Ideally - # we need to check if it can be merged to it. - project.repository.can_be_merged?(diff_head_sha, target_branch) - end - def ff_merge_possible? project.repository.ancestor?(target_branch_sha, diff_head_sha) end @@ -780,7 +783,7 @@ class MergeRequest < ApplicationRecord project.ff_merge_must_be_possible? && !ff_merge_possible? end - def can_cancel_merge_when_pipeline_succeeds?(current_user) + def can_cancel_auto_merge?(current_user) can_be_merged_by?(current_user) || self.author == current_user end @@ -799,6 +802,16 @@ class MergeRequest < ApplicationRecord Gitlab::Utils.to_boolean(merge_params['force_remove_source_branch']) end + def auto_merge_strategy + return unless auto_merge_enabled? + + merge_params['auto_merge_strategy'] || AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS + end + + def auto_merge_strategy=(strategy) + merge_params['auto_merge_strategy'] = strategy + end + def remove_source_branch? should_remove_source_branch? || force_remove_source_branch? end @@ -971,15 +984,16 @@ class MergeRequest < ApplicationRecord end end - def reset_merge_when_pipeline_succeeds - return unless merge_when_pipeline_succeeds? + def reset_auto_merge + return unless auto_merge_enabled? - self.merge_when_pipeline_succeeds = false + self.auto_merge_enabled = false self.merge_user = nil if merge_params merge_params.delete('should_remove_source_branch') merge_params.delete('commit_message') merge_params.delete('squash_commit_message') + merge_params.delete('auto_merge_strategy') end self.save @@ -1088,6 +1102,12 @@ class MergeRequest < ApplicationRecord target_project.repository.fetch_source_branch!(source_project.repository, source_branch, ref_path) end + # Returns the current merge-ref HEAD commit. + # + def merge_ref_head + project.repository.commit(merge_ref_path) + end + def ref_path "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/head" end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 787600569fa..37c129e843a 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -58,6 +58,7 @@ class Milestone < ApplicationRecord validate :uniqueness_of_title, if: :title_changed? validate :milestone_type_check validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? } + validate :dates_within_4_digits strip_attributes :title @@ -326,6 +327,16 @@ class Milestone < ApplicationRecord end end + def dates_within_4_digits + if start_date && start_date > Date.new(9999, 12, 31) + errors.add(:start_date, _("date must not be after 9999-12-31")) + end + + if due_date && due_date > Date.new(9999, 12, 31) + errors.add(:due_date, _("date must not be after 9999-12-31")) + end + end + def issues_finder_params { project_id: project_id } end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 7393ef4b05c..3c270c7396a 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -76,6 +76,7 @@ class Namespace < ApplicationRecord 'namespaces.*', 'COALESCE(SUM(ps.storage_size), 0) AS storage_size', 'COALESCE(SUM(ps.repository_size), 0) AS repository_size', + 'COALESCE(SUM(ps.wiki_size), 0) AS wiki_size', 'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size', 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size', 'COALESCE(SUM(ps.packages_size), 0) AS packages_size' @@ -205,12 +206,12 @@ class Namespace < ApplicationRecord .ancestors(upto: top, hierarchy_order: hierarchy_order) end - def self_and_ancestors + def self_and_ancestors(hierarchy_order: nil) return self.class.where(id: id) unless parent_id Gitlab::ObjectHierarchy .new(self.class.where(id: id)) - .base_and_ancestors + .base_and_ancestors(hierarchy_order: hierarchy_order) end # Returns all the descendants of the current namespace. diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index 6889e0d776b..6f057f79ef6 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -156,23 +156,11 @@ class NotificationRecipient # Returns the notification_setting of the lowest group in hierarchy with non global level def closest_non_global_group_notification_settting return unless @group - return if indexed_group_notification_settings.empty? - notification_setting = nil - - @group.self_and_ancestors_ids.each do |id| - notification_setting = indexed_group_notification_settings[id] - break if notification_setting - end - - notification_setting - end - - def indexed_group_notification_settings - strong_memoize(:indexed_group_notification_settings) do - @group.notification_settings.where(user_id: user.id) - .where.not(level: NotificationSetting.levels[:global]) - .index_by(&:source_id) - end + @group + .notification_settings(hierarchy_order: :asc) + .where(user: user) + .where.not(level: NotificationSetting.levels[:global]) + .first end end diff --git a/app/models/project.rb b/app/models/project.rb index ab4da61dcf8..78d54571d94 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -309,6 +309,7 @@ class Project < ApplicationRecord delegate :group_clusters_enabled?, to: :group, allow_nil: true delegate :root_ancestor, to: :namespace, allow_nil: true delegate :last_pipeline, to: :commit, allow_nil: true + delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true # Validations validates :creator, presence: true, on: :create @@ -406,6 +407,7 @@ class Project < ApplicationRecord scope :with_builds_enabled, -> { with_feature_enabled(:builds) } scope :with_issues_enabled, -> { with_feature_enabled(:issues) } scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) } + scope :with_merge_requests_available_for_user, ->(current_user) { with_feature_available_for_user(:merge_requests, current_user) } scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) } scope :with_remote_mirrors, -> { joins(:remote_mirrors).where(remote_mirrors: { enabled: true }).distinct } @@ -596,6 +598,17 @@ class Project < ApplicationRecord def group_ids joins(:namespace).where(namespaces: { type: 'Group' }).select(:namespace_id) end + + # Returns ids of projects with milestones available for given user + # + # Used on queries to find milestones which user can see + # For example: Milestone.where(project_id: ids_with_milestone_available_for(user)) + def ids_with_milestone_available_for(user) + with_issues_enabled = with_issues_available_for_user(user).select(:id) + with_merge_requests_enabled = with_merge_requests_available_for_user(user).select(:id) + + from_union([with_issues_enabled, with_merge_requests_enabled]).select(:id) + end end def all_pipelines diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb index f972c40f317..67c12363a3c 100644 --- a/app/models/project_auto_devops.rb +++ b/app/models/project_auto_devops.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class ProjectAutoDevops < ApplicationRecord + include IgnorableColumn + + ignore_column :domain + belongs_to :project enum deploy_strategy: { @@ -12,31 +16,10 @@ class ProjectAutoDevops < ApplicationRecord scope :enabled, -> { where(enabled: true) } scope :disabled, -> { where(enabled: false) } - validates :domain, allow_blank: true, hostname: { allow_numeric_hostname: true } - after_save :create_gitlab_deploy_token, if: :needs_to_create_deploy_token? - def instance_domain - Gitlab::CurrentSettings.auto_devops_domain - end - - def has_domain? - domain.present? || instance_domain.present? - end - - # From 11.8, AUTO_DEVOPS_DOMAIN has been replaced by KUBE_INGRESS_BASE_DOMAIN. - # See Clusters::Cluster#predefined_variables and https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24580 - # for more info. - # - # Suppport AUTO_DEVOPS_DOMAIN is scheduled to be removed on - # https://gitlab.com/gitlab-org/gitlab-ce/issues/52363 def predefined_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| - if has_domain? - variables.append(key: 'AUTO_DEVOPS_DOMAIN', - value: domain.presence || instance_domain) - end - variables.concat(deployment_strategy_default_variables) end end diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index fc8afa9bead..aa6b4aa1d5e 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -86,7 +86,7 @@ class KubernetesService < DeploymentService ] end - def actual_namespace + def kubernetes_namespace_for(project) if namespace.present? namespace else @@ -94,10 +94,6 @@ class KubernetesService < DeploymentService end end - def namespace_for(project) - actual_namespace - end - # Check we can connect to the Kubernetes API def test(*args) kubeclient = build_kube_client! @@ -118,7 +114,7 @@ class KubernetesService < DeploymentService variables .append(key: 'KUBE_URL', value: api_url) .append(key: 'KUBE_TOKEN', value: token, public: false, masked: true) - .append(key: 'KUBE_NAMESPACE', value: actual_namespace) + .append(key: 'KUBE_NAMESPACE', value: kubernetes_namespace_for(project)) .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true) if ca_pem.present? @@ -135,8 +131,10 @@ class KubernetesService < DeploymentService # short time later def terminals(environment) with_reactive_cache do |data| + project = environment.project + pods = filter_by_project_environment(data[:pods], project.full_path_slug, environment.slug) - terminals = pods.flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) }.compact + terminals = pods.flat_map { |pod| terminals_for_pod(api_url, kubernetes_namespace_for(project), pod) }.compact terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) } end end @@ -173,7 +171,7 @@ class KubernetesService < DeploymentService def kubeconfig to_kubeconfig( url: api_url, - namespace: actual_namespace, + namespace: kubernetes_namespace_for(project), token: token, ca_pem: ca_pem) end @@ -190,7 +188,7 @@ class KubernetesService < DeploymentService end def build_kube_client! - raise "Incomplete settings" unless api_url && actual_namespace && token + raise "Incomplete settings" unless api_url && kubernetes_namespace_for(project) && token Gitlab::Kubernetes::KubeClient.new( api_url, @@ -204,7 +202,7 @@ class KubernetesService < DeploymentService def read_pods kubeclient = build_kube_client! - kubeclient.get_pods(namespace: actual_namespace).as_json + kubeclient.get_pods(namespace: kubernetes_namespace_for(project)).as_json rescue Kubeclient::ResourceNotFoundError [] end diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb index 7ba69370f14..ae5d5038099 100644 --- a/app/models/project_services/pipelines_email_service.rb +++ b/app/models/project_services/pipelines_email_service.rb @@ -2,11 +2,11 @@ class PipelinesEmailService < Service prop_accessor :recipients - boolean_accessor :notify_only_broken_pipelines + boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch validates :recipients, presence: true, if: :valid_recipients? def initialize_properties - self.properties ||= { notify_only_broken_pipelines: true } + self.properties ||= { notify_only_broken_pipelines: true, notify_only_default_branch: false } end def title @@ -54,7 +54,9 @@ class PipelinesEmailService < Service placeholder: _('Emails separated by comma'), required: true }, { type: 'checkbox', - name: 'notify_only_broken_pipelines' } + name: 'notify_only_broken_pipelines' }, + { type: 'checkbox', + name: 'notify_only_default_branch' } ] end @@ -67,6 +69,16 @@ class PipelinesEmailService < Service end def should_pipeline_be_notified?(data) + notify_for_pipeline_branch?(data) && notify_for_pipeline?(data) + end + + def notify_for_pipeline_branch?(data) + return true unless notify_only_default_branch? + + data[:object_attributes][:ref] == data[:project][:default_branch] + end + + def notify_for_pipeline?(data) case data[:object_attributes][:status] when 'success' !notify_only_broken_pipelines? diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 6fe8cb40d25..11e3737298c 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -4,11 +4,20 @@ class ProjectStatistics < ApplicationRecord belongs_to :project belongs_to :namespace + default_value_for :wiki_size, 0 + + # older migrations fail due to non-existent attribute without this + def wiki_size + has_attribute?(:wiki_size) ? super : 0 + end + before_save :update_storage_size - COLUMNS_TO_REFRESH = [:repository_size, :lfs_objects_size, :commit_count].freeze + COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count].freeze INCREMENTABLE_COLUMNS = { build_artifacts_size: %i[storage_size], packages_size: %i[storage_size] }.freeze + scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) } + def total_repository_size repository_size + lfs_objects_size end @@ -27,22 +36,25 @@ class ProjectStatistics < ApplicationRecord self.commit_count = project.repository.commit_count end - # Repository#size needs to be converted from MB to Byte. def update_repository_size self.repository_size = project.repository.size * 1.megabyte end + def update_wiki_size + self.wiki_size = project.wiki.repository.size * 1.megabyte + end + def update_lfs_objects_size self.lfs_objects_size = project.lfs_objects.sum(:size) end # older migrations fail due to non-existent attribute without this def packages_size - has_attribute?(:packages_size) ? super.to_i : 0 + has_attribute?(:packages_size) ? super : 0 end def update_storage_size - self.storage_size = repository_size + lfs_objects_size + build_artifacts_size + packages_size + self.storage_size = repository_size + wiki_size + lfs_objects_size + build_artifacts_size + packages_size end # Since this incremental update method does not call update_storage_size above, diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index 944895904fe..358473d0a74 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -43,7 +43,7 @@ module Ci if pipeline.ref_exists? _("for %{link_to_pipeline_ref}").html_safe % { link_to_pipeline_ref: link_to_pipeline_ref } else - _("for %{ref}") % { ref: content_tag(:span, pipeline.ref, class: 'ref-name') } + _("for %{ref}").html_safe % { ref: content_tag(:span, pipeline.ref, class: 'ref-name') } end end end diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb index 33b217c8498..1634d2479a0 100644 --- a/app/presenters/clusters/cluster_presenter.rb +++ b/app/presenters/clusters/cluster_presenter.rb @@ -22,10 +22,6 @@ module Clusters "https://console.cloud.google.com/kubernetes/clusters/details/#{provider.zone}/#{name}" if gcp? end - def can_toggle_cluster? - can?(current_user, :update_cluster, cluster) && created? - end - def can_read_cluster? can?(current_user, :read_cluster, cluster) end diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb index c12a202efbc..c9dc0dbf443 100644 --- a/app/presenters/issue_presenter.rb +++ b/app/presenters/issue_presenter.rb @@ -4,6 +4,16 @@ class IssuePresenter < Gitlab::View::Presenter::Delegated presents :issue def web_url - Gitlab::UrlBuilder.build(issue) + url_builder.url + end + + def issue_path + url_builder.issue_path(issue) + end + + private + + def url_builder + @url_builder ||= Gitlab::UrlBuilder.new(issue) end end diff --git a/app/presenters/label_presenter.rb b/app/presenters/label_presenter.rb index 5227ef353c3..1077bf543d9 100644 --- a/app/presenters/label_presenter.rb +++ b/app/presenters/label_presenter.rb @@ -35,6 +35,14 @@ class LabelPresenter < Gitlab::View::Presenter::Delegated issuable_subject.is_a?(Project) && label.is_a?(GroupLabel) end + def project_label? + label.is_a?(ProjectLabel) + end + + def subject_name + label.subject.name + end + private def context_subject diff --git a/app/presenters/member_presenter.rb b/app/presenters/member_presenter.rb index 9e9b6973b8e..2561c3f0244 100644 --- a/app/presenters/member_presenter.rb +++ b/app/presenters/member_presenter.rb @@ -32,6 +32,11 @@ class MemberPresenter < Gitlab::View::Presenter::Delegated request? && can_update? end + # This functionality is only available in EE. + def can_override? + false + end + private def admin_member_permission diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index ba0711ca867..9c44ed711a6 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -22,9 +22,9 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end end - def cancel_merge_when_pipeline_succeeds_path - if can_cancel_merge_when_pipeline_succeeds?(current_user) - cancel_merge_when_pipeline_succeeds_project_merge_request_path(project, merge_request) + def cancel_auto_merge_path + if can_cancel_auto_merge?(current_user) + cancel_auto_merge_project_merge_request_path(project, merge_request) end end diff --git a/app/serializers/analytics_stage_entity.rb b/app/serializers/analytics_stage_entity.rb index ae7c20c3bba..8bc6da5aeeb 100644 --- a/app/serializers/analytics_stage_entity.rb +++ b/app/serializers/analytics_stage_entity.rb @@ -9,7 +9,8 @@ class AnalyticsStageEntity < Grape::Entity expose :description expose :median, as: :value do |stage| - # median returns a BatchLoader instance which we first have to unwrap by using to_i - !stage.median.to_i.zero? ? distance_of_time_in_words(stage.median) : nil + # median returns a BatchLoader instance which we first have to unwrap by using to_f + # we use to_f to make sure results below 1 are presented to the end-user + stage.median.to_f.nonzero? ? distance_of_time_in_words(stage.median) : nil end end diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index 62c26809eeb..67e44ee9d10 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -8,16 +8,18 @@ class BuildDetailsEntity < JobEntity expose :stuck?, as: :stuck expose :user, using: UserEntity expose :runner, using: RunnerEntity + expose :metadata, using: BuildMetadataEntity expose :pipeline, using: PipelineEntity expose :deployment_status, if: -> (*) { build.starts_environment? } do expose :deployment_status, as: :status - - expose :persisted_environment, as: :environment, with: EnvironmentEntity + expose :persisted_environment, as: :environment do |build, options| + options.merge(deployment_details: false).yield_self do |opts| + EnvironmentEntity.represent(build.persisted_environment, opts) + end + end end - expose :metadata, using: BuildMetadataEntity - expose :artifact, if: -> (*) { can?(current_user, :read_build, build) } do expose :download_path, if: -> (*) { build.artifacts? } do |build| download_project_job_artifacts_path(project, build) @@ -40,6 +42,11 @@ class BuildDetailsEntity < JobEntity end end + expose :report_artifacts, + as: :reports, + using: JobArtifactReportEntity, + if: -> (*) { can?(current_user, :read_build, build) } + expose :erased_by, if: -> (*) { build.erased? }, using: UserEntity expose :erase_path, if: -> (*) { build.erasable? && can?(current_user, :erase_build, build) } do |build| erase_project_job_path(project, build) diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index 34ae06278c8..943c707218d 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -20,16 +20,39 @@ class DeploymentEntity < Grape::Entity expose :created_at expose :tag expose :last? - expose :user, using: UserEntity - expose :commit, using: CommitEntity - expose :deployable, using: JobEntity - expose :manual_actions, using: JobEntity, if: -> (*) { can_create_deployment? } - expose :scheduled_actions, using: JobEntity, if: -> (*) { can_create_deployment? } + + expose :deployable do |deployment, opts| + deployment.deployable.yield_self do |deployable| + if include_details? + JobEntity.represent(deployable, opts) + elsif can_read_deployables? + { name: deployable.name, + build_path: project_job_path(deployable.project, deployable) } + end + end + end + + expose :commit, using: CommitEntity, if: -> (*) { include_details? } + expose :manual_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? } + expose :scheduled_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? } private + def include_details? + options.fetch(:deployment_details, true) + end + def can_create_deployment? can?(request.current_user, :create_deployment, request.project) end + + def can_read_deployables? + ## + # We intentionally do not check `:read_build, deployment.deployable` + # because it triggers a policy evaluation that involves multiple + # Gitaly calls that might not be cached. + # + can?(request.current_user, :read_build, request.project) + end end diff --git a/app/serializers/job_artifact_report_entity.rb b/app/serializers/job_artifact_report_entity.rb new file mode 100644 index 00000000000..4280351a6b0 --- /dev/null +++ b/app/serializers/job_artifact_report_entity.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class JobArtifactReportEntity < Grape::Entity + include RequestAwareEntity + + expose :file_type + expose :file_format + expose :size + + expose :download_path do |artifact| + download_project_job_artifacts_path(artifact.job.project, artifact.job, file_type: artifact.file_format) + end +end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index b130f447cce..a428930dbbf 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -9,7 +9,11 @@ class MergeRequestWidgetEntity < IssuableEntity expose :merge_params expose :merge_status expose :merge_user_id - expose :merge_when_pipeline_succeeds + expose :auto_merge_enabled + expose :auto_merge_strategy + expose :available_auto_merge_strategies do |merge_request| + AutoMergeService.new(merge_request.project, current_user).available_strategies(merge_request) # rubocop: disable CodeReuse/ServiceClass + end expose :source_branch expose :source_branch_protected do |merge_request| merge_request.source_project.present? && ProtectedBranch.protected?(merge_request.source_project, merge_request.source_branch) @@ -182,8 +186,8 @@ class MergeRequestWidgetEntity < IssuableEntity presenter(merge_request).remove_wip_path end - expose :cancel_merge_when_pipeline_succeeds_path do |merge_request| - presenter(merge_request).cancel_merge_when_pipeline_succeeds_path + expose :cancel_auto_merge_path do |merge_request| + presenter(merge_request).cancel_auto_merge_path end expose :create_issue_to_resolve_discussions_path do |merge_request| diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb index d78ad4af4dc..dfef4364965 100644 --- a/app/serializers/pipeline_details_entity.rb +++ b/app/serializers/pipeline_details_entity.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true class PipelineDetailsEntity < PipelineEntity + expose :flags do + expose :latest?, as: :latest + end + expose :details do - expose :ordered_stages, as: :stages, using: StageEntity expose :artifacts, using: BuildArtifactEntity expose :manual_actions, using: BuildActionEntity expose :scheduled_actions, using: BuildActionEntity diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index 8fe5df81e6c..ec2698ecbe3 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -4,6 +4,7 @@ class PipelineEntity < Grape::Entity include RequestAwareEntity expose :id + expose :iid expose :user, using: UserEntity expose :active?, as: :active @@ -20,7 +21,6 @@ class PipelineEntity < Grape::Entity end expose :flags do - expose :latest?, as: :latest expose :stuck?, as: :stuck expose :auto_devops_source?, as: :auto_devops expose :merge_request_event?, as: :merge_request @@ -34,6 +34,7 @@ class PipelineEntity < Grape::Entity expose :details do expose :detailed_status, as: :status, with: DetailedStatusEntity + expose :ordered_stages, as: :stages, using: StageEntity expose :duration expose :finished_at end diff --git a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb new file mode 100644 index 00000000000..d0586468859 --- /dev/null +++ b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module AutoMerge + class MergeWhenPipelineSucceedsService < BaseService + def execute(merge_request) + return :failed unless merge_request.actual_head_pipeline + + if merge_request.actual_head_pipeline.active? + merge_request.merge_params.merge!(params) + + unless merge_request.auto_merge_enabled? + merge_request.auto_merge_enabled = true + merge_request.merge_user = @current_user + merge_request.auto_merge_strategy = AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS + + SystemNoteService.merge_when_pipeline_succeeds(merge_request, @project, @current_user, merge_request.diff_head_commit) + end + + return :failed unless merge_request.save + + :merge_when_pipeline_succeeds + elsif merge_request.actual_head_pipeline.success? + # This can be triggered when a user clicks the auto merge button while + # the tests finish at about the same time + merge_request.merge_async(current_user.id, merge_params) + + :success + else + :failed + end + end + + def process(merge_request) + return unless merge_request.actual_head_pipeline&.success? + return unless merge_request.mergeable? + + merge_request.merge_async(merge_request.merge_user_id, merge_request.merge_params) + end + + def cancel(merge_request) + if merge_request.reset_auto_merge + SystemNoteService.cancel_merge_when_pipeline_succeeds(merge_request, @project, @current_user) + + success + else + error("Can't cancel the automatic merge", 406) + end + end + + def available_for?(merge_request) + merge_request.actual_head_pipeline&.active? + end + end +end diff --git a/app/services/auto_merge_service.rb b/app/services/auto_merge_service.rb new file mode 100644 index 00000000000..a3a780ff388 --- /dev/null +++ b/app/services/auto_merge_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class AutoMergeService < BaseService + STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS = 'merge_when_pipeline_succeeds'.freeze + STRATEGIES = [STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS].freeze + + class << self + def all_strategies + STRATEGIES + end + + def get_service_class(strategy) + return unless all_strategies.include?(strategy) + + "::AutoMerge::#{strategy.camelize}Service".constantize + end + end + + def execute(merge_request, strategy) + service = get_service_instance(strategy) + + return :failed unless service&.available_for?(merge_request) + + service.execute(merge_request) + end + + def process(merge_request) + return unless merge_request.auto_merge_enabled? + + get_service_instance(merge_request.auto_merge_strategy).process(merge_request) + end + + def cancel(merge_request) + return error("Can't cancel the automatic merge", 406) unless merge_request.auto_merge_enabled? + + get_service_instance(merge_request.auto_merge_strategy).cancel(merge_request) + end + + def available_strategies(merge_request) + self.class.all_strategies.select do |strategy| + get_service_instance(strategy).available_for?(merge_request) + end + end + + private + + def get_service_instance(strategy) + self.class.get_service_class(strategy)&.new(project, current_user, params) + end +end diff --git a/app/services/ci/pipeline_schedule_service.rb b/app/services/ci/pipeline_schedule_service.rb new file mode 100644 index 00000000000..387d0351490 --- /dev/null +++ b/app/services/ci/pipeline_schedule_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ci + class PipelineScheduleService < BaseService + def execute(schedule) + # Ensure `next_run_at` is set properly before creating a pipeline. + # Otherwise, multiple pipelines could be created in a short interval. + schedule.schedule_next_run! + + RunPipelineScheduleWorker.perform_async(schedule.id, schedule.owner.id) + end + end +end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index baa3f898b2d..dedab98b56d 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -6,7 +6,7 @@ module Ci class RegisterJobService attr_reader :runner - JOB_QUEUE_DURATION_SECONDS_BUCKETS = [1, 3, 10, 30].freeze + JOB_QUEUE_DURATION_SECONDS_BUCKETS = [1, 3, 10, 30, 60, 300].freeze JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET = 5.freeze Result = Struct.new(:build, :valid?) diff --git a/app/services/issuable/clone/content_rewriter.rb b/app/services/issuable/clone/content_rewriter.rb index e1e0b75085d..00d7078859d 100644 --- a/app/services/issuable/clone/content_rewriter.rb +++ b/app/services/issuable/clone/content_rewriter.rb @@ -28,6 +28,7 @@ module Issuable new_params = { project: new_entity.project, noteable: new_entity, note: rewrite_content(new_note.note), + note_html: nil, created_at: note.created_at, updated_at: note.updated_at } diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index 2a19e57a94f..805721212ba 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -29,7 +29,7 @@ module Issues event_service.close_issue(issue, current_user) create_note(issue, closed_via) if system_note - closed_via = "commit #{closed_via.id}" if closed_via.is_a?(Commit) + closed_via = _("commit %{commit_id}") % { commit_id: closed_via.id } if closed_via.is_a?(Commit) notification_service.async.close_issue(issue, current_user, closed_via: closed_via) if notifications todo_service.close_issue(issue, current_user) diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb index e77051bb1c9..b0f6166ea1c 100644 --- a/app/services/merge_requests/close_service.rb +++ b/app/services/merge_requests/close_service.rb @@ -18,6 +18,7 @@ module MergeRequests invalidate_cache_counts(merge_request, users: merge_request.assignees) merge_request.update_project_counter_caches cleanup_environments(merge_request) + cancel_auto_merge(merge_request) end merge_request @@ -33,5 +34,9 @@ module MergeRequests merge_request_metrics_service(merge_request).close(close_event) end end + + def cancel_auto_merge(merge_request) + AutoMergeService.new(project, current_user).cancel(merge_request) + end end end diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb index 87147d90c32..8670b9ccf3d 100644 --- a/app/services/merge_requests/merge_to_ref_service.rb +++ b/app/services/merge_requests/merge_to_ref_service.rb @@ -20,20 +20,14 @@ module MergeRequests raise_error('Conflicts detected during merge') unless commit_id - commit = project.commit(commit_id) - target_id, source_id = commit.parent_ids - - success(commit_id: commit.id, - target_id: target_id, - source_id: source_id) - rescue MergeError => error + success(commit_id: commit_id) + rescue MergeError, ArgumentError => error error(error.message) end private def validate! - authorization_check! error_check! end @@ -43,21 +37,13 @@ module MergeRequests error = if !hooks_validation_pass?(merge_request) hooks_validation_error(merge_request) - elsif !@merge_request.mergeable_to_ref? - "Merge request is not mergeable to #{target_ref}" - elsif !source + elsif source.blank? 'No source for merge' end raise_error(error) if error end - def authorization_check! - unless Ability.allowed?(current_user, :admin_merge_request, project) - raise_error("You are not allowed to merge to this ref") - end - end - def target_ref merge_request.merge_ref_path end diff --git a/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb b/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb deleted file mode 100644 index 973e5b64e88..00000000000 --- a/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -module MergeRequests - class MergeWhenPipelineSucceedsService < MergeRequests::BaseService - # Marks the passed `merge_request` to be merged when the pipeline succeeds or - # updates the params for the automatic merge - def execute(merge_request) - merge_request.merge_params.merge!(params) - - # The service is also called when the merge params are updated. - already_approved = merge_request.merge_when_pipeline_succeeds? - - unless already_approved - merge_request.merge_when_pipeline_succeeds = true - merge_request.merge_user = @current_user - - SystemNoteService.merge_when_pipeline_succeeds(merge_request, @project, @current_user, merge_request.diff_head_commit) - end - - merge_request.save - end - - # Triggers the automatic merge of merge_request once the pipeline succeeds - def trigger(pipeline) - return unless pipeline.success? - - pipeline_merge_requests(pipeline) do |merge_request| - next unless merge_request.merge_when_pipeline_succeeds? - next unless merge_request.mergeable? - - merge_request.merge_async(merge_request.merge_user_id, merge_request.merge_params) - end - end - - # Cancels the automatic merge - def cancel(merge_request) - if merge_request.merge_when_pipeline_succeeds? && merge_request.open? - merge_request.reset_merge_when_pipeline_succeeds - SystemNoteService.cancel_merge_when_pipeline_succeeds(merge_request, @project, @current_user) - - success - else - error("Can't cancel the automatic merge", 406) - end - end - end -end diff --git a/app/services/merge_requests/mergeability_check_service.rb b/app/services/merge_requests/mergeability_check_service.rb new file mode 100644 index 00000000000..ef833774e65 --- /dev/null +++ b/app/services/merge_requests/mergeability_check_service.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module MergeRequests + class MergeabilityCheckService < ::BaseService + include Gitlab::Utils::StrongMemoize + + delegate :project, to: :@merge_request + delegate :repository, to: :project + + def initialize(merge_request) + @merge_request = merge_request + end + + # Updates the MR merge_status. Whenever it switches to a can_be_merged state, + # the merge-ref is refreshed. + # + # Returns a ServiceResponse indicating merge_status is/became can_be_merged + # and the merge-ref is synced. Success in case of being/becoming mergeable, + # error otherwise. + def execute + return ServiceResponse.error(message: 'Invalid argument') unless merge_request + return ServiceResponse.error(message: 'Unsupported operation') if Gitlab::Database.read_only? + + update_merge_status + + unless merge_request.can_be_merged? + return ServiceResponse.error(message: 'Merge request is not mergeable') + end + + unless payload.fetch(:merge_ref_head) + return ServiceResponse.error(message: 'Merge ref was not found') + end + + ServiceResponse.success(payload: payload) + end + + private + + attr_reader :merge_request + + def payload + strong_memoize(:payload) do + { + merge_ref_head: merge_ref_head_payload + } + end + end + + def merge_ref_head_payload + commit = merge_request.merge_ref_head + + return unless commit + + target_id, source_id = commit.parent_ids + + { + commit_id: commit.id, + source_id: source_id, + target_id: target_id + } + end + + def update_merge_status + return unless merge_request.recheck_merge_status? + + if can_git_merge? + merge_to_ref && merge_request.mark_as_mergeable + else + merge_request.mark_as_unmergeable + end + end + + def can_git_merge? + !merge_request.broken? && repository.can_be_merged?(merge_request.diff_head_sha, merge_request.target_branch) + end + + def merge_to_ref + result = MergeRequests::MergeToRefService.new(project, merge_request.author).execute(merge_request) + result[:status] == :success + end + end +end diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index 3abea1ad1ae..08130a531ee 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -24,7 +24,7 @@ module MergeRequests reload_merge_requests outdate_suggestions refresh_pipelines_on_merge_requests - reset_merge_when_pipeline_succeeds + cancel_auto_merge mark_pending_todos_done cache_merge_requests_closing_issues @@ -142,8 +142,10 @@ module MergeRequests end end - def reset_merge_when_pipeline_succeeds - merge_requests_for_source_branch.each(&:reset_merge_when_pipeline_succeeds) + def cancel_auto_merge + merge_requests_for_source_branch.each do |merge_request| + AutoMergeService.new(project, current_user).cancel(merge_request) + end end def mark_pending_todos_done diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 55546432ce4..6a0f3000ffb 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -89,7 +89,7 @@ module MergeRequests merge_request.update(merge_error: nil) if merge_request.head_pipeline && merge_request.head_pipeline.active? - MergeRequests::MergeWhenPipelineSucceedsService.new(project, current_user).execute(merge_request) + AutoMergeService.new(project, current_user).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) else merge_request.merge_async(current_user.id, {}) end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index f797c0f11c6..5aa804666f0 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -238,7 +238,7 @@ class NotificationService merge_request, current_user, :merged_merge_request_email, - skip_current_user: !merge_request.merge_when_pipeline_succeeds? + skip_current_user: !merge_request.auto_merge_enabled? ) end diff --git a/app/services/projects/git_deduplication_service.rb b/app/services/projects/git_deduplication_service.rb new file mode 100644 index 00000000000..74d469ecf37 --- /dev/null +++ b/app/services/projects/git_deduplication_service.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Projects + class GitDeduplicationService < BaseService + include ExclusiveLeaseGuard + + LEASE_TIMEOUT = 86400 + + delegate :pool_repository, to: :project + attr_reader :project + + def initialize(project) + @project = project + end + + def execute + try_obtain_lease do + unless project.has_pool_repository? + disconnect_git_alternates + break + end + + if source_project? && pool_can_fetch_from_source? + fetch_from_source + end + + project.link_pool_repository if same_storage_as_pool?(project.repository) + end + end + + private + + def disconnect_git_alternates + project.repository.disconnect_alternates + end + + def pool_can_fetch_from_source? + project.git_objects_poolable? && + same_storage_as_pool?(pool_repository.source_project.repository) + end + + def same_storage_as_pool?(repository) + pool_repository.object_pool.repository.storage == repository.storage + end + + def fetch_from_source + project.pool_repository.object_pool.fetch + end + + def source_project? + return unless project.has_pool_repository? + + project.pool_repository.source_project == project + end + + def lease_timeout + LEASE_TIMEOUT + end + + def lease_key + "git_deduplication:#{project.id}" + end + end +end diff --git a/app/services/projects/update_statistics_service.rb b/app/services/projects/update_statistics_service.rb index f32a779fab0..28677a398f3 100644 --- a/app/services/projects/update_statistics_service.rb +++ b/app/services/projects/update_statistics_service.rb @@ -3,7 +3,7 @@ module Projects class UpdateStatisticsService < BaseService def execute - return unless project && project.repository.exists? + return unless project Rails.logger.info("Updating statistics for project #{project.id}") diff --git a/app/services/service_response.rb b/app/services/service_response.rb index 1de30e68d87..f3437ba16de 100644 --- a/app/services/service_response.rb +++ b/app/services/service_response.rb @@ -1,19 +1,20 @@ # frozen_string_literal: true class ServiceResponse - def self.success(message: nil) - new(status: :success, message: message) + def self.success(message: nil, payload: {}) + new(status: :success, message: message, payload: payload) end - def self.error(message:, http_status: nil) - new(status: :error, message: message, http_status: http_status) + def self.error(message:, payload: {}, http_status: nil) + new(status: :error, message: message, payload: payload, http_status: http_status) end - attr_reader :status, :message, :http_status + attr_reader :status, :message, :http_status, :payload - def initialize(status:, message: nil, http_status: nil) + def initialize(status:, message: nil, payload: {}, http_status: nil) self.status = status self.message = message + self.payload = payload self.http_status = http_status end @@ -27,5 +28,5 @@ class ServiceResponse private - attr_writer :status, :message, :http_status + attr_writer :status, :message, :http_status, :payload end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index a39ff76b798..1390f7cdf46 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -25,7 +25,7 @@ module SystemNoteService text_parts = ["added #{commits_text}"] text_parts << commits_list(noteable, new_commits, existing_commits, oldrev) - text_parts << "[Compare with previous version](#{diff_comparison_url(noteable, project, oldrev)})" + text_parts << "[Compare with previous version](#{diff_comparison_path(noteable, project, oldrev)})" body = text_parts.join("\n\n") @@ -41,7 +41,7 @@ module SystemNoteService # # Returns the created Note object def tag_commit(noteable, project, author, tag_name) - link = url_helpers.project_tag_url(project, id: tag_name) + link = url_helpers.project_tag_path(project, id: tag_name) body = "tagged commit #{noteable.sha} to [`#{tag_name}`](#{link})" create_note(NoteSummary.new(noteable, project, author, body, action: 'tag')) @@ -272,7 +272,7 @@ module SystemNoteService text_parts = ["changed this line in"] if version_params = merge_request.version_params_for(diff_refs) line_code = change_position.line_code(project.repository) - url = url_helpers.diffs_project_merge_request_url(project, merge_request, version_params.merge(anchor: line_code)) + url = url_helpers.diffs_project_merge_request_path(project, merge_request, version_params.merge(anchor: line_code)) text_parts << "[version #{version_index} of the diff](#{url})" else @@ -405,7 +405,7 @@ module SystemNoteService # # "created branch `201-issue-branch-button`" def new_issue_branch(issue, project, author, branch) - link = url_helpers.project_compare_url(project, from: project.default_branch, to: branch) + link = url_helpers.project_compare_path(project, from: project.default_branch, to: branch) body = "created branch [`#{branch}`](#{link}) to address this issue" @@ -668,10 +668,10 @@ module SystemNoteService @url_helpers ||= Gitlab::Routing.url_helpers end - def diff_comparison_url(merge_request, project, oldrev) + def diff_comparison_path(merge_request, project, oldrev) diff_id = merge_request.merge_request_diff.id - url_helpers.diffs_project_merge_request_url( + url_helpers.diffs_project_merge_request_path( project, merge_request, diff_id: diff_id, diff --git a/app/uploaders/legacy_artifact_uploader.rb b/app/uploaders/legacy_artifact_uploader.rb deleted file mode 100644 index fac3c3dcb8f..00000000000 --- a/app/uploaders/legacy_artifact_uploader.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -## -# TODO: Remove this uploader when we remove :ci_enable_legacy_artifacts feature flag -# See https://gitlab.com/gitlab-org/gitlab-ce/issues/58595 -class LegacyArtifactUploader < GitlabUploader - extend Workhorse::UploadPath - include ObjectStorage::Concern - - ObjectNotReadyError = Class.new(StandardError) - - storage_options Gitlab.config.artifacts - - alias_method :upload, :model - - def store_dir - dynamic_segment - end - - private - - def dynamic_segment - raise ObjectNotReadyError, 'Build is not ready' unless model.id - - File.join(model.created_at.utc.strftime('%Y_%m'), model.project_id.to_s, model.id.to_s) - end -end diff --git a/app/views/abuse_reports/new.html.haml b/app/views/abuse_reports/new.html.haml index 92ae40512c5..a161fbd064e 100644 --- a/app/views/abuse_reports/new.html.haml +++ b/app/views/abuse_reports/new.html.haml @@ -11,12 +11,14 @@ = f.hidden_field :user_id .form-group.row - = f.label :user_id, class: 'col-sm-2 col-form-label' + .col-sm-2.col-form-label + = f.label :user_id .col-sm-10 - name = "#{@abuse_report.user.name} (@#{@abuse_report.user.username})" = text_field_tag :user_name, name, class: "form-control", readonly: true .form-group.row - = f.label :message, class: 'col-sm-2 col-form-label' + .col-sm-2.col-form-label + = f.label :message .col-sm-10 = f.text_area :message, class: "form-control", rows: 2, required: true, value: sanitize(@ref_url) .form-text.text-muted diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml index 01f6c7afe61..7587ecbf9d3 100644 --- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml +++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml @@ -5,7 +5,7 @@ %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p - = _('External Classification Policy Authorization') + = _('External Classification Policy Authorization') .settings-content = form_for @application_setting, url: admin_application_settings_path(anchor: 'js-external-auth-settings'), html: { class: 'fieldset-form' } do |f| diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml index f4bfb5af385..dd56bb99a06 100644 --- a/app/views/admin/application_settings/_outbound.html.haml +++ b/app/views/admin/application_settings/_outbound.html.haml @@ -8,4 +8,12 @@ = f.label :allow_local_requests_from_hooks_and_services, class: 'form-check-label' do Allow requests to the local network from hooks and services + .form-group + .form-check + = f.check_box :dns_rebinding_protection_enabled, class: 'form-check-input' + = f.label :dns_rebinding_protection_enabled, class: 'form-check-label' do + = _('Enforce DNS rebinding attack protection') + %span.form-text.text-muted + = _('Resolves IP addresses once and uses them to submit requests') + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml index 12690343f6e..21e84016c66 100644 --- a/app/views/admin/applications/_form.html.haml +++ b/app/views/admin/applications/_form.html.haml @@ -2,13 +2,15 @@ = form_errors(application) = content_tag :div, class: 'form-group row' do - = f.label :name, class: 'col-sm-2 col-form-label' + .col-sm-2.col-form-label + = f.label :name .col-sm-10 = f.text_field :name, class: 'form-control' = doorkeeper_errors_for application, :name = content_tag :div, class: 'form-group row' do - = f.label :redirect_uri, class: 'col-sm-2 col-form-label' + .col-sm-2.col-form-label + = f.label :redirect_uri .col-sm-10 = f.text_area :redirect_uri, class: 'form-control' = doorkeeper_errors_for application, :redirect_uri @@ -21,14 +23,16 @@ for local tests = content_tag :div, class: 'form-group row' do - = f.label :trusted, class: 'col-sm-2 col-form-label pt-0' + .col-sm-2.col-form-label.pt-0 + = f.label :trusted .col-sm-10 = f.check_box :trusted %span.form-text.text-muted Trusted applications are automatically authorized on GitLab OAuth flow. .form-group.row - = f.label :scopes, class: 'col-sm-2 col-form-label pt-0' + .col-sm-2.col-form-label.pt-0 + = f.label :scopes .col-sm-10 = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index 46beca0465e..c8ee87c6212 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -10,7 +10,8 @@ = form_errors(@broadcast_message) .form-group.row - = f.label :message, class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :message .col-sm-10 = f.text_area :message, class: "form-control js-autosize", required: true, @@ -20,19 +21,23 @@ .col-sm-10.offset-sm-2 = link_to 'Customize colors', '#', class: 'js-toggle-colors-link' .form-group.row.js-toggle-colors-container.toggle-colors.hide - = f.label :color, "Background Color", class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :color, "Background Color" .col-sm-10 = f.color_field :color, class: "form-control" .form-group.row.js-toggle-colors-container.toggle-colors.hide - = f.label :font, "Font Color", class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :font, "Font Color" .col-sm-10 = f.color_field :font, class: "form-control" .form-group.row - = f.label :starts_at, _("Starts at (UTC)"), class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :starts_at, _("Starts at (UTC)") .col-sm-10.datetime-controls = f.datetime_select :starts_at, {}, class: 'form-control form-control-inline' .form-group.row - = f.label :ends_at, _("Ends at (UTC)"), class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :ends_at, _("Ends at (UTC)") .col-sm-10.datetime-controls = f.datetime_select :ends_at, {}, class: 'form-control form-control-inline' .form-actions diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 62d1d01cc83..dd01ef8a29f 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -6,7 +6,8 @@ = render_if_exists 'admin/namespace_plan', f: f .form-group.row.group-description-holder - = f.label :avatar, _("Group avatar"), class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :avatar, _("Group avatar") .col-sm-10 = render 'shared/choose_avatar_button', f: f diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml index 3ab7990d9e2..40a7014e143 100644 --- a/app/views/admin/identities/_form.html.haml +++ b/app/views/admin/identities/_form.html.haml @@ -2,12 +2,14 @@ = form_errors(@identity) .form-group.row - = f.label :provider, class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :provider .col-sm-10 - values = Gitlab::Auth::OAuth::Provider.providers.map { |name| ["#{Gitlab::Auth::OAuth::Provider.label_for(name)} (#{name})", name] } = f.select :provider, values, { allow_blank: false }, class: 'form-control' .form-group.row - = f.label :extern_uid, _("Identifier"), class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :extern_uid, _("Identifier") .col-sm-10 = f.text_field :extern_uid, class: 'form-control', required: true diff --git a/app/views/admin/labels/_form.html.haml b/app/views/admin/labels/_form.html.haml index 5e7b4817461..299d0a12e6c 100644 --- a/app/views/admin/labels/_form.html.haml +++ b/app/views/admin/labels/_form.html.haml @@ -2,15 +2,18 @@ = form_errors(@label) .form-group.row - = f.label :title, class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :title .col-sm-10 = f.text_field :title, class: "form-control", required: true .form-group.row - = f.label :description, class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :description .col-sm-10 = f.text_field :description, class: "form-control js-quick-submit" .form-group.row - = f.label :color, _("Background color"), class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :color, _("Background color") .col-sm-10 .input-group .input-group-prepend @@ -21,10 +24,7 @@ %br = _("Or you can choose one of the suggested colors below") - .suggest-colors - - suggested_colors.each do |color| - = link_to '#', style: "background-color: #{color}", data: { color: color } do - + = render_suggested_colors .form-actions = f.submit _('Save'), class: 'btn btn-success js-save-button' diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml index 9117f63f939..2f7ad35eb3e 100644 --- a/app/views/admin/projects/_projects.html.haml +++ b/app/views/admin/projects/_projects.html.haml @@ -7,7 +7,7 @@ = link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn" %button.delete-project-button.btn.btn-danger{ data: { toggle: 'modal', target: '#delete-project-modal', - delete_project_url: project_path(project), + delete_project_url: admin_project_path(project), project_name: project.name }, type: 'button' } = s_('AdminProjects|Delete') @@ -17,7 +17,7 @@ - if project.archived %span.badge.badge-warning archived .title - = link_to(admin_namespace_project_path(project.namespace, project)) do + = link_to(admin_project_path(project)) do .dash-project-avatar .avatar-container.rect-avatar.s40 = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40) diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 1e1ad9d5e19..e23accc1ea9 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -117,7 +117,8 @@ .card-body = form_for @project, url: transfer_admin_project_path(@project), method: :put do |f| .form-group.row - = f.label :new_namespace_id, "Namespace", class: 'col-form-label col-sm-3' + .col-sm-3.col-form-label + = f.label :new_namespace_id, "Namespace" .col-sm-9 .dropdown = dropdown_toggle('Search for Namespace', { toggle: 'dropdown', field_name: 'new_namespace_id' }, { toggle_class: 'js-namespace-select large' }) diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml index 98b6bc7bc46..77729636f9d 100644 --- a/app/views/admin/users/_access_levels.html.haml +++ b/app/views/admin/users/_access_levels.html.haml @@ -1,18 +1,20 @@ %fieldset %legend Access .form-group.row - .col-sm-2.text-right - = f.label :projects_limit, class: 'col-form-label' - .col-sm-10= f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control' + .col-sm-2.col-form-label + = f.label :projects_limit + .col-sm-10 + = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control' .form-group.row - .col-sm-2.text-right - = f.label :can_create_group, class: 'col-form-label' - .col-sm-10= f.check_box :can_create_group + .col-sm-2.col-form-label + = f.label :can_create_group + .col-sm-10 + = f.check_box :can_create_group .form-group.row - .col-sm-2.text-right - = f.label :access_level, class: 'col-form-label' + .col-sm-2.col-form-label + = f.label :access_level .col-sm-10 - editing_current_user = (current_user == @user) @@ -34,8 +36,8 @@ You cannot remove your own admin rights. .form-group.row - .col-sm-2.text-right - = f.label :external, class: 'col-form-label' + .col-sm-2.col-form-label + = f.label :external .hidden{ data: user_internal_regex_data } .col-sm-10 = f.check_box :external do diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index 0656feb79cb..3281718071c 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -5,20 +5,20 @@ %fieldset %legend Account .form-group.row - .col-sm-2.text-right - = f.label :name, class: 'col-form-label' + .col-sm-2.col-form-label + = f.label :name .col-sm-10 = f.text_field :name, required: true, autocomplete: 'off', class: 'form-control' %span.help-inline * required .form-group.row - .col-sm-2.text-right - = f.label :username, class: 'col-form-label' + .col-sm-2.col-form-label + = f.label :username .col-sm-10 = f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control' %span.help-inline * required .form-group.row - .col-sm-2.text-right - = f.label :email, class: 'col-form-label' + .col-sm-2.col-form-label + = f.label :email .col-sm-10 = f.text_field :email, required: true, autocomplete: 'off', class: 'form-control' %span.help-inline * required @@ -27,8 +27,8 @@ %fieldset %legend Password .form-group.row - .col-sm-2.text-right - = f.label :password, class: 'col-form-label' + .col-sm-2.col-form-label + = f.label :password .col-sm-10 %strong Reset link will be generated and sent to the user. @@ -38,13 +38,15 @@ %fieldset %legend Password .form-group.row - .col-sm-2.text-right - = f.label :password, class: 'col-form-label' - .col-sm-10= f.password_field :password, disabled: f.object.force_random_password, class: 'form-control' + .col-sm-2.col-form-label + = f.label :password + .col-sm-10 + = f.password_field :password, disabled: f.object.force_random_password, class: 'form-control' .form-group.row - .col-sm-2.text-right - = f.label :password_confirmation, class: 'col-form-label' - .col-sm-10= f.password_field :password_confirmation, disabled: f.object.force_random_password, class: 'form-control' + .col-sm-2.col-form-label + = f.label :password_confirmation + .col-sm-10 + = f.password_field :password_confirmation, disabled: f.object.force_random_password, class: 'form-control' = render partial: 'access_levels', locals: { f: f } @@ -55,27 +57,31 @@ %fieldset %legend Profile .form-group.row - .col-sm-2.text-right - = f.label :avatar, class: 'col-form-label' + .col-sm-2.col-form-label + = f.label :avatar .col-sm-10 = f.file_field :avatar .form-group.row - .col-sm-2.text-right - = f.label :skype, class: 'col-form-label' - .col-sm-10= f.text_field :skype, class: 'form-control' + .col-sm-2.col-form-label + = f.label :skype + .col-sm-10 + = f.text_field :skype, class: 'form-control' .form-group.row - .col-sm-2.text-right - = f.label :linkedin, class: 'col-form-label' - .col-sm-10= f.text_field :linkedin, class: 'form-control' + .col-sm-2.col-form-label + = f.label :linkedin + .col-sm-10 + = f.text_field :linkedin, class: 'form-control' .form-group.row - .col-sm-2.text-right - = f.label :twitter, class: 'col-form-label' - .col-sm-10= f.text_field :twitter, class: 'form-control' + .col-sm-2.col-form-label + = f.label :twitter + .col-sm-10 + = f.text_field :twitter, class: 'form-control' .form-group.row - .col-sm-2.text-right - = f.label :website_url, 'Website', class: 'col-form-label' - .col-sm-10= f.text_field :website_url, class: 'form-control' + .col-sm-2.col-form-label + = f.label :website_url + .col-sm-10 + = f.text_field :website_url, class: 'form-control' = render_if_exists 'admin/users/admin_notes', f: f diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index c4178296e67..dcd6f7c8078 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -124,6 +124,8 @@ %strong = Gitlab::Access.human_access_with_none(@user.highest_role) + = render_if_exists 'admin/users/using_license_seat', user: @user + - if @user.ldap_user? %li %span.light LDAP uid: diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml index 8d9c083d223..60ca7e4e267 100644 --- a/app/views/award_emoji/_awards_block.html.haml +++ b/app/views/award_emoji/_awards_block.html.haml @@ -13,7 +13,7 @@ %button.btn.award-control.has-tooltip.js-add-award{ type: 'button', 'aria-label': _('Add reaction'), data: { title: _('Add reaction') } } - %span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face') - %span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley') - %span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile') + %span{ class: "award-control-icon award-control-icon-neutral" }= sprite_icon('slight-smile') + %span{ class: "award-control-icon award-control-icon-positive" }= sprite_icon('smiley') + %span{ class: "award-control-icon award-control-icon-super-positive" }= sprite_icon('smile') = icon('spinner spin', class: "award-control-icon award-control-icon-loading") diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml index 89bd7b31352..ca2521e9bc6 100644 --- a/app/views/ci/variables/_variable_row.html.haml +++ b/app/views/ci/variables/_variable_row.html.haml @@ -59,7 +59,7 @@ .append-right-default = s_("CiVariable|Masked") %button{ type: 'button', - class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if is_masked}", + class: "js-project-feature-toggle project-feature-toggle qa-variable-masked #{'is-checked' if is_masked}", "aria-label": s_("CiVariable|Toggle masked") } %input{ type: "hidden", class: 'js-ci-variable-input-masked js-project-feature-toggle-input', diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml index 160c5f009a7..a5de67be96b 100644 --- a/app/views/clusters/clusters/_banner.html.haml +++ b/app/views/clusters/clusters/_banner.html.haml @@ -5,5 +5,17 @@ .hidden.js-cluster-creating.bs-callout.bs-callout-info{ role: 'alert' } = s_('ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine...') +.hidden.row.js-cluster-api-unreachable.bs-callout.bs-callout-warning{ role: 'alert' } + .col-11 + = s_('ClusterIntegration|Your cluster API is unreachable. Please ensure your API URL is correct.') + .col-1.p-0 + %button.js-close-banner.close.cluster-application-banner-close.h-100.m-0= "×" + +.hidden.js-cluster-authentication-failure.row.js-cluster-api-unreachable.bs-callout.bs-callout-warning{ role: 'alert' } + .col-11 + = s_('ClusterIntegration|There was a problem authenticating with your cluster. Please ensure your CA Certificate and Token are valid.') + .col-1.p-0 + %button.js-close-banner.close.cluster-application-banner-close.h-100.m-0= "×" + .hidden.js-cluster-success.bs-callout.bs-callout-success{ role: 'alert' } = s_("ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details") diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index deb6b21e2be..4dfbb310142 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -24,7 +24,8 @@ help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'), ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-endpoint'), ingress_dns_help_path: help_page_path('user/project/clusters/index.md', anchor: 'manually-determining-the-external-endpoint'), - manage_prometheus_path: manage_prometheus_path } } + manage_prometheus_path: manage_prometheus_path, + cluster_id: @cluster.id } } .js-cluster-application-notice .flash-container diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index a7434059de4..383fd5130ce 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -4,24 +4,24 @@ .devise-errors = render "devise/shared/error_messages", resource: resource .name.form-group - = f.label :name, 'Full name', class: 'label-bold' + = f.label :name, _('Full name'), class: 'label-bold' = f.text_field :name, class: "form-control top qa-new-user-name js-block-emoji", required: true, title: _("This field is required.") .username.form-group = f.label :username, class: 'label-bold' = f.text_field :username, class: "form-control middle qa-new-user-username js-block-emoji", pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.") - %p.validation-error.hide Username is already taken. - %p.validation-success.hide Username is available. - %p.validation-pending.hide Checking username availability... + %p.validation-error.hide= _('Username is already taken.') + %p.validation-success.hide= _('Username is available.') + %p.validation-pending.hide= _('Checking username availability...') .form-group = f.label :email, class: 'label-bold' - = f.email_field :email, class: "form-control middle qa-new-user-email", required: true, title: "Please provide a valid email address." + = f.email_field :email, class: "form-control middle qa-new-user-email", required: true, title: _("Please provide a valid email address.") .form-group = f.label :email_confirmation, class: 'label-bold' - = f.email_field :email_confirmation, class: "form-control middle qa-new-user-email-confirmation", required: true, title: "Please retype the email address." + = f.email_field :email_confirmation, class: "form-control middle qa-new-user-email-confirmation", required: true, title: _("Please retype the email address.") .form-group.append-bottom-20#password-strength = f.label :password, class: 'label-bold' - = f.password_field :password, class: "form-control bottom qa-new-user-password", required: true, pattern: ".{#{@minimum_password_length},}", title: "Minimum length is #{@minimum_password_length} characters." - %p.gl-field-hint.text-secondary Minimum length is #{@minimum_password_length} characters + = f.password_field :password, class: "form-control bottom qa-new-user-password", required: true, pattern: ".{#{@minimum_password_length},}", title: _("Minimum length is %{minimum_password_length} characters.") % { minimum_password_length: @minimum_password_length } + %p.gl-field-hint.text-secondary= _('Minimum length is %{minimum_password_length} characters') % { minimum_password_length: @minimum_password_length } - if Gitlab::CurrentSettings.current_application_settings.enforce_terms? .form-group = check_box_tag :terms_opt_in, '1', false, required: true, class: 'qa-new-user-accept-terms' @@ -34,4 +34,4 @@ - if Gitlab::Recaptcha.enabled? = recaptcha_tags .submit-container - = f.submit "Register", class: "btn-register btn qa-new-user-register-button" + = f.submit _("Register"), class: "btn-register btn qa-new-user-register-button" diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index 69914fccc48..21c418cb0e4 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -32,7 +32,8 @@ - from_label = from = link_to project_compare_path(project, from: from, to: event.commit_to) do - Compare #{from_label}...#{truncate_sha(event.commit_to)} + %span Compare + %span.commit-sha #{from_label}...#{truncate_sha(event.commit_to)} - if create_mr %span diff --git a/app/views/groups/_create_chat_team.html.haml b/app/views/groups/_create_chat_team.html.haml index f950968030f..561e68a9155 100644 --- a/app/views/groups/_create_chat_team.html.haml +++ b/app/views/groups/_create_chat_team.html.haml @@ -1,8 +1,9 @@ .form-group - = f.label :create_chat_team, class: 'col-form-label' do - %span.mattermost-icon - = custom_icon('icon_mattermost') - Mattermost + .col-sm-2.col-form-label + = f.label :create_chat_team do + %span.mattermost-icon + = custom_icon('icon_mattermost') + Mattermost .col-sm-10 .form-check.js-toggle-container .js-toggle-button.form-check-input= f.check_box(:create_chat_team, { checked: true }, true, false) diff --git a/app/views/groups/_group_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml index 7390c42aba2..b8f632d11d3 100644 --- a/app/views/groups/_group_admin_settings.html.haml +++ b/app/views/groups/_group_admin_settings.html.haml @@ -1,5 +1,6 @@ .form-group.row - = f.label :lfs_enabled, 'Large File Storage', class: 'col-form-label col-sm-2 pt-0' + .col-sm-2.col-form-label.pt-0 + = f.label :lfs_enabled, 'Large File Storage' .col-sm-10 .form-check = f.check_box :lfs_enabled, checked: @group.lfs_enabled?, class: 'form-check-input' @@ -10,12 +11,14 @@ %br/ %span.descr This setting can be overridden in each project. .form-group.row - = f.label s_('ProjectCreationLevel|Allowed to create projects'), class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label s_('ProjectCreationLevel|Allowed to create projects') .col-sm-10 = f.select :project_creation_level, options_for_select(::Gitlab::Access.project_creation_options, @group.project_creation_level), {}, class: 'form-control' .form-group.row - = f.label :require_two_factor_authentication, 'Two-factor authentication', class: 'col-form-label col-sm-2 pt-0' + .col-sm-2.col-form-label.pt-0 + = f.label :require_two_factor_authentication, 'Two-factor authentication' .col-sm-10 .form-check = f.check_box :require_two_factor_authentication, class: 'form-check-input' diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml index 9280f12e187..40609fddbde 100644 --- a/app/views/import/bitbucket_server/status.html.haml +++ b/app/views/import/bitbucket_server/status.html.haml @@ -29,7 +29,7 @@ %tr %th= _('From Bitbucket Server') %th= _('To GitLab') - %th= _(' Status') + %th= _('Status') %tbody - @already_added_projects.each do |project| %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" } diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml index 5e4595d930b..a19c8911559 100644 --- a/app/views/import/gitlab_projects/new.html.haml +++ b/app/views/import/gitlab_projects/new.html.haml @@ -7,28 +7,7 @@ %hr = form_tag import_gitlab_project_path, class: 'new_project', multipart: true do - .row - .form-group.project-name.col-sm-12 - = label_tag :name, _('Project name'), class: 'label-bold' - = text_field_tag :name, @name, placeholder: "My awesome project", class: "js-project-name form-control input-lg", autofocus: true - .form-group.col-12.col-sm-6 - = label_tag :namespace_id, _('Project URL'), class: 'label-bold' - .form-group - .input-group - - if current_user.can_select_namespace? - .input-group-prepend.has-tooltip{ title: root_url } - .input-group-text - = root_url - = select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace', tabindex: 1 - - - else - .input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' } - .input-group-text.border-0 - #{user_url(current_user.username)}/ - = hidden_field_tag :namespace_id, value: current_user.namespace_id - .form-group.col-12.col-sm-6.project-path - = label_tag :path, _('Project slug'), class: 'label-bold' - = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control", tabindex: 2, required: true + = render 'import/shared/new_project_form' .row .form-group.col-md-12 diff --git a/app/views/import/manifest/new.html.haml b/app/views/import/manifest/new.html.haml index 056e4922b9e..df00c4d2179 100644 --- a/app/views/import/manifest/new.html.haml +++ b/app/views/import/manifest/new.html.haml @@ -4,9 +4,5 @@ %h3.page-title = _('Manifest file import') -- if @errors.present? - .alert.alert-danger - - @errors.each do |error| - = error - += render 'import/shared/errors' = render 'form' diff --git a/app/views/import/phabricator/new.html.haml b/app/views/import/phabricator/new.html.haml new file mode 100644 index 00000000000..811e126579e --- /dev/null +++ b/app/views/import/phabricator/new.html.haml @@ -0,0 +1,25 @@ +- title = _('Phabricator Server Import') +- page_title title +- breadcrumb_title title +- header_title _("Projects"), root_path + +%h3.page-title + = icon 'issues', text: _('Import tasks from Phabricator into issues') + += render 'import/shared/errors' + += form_tag import_phabricator_path, class: 'new_project', method: :post do + = render 'import/shared/new_project_form' + + %h4.prepend-top-0= _('Enter in your Phabricator Server URL and personal access token below') + + .form-group.row + = label_tag :phabricator_server_url, _('Phabricator Server URL'), class: 'col-form-label col-md-2' + .col-md-4 + = text_field_tag :phabricator_server_url, params[:phabricator_server_url], class: 'form-control append-right-8', placeholder: 'https://your-phabricator-server', size: 40 + .form-group.row + = label_tag :api_token, _('API Token'), class: 'col-form-label col-md-2' + .col-md-4 + = password_field_tag :api_token, params[:api_token], class: 'form-control append-right-8', placeholder: _('Personal Access Token'), size: 40 + .form-actions + = submit_tag _('Import tasks'), class: 'btn btn-success' diff --git a/app/views/import/shared/_errors.html.haml b/app/views/import/shared/_errors.html.haml new file mode 100644 index 00000000000..de60c15351f --- /dev/null +++ b/app/views/import/shared/_errors.html.haml @@ -0,0 +1,4 @@ +- if @errors.present? + .alert.alert-danger + - @errors.each do |error| + = error diff --git a/app/views/import/shared/_new_project_form.html.haml b/app/views/import/shared/_new_project_form.html.haml new file mode 100644 index 00000000000..4d13d4f2869 --- /dev/null +++ b/app/views/import/shared/_new_project_form.html.haml @@ -0,0 +1,21 @@ +.row + .form-group.project-name.col-sm-12 + = label_tag :name, _('Project name'), class: 'label-bold' + = text_field_tag :name, @name, placeholder: "My awesome project", class: "js-project-name form-control input-lg", autofocus: true + .form-group.col-12.col-sm-6 + = label_tag :namespace_id, _('Project URL'), class: 'label-bold' + .form-group + .input-group.flex-nowrap + - if current_user.can_select_namespace? + .input-group-prepend.flex-shrink-0.has-tooltip{ title: root_url } + .input-group-text + = root_url + = select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace', tabindex: 1 + - else + .input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' } + .input-group-text.border-0 + #{user_url(current_user.username)}/ + = hidden_field_tag :namespace_id, value: current_user.namespace_id + .form-group.col-12.col-sm-6.project-path + = label_tag :path, _('Project slug'), class: 'label-bold' + = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control", tabindex: 2, required: true diff --git a/app/views/issues/_issue.atom.builder b/app/views/issues/_issue.atom.builder index 21cf6d0dd65..94c32df7c60 100644 --- a/app/views/issues/_issue.atom.builder +++ b/app/views/issues/_issue.atom.builder @@ -12,6 +12,7 @@ xml.entry do xml.summary issue.title xml.description issue.description if issue.description + xml.content issue.description if issue.description xml.milestone issue.milestone.title if issue.milestone xml.due_date issue.due_date if issue.due_date diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index a6023a1cbb9..496ec3c78b0 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -16,7 +16,7 @@ mr_path: merge_requests_dashboard_path }, aria: { label: _('Search or jump to…') } %button.hidden.js-dropdown-search-toggle{ type: 'button', data: { toggle: 'dropdown' } } - .dropdown-menu.dropdown-select + .dropdown-menu.dropdown-select.js-dashboard-search-options = dropdown_content do %ul %li.dropdown-menu-empty-item diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 043cca6ad38..c38f96f302a 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -10,4 +10,6 @@ = render 'layouts/page', sidebar: sidebar, nav: nav = footer_message + = render_if_exists "shared/onboarding_guide" + = yield :scripts_body diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index f7a561afbb3..ff3410f6268 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -10,15 +10,17 @@ .container.navless-container .content = render "layouts/flash" - .row.append-bottom-15 - .col-sm-7.brand-holder - %h1 + .row.mt-3 + .col-sm-12 + %h1.mb-3.font-weight-normal = brand_title + .row.mb-3 + .col-sm-7.order-12.order-sm-1.brand-holder = brand_image - if current_appearance&.description? = brand_text - else - %h3 + %h3.mt-sm-0 = _('Open source software to collaborate on code') %p @@ -29,7 +31,7 @@ = render_if_exists 'layouts/devise_help_text' - .col-sm-5.new-session-forms-container + .col-sm-5.order-1.order-sm-12.new-session-forms-container = yield %hr.footer-fixed diff --git a/app/views/notify/closed_issue_email.html.haml b/app/views/notify/closed_issue_email.html.haml index f21cf1ad34b..d3733ab3a09 100644 --- a/app/views/notify/closed_issue_email.html.haml +++ b/app/views/notify/closed_issue_email.html.haml @@ -1,2 +1,2 @@ %p - Issue was closed by #{sanitize_name(@updated_by.name)} #{closure_reason_text(@closed_via, format: formats.first)}. + = _("Issue was closed by %{name} %{reason}").html_safe % { name: sanitize_name(@updated_by.name), reason: closure_reason_text(@closed_via, format: formats.first) } diff --git a/app/views/notify/closed_issue_email.text.haml b/app/views/notify/closed_issue_email.text.haml index 5567adc9165..ff2548a4b42 100644 --- a/app/views/notify/closed_issue_email.text.haml +++ b/app/views/notify/closed_issue_email.text.haml @@ -1,3 +1,3 @@ -Issue was closed by #{sanitize_name(@updated_by.name)} #{closure_reason_text(@closed_via, format: formats.first)}. += _("Issue was closed by %{name} %{reason}").html_safe % { name: sanitize_name(@updated_by.name), reason: closure_reason_text(@closed_via, format: formats.first) } Issue ##{@issue.iid}: #{project_issue_url(@issue.project, @issue)} diff --git a/app/views/profiles/_event_table.html.haml b/app/views/profiles/_event_table.html.haml index 9f525547dd9..977ff30d5a6 100644 --- a/app/views/profiles/_event_table.html.haml +++ b/app/views/profiles/_event_table.html.haml @@ -1,14 +1,12 @@ %h5.prepend-top-0 - History of authentications + = _('History of authentications') %ul.content-list - events.each do |event| %li %span.description = audit_icon(event.details[:with], class: "append-right-5") - Signed in with - = event.details[:with] - authentication + = _('Signed in with %{authentication} authentication') % { authentication: event.details[:with]} %span.float-right= time_ago_with_tooltip(event.created_at) = paginate events, theme: "gitlab" diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/profiles/active_sessions/_active_session.html.haml index 2bf514d72a5..bb31049111c 100644 --- a/app/views/profiles/active_sessions/_active_session.html.haml +++ b/app/views/profiles/active_sessions/_active_session.html.haml @@ -8,18 +8,19 @@ %div %strong= active_session.ip_address - if is_current_session - %div This is your current session + %div + = _('This is your current session') - else %div - Last accessed on + = _('Last accessed on') = l(active_session.updated_at, format: :short) %div %strong= active_session.browser - on + = s_('ProfileSession|on') %strong= active_session.os %div - %strong Signed in - on + %strong= _('Signed in') + = s_('ProfileSession|on') = l(active_session.created_at, format: :short) diff --git a/app/views/profiles/active_sessions/index.html.haml b/app/views/profiles/active_sessions/index.html.haml index 8688a52843d..d651319fc3f 100644 --- a/app/views/profiles/active_sessions/index.html.haml +++ b/app/views/profiles/active_sessions/index.html.haml @@ -1,4 +1,4 @@ -- page_title 'Active Sessions' +- page_title _('Active Sessions') - @content_class = "limit-container-width" unless fluid_layout .row.prepend-top-default @@ -6,7 +6,7 @@ %h4.prepend-top-0 = page_title %p - This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize. + = _('This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize.') .col-lg-8 .append-bottom-default diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml index a924369050b..275c0428d34 100644 --- a/app/views/profiles/audit_log.html.haml +++ b/app/views/profiles/audit_log.html.haml @@ -1,4 +1,4 @@ -- page_title "Authentication log" +- page_title _('Authentication log') - @content_class = "limit-container-width" unless fluid_layout .row.prepend-top-default @@ -6,6 +6,6 @@ %h4.prepend-top-0 = page_title %p - This is a security log of important events involving your account. + = _('This is a security log of important events involving your account.') .col-lg-8 = render 'event_table', events: @events diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml index 9e82e47c1e1..ff67f92ad07 100644 --- a/app/views/profiles/chat_names/_chat_name.html.haml +++ b/app/views/profiles/chat_names/_chat_name.html.haml @@ -21,7 +21,7 @@ - if chat_name.last_used_at = time_ago_with_tooltip(chat_name.last_used_at) - else - Never + = _('Never') %td - = link_to 'Remove', profile_chat_name_path(chat_name), method: :delete, class: 'btn btn-danger float-right', data: { confirm: 'Are you sure you want to revoke this nickname?' } + = link_to _('Remove'), profile_chat_name_path(chat_name), method: :delete, class: 'btn btn-danger float-right', data: { confirm: _('Are you sure you want to revoke this nickname?') } diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml index 4b6e419af50..0c8098a97d5 100644 --- a/app/views/profiles/chat_names/index.html.haml +++ b/app/views/profiles/chat_names/index.html.haml @@ -1,4 +1,4 @@ -- page_title 'Chat' +- page_title _('Chat') - @content_class = "limit-container-width" unless fluid_layout .row.prepend-top-default @@ -6,7 +6,7 @@ %h4.prepend-top-0 = page_title %p - You can see your Chat accounts. + = _('You can see your chat accounts.') .col-lg-8 %h5 Active chat names (#{@chat_names.size}) @@ -16,15 +16,15 @@ %table.table.chat-names %thead %tr - %th Project - %th Service - %th Team domain - %th Nickname - %th Last used + %th= _('Project') + %th= _('Service') + %th= _('Team domain') + %th= _('Nickname') + %th= _('Last used') %th %tbody = render @chat_names - else .settings-message.text-center - You don't have any active chat names. + = _("You don't have any active chat names.") diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index 1823f191fb3..c90a0b3e329 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -26,7 +26,9 @@ %li Your Commit Email will be used for web based operations, such as edits and merges. %li - Your Notification Email will be used for account notifications. + Your Default Notification Email will be used for account notifications if a + = link_to 'group-specific email address', profile_notifications_path + is not set. %li Your Public Email will be displayed on your public profile. %li @@ -41,7 +43,7 @@ - if @primary_email === current_user.public_email %span.badge.badge-info Public email - if @primary_email === current_user.notification_email - %span.badge.badge-info Notification email + %span.badge.badge-info Default notification email - @emails.each do |email| %li = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? } diff --git a/app/views/profiles/gpg_keys/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml index 6c4cb614a2b..225487b2638 100644 --- a/app/views/profiles/gpg_keys/_form.html.haml +++ b/app/views/profiles/gpg_keys/_form.html.haml @@ -3,8 +3,8 @@ = form_errors(@gpg_key) .form-group - = f.label :key, class: 'label-bold' - = f.text_area :key, class: "form-control", rows: 8, required: true, placeholder: "Don't paste the private part of the GPG key. Paste the public part which begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'." + = f.label :key, s_('Profiles|Key'), class: 'label-bold' + = f.text_area :key, class: "form-control", rows: 8, required: true, placeholder: _("Don't paste the private part of the GPG key. Paste the public part which begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'.") .prepend-top-default - = f.submit 'Add key', class: "btn btn-success" + = f.submit s_('Profiles|Add key'), class: "btn btn-success" diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml index d1fd7bc8e71..f8351644df5 100644 --- a/app/views/profiles/gpg_keys/_key.html.haml +++ b/app/views/profiles/gpg_keys/_key.html.haml @@ -9,17 +9,19 @@ %code= key.fingerprint - if key.subkeys.present? .subkeys - %span.bold Subkeys: + %span.bold + = _('Subkeys') + = ':' %ul.subkeys-list - key.subkeys.each do |subkey| %li %code= subkey.fingerprint .float-right %span.key-created-at - created #{time_ago_with_tooltip(key.created_at)} - = link_to profile_gpg_key_path(key), data: { confirm: 'Are you sure? Removing this GPG key does not affect already signed commits.' }, method: :delete, class: "btn btn-danger prepend-left-10" do - %span.sr-only Remove + = s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)} + = link_to profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: "btn btn-danger prepend-left-10" do + %span.sr-only= _('Remove') = icon('trash') - = link_to revoke_profile_gpg_key_path(key), data: { confirm: 'Are you sure? All commits that were signed with this GPG key will be unverified.' }, method: :put, class: "btn btn-danger prepend-left-10" do - %span.sr-only Revoke - Revoke + = link_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: "btn btn-danger prepend-left-10" do + %span.sr-only= _('Revoke') + = _('Revoke') diff --git a/app/views/profiles/gpg_keys/_key_table.html.haml b/app/views/profiles/gpg_keys/_key_table.html.haml index b9b60c218fd..ebbd1c8f672 100644 --- a/app/views/profiles/gpg_keys/_key_table.html.haml +++ b/app/views/profiles/gpg_keys/_key_table.html.haml @@ -6,6 +6,6 @@ - else %p.settings-message.text-center - if is_admin - There are no GPG keys associated with this account. + = _('There are no GPG keys associated with this account.') - else - There are no GPG keys with access to your account. + = _('There are no GPG keys with access to your account.') diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml index 1d2e41cb437..f9f898a9225 100644 --- a/app/views/profiles/gpg_keys/index.html.haml +++ b/app/views/profiles/gpg_keys/index.html.haml @@ -1,4 +1,4 @@ -- page_title "GPG Keys" +- page_title _('GPG Keys') - @content_class = "limit-container-width" unless fluid_layout .row.prepend-top-default @@ -6,16 +6,16 @@ %h4.prepend-top-0 = page_title %p - GPG keys allow you to verify signed commits. + = _('GPG keys allow you to verify signed commits.') .col-lg-8 %h5.prepend-top-0 - Add a GPG key + = _('Add a GPG key') %p.profile-settings-content - Before you can add a GPG key you need to - = link_to 'generate it.', help_page_path('user/project/repository/gpg_signed_commits/index.md') + - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/repository/gpg_signed_commits/index.md') } + = _('Before you can add a GPG key you need to %{help_link_start}Generate it.%{help_link_end}'.html_safe) % {help_link_start: help_link_start, help_link_end:'</a>'.html_safe } = render 'form' %hr %h5 - Your GPG keys (#{@gpg_keys.count}) + = _('Your GPG keys (%{count})') % { count:@gpg_keys.count} .append-bottom-default = render 'key_table' diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml index 21eef08983c..7846cdbcd52 100644 --- a/app/views/profiles/keys/_form.html.haml +++ b/app/views/profiles/keys/_form.html.haml @@ -3,11 +3,11 @@ = form_errors(@key) .form-group - = f.label :key, class: 'label-bold' + = f.label :key, s_('Profiles|Key'), class: 'label-bold' %p= _("Paste your public SSH key, which is usually contained in the file '~/.ssh/id_rsa.pub' and begins with 'ssh-rsa'. Don't use your private SSH key.") = f.text_area :key, class: "form-control js-add-ssh-key-validation-input qa-key-public-key-field", rows: 8, required: true, placeholder: s_('Profiles|Typically starts with "ssh-rsa …"') .form-group - = f.label :title, class: 'label-bold' + = f.label :title, _('Title'), class: 'label-bold' = f.text_field :title, class: "form-control input-lg qa-key-title-field", required: true, placeholder: s_('Profiles|e.g. My MacBook key') %p.form-text.text-muted= _('Name your individual key via a title') diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index ce20994b0f4..b9d73d89334 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -17,7 +17,8 @@ = key.last_used_at ? time_ago_with_tooltip(key.last_used_at) : 'n/a' .float-right %span.key-created-at - created #{time_ago_with_tooltip(key.created_at)} - = link_to path_to_key(key, is_admin), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-transparent prepend-left-10" do - %span.sr-only Remove - = icon('trash') + = s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)} + - if key.can_delete? + = link_to path_to_key(key, is_admin), data: { confirm: _('Are you sure?')}, method: :delete, class: "btn btn-transparent prepend-left-10" do + %span.sr-only= _('Remove') + = icon('trash') diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index 88473c7f72d..0ef01dec493 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -3,25 +3,26 @@ .col-md-4 .card .card-header - SSH Key + = _('SSH Key') %ul.content-list %li - %span.light Title: + %span.light= _('Title:') %strong= @key.title %li - %span.light Created on: + %span.light= _('Created on:') %strong= @key.created_at.to_s(:medium) %li - %span.light Last used on: + %span.light= _('Last used on:') %strong= @key.last_used_at.try(:to_s, :medium) || 'N/A' .col-md-8 = form_errors(@key, type: 'key') unless @key.valid? %p - %span.light Fingerprint: + %span.light= _('Fingerprint:') %code.key-fingerprint= @key.fingerprint %pre.well-pre = @key.key .col-md-12 .float-right - = link_to 'Remove', path_to_key(@key, is_admin), data: {confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove delete-key qa-delete-key-button" + - if @key.can_delete? + = link_to _('Remove'), path_to_key(@key, is_admin), data: {confirm: _('Are you sure?')}, method: :delete, class: "btn btn-remove delete-key qa-delete-key-button" diff --git a/app/views/profiles/keys/_key_table.html.haml b/app/views/profiles/keys/_key_table.html.haml index e088140fdd2..4a6d8a1870d 100644 --- a/app/views/profiles/keys/_key_table.html.haml +++ b/app/views/profiles/keys/_key_table.html.haml @@ -6,6 +6,6 @@ - else %p.settings-message.text-center - if is_admin - There are no SSH keys associated with this account. + = _('There are no SSH keys associated with this account.') - else - There are no SSH keys with access to your account. + = _('There are no SSH keys with access to your account.') diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml index 55ca8d0ebd4..da6aa0fce3a 100644 --- a/app/views/profiles/keys/index.html.haml +++ b/app/views/profiles/keys/index.html.haml @@ -1,4 +1,4 @@ -- page_title "SSH Keys" +- page_title _('SSH Keys') - @content_class = "limit-container-width" unless fluid_layout .row.prepend-top-default @@ -6,10 +6,10 @@ %h4.prepend-top-0 = page_title %p - SSH keys allow you to establish a secure connection between your computer and GitLab. + = _('SSH keys allow you to establish a secure connection between your computer and GitLab.') .col-lg-8 %h5.prepend-top-0 - Add an SSH key + = _('Add an SSH key') %p.profile-settings-content - generate_link_url = help_page_path("ssh/README", anchor: 'generating-a-new-ssh-key-pair') - existing_link_url = help_page_path("ssh/README", anchor: 'locating-an-existing-ssh-key-pair') @@ -19,6 +19,6 @@ = render 'form' %hr %h5 - Your SSH keys (#{@keys.count}) + = _('Your SSH keys (%{count})') % { count:@keys.count } .append-bottom-default = render 'key_table' diff --git a/app/views/profiles/keys/show.html.haml b/app/views/profiles/keys/show.html.haml index 28be6172219..360de7a0c11 100644 --- a/app/views/profiles/keys/show.html.haml +++ b/app/views/profiles/keys/show.html.haml @@ -1,5 +1,5 @@ - add_to_breadcrumbs "SSH Keys", profile_keys_path - breadcrumb_title @key.title -- page_title @key.title, "SSH Keys" +- page_title @key.title, _('SSH Keys') - @content_class = "limit-container-width" unless fluid_layout = render "key_details" diff --git a/app/views/profiles/notifications/_group_settings.html.haml b/app/views/profiles/notifications/_group_settings.html.haml index a12246bcdcc..cf17ee44145 100644 --- a/app/views/profiles/notifications/_group_settings.html.haml +++ b/app/views/profiles/notifications/_group_settings.html.haml @@ -1,12 +1,17 @@ -%li.notification-list-item - %span.notification.fa.fa-holder.append-right-5 - - if setting.global? - = notification_icon(current_user.global_notification_setting.level) - - else - = notification_icon(setting.level) +.gl-responsive-table-row.notification-list-item + .table-section.section-40 + %span.notification.fa.fa-holder.append-right-5 + - if setting.global? + = notification_icon(current_user.global_notification_setting.level) + - else + = notification_icon(setting.level) - %span.str-truncated - = link_to group.name, group_path(group) + %span.str-truncated + = link_to group.name, group_path(group) - .float-right + .table-section.section-30.text-right = render 'shared/notifications/button', notification_setting: setting + + .table-section.section-30 + = form_for @user.notification_settings.find { |ns| ns.source == group }, url: profile_notifications_group_path(group), method: :put, html: { class: 'update-notifications' } do |f| + = f.select :notification_email, @user.all_emails, { include_blank: 'Global notification email' }, class: 'select2 js-group-notification-email' diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index e616e5546b3..1f311e9a4a4 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -1,4 +1,4 @@ -- page_title "Notifications" +- page_title _('Notifications') - @content_class = "limit-container-width" unless fluid_layout %div @@ -14,12 +14,12 @@ %h4.prepend-top-0 = page_title %p - You can specify notification level per group or per project. + = _('You can specify notification level per group or per project.') %p - By default, all projects and groups will use the global notifications setting. + = _('By default, all projects and groups will use the global notifications setting.') .col-lg-8 %h5.prepend-top-0 - Global notification settings + = _('Global notification settings') = form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications prepend-top-default' } do |f| = render_if_exists 'profiles/notifications/email_settings', form: f @@ -35,19 +35,18 @@ = form_for @user, url: profile_notifications_path, method: :put do |f| %label{ for: 'user_notified_of_own_activity' } = f.check_box :notified_of_own_activity - %span Receive notifications about your own activity + %span= _('Receive notifications about your own activity') %hr %h5 - Groups (#{@group_notifications.count}) + = _('Groups (%{count})') % { count: @group_notifications.count } %div - %ul.bordered-list - - @group_notifications.each do |setting| - = render 'group_settings', setting: setting, group: setting.source + - @group_notifications.each do |setting| + = render 'group_settings', setting: setting, group: setting.source %h5 - Projects (#{@project_notifications.count}) + = _('Projects (%{count})') % { count: @project_notifications.count } %p.account-well - To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there. + = _('To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there.') .append-bottom-default %ul.bordered-list - @project_notifications.each do |setting| diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml index 0b4b9841ea1..ac8c31189d0 100644 --- a/app/views/profiles/passwords/edit.html.haml +++ b/app/views/profiles/passwords/edit.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title "Edit Password" -- page_title "Password" +- breadcrumb_title _('Edit Password') +- page_title _('Password') - @content_class = "limit-container-width" unless fluid_layout .row.prepend-top-default @@ -7,28 +7,29 @@ %h4.prepend-top-0 = page_title %p - After a successful password update, you will be redirected to the login page where you can log in with your new password. + = _('After a successful password update, you will be redirected to the login page where you can log in with your new password.') .col-lg-8 %h5.prepend-top-0 - Change your password - - unless @user.password_automatically_set? - or recover your current one + - if @user.password_automatically_set + = _('Change your password') + - else + = _('Change your password or recover your current one') = form_for @user, url: profile_password_path, method: :put, html: {class: "update-password"} do |f| = form_errors(@user) - unless @user.password_automatically_set? .form-group - = f.label :current_password, class: 'label-bold' + = f.label :current_password, _('Current password'), class: 'label-bold' = f.password_field :current_password, required: true, class: 'form-control' %p.form-text.text-muted - You must provide your current password in order to change it. + = _('You must provide your current password in order to change it.') .form-group - = f.label :password, 'New password', class: 'label-bold' + = f.label :password, _('New password'), class: 'label-bold' = f.password_field :password, required: true, class: 'form-control' .form-group - = f.label :password_confirmation, class: 'label-bold' + = f.label :password_confirmation, _('Password confirmation'), class: 'label-bold' = f.password_field :password_confirmation, required: true, class: 'form-control' .prepend-top-default.append-bottom-default - = f.submit 'Save password', class: "btn btn-success append-right-10" + = f.submit _('Save password'), class: "btn btn-success append-right-10" - unless @user.password_automatically_set? - = link_to "I forgot my password", reset_profile_password_path, method: :put, class: "account-btn-link" + = link_to _('I forgot my password'), reset_profile_password_path, method: :put, class: "account-btn-link" diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/profiles/passwords/new.html.haml index 4b84835429c..ce60455ab89 100644 --- a/app/views/profiles/passwords/new.html.haml +++ b/app/views/profiles/passwords/new.html.haml @@ -13,13 +13,18 @@ - unless @user.password_automatically_set? .form-group.row - = f.label :current_password, class: 'col-form-label col-sm-2' - .col-sm-10= f.password_field :current_password, required: true, class: 'form-control' + .col-sm-2.col-form-label + = f.label :current_password, _('Current password') + .col-sm-10 + = f.password_field :current_password, required: true, class: 'form-control' .form-group.row - = f.label :password, class: 'col-form-label col-sm-2' - .col-sm-10= f.password_field :password, required: true, class: 'form-control' + .col-sm-2.col-form-label + = f.label :password, _('New password') + .col-sm-10 + = f.password_field :password, required: true, class: 'form-control' .form-group.row - = f.label :password_confirmation, class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :password_confirmation, _('Password confirmation') .col-sm-10 = f.password_field :password_confirmation, required: true, class: 'form-control' .form-actions diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 58f2eb229ba..46384bc28ef 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -1,11 +1,11 @@ -- page_title 'Preferences' +- page_title _('Preferences') - @content_class = "limit-container-width" unless fluid_layout = form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f| .col-lg-4.application-theme %h4.prepend-top-0 = s_('Preferences|Navigation theme') - %p Customize the appearance of the application header and navigation sidebar. + %p= _('Customize the appearance of the application header and navigation sidebar.') .col-lg-8.application-theme - Gitlab::Themes.each do |theme| = label_tag do @@ -18,11 +18,11 @@ .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 - Syntax highlighting theme + = _('Syntax highlighting theme') %p - This setting allows you to customize the appearance of the syntax. + = _('This setting allows you to customize the appearance of the syntax.') = succeed '.' do - = link_to 'Learn more', help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank' + = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank' .col-lg-8.syntax-theme - Gitlab::ColorSchemes.each do |scheme| = label_tag do @@ -35,31 +35,31 @@ .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 - Behavior + = _('Behavior') %p - This setting allows you to customize the behavior of the system layout and default views. + = _('This setting allows you to customize the behavior of the system layout and default views.') = succeed '.' do - = link_to 'Learn more', help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank' + = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank' .col-lg-8 .form-group = f.label :layout, class: 'label-bold' do - Layout width + = _('Layout width') = f.select :layout, layout_choices, {}, class: 'form-control' .form-text.text-muted - Choose between fixed (max. 1280px) and fluid (100%) application layout. + = _('Choose between fixed (max. 1280px) and fluid (100%%) application layout.') .form-group = f.label :dashboard, class: 'label-bold' do - Default dashboard + = _('Default dashboard') = f.select :dashboard, dashboard_choices, {}, class: 'form-control' = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific .form-group = f.label :project_view, class: 'label-bold' do - Project overview content + = _('Project overview content') = f.select :project_view, project_view_choices, {}, class: 'form-control' .form-text.text-muted - Choose what content you want to see on a project’s overview page. + = _('Choose what content you want to see on a project’s overview page.') .col-sm-12 %hr diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 917e7acc353..e36d5192a29 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -47,9 +47,9 @@ - if @user.status = emoji_icon @user.status.emoji %span#js-no-emoji-placeholder.no-emoji-placeholder{ class: ('hidden' if @user.status) } - = sprite_icon('emoji_slightly_smiling_face', css_class: 'award-control-icon-neutral') - = sprite_icon('emoji_smiley', css_class: 'award-control-icon-positive') - = sprite_icon('emoji_smile', css_class: 'award-control-icon-super-positive') + = sprite_icon('slight-smile', css_class: 'award-control-icon-neutral') + = sprite_icon('smiley', css_class: 'award-control-icon-positive') + = sprite_icon('smile', css_class: 'award-control-icon-super-positive') - reset_message_button = button_tag type: :button, id: 'js-clear-user-status-button', class: 'clear-user-status btn has-tooltip', diff --git a/app/views/profiles/two_factor_auths/_codes.html.haml b/app/views/profiles/two_factor_auths/_codes.html.haml index 759d39cf5f5..be0af977011 100644 --- a/app/views/profiles/two_factor_auths/_codes.html.haml +++ b/app/views/profiles/two_factor_auths/_codes.html.haml @@ -1,8 +1,6 @@ %p.slead - Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one - time each to regain access to your account. Please save them in a safe place, or you - %b will - lose access to your account. + - lose_2fa_message = _('Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{b_start}will%{b_end} lose access to your account.') % { b_start:'<b>', b_end:'</b>' } + = lose_2fa_message.html_safe .codes.card %ul @@ -11,5 +9,5 @@ %span.monospace= code .d-flex - = link_to 'Proceed', profile_account_path, class: 'btn btn-success append-right-10' - = link_to 'Download codes', "data:text/plain;charset=utf-8,#{CGI.escape(@codes.join("\n"))}", download: "gitlab-recovery-codes.txt", class: 'btn btn-default' + = link_to _('Proceed'), profile_account_path, class: 'btn btn-success append-right-10' + = link_to _('Download codes'), "data:text/plain;charset=utf-8,#{CGI.escape(@codes.join("\n"))}", download: "gitlab-recovery-codes.txt", class: 'btn btn-default' diff --git a/app/views/profiles/two_factor_auths/codes.html.haml b/app/views/profiles/two_factor_auths/codes.html.haml index addf356697a..53907ebffab 100644 --- a/app/views/profiles/two_factor_auths/codes.html.haml +++ b/app/views/profiles/two_factor_auths/codes.html.haml @@ -1,5 +1,6 @@ -- page_title 'Recovery Codes', 'Two-factor Authentication' +- page_title _('Recovery Codes'), _('Two-factor Authentication') -%h3.page-title Two-factor Authentication Recovery codes +%h3.page-title + = _('Two-factor Authentication Recovery codes') %hr = render 'codes' diff --git a/app/views/profiles/two_factor_auths/create.html.haml b/app/views/profiles/two_factor_auths/create.html.haml index e330aadac13..973eb8136c4 100644 --- a/app/views/profiles/two_factor_auths/create.html.haml +++ b/app/views/profiles/two_factor_auths/create.html.haml @@ -1,6 +1,6 @@ -- page_title 'Two-factor Authentication', 'Account' +- page_title _('Two-factor Authentication'), _('Account') .alert.alert-success - Congratulations! You have enabled Two-factor Authentication! + = _('Congratulations! You have enabled Two-factor Authentication!') = render 'codes' diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index d986c566928..5501e63e027 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -1,72 +1,68 @@ -- page_title 'Two-Factor Authentication', 'Account' -- add_to_breadcrumbs("Two-Factor Authentication", profile_account_path) +- page_title _('Two-Factor Authentication'), _('Account') +- add_to_breadcrumbs(_('Two-Factor Authentication'), profile_account_path) - @content_class = "limit-container-width" unless fluid_layout .js-two-factor-auth{ 'data-two-factor-skippable' => "#{two_factor_skippable?}", 'data-two_factor_skip_url' => skip_profile_two_factor_auth_path } .row.prepend-top-default .col-lg-4 %h4.prepend-top-0 - Register Two-Factor Authenticator + = _('Register Two-Factor Authenticator') %p - Use an one time password authenticator on your mobile device or computer to enable two-factor authentication (2FA). + = _('Use an one time password authenticator on your mobile device or computer to enable two-factor authentication (2FA).') .col-lg-8 - if current_user.two_factor_otp_enabled? %p - You've already enabled two-factor authentication using one time password authenticators. In order to register a different device, you must first disable two-factor authentication. + = _("You've already enabled two-factor authentication using one time password authenticators. In order to register a different device, you must first disable two-factor authentication.") %p - If you lose your recovery codes you can generate new ones, invalidating all previous codes. + = _('If you lose your recovery codes you can generate new ones, invalidating all previous codes.') %div - = link_to 'Disable two-factor authentication', profile_two_factor_auth_path, + = link_to _('Disable two-factor authentication'), profile_two_factor_auth_path, method: :delete, - data: { confirm: "Are you sure? This will invalidate your registered applications and U2F devices." }, + data: { confirm: _('Are you sure? This will invalidate your registered applications and U2F devices.') }, class: 'btn btn-danger append-right-10' = form_tag codes_profile_two_factor_auth_path, {style: 'display: inline-block', method: :post} do |f| - = submit_tag 'Regenerate recovery codes', class: 'btn' + = submit_tag _('Regenerate recovery codes'), class: 'btn' - else %p - Install a soft token authenticator like <a href="https://freeotp.github.io/">FreeOTP</a> - or Google Authenticator from your application repository and scan this QR code. - More information is available in the #{link_to('documentation', help_page_path('user/profile/account/two_factor_authentication'))}. + - help_link_start = '<a href="%{url}" target="_blank">' % { url: help_page_path('user/profile/account/two_factor_authentication') } + - register_2fa_token = _('Install a soft token authenticator like %{free_otp_link} or Google Authenticator from your application repository and scan this QR code. More information is available in the %{help_link_start}documentation%{help_link_end}.') % { free_otp_link:'<a href="https://freeotp.github.io/">FreeOTP</a>', help_link_start:help_link_start, help_link_end:'</a>' } + = register_2fa_token.html_safe .row.append-bottom-10 .col-md-4 = raw @qr_code .col-md-8 .account-well %p.prepend-top-0.append-bottom-0 - Can't scan the code? + = _("Can't scan the code?") %p.prepend-top-0.append-bottom-0 - To add the entry manually, provide the following details to the application on your phone. + = _('To add the entry manually, provide the following details to the application on your phone.') %p.prepend-top-0.append-bottom-0 - Account: - = @account_string + = _('Account: %{account}') % { account: @account_string } %p.prepend-top-0.append-bottom-0 - Key: - = current_user.otp_secret.scan(/.{4}/).join(' ') + = _('Key: %{key}') %{ key: current_user.otp_secret.scan(/.{4}/).join(' ') } %p.two-factor-new-manual-content - Time based: Yes + = _('Time based: Yes') = form_tag profile_two_factor_auth_path, method: :post do |f| - if @error .alert.alert-danger = @error .form-group - = label_tag :pin_code, nil, class: "label-bold" + = label_tag :pin_code, _('Pin code'), class: "label-bold" = text_field_tag :pin_code, nil, class: "form-control", required: true .prepend-top-default - = submit_tag 'Register with two-factor app', class: 'btn btn-success' + = submit_tag _('Register with two-factor app'), class: 'btn btn-success' %hr .row.prepend-top-default .col-lg-4 %h4.prepend-top-0 - Register Universal Two-Factor (U2F) Device + = _('Register Universal Two-Factor (U2F) Device') %p - Use a hardware device to add the second factor of authentication. + = _('Use a hardware device to add the second factor of authentication.') %p - As U2F devices are only supported by a few browsers, we require that you set up a - two-factor authentication app before a U2F device. That way you'll always be able to - log in - even when you're using an unsupported browser. + = _("As U2F devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser.") .col-lg-8 - if @u2f_registration.errors.present? = form_errors(@u2f_registration) @@ -74,7 +70,8 @@ %hr - %h5 U2F Devices (#{@u2f_registrations.length}) + %h5 + = _('U2F Devices (%{length})') % { length: @u2f_registrations.length } - if @u2f_registrations.present? .table-responsive @@ -85,16 +82,16 @@ %col{ width: "20%" } %thead %tr - %th Name - %th Registered On + %th= _('Name') + %th= s_('2FADevice|Registered On') %th %tbody - @u2f_registrations.each do |registration| %tr - %td= registration.name.presence || "<no name set>" + %td= registration.name.presence || _("<no name set>") %td= registration.created_at.to_date.to_s(:medium) - %td= link_to "Delete", profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger float-right", data: { confirm: "Are you sure you want to delete this device? This action cannot be undone." } + %td= link_to _('Delete'), profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger float-right", data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.') } - else .settings-message.text-center - You don't have any U2F devices registered yet. + = _("You don't have any U2F devices registered yet.") diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 0edd8ee5e46..2b0c3985755 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -4,7 +4,6 @@ - project = local_assigns.fetch(:project) { @project } - content_url = local_assigns.fetch(:content_url) { @tree.readme ? project_blob_path(@project, tree_join(@ref, @tree.readme.path)) : project_tree_path(@project, @ref) } - show_auto_devops_callout = show_auto_devops_callout?(@project) -- vue_file_list = Feature.enabled?(:vue_file_list, @project) #tree-holder.tree-holder.clearfix .nav-block @@ -14,11 +13,11 @@ = render 'shared/commit_well', commit: commit, ref: ref, project: project - if is_project_overview - .project-buttons.append-bottom-default{ class: ("js-hide-on-navigation" if vue_file_list) } + .project-buttons.append-bottom-default{ class: ("js-keep-hidden-on-navigation" if vue_file_list_enabled?) } = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout) - - if vue_file_list - #js-tree-list{ data: { project_path: @project.full_path, ref: ref } } + - if vue_file_list_enabled? + #js-tree-list{ data: { project_path: @project.full_path, project_short_path: @project.path, ref: ref, full_name: @project.name_with_namespace } } - if @tree.readme = render "projects/tree/readme", readme: @tree.readme - else diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index a97322dace4..9f5241344a7 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -1,7 +1,7 @@ - empty_repo = @project.empty_repo? - show_auto_devops_callout = show_auto_devops_callout?(@project) - max_project_topic_length = 15 -.project-home-panel{ class: [("empty-project" if empty_repo), ("js-hide-on-navigation" if Feature.enabled?(:vue_file_list, @project))] } +.project-home-panel{ class: [("empty-project" if empty_repo), ("js-keep-hidden-on-navigation" if vue_file_list_enabled?)] } .row.append-bottom-8 .home-panel-title-row.col-md-12.col-lg-6.d-flex .avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml index 9c854369c93..b5678b56ca6 100644 --- a/app/views/projects/_import_project_pane.html.haml +++ b/app/views/projects/_import_project_pane.html.haml @@ -63,6 +63,13 @@ = link_to new_import_manifest_path, class: 'btn import_manifest', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "manifest_file" } do = icon('file-text-o', text: 'Manifest file') + - if phabricator_import_enabled? + %div + = link_to new_import_phabricator_path, class: 'btn import_phabricator', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "phabricator" } do + = custom_icon('issues') + = _("Phabricator Tasks") + + .js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') } = form_for @project, html: { class: 'new_project' } do |f| %hr diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml index 88fa31a73b0..7ed71a7d43c 100644 --- a/app/views/projects/blob/_header_content.html.haml +++ b/app/views/projects/blob/_header_content.html.haml @@ -6,7 +6,7 @@ = copy_file_path_button(blob.path) - %small + %small.mr-1 = number_to_human_size(blob.raw_size) - if blob.stored_externally? && blob.external_storage == :lfs diff --git a/app/views/projects/buttons/_download_links.html.haml b/app/views/projects/buttons/_download_links.html.haml index 7f2cd8e109e..d344167a6c5 100644 --- a/app/views/projects/buttons/_download_links.html.haml +++ b/app/views/projects/buttons/_download_links.html.haml @@ -1,5 +1,5 @@ - formats = [['zip', 'btn-primary'], ['tar.gz'], ['tar.bz2'], ['tar']] -.d-flex.justify-content-between +.btn-group.ml-0.w-100 - formats.each do |(fmt, extra_class)| = link_to fmt, project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: fmt), rel: 'nofollow', download: '', class: "btn btn-xs #{extra_class}" diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index f4560404c03..bdf7b933ab8 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -53,9 +53,10 @@ %span.badge.badge-info= _('manual') - if pipeline_link - %td - = link_to pipeline_path(pipeline) do + %td.pipeline-link + = link_to pipeline_path(pipeline), class: 'has-tooltip', title: _('Pipeline ID (IID)') do %span.pipeline-id ##{pipeline.id} + %span.pipeline-iid (##{pipeline.iid}) %span by - if pipeline.user = user_avatar(user: pipeline.user, size: 20) diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index a0db48bf8ff..ef2777e6601 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -81,7 +81,7 @@ = link_to project_pipeline_path(@project, last_pipeline.id), class: "ci-status-icon-#{last_pipeline.status}" do = ci_icon_for_status(last_pipeline.status) #{ _('Pipeline') } - = link_to "##{last_pipeline.id}", project_pipeline_path(@project, last_pipeline.id) + = link_to "##{last_pipeline.id} (##{last_pipeline.iid})", project_pipeline_path(@project, last_pipeline.id), class: "has-tooltip", title: _('Pipeline ID (IID)') = ci_label_for_status(last_pipeline.status) - if last_pipeline.stages_count.nonzero? #{ n_(s_('Pipeline|with stage'), s_('Pipeline|with stages'), last_pipeline.stages_count) } diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index e2d078855d9..87b9920e8b4 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -9,10 +9,13 @@ - commit_status = commit.present(current_user: current_user).status_for(ref) - link = commit_path(project, commit, merge_request: merge_request) + +- show_project_name = local_assigns.fetch(:show_project_name, false) + %li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" } .avatar-cell.d-none.d-sm-block - = author_avatar(commit, size: 36, has_tooltip: false) + = author_avatar(commit, size: 40, has_tooltip: false) .commit-detail.flex-list .commit-content.qa-commit-content @@ -32,6 +35,7 @@ - commit_timeago = time_ago_with_tooltip(commit.authored_date, placement: 'bottom') - commit_text = _('%{commit_author_link} authored %{commit_timeago}') % { commit_author_link: commit_author_link, commit_timeago: commit_timeago } #{ commit_text.html_safe } + = render_if_exists 'projects/commits/project_namespace', show_project_name: show_project_name, project: project - if commit.description? %pre.commit-row-description.js-toggle-content.append-bottom-8 diff --git a/app/views/projects/jobs/_table.html.haml b/app/views/projects/jobs/_table.html.haml index d124d3ebfc1..b08223546f7 100644 --- a/app/views/projects/jobs/_table.html.haml +++ b/app/views/projects/jobs/_table.html.haml @@ -16,7 +16,7 @@ %th Runner %th Stage %th Name - %th + %th Timing %th Coverage %th diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index 78b416edd5c..1cee8be604a 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -59,7 +59,7 @@ = render_if_exists 'shared/milestones/burndown', milestone: @milestone, project: @project - - if can?(current_user, :read_issue, @project) && @milestone.total_items_count(current_user).zero? + - if can?(current_user, :read_issue, @project) && @milestone.total_issues_count(current_user).zero? .alert.alert-success.prepend-top-default %span= _('Assign some issues to this milestone.') - elsif @milestone.complete?(current_user) && @milestone.active? diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index eb6838cec8d..044adb75bea 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -41,9 +41,9 @@ .note-actions-item = button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji} has-tooltip btn btn-transparent", data: { position: 'right', container: 'body' } do = icon('spinner spin') - %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') - %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') - %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') + %span{ class: 'link-highlight award-control-icon-neutral' }= sprite_icon('slight-smile') + %span{ class: 'link-highlight award-control-icon-positive' }= sprite_icon('smiley') + %span{ class: 'link-highlight award-control-icon-super-positive' }= sprite_icon('smile') - if note_editable .note-actions-item diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml index b7b46c56c37..1e50a101c1e 100644 --- a/app/views/projects/pages_domains/_form.html.haml +++ b/app/views/projects/pages_domains/_form.html.haml @@ -5,22 +5,22 @@ %p= msg .form-group.row - = f.label :domain, class: 'col-form-label col-sm-2' do - = _("Domain") + .col-sm-2.col-form-label + = f.label :domain, _("Domain") .col-sm-10 = f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control', disabled: @domain.persisted? - if Gitlab.config.pages.external_https .form-group.row - = f.label :certificate, class: 'col-form-label col-sm-2' do - = _("Certificate (PEM)") + .col-sm-2.col-form-label + = f.label :certificate, _("Certificate (PEM)") .col-sm-10 = f.text_area :certificate, rows: 5, class: 'form-control' %span.help-inline= _("Upload a certificate for your domain with all intermediates") .form-group.row - = f.label :key, class: 'col-form-label col-sm-2' do - = _("Key (PEM)") + .col-sm-2.col-form-label + = f.label :key, _("Key (PEM)") .col-sm-10 = f.text_area :key, rows: 5, class: 'form-control' %span.help-inline= _("Upload a private key for your certificate") diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 8373903443e..cc98ba64f08 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -1,29 +1,35 @@ - page_title _("Members") +- can_admin_project_members = can?(current_user, :admin_project_member, @project) .row.prepend-top-default .col-lg-12 - %h4 - = _("Project members") - - if can?(current_user, :admin_project_member, @project) - %p - = _("You can invite a new member to <strong>%{project_name}</strong> or invite another group.").html_safe % { project_name: sanitize(@project.name, tags: []) } - - else - %p - = _("Members can be added by project <i>Maintainers</i> or <i>Owners</i>").html_safe + - if project_can_be_shared? + %h4 + = _("Project members") + - if can_admin_project_members + %p= share_project_description(@project) + - else + %p + = _("Members can be added by project <i>Maintainers</i> or <i>Owners</i>").html_safe + .light - - if can?(current_user, :admin_project_member, @project) - %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } - %li.nav-tab{ role: 'presentation' } - %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member") - - if @project.allowed_to_share_with_group? + - if can_admin_project_members && project_can_be_shared? + - if !membership_locked? && @project.allowed_to_share_with_group? + %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } %li.nav-tab{ role: 'presentation' } + %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member") + %li.nav-tab{ role: 'presentation', class: ('active' if membership_locked?) } %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite group") - .tab-content.gitlab-tab-content - .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } - = render 'projects/project_members/new_project_member', tab_title: _('Invite member') - .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' } - = render 'projects/project_members/new_project_group', tab_title: _('Invite group') + .tab-content.gitlab-tab-content + .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } + = render 'projects/project_members/new_project_member', tab_title: _('Invite member') + .tab-pane{ id: 'invite-group-pane', role: 'tabpanel', class: ('active' if membership_locked?) } + = render 'projects/project_members/new_project_group', tab_title: _('Invite group') + - elsif !membership_locked? + .invite-member= render 'projects/project_members/new_project_member', tab_title: _('Invite member') + - elsif @project.allowed_to_share_with_group? + .invite-group= render 'projects/project_members/new_project_group', tab_title: _('Invite group') = render 'shared/members/requests', membership_source: @project, requesters: @requesters .clearfix diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml index d617d85afc2..3644a623d2c 100644 --- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml @@ -6,8 +6,8 @@ .card-body = form_errors(@protected_branch) .form-group.row - = f.label :name, class: 'col-md-2 text-right' do - Branch: + .col-md-2.text-right + = f.label :name, 'Branch:' .col-md-10 = render partial: "projects/protected_branches/shared/dropdown", locals: { f: f } .form-text.text-muted diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml index cbf1938664c..020e6e187a6 100644 --- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml @@ -6,8 +6,8 @@ .card-body = form_errors(@protected_tag) .form-group.row - = f.label :name, class: 'col-md-2 text-right' do - Tag: + .col-md-2.text-right + = f.label :name, 'Tag:' .col-md-10.protected-tags-dropdown = render partial: "projects/protected_tags/shared/dropdown", locals: { f: f } .form-text.text-muted diff --git a/app/views/projects/registry/repositories/_tag.html.haml b/app/views/projects/registry/repositories/_tag.html.haml index a4cde53e8c6..9594c9184a2 100644 --- a/app/views/projects/registry/repositories/_tag.html.haml +++ b/app/views/projects/registry/repositories/_tag.html.haml @@ -1,7 +1,7 @@ %tr.tag %td = escape_once(tag.name) - = clipboard_button(text: "docker pull #{tag.location}") + = clipboard_button(text: "#{tag.location}") %td - if tag.revision %span.has-tooltip{ title: "#{tag.revision}" } diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml index 9409418bbcc..82c1d57c97e 100644 --- a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml @@ -18,22 +18,22 @@ .help-form .form-group - = label_tag :display_name, 'Display name', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :display_name, 'Display name', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :display_name, "GitLab / #{@project.full_name}", class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#display_name', class: 'input-group-text') .form-group - = label_tag :description, 'Description', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :description, 'Description', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#description', class: 'input-group-text') .form-group - = label_tag nil, 'Command trigger word', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block + = label_tag nil, 'Command trigger word', class: 'col-12 col-form-label label-bold' + .col-12 %p Fill in the word that works best for your team. %p Suggestions: @@ -42,44 +42,44 @@ %code= @project.full_path .form-group - = label_tag :request_url, 'Request URL', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :request_url, 'Request URL', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :request_url, service_trigger_url(subject), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#request_url', class: 'input-group-text') .form-group - = label_tag nil, 'Request method', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block POST + = label_tag nil, 'Request method', class: 'col-12 col-form-label label-bold' + .col-12 POST .form-group - = label_tag :response_username, 'Response username', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :response_username, 'Response username', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :response_username, 'GitLab', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#response_username', class: 'input-group-text') .form-group - = label_tag :response_icon, 'Response icon', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :response_icon, 'Response icon', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :response_icon, asset_url('gitlab_logo.png'), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#response_icon', class: 'input-group-text') .form-group - = label_tag nil, 'Autocomplete', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block Yes + = label_tag nil, 'Autocomplete', class: 'col-12 col-form-label label-bold' + .col-12 Yes .form-group - = label_tag :autocomplete_hint, 'Autocomplete hint', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :autocomplete_hint, 'Autocomplete hint', class: 'col-12 col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :autocomplete_hint, '[help]', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#autocomplete_hint', class: 'input-group-text') .form-group - = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#autocomplete_description', class: 'input-group-text') diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml index 9a7004f89c0..9b7732abc62 100644 --- a/app/views/projects/services/slack_slash_commands/_help.html.haml +++ b/app/views/projects/services/slack_slash_commands/_help.html.haml @@ -27,8 +27,8 @@ .help-form .form-group - = label_tag nil, 'Command', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block + = label_tag nil, 'Command', class: 'col-12 col-form-label label-bold' + .col-12 %p Fill in the word that works best for your team. %p Suggestions: @@ -37,50 +37,50 @@ %code= @project.full_path .form-group - = label_tag :url, 'URL', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :url, 'URL', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :url, service_trigger_url(subject), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#url', class: 'input-group-text') .form-group - = label_tag nil, 'Method', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block POST + = label_tag nil, 'Method', class: 'col-12 col-form-label label-bold' + .col-12 POST .form-group - = label_tag :customize_name, 'Customize name', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :customize_name, 'Customize name', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :customize_name, 'GitLab', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#customize_name', class: 'input-group-text') .form-group - = label_tag nil, 'Customize icon', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block - = image_tag(asset_url('slash-command-logo.png'), width: 36, height: 36) + = label_tag nil, 'Customize icon', class: 'col-12 col-form-label label-bold' + .col-12 + = image_tag(asset_url('slash-command-logo.png'), width: 36, height: 36, class: 'mr-3') = link_to('Download image', asset_url('gitlab_logo.png'), class: 'btn btn-sm', target: '_blank', rel: 'noopener noreferrer') .form-group - = label_tag nil, 'Autocomplete', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block Show this command in the autocomplete list + = label_tag nil, 'Autocomplete', class: 'col-12 col-form-label label-bold' + .col-12 Show this command in the autocomplete list .form-group - = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#autocomplete_description', class: 'input-group-text') .form-group - = label_tag :autocomplete_usage_hint, 'Autocomplete usage hint', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :autocomplete_usage_hint, 'Autocomplete usage hint', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :autocomplete_usage_hint, '[help]', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#autocomplete_usage_hint', class: 'input-group-text') .form-group - = label_tag :descriptive_label, 'Descriptive label', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :descriptive_label, 'Descriptive label', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :descriptive_label, 'Perform common operations on GitLab project', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#descriptive_label', class: 'input-group-text') diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml index 451a79becc3..583fc08f375 100644 --- a/app/views/projects/settings/operations/_error_tracking.html.haml +++ b/app/views/projects/settings/operations/_error_tracking.html.haml @@ -2,10 +2,12 @@ - setting = error_tracking_setting -%section.settings.expanded.no-animate +%section.settings.no-animate.js-error-tracking-settings .settings-header %h4 = _('Error Tracking') + %button.btn.js-settings-toggle{ type: 'button' } + = _('Expand') %p = _('To link Sentry to GitLab, enter your Sentry URL and Auth Token.') = link_to _('More information'), help_page_path('user/project/operations/error_tracking'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/settings/operations/_external_dashboard.html.haml b/app/views/projects/settings/operations/_external_dashboard.html.haml index 2fbb9195a04..a124283921d 100644 --- a/app/views/projects/settings/operations/_external_dashboard.html.haml +++ b/app/views/projects/settings/operations/_external_dashboard.html.haml @@ -1,2 +1,3 @@ -.js-operation-settings{ data: { external_dashboard: { path: '', +.js-operation-settings{ data: { operations_settings_endpoint: project_settings_operations_path(@project), + external_dashboard: { url: metrics_external_dashboard_url, help_page_path: help_page_path('user/project/operations/link_to_external_dashboard') } } } diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml index edc2c58a8ed..0a7a155bc12 100644 --- a/app/views/projects/settings/operations/show.html.haml +++ b/app/views/projects/settings/operations/show.html.haml @@ -3,6 +3,6 @@ - breadcrumb_title _('Operations Settings') = render_if_exists 'projects/settings/operations/incidents' -= render 'projects/settings/operations/error_tracking', expanded: true += render 'projects/settings/operations/error_tracking' = render 'projects/settings/operations/external_dashboard' = render_if_exists 'projects/settings/operations/tracing' diff --git a/app/views/projects/settings/repository/_protected_branches.html.haml b/app/views/projects/settings/repository/_protected_branches.html.haml new file mode 100644 index 00000000000..31630828571 --- /dev/null +++ b/app/views/projects/settings/repository/_protected_branches.html.haml @@ -0,0 +1,2 @@ += render "projects/protected_branches/index" += render "projects/protected_tags/index" diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index cb3a035c49e..ff30cc4f6db 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -3,14 +3,17 @@ - @content_class = "limit-container-width" unless fluid_layout = render "projects/default_branch/show" += render_if_exists "projects/push_rules/index" = render "projects/mirrors/mirror_repos" -# Protected branches & tags use a lot of nested partials. -# The shared parts of the views can be found in the `shared` directory. -# Those are used throughout the actual views. These `shared` views are then -# reused in EE. -= render "projects/protected_branches/index" -= render "projects/protected_tags/index" += render "projects/settings/repository/protected_branches" + = render @deploy_keys = render "projects/deploy_tokens/index" = render "projects/cleanup/show" + += render_if_exists 'shared/promotions/promote_repository_features' diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml index e935af23659..4f6c7e1f9a6 100644 --- a/app/views/projects/tree/_readme.html.haml +++ b/app/views/projects/tree/_readme.html.haml @@ -1,5 +1,5 @@ - if readme.rich_viewer - %article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout), ("js-hide-on-navigation" if Feature.enabled?(:vue_file_list, @project))] } + %article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout), ("js-hide-on-navigation" if vue_file_list_enabled?)] } .js-file-title.file-title = blob_icon readme.mode, readme.name = link_to project_blob_path(@project, tree_join(@ref, readme.path)) do diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index ec8e5234bd4..ea6349f2f57 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -6,71 +6,74 @@ = render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true - if on_top_of_branch? - - addtotree_toggle_attributes = { href: '#', 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown', 'data-boundary': 'window' } + - addtotree_toggle_attributes = { 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown', 'data-boundary': 'window' } - else - addtotree_toggle_attributes = { title: _("You can only add files when you are on a branch"), data: { container: 'body' }, class: 'disabled has-tooltip' } - %ul.breadcrumb.repo-breadcrumb - %li.breadcrumb-item - = link_to project_tree_path(@project, @ref) do - = @project.path - - path_breadcrumbs do |title, path| + - if vue_file_list_enabled? + #js-repo-breadcrumb + - else + %ul.breadcrumb.repo-breadcrumb %li.breadcrumb-item - = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path)) + = link_to project_tree_path(@project, @ref) do + = @project.path + - path_breadcrumbs do |title, path| + %li.breadcrumb-item + = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path)) - - if can_collaborate || can_create_mr_from_fork - %li.breadcrumb-item - %a.btn.add-to-tree.qa-add-to-tree{ addtotree_toggle_attributes } - = sprite_icon('plus', size: 16, css_class: 'float-left') - = sprite_icon('arrow-down', size: 16, css_class: 'float-left') - - if on_top_of_branch? - .add-to-tree-dropdown - %ul.dropdown-menu - - if can_edit_tree? - %li.dropdown-header - #{ _('This directory') } - %li - = link_to project_new_blob_path(@project, @id), class: 'qa-new-file-option' do - #{ _('New file') } - %li - = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do - #{ _('Upload file') } - %li - = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do - #{ _('New directory') } - - elsif can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project) - %li - - continue_params = { to: project_new_blob_path(@project, @id), - notice: edit_in_new_fork_notice, - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) - = link_to fork_path, method: :post do - #{ _('New file') } - %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to upload a file again.", - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) - = link_to fork_path, method: :post do - #{ _('Upload file') } - %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to create a new directory again.", - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) - = link_to fork_path, method: :post do - #{ _('New directory') } + - if can_collaborate || can_create_mr_from_fork + %li.breadcrumb-item + %button.btn.add-to-tree.qa-add-to-tree{ addtotree_toggle_attributes, type: 'button' } + = sprite_icon('plus', size: 16, css_class: 'float-left') + = sprite_icon('arrow-down', size: 16, css_class: 'float-left') + - if on_top_of_branch? + .add-to-tree-dropdown + %ul.dropdown-menu + - if can_edit_tree? + %li.dropdown-header + #{ _('This directory') } + %li + = link_to project_new_blob_path(@project, @id), class: 'qa-new-file-option' do + #{ _('New file') } + %li + = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do + #{ _('Upload file') } + %li + = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do + #{ _('New directory') } + - elsif can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project) + %li + - continue_params = { to: project_new_blob_path(@project, @id), + notice: edit_in_new_fork_notice, + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) + = link_to fork_path, method: :post do + #{ _('New file') } + %li + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to upload a file again.", + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) + = link_to fork_path, method: :post do + #{ _('Upload file') } + %li + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to create a new directory again.", + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) + = link_to fork_path, method: :post do + #{ _('New directory') } - - if can?(current_user, :push_code, @project) - %li.divider - %li.dropdown-header - #{ _('This repository') } - %li - = link_to new_project_branch_path(@project) do - #{ _('New branch') } - %li - = link_to new_project_tag_path(@project) do - #{ _('New tag') } + - if can?(current_user, :push_code, @project) + %li.divider + %li.dropdown-header + #{ _('This repository') } + %li + = link_to new_project_branch_path(@project) do + #{ _('New branch') } + %li + = link_to new_project_tag_path(@project) do + #{ _('New tag') } .tree-controls = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' diff --git a/app/views/search/_form.html.haml b/app/views/search/_form.html.haml index 4af0c6bf84a..db0dcc8adfb 100644 --- a/app/views/search/_form.html.haml +++ b/app/views/search/_form.html.haml @@ -13,3 +13,4 @@ - unless params[:snippets].eql? 'true' = render 'filter' = button_tag _("Search"), class: "btn btn-success btn-search" + = render_if_exists 'search/form_elasticsearch' diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 8ae2807729b..cb8a8a24be8 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -1,5 +1,6 @@ - if @search_objects.to_a.empty? = render partial: "search/results/empty" + = render_if_exists 'shared/promotions/promote_advanced_search' - else .row-content-block - unless @search_objects.is_a?(Kaminari::PaginatableWithoutCount) @@ -11,7 +12,7 @@ - elsif @group - link_to_group = link_to(@group.name, @group) = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } - + = render_if_exists 'shared/promotions/promote_advanced_search' .results.prepend-top-10 - if @scope == 'commits' %ul.content-list.commit-list diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml index ca392e1adfc..22fcfcda297 100644 --- a/app/views/sent_notifications/unsubscribe.html.haml +++ b/app/views/sent_notifications/unsubscribe.html.haml @@ -1,6 +1,6 @@ - noteable = @sent_notification.noteable - noteable_type = @sent_notification.noteable_type.titleize.downcase -- noteable_text = %(#{noteable.title} (#{noteable.to_reference})) +- noteable_text = show_unsubscribe_title?(noteable) ? %(#{noteable.title} (#{noteable.to_reference})) : %(#{noteable.to_reference}) - page_title _("Unsubscribe"), noteable_text, noteable_type.pluralize, @sent_notification.project.full_name %h3.page-title diff --git a/app/views/shared/_delete_label_modal.html.haml b/app/views/shared/_delete_label_modal.html.haml index 6bd8cadd7d9..f37dd2cdf02 100644 --- a/app/views/shared/_delete_label_modal.html.haml +++ b/app/views/shared/_delete_label_modal.html.haml @@ -9,7 +9,7 @@ .modal-body %p %strong= label.name - %span will be permanently deleted from #{label.subject.name}. This cannot be undone. + %span will be permanently deleted from #{label.subject_name}. This cannot be undone. .modal-footer %a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' } Cancel diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml index 7b593ca4f76..d0f9374e832 100644 --- a/app/views/shared/_import_form.html.haml +++ b/app/views/shared/_import_form.html.haml @@ -1,11 +1,26 @@ - ci_cd_only = local_assigns.fetch(:ci_cd_only, false) +- import_url = Gitlab::UrlSanitizer.new(f.object.import_url) -.form-group.import-url-data - = f.label :import_url, class: 'label-bold' do - %span - = _('Git repository URL') +.import-url-data + .form-group + = f.label :import_url, class: 'label-bold' do + %span + = _('Git repository URL') + = f.text_field :import_url, value: import_url.sanitized_url, + autocomplete: 'off', class: 'form-control', placeholder: 'https://gitlab.company.com/group/project.git', required: true - = f.text_field :import_url, autocomplete: 'off', class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git', required: true + .row + .form-group.col-md-6 + = f.label :import_url_user, class: 'label-bold' do + %span + = _('Username (optional)') + = f.text_field :import_url_user, value: import_url.user, class: 'form-control', required: false, autocomplete: 'new-password' + + .form-group.col-md-6 + = f.label :import_url_password, class: 'label-bold' do + %span + = _('Password (optional)') + = f.password_field :import_url_password, class: 'form-control', required: false, autocomplete: 'new-password' .info-well.prepend-top-20 .well-segment @@ -13,8 +28,11 @@ %li = _('The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>.').html_safe %li - = _('If your HTTP repository is not publicly accessible, add authentication information to the URL: <code>https://username:password@gitlab.company.com/group/project.git</code>.').html_safe + = _('If your HTTP repository is not publicly accessible, add your credentials.') %li = import_will_timeout_message(ci_cd_only) %li = import_svn_message(ci_cd_only) + = render_if_exists 'shared/ci_cd_only_link', ci_cd_only: ci_cd_only + += render_if_exists 'shared/ee/import_form', f: f, ci_cd_only: ci_cd_only diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index 2b4a24a001f..c4b7ef481fd 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -30,7 +30,7 @@ = sprite_icon('ellipsis_v') .dropdown-menu.dropdown-open-left %ul - - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group) + - if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group) %li %button.js-promote-project-label-button.btn.btn-transparent.btn-action{ disabled: true, type: 'button', data: { url: promote_project_label_path(label.project, label), diff --git a/app/views/shared/_old_visibility_level.html.haml b/app/views/shared/_old_visibility_level.html.haml index fd576e4fbea..e8f3d888cce 100644 --- a/app/views/shared/_old_visibility_level.html.haml +++ b/app/views/shared/_old_visibility_level.html.haml @@ -1,6 +1,6 @@ .form-group.row .col-sm-2.col-form-label = _('Visibility level') - = link_to icon('question-circle'), help_page_path("public_access/public_access") + = link_to icon('question-circle'), help_page_path("public_access/public_access"), target: '_blank' .col-sm-10 = render 'shared/visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_visibility_level, form_model: form_model, with_label: with_label diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml index 311dc69d213..c50826a7cda 100644 --- a/app/views/shared/boards/components/sidebar/_labels.html.haml +++ b/app/views/shared/boards/components/sidebar/_labels.html.haml @@ -32,7 +32,7 @@ %span.dropdown-toggle-text {{ labelDropdownTitle }} = icon('chevron-down') - .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable + .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height = render partial: "shared/issuable/label_page_default" - if can?(current_user, :admin_label, current_board_parent) = render partial: "shared/issuable/label_page_create", locals: { show_add_list: true } diff --git a/app/views/shared/icons/_emoji_slightly_smiling_face.svg b/app/views/shared/icons/_emoji_slightly_smiling_face.svg deleted file mode 100644 index 56dbad91554..00000000000 --- a/app/views/shared/icons/_emoji_slightly_smiling_face.svg +++ /dev/null @@ -1 +0,0 @@ -<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369.721.721 0 0 1 .568.047.715.715 0 0 1 .37.445c.195.625.556 1.131 1.084 1.518A2.93 2.93 0 0 0 9 12.75a2.93 2.93 0 0 0 1.775-.58 2.913 2.913 0 0 0 1.084-1.518.711.711 0 0 1 .375-.445.737.737 0 0 1 .575-.047c.195.063.34.186.433.37.094.183.11.372.047.568zM7.5 6c0 .414-.146.768-.44 1.06-.292.294-.646.44-1.06.44-.414 0-.768-.146-1.06-.44A1.445 1.445 0 0 1 4.5 6c0-.414.146-.768.44-1.06.292-.294.646-.44 1.06-.44.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm6 0c0 .414-.146.768-.44 1.06-.292.294-.646.44-1.06.44-.414 0-.768-.146-1.06-.44A1.445 1.445 0 0 1 10.5 6c0-.414.146-.768.44-1.06.292-.294.646-.44 1.06-.44.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm3 3a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></svg> diff --git a/app/views/shared/icons/_emoji_smile.svg b/app/views/shared/icons/_emoji_smile.svg deleted file mode 100644 index ce645fee46f..00000000000 --- a/app/views/shared/icons/_emoji_smile.svg +++ /dev/null @@ -1 +0,0 @@ -<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369c.195-.062 7.41-.062 7.606 0 .195.063.34.186.433.37.094.183.11.372.047.568zM14 6.37c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm-6.5 0c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm9 2.63a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></svg> diff --git a/app/views/shared/icons/_emoji_smiley.svg b/app/views/shared/icons/_emoji_smiley.svg deleted file mode 100644 index ddfae50e566..00000000000 --- a/app/views/shared/icons/_emoji_smiley.svg +++ /dev/null @@ -1 +0,0 @@ -<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369c.195-.062 7.41-.062 7.606 0 .195.063.34.186.433.37.094.183.11.372.047.568h.001zM7.5 6c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 6 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 4.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 6 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm6 0c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 12 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 10.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 12 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm3 3a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6c.92.397 1.91.6 2.912.598a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39c.397-.92.6-1.91.598-2.912zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="nonzero"/></svg> diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml index f2c0c77a583..483652852b6 100644 --- a/app/views/shared/issuable/_label_dropdown.html.haml +++ b/app/views/shared/issuable/_label_dropdown.html.haml @@ -25,7 +25,7 @@ %span.dropdown-toggle-text{ class: ("is-default" if apply_is_default_styles) } = multi_label_name(selected, label_name) = icon('chevron-down') - .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable + .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height = render partial: "shared/issuable/label_page_default", locals: { title: dropdown_title, show_footer: show_footer, show_create: show_create } - if show_create && project && can?(current_user, :admin_label, project) = render partial: "shared/issuable/label_page_create" diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml index d173e3c0192..a0d3bc64f1f 100644 --- a/app/views/shared/issuable/_label_page_create.html.haml +++ b/app/views/shared/issuable/_label_page_create.html.haml @@ -9,9 +9,7 @@ .dropdown-labels-error.js-label-error %input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') } .suggest-colors.suggest-colors-dropdown - - suggested_colors.each do |color| - = link_to '#', style: "background-color: #{color}", data: { color: color } do -   + = render_suggested_colors .dropdown-label-color-input .dropdown-label-color-preview.js-dropdown-label-color-preview %input#new_label_color.default-dropdown-input{ type: "text", placeholder: _('Assign custom color like #FF0000') } diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 63557c882f4..3a5adb34ad1 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -118,7 +118,7 @@ %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) } = multi_label_name(selected_labels, "Labels") = icon('chevron-down', 'aria-hidden': 'true') - .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable + .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height = render partial: "shared/issuable/label_page_default" - if issuable_sidebar.dig(:current_user, :can_admin_label) = render partial: "shared/issuable/label_page_create" @@ -158,7 +158,7 @@ %button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } = _('Move issue') - .dropdown-menu.dropdown-menu-selectable + .dropdown-menu.dropdown-menu-selectable.dropdown-extended-height = dropdown_title(_('Move issue')) = dropdown_filter(_('Search project'), search_id: 'sidebar-move-issue-dropdown-search') = dropdown_content diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml index 967f31c8325..1dd97bc4ed1 100644 --- a/app/views/shared/issuable/_sort_dropdown.html.haml +++ b/app/views/shared/issuable/_sort_dropdown.html.haml @@ -10,12 +10,13 @@ = icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort %li - = sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority), sort_title) - = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date), sort_title) - = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated), sort_title) - = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone), sort_title) - = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date), sort_title) if viewing_issues - = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity), sort_title) - = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority), sort_title) + = sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority), sort_title) + = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date), sort_title) + = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated), sort_title) + = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone), sort_title) + = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date), sort_title) if viewing_issues + = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity), sort_title) + = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority), sort_title) + = sortable_item(sort_title_relative_position, page_filter_path(sort: sort_value_relative_position), sort_title) if viewing_issues && Feature.enabled?(:manual_sorting) = render_if_exists('shared/ee/issuable/sort_dropdown', viewing_issues: viewing_issues, sort_title: sort_title) = issuable_sort_direction_button(sort_value) diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml index 743ee1435e8..78ff225daad 100644 --- a/app/views/shared/labels/_form.html.haml +++ b/app/views/shared/labels/_form.html.haml @@ -2,17 +2,20 @@ = form_errors(@label) .form-group.row - = f.label :title, class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :title .col-sm-10 = f.text_field :title, class: "form-control js-label-title qa-label-title", required: true, autofocus: true = render_if_exists 'shared/labels/create_label_help_text' .form-group.row - = f.label :description, class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :description .col-sm-10 = f.text_field :description, class: "form-control js-quick-submit qa-label-description" .form-group.row - = f.label :color, "Background color", class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :color, "Background color" .col-sm-10 .input-group .input-group-prepend @@ -22,12 +25,7 @@ Choose any color. %br Or you can choose one of the suggested colors below - - .suggest-colors - - suggested_colors.each do |color| - = link_to '#', style: "background-color: #{color}", data: { color: color } do - - + = render_suggested_colors .form-actions - if @label.persisted? = f.submit 'Save changes', class: 'btn btn-success js-save-button' diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 2db1f67a793..afcb2b71472 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -4,8 +4,9 @@ - member = local_assigns.fetch(:member) - user = local_assigns.fetch(:user, member.user) - source = member.source +- override = member.try(:override) -%li.member{ class: dom_class(member), id: dom_id(member) } +%li.member{ class: [dom_class(member), ("is-overridden" if override)], id: dom_id(member) } %span.list-item-name - if user = image_tag avatar_icon_for_user(user, 40), class: "avatar s40", alt: '' @@ -54,6 +55,7 @@ - if show_roles - current_resource = @project || @group .controls.member-controls + = render_if_exists 'shared/members/ee/ldap_tag', can_override: member.can_override? - if show_controls && member.source == current_resource - if member.can_resend_invite? @@ -67,6 +69,7 @@ = f.hidden_field :access_level .member-form-control.dropdown.append-right-5 %button.dropdown-menu-toggle.js-member-permissions-dropdown{ type: "button", + disabled: member.can_override? && !override, data: { toggle: "dropdown", field_name: "#{f.object_name}[access_level]" } } %span.dropdown-toggle-text = member.human_access @@ -80,8 +83,13 @@ = link_to role, "javascript:void(0)", class: ("is-active" if member.access_level == role_id), data: { id: role_id, el_id: dom_id(member) } + = render_if_exists 'shared/members/ee/revert_ldap_group_sync_option', + group: @group, + member: member, + can_override: member.can_override? .prepend-left-5.clearable-input.member-form-control = f.text_field :expires_at, + disabled: member.can_override? && !override, class: 'form-control js-access-expiration-date js-member-update-control', placeholder: _('Expiration date'), id: "member_expires_at_#{member.id}", @@ -116,5 +124,8 @@ = _("Delete") - unless force_mobile_view = icon('trash', class: 'd-none d-sm-block') + = render_if_exists 'shared/members/ee/override_member_buttons', group: @group, member: member, user: user, action: :edit, can_override: member.can_override? - else %span.member-access-text= member.human_access + += render_if_exists 'shared/members/ee/override_member_buttons', group: @group, member: member, user: user, action: :confirm, can_override: member.can_override? diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml index 46f3f8428f1..fae7d6526e8 100644 --- a/app/views/shared/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml @@ -28,8 +28,9 @@ or %button.attach-new-file.markdown-selector{ type: 'button' }= _("attach a new file") - %button.markdown-selector.button-attach-file{ type: 'button', tabindex: '-1' } + %button.markdown-selector.button-attach-file.btn-link{ type: 'button', tabindex: '-1' } = icon('file-image-o', class: 'toolbar-button-icon') - = _("Attach a file") + %span.text-attach-file<> + = _("Attach a file") %button.btn.btn-default.btn-sm.hide.button-cancel-uploading-files{ type: 'button' }= _("Cancel") diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml index 2ece7b7f701..749aa258af6 100644 --- a/app/views/shared/notifications/_button.html.haml +++ b/app/views/shared/notifications/_button.html.haml @@ -1,24 +1,26 @@ - btn_class = local_assigns.fetch(:btn_class, nil) - if notification_setting - .js-notification-dropdown.notification-dropdown.home-panel-action-button.dropdown.inline + .js-notification-dropdown.notification-dropdown.mr-md-2.home-panel-action-button.dropdown.inline = form_for notification_setting, remote: true, html: { class: "inline notification-form" } do |f| = hidden_setting_source_input(notification_setting) = f.hidden_field :level, class: "notification_setting_level" .js-notification-toggle-btns %div{ class: ("btn-group" if notification_setting.custom?) } - if notification_setting.custom? - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting"), class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: _("Notification setting"), class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } = icon("bell", class: "js-notification-loading") = notification_title(notification_setting.level) %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } = icon('caret-down') .sr-only Toggle dropdown - else - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } - = icon("bell", class: "js-notification-loading") - = notification_title(notification_setting.level) - = icon("caret-down") + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting"), class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + .float-left + = icon("bell", class: "js-notification-loading") + = notification_title(notification_setting.level) + .float-right + = icon("caret-down") = render "shared/notifications/notification_dropdown", notification_setting: notification_setting diff --git a/app/views/shared/notifications/_new_button.html.haml b/app/views/shared/notifications/_new_button.html.haml index af8ab992f0e..052e6da5bae 100644 --- a/app/views/shared/notifications/_new_button.html.haml +++ b/app/views/shared/notifications/_new_button.html.haml @@ -16,7 +16,7 @@ = sprite_icon("arrow-down", css_class: "icon mr-0") .sr-only Toggle dropdown - else - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting - #{notification_title(notification_setting.level)}", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", placement: 'top', toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", placement: 'top', toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } = notification_setting_icon(notification_setting) %span.js-notification-loading.fa.hidden = sprite_icon("arrow-down", css_class: "icon") diff --git a/app/views/shared/notifications/_notification_dropdown.html.haml b/app/views/shared/notifications/_notification_dropdown.html.haml index 85ad74f9a39..a6ef2d51171 100644 --- a/app/views/shared/notifications/_notification_dropdown.html.haml +++ b/app/views/shared/notifications/_notification_dropdown.html.haml @@ -8,5 +8,5 @@ %li.divider %li %a.update-notification{ href: "#", role: "button", class: ("is-active" if notification_setting.custom?), data: { toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), notification_level: "custom", notification_title: "Custom" } } - %strong.dropdown-menu-inner-title Custom + %strong.dropdown-menu-inner-title= s_('NotificationSetting|Custom') %span.dropdown-menu-inner-content= notification_description("custom") diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index 6f2ddc5bdba..2d2382e469a 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -7,7 +7,8 @@ = form_errors(@snippet) .form-group.row - = f.label :title, class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :title .col-sm-10 = f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true @@ -17,7 +18,8 @@ .file-editor .form-group.row - = f.label :file_name, "File", class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :file_name, "File" .col-sm-10 .file-holder.snippet .js-file-title.file-title diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml index ef8664e6f47..9952f373156 100644 --- a/app/views/snippets/_actions.html.haml +++ b/app/views/snippets/_actions.html.haml @@ -7,7 +7,7 @@ - if can?(current_user, :admin_personal_snippet, @snippet) = link_to snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-inverted btn-remove", title: _('Delete Snippet') do = _("Delete") - = link_to new_snippet_path, class: "btn btn-grouped btn-inverted btn-create", title: _("New snippet") do + = link_to new_snippet_path, class: "btn btn-grouped btn-success btn-inverted", title: _("New snippet") do = _("New snippet") - if @snippet.submittable_as_spam_by?(current_user) = link_to _('Submit as spam'), mark_as_spam_snippet_path(@snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam') diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml index 01b95145937..6e20890a47f 100644 --- a/app/views/snippets/notes/_actions.html.haml +++ b/app/views/snippets/notes/_actions.html.haml @@ -3,9 +3,9 @@ .note-actions-item = link_to '#', title: _('Add reaction'), class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip", data: { position: 'right' } do = icon('spinner spin') - %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') - %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') - %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') + %span{ class: 'link-highlight award-control-icon-neutral' }= sprite_icon('slight-smile') + %span{ class: 'link-highlight award-control-icon-positive' }= sprite_icon('smiley') + %span{ class: 'link-highlight award-control-icon-super-positive' }= sprite_icon('smile') - if note_editable .note-actions-item diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index e4e85de93da..fd0cc5fb24e 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1,6 +1,8 @@ --- - auto_devops:auto_devops_disable +- auto_merge:auto_merge_process + - cronjob:admin_email - cronjob:expire_build_artifacts - cronjob:gitlab_usage_ping diff --git a/app/workers/auto_merge_process_worker.rb b/app/workers/auto_merge_process_worker.rb new file mode 100644 index 00000000000..cd81cdbc60c --- /dev/null +++ b/app/workers/auto_merge_process_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AutoMergeProcessWorker + include ApplicationWorker + + queue_namespace :auto_merge + + def perform(merge_request_id) + MergeRequest.find_by_id(merge_request_id).try do |merge_request| + AutoMergeService.new(merge_request.project, merge_request.merge_user) + .process(merge_request) + end + end +end diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb index d4a6f53dae5..489d6215774 100644 --- a/app/workers/git_garbage_collect_worker.rb +++ b/app/workers/git_garbage_collect_worker.rb @@ -23,7 +23,9 @@ class GitGarbageCollectWorker end task = task.to_sym - project.link_pool_repository + + ::Projects::GitDeduplicationService.new(project).execute + gitaly_call(task, project.repository.raw_repository) # Refresh the branch cache in case garbage collection caused a ref lookup to fail diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb index 8a9ee7808e4..9410fd1a786 100644 --- a/app/workers/pipeline_schedule_worker.rb +++ b/app/workers/pipeline_schedule_worker.rb @@ -3,47 +3,12 @@ class PipelineScheduleWorker include ApplicationWorker include CronjobQueue - include ::Gitlab::ExclusiveLeaseHelpers - EXCLUSIVE_LOCK_KEY = 'pipeline_schedules:run:lock' - LOCK_TIMEOUT = 50.minutes - - # rubocop: disable CodeReuse/ActiveRecord def perform - in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do - Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now) - .preload(:owner, :project).find_each do |schedule| - - schedule.schedule_next_run! - - Ci::CreatePipelineService.new(schedule.project, - schedule.owner, - ref: schedule.ref) - .execute!(:schedule, ignore_skip_ci: true, save_on_errors: true, schedule: schedule) - rescue => e - error(schedule, e) + Ci::PipelineSchedule.runnable_schedules.preloaded.find_in_batches do |schedules| + schedules.each do |schedule| + Ci::PipelineScheduleService.new(schedule.project, schedule.owner).execute(schedule) end end end - # rubocop: enable CodeReuse/ActiveRecord - - private - - def error(schedule, error) - failed_creation_counter.increment - - Rails.logger.error "Failed to create a scheduled pipeline. " \ - "schedule_id: #{schedule.id} message: #{error.message}" - - Gitlab::Sentry - .track_exception(error, - issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/41231', - extra: { schedule_id: schedule.id }) - end - - def failed_creation_counter - @failed_creation_counter ||= - Gitlab::Metrics.counter(:pipeline_schedule_creation_failed_total, - "Counter of failed attempts of pipeline schedule creation") - end end diff --git a/app/workers/pipeline_success_worker.rb b/app/workers/pipeline_success_worker.rb index 4f349ed922c..666331e6cd4 100644 --- a/app/workers/pipeline_success_worker.rb +++ b/app/workers/pipeline_success_worker.rb @@ -6,13 +6,7 @@ class PipelineSuccessWorker queue_namespace :pipeline_processing - # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) - Ci::Pipeline.find_by(id: pipeline_id).try do |pipeline| - MergeRequests::MergeWhenPipelineSucceedsService - .new(pipeline.project, nil) - .trigger(pipeline) - end + # no-op end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 9a9c0c9d803..3f1639ec2ed 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -74,6 +74,8 @@ class PostReceive def process_wiki_changes(post_received) post_received.project.touch(:last_activity_at, :last_repository_updated_at) + post_received.project.wiki.repository.expire_statistics_caches + ProjectCacheWorker.perform_async(post_received.project.id, [], [:wiki_size]) end def log(message) diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb index b2e0701008a..4e8ea903139 100644 --- a/app/workers/project_cache_worker.rb +++ b/app/workers/project_cache_worker.rb @@ -16,10 +16,12 @@ class ProjectCacheWorker def perform(project_id, files = [], statistics = []) project = Project.find_by(id: project_id) - return unless project && project.repository.exists? + return unless project update_statistics(project, statistics) + return unless project.repository.exists? + project.repository.refresh_method_caches(files.map(&:to_sym)) project.cleanup diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index f72331c003a..43e0b9db22f 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -21,6 +21,30 @@ class RunPipelineScheduleWorker Ci::CreatePipelineService.new(schedule.project, user, ref: schedule.ref) - .execute(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule) + .execute!(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule) + rescue Ci::CreatePipelineService::CreateError + # no-op. This is a user operation error such as corrupted .gitlab-ci.yml. + rescue => e + error(schedule, e) + end + + private + + def error(schedule, error) + failed_creation_counter.increment + + Rails.logger.error "Failed to create a scheduled pipeline. " \ + "schedule_id: #{schedule.id} message: #{error.message}" + + Gitlab::Sentry + .track_exception(error, + issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/41231', + extra: { schedule_id: schedule.id }) + end + + def failed_creation_counter + @failed_creation_counter ||= + Gitlab::Metrics.counter(:pipeline_schedule_creation_failed_total, + "Counter of failed attempts of pipeline schedule creation") end end |