diff options
Diffstat (limited to 'app')
174 files changed, 3129 insertions, 1109 deletions
diff --git a/app/assets/javascripts/abuse_reports.js b/app/assets/javascripts/abuse_reports.js index 3de192d56eb..d2d3a257c0d 100644 --- a/app/assets/javascripts/abuse_reports.js +++ b/app/assets/javascripts/abuse_reports.js @@ -1,3 +1,5 @@ +import { truncate } from './lib/utils/text_utility'; + const MAX_MESSAGE_LENGTH = 500; const MESSAGE_CELL_SELECTOR = '.abuse-reports .message'; @@ -15,7 +17,7 @@ export default class AbuseReports { if (reportMessage.length > MAX_MESSAGE_LENGTH) { $messageCellElement.data('original-message', reportMessage); $messageCellElement.data('message-truncated', 'true'); - $messageCellElement.text(window.gl.text.truncate(reportMessage, MAX_MESSAGE_LENGTH)); + $messageCellElement.text(truncate(reportMessage, MAX_MESSAGE_LENGTH)); } } diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js index de9e44cef35..182957113a2 100644 --- a/app/assets/javascripts/boards/components/modal/footer.js +++ b/app/assets/javascripts/boards/components/modal/footer.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import Flash from '../../../flash'; import './lists_dropdown'; +import { pluralize } from '../../../lib/utils/text_utility'; const ModalStore = gl.issueBoards.ModalStore; @@ -21,7 +22,7 @@ gl.issueBoards.ModalFooter = Vue.extend({ submitText() { const count = ModalStore.selectedCount(); - return `Add ${count > 0 ? count : ''} ${gl.text.pluralize('issue', count)}`; + return `Add ${count > 0 ? count : ''} ${pluralize('issue', count)}`; }, }, methods: { diff --git a/app/assets/javascripts/clusters.js b/app/assets/javascripts/clusters.js deleted file mode 100644 index c9fef94efea..00000000000 --- a/app/assets/javascripts/clusters.js +++ /dev/null @@ -1,123 +0,0 @@ -/* globals Flash */ -import Visibility from 'visibilityjs'; -import axios from 'axios'; -import setAxiosCsrfToken from './lib/utils/axios_utils'; -import Poll from './lib/utils/poll'; -import { s__ } from './locale'; -import initSettingsPanels from './settings_panels'; -import Flash from './flash'; - -/** - * Cluster page has 2 separate parts: - * Toggle button - * - * - Polling status while creating or scheduled - * -- Update status area with the response result - */ - -class ClusterService { - constructor(options = {}) { - this.options = options; - setAxiosCsrfToken(); - } - fetchData() { - return axios.get(this.options.endpoint); - } -} - -export default class Clusters { - constructor() { - initSettingsPanels(); - - const dataset = document.querySelector('.js-edit-cluster-form').dataset; - - this.state = { - statusPath: dataset.statusPath, - clusterStatus: dataset.clusterStatus, - clusterStatusReason: dataset.clusterStatusReason, - toggleStatus: dataset.toggleStatus, - }; - - this.service = new ClusterService({ endpoint: this.state.statusPath }); - this.toggleButton = document.querySelector('.js-toggle-cluster'); - this.toggleInput = document.querySelector('.js-toggle-input'); - this.errorContainer = document.querySelector('.js-cluster-error'); - this.successContainer = document.querySelector('.js-cluster-success'); - this.creatingContainer = document.querySelector('.js-cluster-creating'); - this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason'); - - this.toggleButton.addEventListener('click', this.toggle.bind(this)); - - if (this.state.clusterStatus !== 'created') { - this.updateContainer(this.state.clusterStatus, this.state.clusterStatusReason); - } - - if (this.state.statusPath) { - this.initPolling(); - } - } - - toggle() { - this.toggleButton.classList.toggle('checked'); - this.toggleInput.setAttribute('value', this.toggleButton.classList.contains('checked').toString()); - } - - initPolling() { - this.poll = new Poll({ - resource: this.service, - method: 'fetchData', - successCallback: data => this.handleSuccess(data), - errorCallback: () => Clusters.handleError(), - }); - - if (!Visibility.hidden()) { - this.poll.makeRequest(); - } else { - this.service.fetchData() - .then(data => this.handleSuccess(data)) - .catch(() => Clusters.handleError()); - } - - Visibility.change(() => { - if (!Visibility.hidden()) { - this.poll.restart(); - } else { - this.poll.stop(); - } - }); - } - - static handleError() { - Flash(s__('ClusterIntegration|Something went wrong on our end.')); - } - - handleSuccess(data) { - const { status, status_reason } = data.data; - this.updateContainer(status, status_reason); - } - - hideAll() { - this.errorContainer.classList.add('hidden'); - this.successContainer.classList.add('hidden'); - this.creatingContainer.classList.add('hidden'); - } - - updateContainer(status, error) { - this.hideAll(); - switch (status) { - case 'created': - this.successContainer.classList.remove('hidden'); - break; - case 'errored': - this.errorContainer.classList.remove('hidden'); - this.errorReasonContainer.textContent = error; - break; - case 'scheduled': - case 'creating': - this.creatingContainer.classList.remove('hidden'); - break; - default: - this.hideAll(); - } - } -} diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js new file mode 100644 index 00000000000..dc443475952 --- /dev/null +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -0,0 +1,221 @@ +import Visibility from 'visibilityjs'; +import Vue from 'vue'; +import { s__, sprintf } from '../locale'; +import Flash from '../flash'; +import Poll from '../lib/utils/poll'; +import initSettingsPanels from '../settings_panels'; +import eventHub from './event_hub'; +import { + APPLICATION_INSTALLED, + REQUEST_LOADING, + REQUEST_SUCCESS, + REQUEST_FAILURE, +} from './constants'; +import ClustersService from './services/clusters_service'; +import ClustersStore from './stores/clusters_store'; +import applications from './components/applications.vue'; + +/** + * Cluster page has 2 separate parts: + * Toggle button and applications section + * + * - Polling status while creating or scheduled + * - Update status area with the response result + */ + +export default class Clusters { + constructor() { + const { + statusPath, + installHelmPath, + installIngressPath, + installRunnerPath, + clusterStatus, + clusterStatusReason, + helpPath, + } = document.querySelector('.js-edit-cluster-form').dataset; + + this.store = new ClustersStore(); + this.store.setHelpPath(helpPath); + this.store.updateStatus(clusterStatus); + this.store.updateStatusReason(clusterStatusReason); + this.service = new ClustersService({ + endpoint: statusPath, + installHelmEndpoint: installHelmPath, + installIngressEndpoint: installIngressPath, + installRunnerEndpoint: installRunnerPath, + }); + + this.toggle = this.toggle.bind(this); + this.installApplication = this.installApplication.bind(this); + + this.toggleButton = document.querySelector('.js-toggle-cluster'); + this.toggleInput = document.querySelector('.js-toggle-input'); + this.errorContainer = document.querySelector('.js-cluster-error'); + this.successContainer = document.querySelector('.js-cluster-success'); + this.creatingContainer = document.querySelector('.js-cluster-creating'); + this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason'); + this.successApplicationContainer = document.querySelector('.js-cluster-application-notice'); + + initSettingsPanels(); + this.initApplications(); + + if (this.store.state.status !== 'created') { + this.updateContainer(null, this.store.state.status, this.store.state.statusReason); + } + + this.addListeners(); + if (statusPath) { + this.initPolling(); + } + } + + initApplications() { + const store = this.store; + const el = document.querySelector('#js-cluster-applications'); + + this.applications = new Vue({ + el, + components: { + applications, + }, + data() { + return { + state: store.state, + }; + }, + render(createElement) { + return createElement('applications', { + props: { + applications: this.state.applications, + helpPath: this.state.helpPath, + }, + }); + }, + }); + } + + addListeners() { + this.toggleButton.addEventListener('click', this.toggle); + eventHub.$on('installApplication', this.installApplication); + } + + removeListeners() { + this.toggleButton.removeEventListener('click', this.toggle); + eventHub.$off('installApplication', this.installApplication); + } + + initPolling() { + this.poll = new Poll({ + resource: this.service, + method: 'fetchData', + successCallback: data => this.handleSuccess(data), + errorCallback: () => Clusters.handleError(), + }); + + if (!Visibility.hidden()) { + this.poll.makeRequest(); + } else { + this.service.fetchData() + .then(data => this.handleSuccess(data)) + .catch(() => Clusters.handleError()); + } + + Visibility.change(() => { + if (!Visibility.hidden() && !this.destroyed) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); + } + + static handleError() { + Flash(s__('ClusterIntegration|Something went wrong on our end.')); + } + + handleSuccess(data) { + const prevStatus = this.store.state.status; + const prevApplicationMap = Object.assign({}, this.store.state.applications); + + this.store.updateStateFromServer(data.data); + + this.checkForNewInstalls(prevApplicationMap, this.store.state.applications); + this.updateContainer(prevStatus, this.store.state.status, this.store.state.statusReason); + } + + toggle() { + this.toggleButton.classList.toggle('checked'); + this.toggleInput.setAttribute('value', this.toggleButton.classList.contains('checked').toString()); + } + + hideAll() { + this.errorContainer.classList.add('hidden'); + this.successContainer.classList.add('hidden'); + this.creatingContainer.classList.add('hidden'); + } + + checkForNewInstalls(prevApplicationMap, newApplicationMap) { + const appTitles = Object.keys(newApplicationMap) + .filter(appId => newApplicationMap[appId].status === APPLICATION_INSTALLED && + prevApplicationMap[appId].status !== APPLICATION_INSTALLED && + prevApplicationMap[appId].status !== null) + .map(appId => newApplicationMap[appId].title); + + if (appTitles.length > 0) { + const text = sprintf(s__('ClusterIntegration|%{appList} was successfully installed on your cluster'), { + appList: appTitles.join(', '), + }); + Flash(text, 'notice', this.successApplicationContainer); + } + } + + updateContainer(prevStatus, status, error) { + this.hideAll(); + + // 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) { + case 'created': + this.successContainer.classList.remove('hidden'); + break; + case 'errored': + this.errorContainer.classList.remove('hidden'); + this.errorReasonContainer.textContent = error; + break; + case 'scheduled': + case 'creating': + this.creatingContainer.classList.remove('hidden'); + break; + default: + this.hideAll(); + } + } + } + + installApplication(appId) { + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_LOADING); + this.store.updateAppProperty(appId, 'requestReason', null); + + this.service.installApplication(appId) + .then(() => { + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS); + }) + .catch(() => { + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); + this.store.updateAppProperty(appId, 'requestReason', s__('ClusterIntegration|Request to begin installing failed')); + }); + } + + destroy() { + this.destroyed = true; + + this.removeListeners(); + + if (this.poll) { + this.poll.stop(); + } + + this.applications.$destroy(); + } +} diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue new file mode 100644 index 00000000000..872abf03ef1 --- /dev/null +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -0,0 +1,185 @@ +<script> +import { s__, sprintf } from '../../locale'; +import eventHub from '../event_hub'; +import loadingButton from '../../vue_shared/components/loading_button.vue'; +import { + APPLICATION_NOT_INSTALLABLE, + APPLICATION_SCHEDULED, + APPLICATION_INSTALLABLE, + APPLICATION_INSTALLING, + APPLICATION_INSTALLED, + APPLICATION_ERROR, + REQUEST_LOADING, + REQUEST_SUCCESS, + REQUEST_FAILURE, +} from '../constants'; + +export default { + props: { + id: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + titleLink: { + type: String, + required: false, + }, + description: { + type: String, + required: true, + }, + status: { + type: String, + required: false, + }, + statusReason: { + type: String, + required: false, + }, + requestStatus: { + type: String, + required: false, + }, + requestReason: { + type: String, + required: false, + }, + }, + components: { + loadingButton, + }, + computed: { + rowJsClass() { + return `js-cluster-application-row-${this.id}`; + }, + installButtonLoading() { + return !this.status || + this.status === APPLICATION_SCHEDULED || + this.status === APPLICATION_INSTALLING || + this.requestStatus === REQUEST_LOADING; + }, + installButtonDisabled() { + // Avoid the potential for the real-time data to say APPLICATION_INSTALLABLE but + // we already made a request to install and are just waiting for the real-time + // to sync up. + return (this.status !== APPLICATION_INSTALLABLE && this.status !== APPLICATION_ERROR) || + this.requestStatus === REQUEST_LOADING || + this.requestStatus === REQUEST_SUCCESS; + }, + installButtonLabel() { + let label; + if ( + this.status === APPLICATION_NOT_INSTALLABLE || + this.status === APPLICATION_INSTALLABLE || + this.status === APPLICATION_ERROR + ) { + label = s__('ClusterIntegration|Install'); + } else if (this.status === APPLICATION_SCHEDULED || this.status === APPLICATION_INSTALLING) { + label = s__('ClusterIntegration|Installing'); + } else if (this.status === APPLICATION_INSTALLED) { + label = s__('ClusterIntegration|Installed'); + } + + return label; + }, + hasError() { + return this.status === APPLICATION_ERROR || this.requestStatus === REQUEST_FAILURE; + }, + generalErrorDescription() { + return sprintf( + s__('ClusterIntegration|Something went wrong while installing %{title}'), { + title: this.title, + }, + ); + }, + }, + methods: { + installClicked() { + eventHub.$emit('installApplication', this.id); + }, + }, +}; +</script> + +<template> + <div + class="gl-responsive-table-row gl-responsive-table-row-col-span" + :class="rowJsClass" + > + <div + class="gl-responsive-table-row-layout" + role="row" + > + <a + v-if="titleLink" + :href="titleLink" + target="blank" + rel="noopener noreferrer" + role="gridcell" + class="table-section section-15 section-align-top js-cluster-application-title" + > + {{ title }} + </a> + <span + v-else + class="table-section section-15 section-align-top js-cluster-application-title" + > + {{ title }} + </span> + <div + class="table-section section-wrap" + role="gridcell" + > + <div v-html="description"></div> + </div> + <div + class="table-section table-button-footer section-15 section-align-top" + role="gridcell" + > + <div class="btn-group table-action-buttons"> + <loading-button + class="js-cluster-application-install-button" + :loading="installButtonLoading" + :disabled="installButtonDisabled" + :label="installButtonLabel" + @click="installClicked" + /> + </div> + </div> + </div> + <div + v-if="hasError" + class="gl-responsive-table-row-layout" + role="row" + > + <div + class="alert alert-danger alert-block append-bottom-0 table-section section-100" + role="gridcell" + > + <div> + <p class="js-cluster-application-general-error-message"> + {{ generalErrorDescription }} + </p> + <ul v-if="statusReason || requestReason"> + <li + v-if="statusReason" + class="js-cluster-application-status-error-message" + > + {{ statusReason }} + </li> + <li + v-if="requestReason" + class="js-cluster-application-request-error-message" + > + {{ requestReason }} + </li> + </ul> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue new file mode 100644 index 00000000000..e5ae439d26e --- /dev/null +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -0,0 +1,114 @@ +<script> +import _ from 'underscore'; +import { s__, sprintf } from '../../locale'; +import applicationRow from './application_row.vue'; + +export default { + props: { + applications: { + type: Object, + required: false, + default: () => ({}), + }, + helpPath: { + type: String, + required: false, + }, + }, + components: { + applicationRow, + }, + computed: { + generalApplicationDescription() { + return sprintf( + _.escape(s__('ClusterIntegration|Install applications on your cluster. Read more about %{helpLink}')), { + helpLink: `<a href="${this.helpPath}"> + ${_.escape(s__('ClusterIntegration|installing applications'))} + </a>`, + }, + false, + ); + }, + helmTillerDescription() { + return _.escape(s__( + `ClusterIntegration|Helm streamlines installing and managing Kubernets applications. + Tiller runs inside of your Kubernetes Cluster, and manages + releases of your charts.`, + )); + }, + ingressDescription() { + const descriptionParagraph = _.escape(s__( + `ClusterIntegration|Ingress gives you a way to route requests to services based on the + request host or path, centralizing a number of services into a single entrypoint.`, + )); + + const extraCostParagraph = sprintf( + _.escape(s__('ClusterIntegration|%{boldNotice} This will add some extra resources like a load balancer, which incur additional costs. See %{pricingLink}')), { + boldNotice: `<strong>${_.escape(s__('ClusterIntegration|Note:'))}</strong>`, + pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb" target="_blank" rel="noopener noreferrer"> + ${_.escape(s__('ClusterIntegration|GKE pricing'))} + </a>`, + }, + false, + ); + + return ` + <p> + ${descriptionParagraph} + </p> + <p class="append-bottom-0"> + ${extraCostParagraph} + </p> + `; + }, + gitlabRunnerDescription() { + return _.escape(s__( + `ClusterIntegration|GitLab Runner is the open source project that is used to run your jobs + and send the results back to GitLab.`, + )); + }, + }, +}; +</script> + +<template> + <section class="settings no-animate expanded"> + <div class="settings-header"> + <h4> + {{ s__('ClusterIntegration|Applications') }} + </h4> + <p + class="append-bottom-0" + v-html="generalApplicationDescription" + > + </p> + </div> + + <div class="settings-content"> + <div class="append-bottom-20"> + <application-row + id="helm" + :title="applications.helm.title" + title-link="https://docs.helm.sh/" + :description="helmTillerDescription" + :status="applications.helm.status" + :status-reason="applications.helm.statusReason" + :request-status="applications.helm.requestStatus" + :request-reason="applications.helm.requestReason" + /> + <application-row + id="ingress" + :title="applications.ingress.title" + title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/" + :description="ingressDescription" + :status="applications.ingress.status" + :status-reason="applications.ingress.statusReason" + :request-status="applications.ingress.requestStatus" + :request-reason="applications.ingress.requestReason" + /> + <!-- NOTE: Don't forget to update `clusters.scss` min-height for this block and uncomment `application_spec` tests --> + <!-- Add GitLab Runner row, all other plumbing is complete --> + </div> + </div> + </section> +</template> diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js new file mode 100644 index 00000000000..93223aefff8 --- /dev/null +++ b/app/assets/javascripts/clusters/constants.js @@ -0,0 +1,12 @@ +// These need to match what is returned from the server +export const APPLICATION_NOT_INSTALLABLE = 'not_installable'; +export const APPLICATION_INSTALLABLE = 'installable'; +export const APPLICATION_SCHEDULED = 'scheduled'; +export const APPLICATION_INSTALLING = 'installing'; +export const APPLICATION_INSTALLED = 'installed'; +export const APPLICATION_ERROR = 'errored'; + +// These are only used client-side +export const REQUEST_LOADING = 'request-loading'; +export const REQUEST_SUCCESS = 'request-success'; +export const REQUEST_FAILURE = 'request-failure'; diff --git a/app/assets/javascripts/clusters/event_hub.js b/app/assets/javascripts/clusters/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/clusters/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js new file mode 100644 index 00000000000..0ac8e68187d --- /dev/null +++ b/app/assets/javascripts/clusters/services/clusters_service.js @@ -0,0 +1,24 @@ +import axios from 'axios'; +import setAxiosCsrfToken from '../../lib/utils/axios_utils'; + +export default class ClusterService { + constructor(options = {}) { + setAxiosCsrfToken(); + + this.options = options; + this.appInstallEndpointMap = { + helm: this.options.installHelmEndpoint, + ingress: this.options.installIngressEndpoint, + runner: this.options.installRunnerEndpoint, + }; + } + + fetchData() { + return axios.get(this.options.endpoint); + } + + installApplication(appId) { + const endpoint = this.appInstallEndpointMap[appId]; + return axios.post(endpoint); + } +} diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js new file mode 100644 index 00000000000..e731cdc3042 --- /dev/null +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -0,0 +1,68 @@ +import { s__ } from '../../locale'; + +export default class ClusterStore { + constructor() { + this.state = { + helpPath: null, + status: null, + statusReason: null, + applications: { + helm: { + title: s__('ClusterIntegration|Helm Tiller'), + status: null, + statusReason: null, + requestStatus: null, + requestReason: null, + }, + ingress: { + title: s__('ClusterIntegration|Ingress'), + status: null, + statusReason: null, + requestStatus: null, + requestReason: null, + }, + runner: { + title: s__('ClusterIntegration|GitLab Runner'), + status: null, + statusReason: null, + requestStatus: null, + requestReason: null, + }, + }, + }; + } + + setHelpPath(helpPath) { + this.state.helpPath = helpPath; + } + + updateStatus(status) { + this.state.status = status; + } + + updateStatusReason(reason) { + this.state.statusReason = reason; + } + + updateAppProperty(appId, prop, value) { + this.state.applications[appId][prop] = value; + } + + updateStateFromServer(serverState = {}) { + this.state.status = serverState.status; + this.state.statusReason = serverState.status_reason; + serverState.applications.forEach((serverAppEntry) => { + const { + name: appId, + status, + status_reason: statusReason, + } = serverAppEntry; + + this.state.applications[appId] = { + ...(this.state.applications[appId] || {}), + status, + statusReason, + }; + }); + } +} diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js index ae6b8902032..9b952ea7b60 100644 --- a/app/assets/javascripts/commits.js +++ b/app/assets/javascripts/commits.js @@ -3,6 +3,8 @@ prefer-template, object-shorthand, prefer-arrow-callback */ /* global Pager */ +import { pluralize } from './lib/utils/text_utility'; + export default (function () { const CommitsList = {}; @@ -86,7 +88,7 @@ export default (function () { // Update commits count in the previous commits header. commitsCount += Number($(processedData).nextUntil('li.js-commit-header').first().find('li.commit').length); - $commitsHeadersLast.find('span.commits-count').text(`${commitsCount} ${gl.text.pluralize('commit', commitsCount)}`); + $commitsHeadersLast.find('span.commits-count').text(`${commitsCount} ${pluralize('commit', commitsCount)}`); } gl.utils.localTimeAgo($processedData.find('.js-timeago')); diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js index 3bed0678350..9a4c9bfcc80 100644 --- a/app/assets/javascripts/create_label.js +++ b/app/assets/javascripts/create_label.js @@ -1,5 +1,6 @@ /* eslint-disable func-names, prefer-arrow-callback */ import Api from './api'; +import { humanize } from './lib/utils/text_utility'; export default class CreateLabelDropdown { constructor($el, namespacePath, projectPath) { @@ -107,7 +108,7 @@ export default class CreateLabelDropdown { errors = label.message; } else { errors = Object.keys(label.message).map(key => - `${gl.text.humanize(key)} ${label.message[key].join(', ')}`, + `${humanize(key)} ${label.message[key].join(', ')}`, ).join('<br/>'); } diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js index 8bf9ae17de0..a8cd8c20f8f 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js @@ -1,7 +1,7 @@ /* eslint-disable no-param-reassign */ import { __ } from '../locale'; -import '../lib/utils/text_utility'; +import { dasherize } from '../lib/utils/text_utility'; import DEFAULT_EVENT_OBJECTS from './default_event_objects'; const EMPTY_STAGE_TEXTS = { @@ -36,7 +36,7 @@ export default { }); newData.stages.forEach((item) => { - const stageSlug = gl.text.dasherize(item.name.toLowerCase()); + const stageSlug = dasherize(item.name.toLowerCase()); item.active = false; item.isUserAllowed = data.permissions[stageSlug]; item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug]; diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 760fb0cdf67..d716218d9a4 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -1,4 +1,5 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, wrap-iife, no-shadow, consistent-return, one-var, one-var-declaration-per-line, camelcase, default-case, no-new, quotes, no-duplicate-case, no-case-declarations, no-fallthrough, max-len */ +import { s__ } from './locale'; /* global ProjectSelect */ import IssuableIndex from './issuable_index'; /* global Milestone */ @@ -19,19 +20,20 @@ import groupsSelect from './groups_select'; import NamespaceSelect from './namespace_select'; /* global NewCommitForm */ /* global NewBranchForm */ -/* global Project */ -/* global ProjectAvatar */ +import Project from './project'; +import projectAvatar from './project_avatar'; /* global MergeRequest */ /* global Compare */ /* global CompareAutocomplete */ /* global ProjectFindFile */ /* global ProjectNew */ /* global ProjectShow */ -/* global ProjectImport */ +import projectImport from './project_import'; import Labels from './labels'; import LabelManager from './label_manager'; /* global Sidebar */ +import Flash from './flash'; import CommitsList from './commits'; import Issue from './issue'; import BindInOut from './behaviors/bind_in_out'; @@ -376,7 +378,7 @@ import Diff from './diff'; initSettingsPanels(); break; case 'projects:imports:show': - new ProjectImport(); + projectImport(); break; case 'projects:pipelines:new': new NewBranchForm($('.js-new-pipeline-form')); @@ -543,9 +545,12 @@ import Diff from './diff'; new DueDateSelectors(); break; case 'projects:clusters:show': - import(/* webpackChunkName: "clusters" */ './clusters') + import(/* webpackChunkName: "clusters" */ './clusters/clusters_bundle') .then(cluster => new cluster.default()) // eslint-disable-line new-cap - .catch(() => {}); + .catch((err) => { + Flash(s__('ClusterIntegration|Problem setting up the cluster JavaScript')); + throw err; + }); break; } switch (path[0]) { @@ -599,7 +604,7 @@ import Diff from './diff'; break; case 'projects': new Project(); - new ProjectAvatar(); + projectAvatar(); switch (path[1]) { case 'compare': new CompareAutocomplete(); diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index fc0308b81ba..9d25f806c0d 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -2,7 +2,7 @@ import Timeago from 'timeago.js'; import _ from 'underscore'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; -import '../../lib/utils/text_utility'; +import { humanize } from '../../lib/utils/text_utility'; import ActionsComponent from './environment_actions.vue'; import ExternalUrlComponent from './environment_external_url.vue'; import StopComponent from './environment_stop.vue'; @@ -139,7 +139,7 @@ export default { if (this.hasManualActions) { return this.model.last_deployment.manual_actions.map((action) => { const parsedAction = { - name: gl.text.humanize(action.name), + name: humanize(action.name), play_path: action.play_path, playable: action.playable, }; diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 5c624b79d45..a642464c920 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -338,7 +338,8 @@ class GfmAutoComplete { let resultantValue = value; if (value && !this.setting.skipSpecialCharacterTest) { const withoutAt = value.substring(1); - if (withoutAt && /[^\w\d]/.test(withoutAt)) { + const regex = value.charAt() === '~' ? /\W|^\d+$/ : /\W/; + if (withoutAt && regex.test(withoutAt)) { resultantValue = `${value.charAt()}"${withoutAt}"`; } } diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index c4202f92443..4e7a6e54f90 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -331,7 +331,7 @@ GitLabDropdown = (function() { if (_this.dropdown.find('.dropdown-toggle-page').length) { selector = ".dropdown-page-one " + selector; } - return $(selector); + return $(selector, this.instance.dropdown); }; })(this), data: (function(_this) { diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 48cd43d3348..d0f9e6af0f8 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -2,6 +2,7 @@ import GfmAutoComplete from './gfm_auto_complete'; import dropzoneInput from './dropzone_input'; +import textUtils from './lib/utils/text_markdown'; export default class GLForm { constructor(form, enableGFM = false) { @@ -46,7 +47,7 @@ export default class GLForm { } // form and textarea event listeners this.addEventListeners(); - gl.text.init(this.form); + textUtils.init(this.form); // hide discard button this.form.find('.js-note-discard').hide(); this.form.show(); @@ -85,7 +86,7 @@ export default class GLForm { clearEventListeners() { this.textarea.off('focus'); this.textarea.off('blur'); - gl.text.removeListeners(this.form); + textUtils.removeListeners(this.form); } addEventListeners() { diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index acd5730cf3c..7de07e9403d 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -1,6 +1,6 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, no-underscore-dangle, one-var-declaration-per-line, object-shorthand, no-unused-vars, no-new, comma-dangle, consistent-return, quotes, dot-notation, quote-props, prefer-arrow-callback, max-len */ import 'vendor/jquery.waitforimages'; -import '~/lib/utils/text_utility'; +import { addDelimiter } from './lib/utils/text_utility'; import Flash from './flash'; import TaskList from './task_list'; import CreateMergeRequestDropdown from './create_merge_request_dropdown'; @@ -73,7 +73,7 @@ export default class Issue { let numProjectIssues = Number(projectIssuesCounter.first().text().trim().replace(/[^\d]/, '')); numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1; - projectIssuesCounter.text(gl.text.addDelimiter(numProjectIssues)); + projectIssuesCounter.text(addDelimiter(numProjectIssues)); if (this.createMergeRequestDropdown) { if (isClosed) { diff --git a/app/assets/javascripts/job.js b/app/assets/javascripts/job.js index c6b5844dff6..cf8fda9a4fa 100644 --- a/app/assets/javascripts/job.js +++ b/app/assets/javascripts/job.js @@ -14,8 +14,8 @@ export default class Job { this.state = this.options.logState; this.buildStage = this.options.buildStage; this.$document = $(document); + this.$window = $(window); this.logBytes = 0; - this.hasBeenScrolled = false; this.updateDropdown = this.updateDropdown.bind(this); this.$buildTrace = $('#build-trace'); @@ -54,23 +54,18 @@ export default class Job { this.scrollThrottled = _.throttle(this.toggleScroll.bind(this), 100); - $(window) + this.$window .off('scroll') .on('scroll', () => { - const contentHeight = this.$buildTraceOutput.height(); - if (contentHeight > this.windowSize) { - // means the user did not scroll, the content was updated. - this.windowSize = contentHeight; - } else { - // User scrolled - this.hasBeenScrolled = true; + if (!this.isScrolledToBottom()) { this.toggleScrollAnimation(false); + } else if (this.isScrolledToBottom() && !this.isLogComplete) { + this.toggleScrollAnimation(true); } - this.scrollThrottled(); }); - $(window) + this.$window .off('resize.build') .on('resize.build', _.throttle(this.sidebarOnResize.bind(this), 100)); @@ -99,14 +94,14 @@ export default class Job { // eslint-disable-next-line class-methods-use-this canScroll() { - return $(document).height() > $(window).height(); + return this.$document.height() > this.$window.height(); } toggleScroll() { - const currentPosition = $(document).scrollTop(); - const scrollHeight = $(document).height(); + const currentPosition = this.$document.scrollTop(); + const scrollHeight = this.$document.height(); - const windowHeight = $(window).height(); + const windowHeight = this.$window.height(); if (this.canScroll()) { if (currentPosition > 0 && (scrollHeight - currentPosition !== windowHeight)) { @@ -119,7 +114,7 @@ export default class Job { this.toggleDisableButton(this.$scrollTopBtn, true); this.toggleDisableButton(this.$scrollBottomBtn, false); - } else if (scrollHeight - currentPosition === windowHeight) { + } else if (this.isScrolledToBottom()) { // User is at the bottom of the build log. this.toggleDisableButton(this.$scrollTopBtn, false); @@ -131,9 +126,17 @@ export default class Job { } } + isScrolledToBottom() { + const currentPosition = this.$document.scrollTop(); + const scrollHeight = this.$document.height(); + + const windowHeight = this.$window.height(); + return scrollHeight - currentPosition === windowHeight; + } + // eslint-disable-next-line class-methods-use-this scrollDown() { - $(document).scrollTop($(document).height()); + this.$document.scrollTop(this.$document.height()); } scrollToBottom() { @@ -143,7 +146,7 @@ export default class Job { } scrollToTop() { - $(document).scrollTop(0); + this.$document.scrollTop(0); this.hasBeenScrolled = true; this.toggleScroll(); } @@ -174,7 +177,7 @@ export default class Job { this.state = log.state; } - this.windowSize = this.$buildTraceOutput.height(); + this.isScrollInBottom = this.isScrolledToBottom(); if (log.append) { this.$buildTraceOutput.append(log.html); @@ -194,14 +197,9 @@ export default class Job { } else { this.$truncatedInfo.addClass('hidden'); } + this.isLogComplete = log.complete; if (!log.complete) { - if (!this.hasBeenScrolled) { - this.toggleScrollAnimation(true); - } else { - this.toggleScrollAnimation(false); - } - this.timeout = setTimeout(() => { this.getBuildTrace(); }, 4000); @@ -218,7 +216,7 @@ export default class Job { this.$buildRefreshAnimation.remove(); }) .then(() => { - if (!this.hasBeenScrolled) { + if (this.isScrollInBottom) { this.scrollDown(); } }) diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 07899777a1e..5c4926d6ac8 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -172,7 +172,6 @@ export const getSelectedFragment = () => { return documentFragment; }; -// TODO: Update this name, there is a gl.text.insertText function. export const insertText = (target, text) => { // Firefox doesn't support `document.execCommand('insertText', false, text)` on textareas const selectionStart = target.selectionStart; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 29fc91733b3..5679b8c9a09 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -2,6 +2,7 @@ import timeago from 'timeago.js'; import dateFormat from 'vendor/date.format'; +import { pluralize } from './text_utility'; import { lang, @@ -143,9 +144,9 @@ export function timeIntervalInWords(intervalInSeconds) { let text = ''; if (minutes >= 1) { - text = `${minutes} ${gl.text.pluralize('minute', minutes)} ${seconds} ${gl.text.pluralize('second', seconds)}`; + text = `${minutes} ${pluralize('minute', minutes)} ${seconds} ${pluralize('second', seconds)}`; } else { - text = `${seconds} ${gl.text.pluralize('second', seconds)}`; + text = `${seconds} ${pluralize('second', seconds)}`; } return text; } diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index 917a45eb06b..a02c79b787e 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -52,3 +52,31 @@ export function bytesToKiB(number) { export function bytesToMiB(number) { return number / (BYTES_IN_KIB * BYTES_IN_KIB); } + +/** + * Utility function that calculates GiB of the given bytes. + * @param {Number} number + * @returns {Number} + */ +export function bytesToGiB(number) { + return number / (BYTES_IN_KIB * BYTES_IN_KIB * BYTES_IN_KIB); +} + +/** + * Port of rails number_to_human_size + * Formats the bytes in number into a more understandable + * representation (e.g., giving it 1500 yields 1.5 KB). + * + * @param {Number} size + * @returns {String} + */ +export function numberToHumanSize(size) { + if (size < BYTES_IN_KIB) { + return `${size} bytes`; + } else if (size < BYTES_IN_KIB * BYTES_IN_KIB) { + return `${bytesToKiB(size).toFixed(2)} KiB`; + } else if (size < BYTES_IN_KIB * BYTES_IN_KIB * BYTES_IN_KIB) { + return `${bytesToMiB(size).toFixed(2)} MiB`; + } + return `${bytesToGiB(size).toFixed(2)} GiB`; +} diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js new file mode 100644 index 00000000000..2dc9cf0cc29 --- /dev/null +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -0,0 +1,153 @@ +/* eslint-disable import/prefer-default-export, func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len, vars-on-top */ + +const textUtils = {}; + +textUtils.selectedText = function(text, textarea) { + return text.substring(textarea.selectionStart, textarea.selectionEnd); +}; + +textUtils.lineBefore = function(text, textarea) { + var split; + split = text.substring(0, textarea.selectionStart).trim().split('\n'); + return split[split.length - 1]; +}; + +textUtils.lineAfter = function(text, textarea) { + return text.substring(textarea.selectionEnd).trim().split('\n')[0]; +}; + +textUtils.blockTagText = function(text, textArea, blockTag, selected) { + var lineAfter, lineBefore; + lineBefore = this.lineBefore(text, textArea); + lineAfter = this.lineAfter(text, textArea); + if (lineBefore === blockTag && lineAfter === blockTag) { + // To remove the block tag we have to select the line before & after + if (blockTag != null) { + textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1); + textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1); + } + return selected; + } else { + return blockTag + "\n" + selected + "\n" + blockTag; + } +}; + +textUtils.insertText = function(textArea, text, tag, blockTag, selected, wrap) { + var insertText, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine; + removedLastNewLine = false; + removedFirstNewLine = false; + currentLineEmpty = false; + + // Remove the first newline + if (selected.indexOf('\n') === 0) { + removedFirstNewLine = true; + selected = selected.replace(/\n+/, ''); + } + + // Remove the last newline + if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) { + removedLastNewLine = true; + selected = selected.replace(/\n$/, ''); + } + + selectedSplit = selected.split('\n'); + + if (!wrap) { + lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n'); + + // Check whether the current line is empty or consists only of spaces(=handle as empty) + if (/^\s*$/.test(textArea.value.substring(lastNewLine, textArea.selectionStart))) { + currentLineEmpty = true; + } + } + + startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : ''; + + if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) { + if (blockTag != null && blockTag !== '') { + insertText = this.blockTagText(text, textArea, blockTag, selected); + } else { + insertText = selectedSplit.map(function(val) { + if (val.indexOf(tag) === 0) { + return "" + (val.replace(tag, '')); + } else { + return "" + tag + val; + } + }).join('\n'); + } + } else { + insertText = "" + startChar + tag + selected + (wrap ? tag : ' '); + } + + if (removedFirstNewLine) { + insertText = '\n' + insertText; + } + + if (removedLastNewLine) { + insertText += '\n'; + } + + if (document.queryCommandSupported('insertText')) { + inserted = document.execCommand('insertText', false, insertText); + } + if (!inserted) { + try { + document.execCommand("ms-beginUndoUnit"); + } catch (error) {} + textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText); + try { + document.execCommand("ms-endUndoUnit"); + } catch (error) {} + } + return this.moveCursor(textArea, tag, wrap, removedLastNewLine); +}; + +textUtils.moveCursor = function(textArea, tag, wrapped, removedLastNewLine) { + var pos; + if (!textArea.setSelectionRange) { + return; + } + if (textArea.selectionStart === textArea.selectionEnd) { + if (wrapped) { + pos = textArea.selectionStart - tag.length; + } else { + pos = textArea.selectionStart; + } + + if (removedLastNewLine) { + pos -= 1; + } + + return textArea.setSelectionRange(pos, pos); + } +}; + +textUtils.updateText = function(textArea, tag, blockTag, wrap) { + var $textArea, selected, text; + $textArea = $(textArea); + textArea = $textArea.get(0); + text = $textArea.val(); + selected = this.selectedText(text, textArea); + $textArea.focus(); + return this.insertText(textArea, text, tag, blockTag, selected, wrap); +}; + +textUtils.init = function(form) { + var self; + self = this; + return $('.js-md', form).off('click').on('click', function() { + var $this; + $this = $(this); + return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend')); + }); +}; + +textUtils.removeListeners = function(form) { + return $('.js-md', form).off('click'); +}; + +textUtils.replaceRange = function(s, start, end, substitute) { + return s.substring(0, start) + substitute + s.substring(end); +}; + +export default textUtils; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index f776829f69c..28ab9dddc4c 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -1,18 +1,13 @@ -/* eslint-disable import/prefer-default-export, func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len, vars-on-top */ - -import 'vendor/latinise'; - -var base; -var w = window; -if (w.gl == null) { - w.gl = {}; -} -if ((base = w.gl).text == null) { - base.text = {}; -} -gl.text.addDelimiter = function(text) { - return text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") : text; -}; +/** + * Adds a , to a string composed by numbers, at every 3 chars. + * + * 2333 -> 2,333 + * 232324 -> 232,324 + * + * @param {String} text + * @returns {String} + */ +export const addDelimiter = text => (text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text); /** * Returns '99+' for numbers bigger than 99. @@ -20,178 +15,43 @@ gl.text.addDelimiter = function(text) { * @param {Number} count * @return {Number|String} */ -export function highCountTrim(count) { - return count > 99 ? '99+' : count; -} - -gl.text.randomString = function() { - return Math.random().toString(36).substring(7); -}; -gl.text.replaceRange = function(s, start, end, substitute) { - return s.substring(0, start) + substitute + s.substring(end); -}; -gl.text.getTextWidth = function(text, font) { - /** - * Uses canvas.measureText to compute and return the width of the given text of given font in pixels. - * - * @param {String} text The text to be rendered. - * @param {String} font The css font descriptor that text is to be rendered with (e.g. "bold 14px verdana"). - * - * @see http://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393 - */ - // re-use canvas object for better performance - var canvas = gl.text.getTextWidth.canvas || (gl.text.getTextWidth.canvas = document.createElement('canvas')); - var context = canvas.getContext('2d'); - context.font = font; - return context.measureText(text).width; -}; -gl.text.selectedText = function(text, textarea) { - return text.substring(textarea.selectionStart, textarea.selectionEnd); -}; -gl.text.lineBefore = function(text, textarea) { - var split; - split = text.substring(0, textarea.selectionStart).trim().split('\n'); - return split[split.length - 1]; -}; -gl.text.lineAfter = function(text, textarea) { - return text.substring(textarea.selectionEnd).trim().split('\n')[0]; -}; -gl.text.blockTagText = function(text, textArea, blockTag, selected) { - var lineAfter, lineBefore; - lineBefore = this.lineBefore(text, textArea); - lineAfter = this.lineAfter(text, textArea); - if (lineBefore === blockTag && lineAfter === blockTag) { - // To remove the block tag we have to select the line before & after - if (blockTag != null) { - textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1); - textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1); - } - return selected; - } else { - return blockTag + "\n" + selected + "\n" + blockTag; - } -}; -gl.text.insertText = function(textArea, text, tag, blockTag, selected, wrap) { - var insertText, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine; - removedLastNewLine = false; - removedFirstNewLine = false; - currentLineEmpty = false; - - // Remove the first newline - if (selected.indexOf('\n') === 0) { - removedFirstNewLine = true; - selected = selected.replace(/\n+/, ''); - } - - // Remove the last newline - if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) { - removedLastNewLine = true; - selected = selected.replace(/\n$/, ''); - } - - selectedSplit = selected.split('\n'); - - if (!wrap) { - lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n'); - - // Check whether the current line is empty or consists only of spaces(=handle as empty) - if (/^\s*$/.test(textArea.value.substring(lastNewLine, textArea.selectionStart))) { - currentLineEmpty = true; - } - } - - startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : ''; +export const highCountTrim = count => (count > 99 ? '99+' : count); - if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) { - if (blockTag != null && blockTag !== '') { - insertText = this.blockTagText(text, textArea, blockTag, selected); - } else { - insertText = selectedSplit.map(function(val) { - if (val.indexOf(tag) === 0) { - return "" + (val.replace(tag, '')); - } else { - return "" + tag + val; - } - }).join('\n'); - } - } else { - insertText = "" + startChar + tag + selected + (wrap ? tag : ' '); - } +/** + * Converst first char to uppercase and replaces undercores with spaces + * @param {String} string + * @requires {String} + */ +export const humanize = string => string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); - if (removedFirstNewLine) { - insertText = '\n' + insertText; - } +/** + * Adds an 's' to the end of the string when count is bigger than 0 + * @param {String} str + * @param {Number} count + * @returns {String} + */ +export const pluralize = (str, count) => str + (count > 1 || count === 0 ? 's' : ''); - if (removedLastNewLine) { - insertText += '\n'; - } +/** + * Replaces underscores with dashes + * @param {*} str + * @returns {String} + */ +export const dasherize = str => str.replace(/[_\s]+/g, '-'); - if (document.queryCommandSupported('insertText')) { - inserted = document.execCommand('insertText', false, insertText); - } - if (!inserted) { - try { - document.execCommand("ms-beginUndoUnit"); - } catch (error) {} - textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText); - try { - document.execCommand("ms-endUndoUnit"); - } catch (error) {} - } - return this.moveCursor(textArea, tag, wrap, removedLastNewLine); -}; -gl.text.moveCursor = function(textArea, tag, wrapped, removedLastNewLine) { - var pos; - if (!textArea.setSelectionRange) { - return; - } - if (textArea.selectionStart === textArea.selectionEnd) { - if (wrapped) { - pos = textArea.selectionStart - tag.length; - } else { - pos = textArea.selectionStart; - } +/** + * Removes accents and converts to lower case + * @param {String} str + * @returns {String} + */ +export const slugify = str => str.trim().toLowerCase(); - if (removedLastNewLine) { - pos -= 1; - } +/** + * Truncates given text + * + * @param {String} string + * @param {Number} maxLength + * @returns {String} + */ +export const truncate = (string, maxLength) => `${string.substr(0, (maxLength - 3))}...`; - return textArea.setSelectionRange(pos, pos); - } -}; -gl.text.updateText = function(textArea, tag, blockTag, wrap) { - var $textArea, selected, text; - $textArea = $(textArea); - textArea = $textArea.get(0); - text = $textArea.val(); - selected = this.selectedText(text, textArea); - $textArea.focus(); - return this.insertText(textArea, text, tag, blockTag, selected, wrap); -}; -gl.text.init = function(form) { - var self; - self = this; - return $('.js-md', form).off('click').on('click', function() { - var $this; - $this = $(this); - return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend')); - }); -}; -gl.text.removeListeners = function(form) { - return $('.js-md', form).off('click'); -}; -gl.text.humanize = function(string) { - return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); -}; -gl.text.pluralize = function(str, count) { - return str + (count > 1 || count === 0 ? 's' : ''); -}; -gl.text.truncate = function(string, maxLength) { - return string.substr(0, (maxLength - 3)) + '...'; -}; -gl.text.dasherize = function(str) { - return str.replace(/[_\s]+/g, '-'); -}; -gl.text.slugify = function(str) { - return str.trim().toLowerCase().latinise(); -}; diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 1aa63216baf..17236c91490 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -100,6 +100,10 @@ export function visitUrl(url, external = false) { } } +export function redirectTo(url) { + return window.location.assign(url); +} + window.gl = window.gl || {}; window.gl.utils = { ...(window.gl.utils || {}), diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 31c5cfc5e55..0035dd23011 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -30,7 +30,6 @@ import './commit/image_file'; import { handleLocationHash } from './lib/utils/common_utils'; import './lib/utils/datetime_utility'; import './lib/utils/pretty_time'; -import './lib/utils/text_utility'; import './lib/utils/url_utility'; // behaviors @@ -70,8 +69,6 @@ import './notifications_dropdown'; import './notifications_form'; import './pager'; import './preview_markdown'; -import './project'; -import './project_avatar'; import './project_find_file'; import './project_import'; import './project_label_subscription'; diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index 6264750a4fb..52315e969d1 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -5,7 +5,6 @@ export default class Members { } addListeners() { - $('.project_member, .group_member').off('ajax:success').on('ajax:success', this.removeRow); $('.js-member-update-control').off('change').on('change', this.formSubmit.bind(this)); $('.js-edit-member-form').off('ajax:success').on('ajax:success', this.formSuccess.bind(this)); gl.utils.disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change'); @@ -33,17 +32,6 @@ export default class Members { }); }); } - // eslint-disable-next-line class-methods-use-this - removeRow(e) { - const $target = $(e.target); - - if ($target.hasClass('btn-remove')) { - $target.closest('.member') - .fadeOut(function fadeOutMemberRow() { - $(this).remove(); - }); - } - } formSubmit(e, $el = null) { const $this = e ? $(e.currentTarget) : $el; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index af0658eb668..d30ff12bb59 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -5,6 +5,7 @@ import 'vendor/jquery.waitforimages'; import TaskList from './task_list'; import './merge_request_tabs'; import IssuablesHelper from './helpers/issuables_helper'; +import { addDelimiter } from './lib/utils/text_utility'; (function() { this.MergeRequest = (function() { @@ -124,7 +125,7 @@ import IssuablesHelper from './helpers/issuables_helper'; const $el = $('.nav-links .js-merge-counter'); const count = Math.max((parseInt($el.text().replace(/[^\d]/, ''), 10) - by), 0); - $el.text(gl.text.addDelimiter(count)); + $el.text(addDelimiter(count)); }; MergeRequest.prototype.hideCloseButton = function() { diff --git a/app/assets/javascripts/notes/components/issue_comment_form.vue b/app/assets/javascripts/notes/components/issue_comment_form.vue index db8f85759b2..30e02554b65 100644 --- a/app/assets/javascripts/notes/components/issue_comment_form.vue +++ b/app/assets/javascripts/notes/components/issue_comment_form.vue @@ -357,7 +357,8 @@ @click="handleSave(true)" v-if="canUpdateIssue" :class="actionButtonClassNames" - class="btn btn-comment btn-comment-and-close"> + :disabled="isSubmitting" + class="btn btn-comment btn-comment-and-close js-action-button"> {{issueActionButtonTitle}} </button> <button diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index 547140b1a43..19d8e1f49cf 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -1,7 +1,7 @@ <script> import tooltip from '../../../vue_shared/directives/tooltip'; import icon from '../../../vue_shared/components/icon.vue'; - + import { dasherize } from '../../../lib/utils/text_utility'; /** * Renders either a cancel, retry or play icon pointing to the given path. * TODO: Remove UJS from here and use an async request instead. @@ -39,7 +39,7 @@ computed: { cssClass() { - const actionIconDash = gl.text.dasherize(this.actionIcon); + const actionIconDash = dasherize(this.actionIcon); return `${actionIconDash} js-icon-${actionIconDash}`; }, }, diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js index fe6602259e2..ddb78aaeea1 100644 --- a/app/assets/javascripts/project.js +++ b/app/assets/javascripts/project.js @@ -1,139 +1,131 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, quotes, consistent-return, no-new, prefer-arrow-callback, no-return-assign, one-var, one-var-declaration-per-line, object-shorthand, comma-dangle, no-else-return, newline-per-chained-call, no-shadow, vars-on-top, prefer-template, max-len */ +/* eslint-disable func-names, space-before-function-paren, no-var, consistent-return, no-new, prefer-arrow-callback, no-return-assign, one-var, one-var-declaration-per-line, object-shorthand, no-else-return, newline-per-chained-call, no-shadow, vars-on-top, prefer-template, max-len */ /* global ProjectSelect */ import Cookies from 'js-cookie'; -(function() { - this.Project = (function() { - function Project() { - const $cloneOptions = $('ul.clone-options-dropdown'); - const $projectCloneField = $('#project_clone'); - const $cloneBtnText = $('a.clone-dropdown-btn span'); +export default class Project { + constructor() { + const $cloneOptions = $('ul.clone-options-dropdown'); + const $projectCloneField = $('#project_clone'); + const $cloneBtnText = $('a.clone-dropdown-btn span'); - const selectedCloneOption = $cloneBtnText.text().trim(); - if (selectedCloneOption.length > 0) { - $(`a:contains('${selectedCloneOption}')`, $cloneOptions).addClass('is-active'); - } - - $('a', $cloneOptions).on('click', (e) => { - const $this = $(e.currentTarget); - const url = $this.attr('href'); - - e.preventDefault(); - - $('.is-active', $cloneOptions).not($this).removeClass('is-active'); - $this.toggleClass('is-active'); - $projectCloneField.val(url); - $cloneBtnText.text($this.text()); - - return $('.clone').text(url); - }); - // Ref switcher - this.initRefSwitcher(); - $('.project-refs-select').on('change', function() { - return $(this).parents('form').submit(); - }); - $('.hide-no-ssh-message').on('click', function(e) { - Cookies.set('hide_no_ssh_message', 'false'); - $(this).parents('.no-ssh-key-message').remove(); - return e.preventDefault(); - }); - $('.hide-no-password-message').on('click', function(e) { - Cookies.set('hide_no_password_message', 'false'); - $(this).parents('.no-password-message').remove(); - return e.preventDefault(); - }); - this.projectSelectDropdown(); + const selectedCloneOption = $cloneBtnText.text().trim(); + if (selectedCloneOption.length > 0) { + $(`a:contains('${selectedCloneOption}')`, $cloneOptions).addClass('is-active'); } - Project.prototype.projectSelectDropdown = function() { - new ProjectSelect(); - $('.project-item-select').on('click', (function(_this) { - return function(e) { - return _this.changeProject($(e.currentTarget).val()); - }; - })(this)); - }; - - Project.prototype.changeProject = function(url) { - return window.location = url; - }; - - Project.prototype.initRefSwitcher = function() { - var refListItem = document.createElement('li'); - var refLink = document.createElement('a'); - - refLink.href = '#'; - - return $('.js-project-refs-dropdown').each(function() { - var $dropdown, selected; - $dropdown = $(this); - selected = $dropdown.data('selected'); - return $dropdown.glDropdown({ - data: function(term, callback) { - return $.ajax({ - url: $dropdown.data('refs-url'), - data: { - ref: $dropdown.data('ref'), - search: term - }, - dataType: "json" - }).done(function(refs) { - return callback(refs); - }); - }, - selectable: true, - filterable: true, - filterRemote: true, - filterByText: true, - inputFieldName: $dropdown.data('input-field-name'), - fieldName: $dropdown.data('field-name'), - renderRow: function(ref) { - var li = refListItem.cloneNode(false); - - if (ref.header != null) { - li.className = 'dropdown-header'; - li.textContent = ref.header; - } else { - var link = refLink.cloneNode(false); - - if (ref === selected) { - link.className = 'is-active'; - } - - link.textContent = ref; - link.dataset.ref = ref; - - li.appendChild(link); + $('a', $cloneOptions).on('click', (e) => { + const $this = $(e.currentTarget); + const url = $this.attr('href'); + + e.preventDefault(); + + $('.is-active', $cloneOptions).not($this).removeClass('is-active'); + $this.toggleClass('is-active'); + $projectCloneField.val(url); + $cloneBtnText.text($this.text()); + + return $('.clone').text(url); + }); + // Ref switcher + Project.initRefSwitcher(); + $('.project-refs-select').on('change', function() { + return $(this).parents('form').submit(); + }); + $('.hide-no-ssh-message').on('click', function(e) { + Cookies.set('hide_no_ssh_message', 'false'); + $(this).parents('.no-ssh-key-message').remove(); + return e.preventDefault(); + }); + $('.hide-no-password-message').on('click', function(e) { + Cookies.set('hide_no_password_message', 'false'); + $(this).parents('.no-password-message').remove(); + return e.preventDefault(); + }); + Project.projectSelectDropdown(); + } + + static projectSelectDropdown () { + new ProjectSelect(); + $('.project-item-select').on('click', e => Project.changeProject($(e.currentTarget).val())); + } + + static changeProject(url) { + return window.location = url; + } + + static initRefSwitcher() { + var refListItem = document.createElement('li'); + var refLink = document.createElement('a'); + + refLink.href = '#'; + + return $('.js-project-refs-dropdown').each(function() { + var $dropdown, selected; + $dropdown = $(this); + selected = $dropdown.data('selected'); + return $dropdown.glDropdown({ + data: function(term, callback) { + return $.ajax({ + url: $dropdown.data('refs-url'), + data: { + ref: $dropdown.data('ref'), + search: term, + }, + dataType: 'json', + }).done(function(refs) { + return callback(refs); + }); + }, + selectable: true, + filterable: true, + filterRemote: true, + filterByText: true, + inputFieldName: $dropdown.data('input-field-name'), + fieldName: $dropdown.data('field-name'), + renderRow: function(ref) { + var li = refListItem.cloneNode(false); + + if (ref.header != null) { + li.className = 'dropdown-header'; + li.textContent = ref.header; + } else { + var link = refLink.cloneNode(false); + + if (ref === selected) { + link.className = 'is-active'; } - return li; - }, - id: function(obj, $el) { - return $el.attr('data-ref'); - }, - toggleLabel: function(obj, $el) { - return $el.text().trim(); - }, - clicked: function(options) { - const { e } = options; - e.preventDefault(); - if ($('input[name="ref"]').length) { - var $form = $dropdown.closest('form'); - - var $visit = $dropdown.data('visit'); - var shouldVisit = $visit ? true : $visit; - var action = $form.attr('action'); - var divider = action.indexOf('?') === -1 ? '?' : '&'; - if (shouldVisit) { - gl.utils.visitUrl(`${action}${divider}${$form.serialize()}`); - } + link.textContent = ref; + link.dataset.ref = ref; + + li.appendChild(link); + } + + return li; + }, + id: function(obj, $el) { + return $el.attr('data-ref'); + }, + toggleLabel: function(obj, $el) { + return $el.text().trim(); + }, + clicked: function(options) { + const { e } = options; + e.preventDefault(); + if ($('input[name="ref"]').length) { + var $form = $dropdown.closest('form'); + + var $visit = $dropdown.data('visit'); + var shouldVisit = $visit ? true : $visit; + var action = $form.attr('action'); + var divider = action.indexOf('?') === -1 ? '?' : '&'; + if (shouldVisit) { + gl.utils.visitUrl(`${action}${divider}${$form.serialize()}`); } } - }); + }, }); - }; - - return Project; - })(); -}).call(window); + }); + } +} diff --git a/app/assets/javascripts/project_avatar.js b/app/assets/javascripts/project_avatar.js index aabdfbf65e2..56627aa155c 100644 --- a/app/assets/javascripts/project_avatar.js +++ b/app/assets/javascripts/project_avatar.js @@ -1,20 +1,13 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, no-useless-escape, max-len */ -(function() { - this.ProjectAvatar = (function() { - function ProjectAvatar() { - $('.js-choose-project-avatar-button').bind('click', function() { - var form; - form = $(this).closest('form'); - return form.find('.js-project-avatar-input').click(); - }); - $('.js-project-avatar-input').bind('change', function() { - var filename, form; - form = $(this).closest('form'); - filename = $(this).val().replace(/^.*[\\\/]/, ''); - return form.find('.js-avatar-filename').text(filename); - }); - } +export default function projectAvatar() { + $('.js-choose-project-avatar-button').bind('click', function onClickAvatar() { + const form = $(this).closest('form'); + return form.find('.js-project-avatar-input').click(); + }); - return ProjectAvatar; - })(); -}).call(window); + $('.js-project-avatar-input').bind('change', function onClickAvatarInput() { + const form = $(this).closest('form'); + // eslint-disable-next-line no-useless-escape + const filename = $(this).val().replace(/^.*[\\\/]/, ''); + return form.find('.js-avatar-filename').text(filename); + }); +} diff --git a/app/assets/javascripts/project_import.js b/app/assets/javascripts/project_import.js index 08334bf1ec5..d2d26d6f67e 100644 --- a/app/assets/javascripts/project_import.js +++ b/app/assets/javascripts/project_import.js @@ -1,13 +1,8 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, max-len */ +import { visitUrl } from './lib/utils/url_utility'; -(function() { - this.ProjectImport = (function() { - function ProjectImport() { - setTimeout(function() { - return gl.utils.visitUrl(location.href); - }, 5000); - } +export default function projectImport() { + setTimeout(() => { + visitUrl(location.href); + }, 5000); +} - return ProjectImport; - })(); -}).call(window); diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index e917279947e..14d43e135fe 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -8,6 +8,7 @@ import tooltip from '../../vue_shared/directives/tooltip'; import timeagoMixin from '../../vue_shared/mixins/timeago'; import { errorMessages, errorMessagesTypes } from '../constants'; + import { numberToHumanSize } from '../../lib/utils/number_utils'; export default { props: { @@ -41,6 +42,10 @@ return item.layers ? n__('%d layer', '%d layers', item.layers) : ''; }, + formatSize(size) { + return numberToHumanSize(size); + }, + handleDeleteRegistry(registry) { this.deleteRegistry(registry) .then(() => this.fetchList({ repo: this.repo })) @@ -97,7 +102,7 @@ </span> </td> <td> - {{item.size}} + {{formatSize(item.size)}} <template v-if="item.size && item.layers"> · </template> diff --git a/app/assets/javascripts/repo/components/repo_file.vue b/app/assets/javascripts/repo/components/repo_file.vue index 7a23154b340..5be47d568e7 100644 --- a/app/assets/javascripts/repo/components/repo_file.vue +++ b/app/assets/javascripts/repo/components/repo_file.vue @@ -1,11 +1,15 @@ <script> import { mapActions, mapGetters } from 'vuex'; import timeAgoMixin from '../../vue_shared/mixins/timeago'; + import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue'; export default { mixins: [ timeAgoMixin, ], + components: { + skeletonLoadingContainer, + }, props: { file: { type: Object, @@ -16,6 +20,9 @@ ...mapGetters([ 'isCollapsed', ]), + isSubmodule() { + return this.file.type === 'submodule'; + }, fileIcon() { return { 'fa-spinner fa-spin': this.file.loading, @@ -31,6 +38,9 @@ shortId() { return this.file.id.substr(0, 8); }, + submoduleColSpan() { + return !this.isCollapsed && this.isSubmodule ? 3 : 1; + }, }, methods: { ...mapActions([ @@ -44,7 +54,10 @@ <tr class="file" @click.prevent="clickedTreeRow(file)"> - <td> + <td + class="multi-file-table-col-name" + :colspan="submoduleColSpan" + > <i class="fa fa-fw file-icon" :class="fileIcon" @@ -58,7 +71,7 @@ > {{ file.name }} </a> - <template v-if="file.type === 'submodule' && file.id"> + <template v-if="isSubmodule && file.id"> @ <span class="commit-sha"> <a @@ -71,15 +84,20 @@ </template> </td> - <template v-if="!isCollapsed"> + <template v-if="!isCollapsed && !isSubmodule"> <td class="hidden-sm hidden-xs"> <a + v-if="file.lastCommit.message" @click.stop :href="file.lastCommit.url" class="commit-message" > {{ file.lastCommit.message }} </a> + <skeleton-loading-container + v-else + :small="true" + /> </td> <td class="commit-update hidden-xs text-right"> @@ -89,6 +107,11 @@ > {{ timeFormated(file.lastCommit.updatedAt) }} </span> + <skeleton-loading-container + v-else + class="animation-container-right" + :small="true" + /> </td> </template> </tr> diff --git a/app/assets/javascripts/repo/components/repo_loading_file.vue b/app/assets/javascripts/repo/components/repo_loading_file.vue index 1e6c405f292..8fa637d771f 100644 --- a/app/assets/javascripts/repo/components/repo_loading_file.vue +++ b/app/assets/javascripts/repo/components/repo_loading_file.vue @@ -1,17 +1,16 @@ <script> import { mapGetters } from 'vuex'; + import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue'; export default { + components: { + skeletonLoadingContainer, + }, computed: { ...mapGetters([ 'isCollapsed', ]), }, - methods: { - lineOfCode(n) { - return `skeleton-line-${n}`; - }, - }, }; </script> @@ -20,37 +19,25 @@ class="loading-file" aria-label="Loading files" > - <td> - <div - class="animation-container animation-container-small"> - <div - v-for="n in 6" - :key="n" - :class="lineOfCode(n)"> - </div> - </div> + <td class="multi-file-table-col-name"> + <skeleton-loading-container + :small="true" + /> </td> <template v-if="!isCollapsed"> <td class="hidden-sm hidden-xs"> - <div class="animation-container"> - <div - v-for="n in 6" - :key="n" - :class="lineOfCode(n)"> - </div> - </div> + <skeleton-loading-container + :small="true" + /> </td> <td class="hidden-xs"> - <div class="animation-container animation-container-small animation-container-right"> - <div - v-for="n in 6" - :key="n" - :class="lineOfCode(n)"> - </div> - </div> + <skeleton-loading-container + class="animation-container-right" + :small="true" + /> </td> </template> </tr> diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue index 63c0d70f5c0..9365b09326f 100644 --- a/app/assets/javascripts/repo/components/repo_sidebar.vue +++ b/app/assets/javascripts/repo/components/repo_sidebar.vue @@ -57,7 +57,7 @@ export default { </strong> </th> <template v-else> - <th class="name"> + <th class="name multi-file-table-col-name"> Name </th> <th class="hidden-sm hidden-xs last-commit"> @@ -80,7 +80,7 @@ export default { /> <repo-file v-for="(file, index) in treeList" - :key="index" + :key="file.key" :file="file" /> </tbody> diff --git a/app/assets/javascripts/repo/services/index.js b/app/assets/javascripts/repo/services/index.js index dc222ccac01..2fb45dcb03c 100644 --- a/app/assets/javascripts/repo/services/index.js +++ b/app/assets/javascripts/repo/services/index.js @@ -30,4 +30,11 @@ export default { commit(projectId, payload) { return Api.commitMultiple(projectId, payload); }, + getTreeLastCommit(endpoint) { + return Vue.http.get(endpoint, { + params: { + format: 'json', + }, + }); + }, }; diff --git a/app/assets/javascripts/repo/stores/actions.js b/app/assets/javascripts/repo/stores/actions.js index ca2f2a5ce7a..120ce96f44d 100644 --- a/app/assets/javascripts/repo/stores/actions.js +++ b/app/assets/javascripts/repo/stores/actions.js @@ -3,7 +3,7 @@ import flash from '../../flash'; import service from '../services'; import * as types from './mutation_types'; -export const redirectToUrl = url => gl.utils.visitUrl(url); +export const redirectToUrl = (_, url) => gl.utils.visitUrl(url); export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); @@ -64,7 +64,7 @@ export const checkCommitStatus = ({ state }) => service.getBranchData( }) .catch(() => flash('Error checking branch data. Please try again.')); -export const commitChanges = ({ commit, state, dispatch }, { payload, newMr }) => +export const commitChanges = ({ commit, state, dispatch, getters }, { payload, newMr }) => service.commit(state.project.id, payload) .then((data) => { const { branch } = payload; @@ -73,12 +73,28 @@ export const commitChanges = ({ commit, state, dispatch }, { payload, newMr }) = return; } + const lastCommit = { + commit_path: `${state.project.url}/commit/${data.id}`, + commit: { + message: data.message, + authored_date: data.committed_date, + }, + }; + flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice'); if (newMr) { - redirectToUrl(`${state.endpoints.newMergeRequestUrl}${branch}`); + dispatch('redirectToUrl', `${state.endpoints.newMergeRequestUrl}${branch}`); } else { commit(types.SET_COMMIT_REF, data.id); + + getters.changedFiles.forEach((entry) => { + commit(types.SET_LAST_COMMIT_DATA, { + entry, + lastCommit, + }); + }); + dispatch('discardAllChanges'); dispatch('closeAllFiles'); dispatch('toggleEditMode'); diff --git a/app/assets/javascripts/repo/stores/actions/branch.js b/app/assets/javascripts/repo/stores/actions/branch.js index b81a70dfd1e..61d9a5af3e3 100644 --- a/app/assets/javascripts/repo/stores/actions/branch.js +++ b/app/assets/javascripts/repo/stores/actions/branch.js @@ -3,16 +3,16 @@ import * as types from '../mutation_types'; import { pushState } from '../utils'; // eslint-disable-next-line import/prefer-default-export -export const createNewBranch = ({ rootState, commit }, branch) => service.createBranch( - rootState.project.id, +export const createNewBranch = ({ state, commit }, branch) => service.createBranch( + state.project.id, { branch, - ref: rootState.currentBranch, + ref: state.currentBranch, }, ).then(res => res.json()) .then((data) => { const branchName = data.name; - const url = location.href.replace(rootState.currentBranch, branchName); + const url = location.href.replace(state.currentBranch, branchName); pushState(url); diff --git a/app/assets/javascripts/repo/stores/actions/file.js b/app/assets/javascripts/repo/stores/actions/file.js index afbe0b78a82..5bae4fa826a 100644 --- a/app/assets/javascripts/repo/stores/actions/file.js +++ b/app/assets/javascripts/repo/stores/actions/file.js @@ -27,6 +27,8 @@ export const closeFile = ({ commit, state, dispatch }, { file, force = false }) } else if (!state.openFiles.length) { pushState(file.parentTreeUrl); } + + dispatch('getLastCommitData'); }; export const setFileActive = ({ commit, state, getters, dispatch }, file) => { diff --git a/app/assets/javascripts/repo/stores/actions/tree.js b/app/assets/javascripts/repo/stores/actions/tree.js index 129743c66c2..aa830e946a2 100644 --- a/app/assets/javascripts/repo/stores/actions/tree.js +++ b/app/assets/javascripts/repo/stores/actions/tree.js @@ -7,10 +7,11 @@ import { setPageTitle, findEntry, createTemp, + createOrMergeEntry, } from '../utils'; export const getTreeData = ( - { commit, state }, + { commit, state, dispatch }, { endpoint = state.endpoints.rootEndpoint, tree = state } = {}, ) => { commit(types.TOGGLE_LOADING, tree); @@ -24,14 +25,20 @@ export const getTreeData = ( return res.json(); }) .then((data) => { + const prevLastCommitPath = tree.lastCommitPath; if (!state.isInitialRoot) { commit(types.SET_ROOT, data.path === '/'); } - commit(types.SET_DIRECTORY_DATA, { data, tree }); + dispatch('updateDirectoryData', { data, tree }); commit(types.SET_PARENT_TREE_URL, data.parent_tree_url); + commit(types.SET_LAST_COMMIT_URL, { tree, url: data.last_commit_path }); commit(types.TOGGLE_LOADING, tree); + if (prevLastCommitPath !== null) { + dispatch('getLastCommitData', tree); + } + pushState(endpoint); }) .catch(() => { @@ -48,7 +55,7 @@ export const toggleTreeOpen = ({ commit, dispatch }, { endpoint, tree }) => { pushState(tree.parentTreeUrl); commit(types.SET_PREVIOUS_URL, tree.parentTreeUrl); - commit(types.SET_DIRECTORY_DATA, { data, tree }); + dispatch('updateDirectoryData', { data, tree }); } else { commit(types.SET_PREVIOUS_URL, endpoint); dispatch('getTreeData', { endpoint, tree }); @@ -108,3 +115,48 @@ export const createTempTree = ({ state, commit, dispatch }, name) => { }); } }; + +export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => { + if (tree.lastCommitPath === null || getters.isCollapsed) return; + + service.getTreeLastCommit(tree.lastCommitPath) + .then((res) => { + const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null; + + commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath }); + + return res.json(); + }) + .then((data) => { + data.forEach((lastCommit) => { + const entry = findEntry(tree, lastCommit.type, lastCommit.file_name); + + if (entry) { + commit(types.SET_LAST_COMMIT_DATA, { entry, lastCommit }); + } + }); + + dispatch('getLastCommitData', tree); + }) + .catch(() => flash('Error fetching log data.')); +}; + +export const updateDirectoryData = ({ commit, state }, { data, tree }) => { + const level = tree.level !== undefined ? tree.level + 1 : 0; + const parentTreeUrl = data.parent_tree_url ? `${data.parent_tree_url}${data.path}` : state.endpoints.rootUrl; + const createEntry = (entry, type) => createOrMergeEntry({ + tree, + entry, + level, + type, + parentTreeUrl, + }); + + const formattedData = [ + ...data.trees.map(t => createEntry(t, 'tree')), + ...data.submodules.map(m => createEntry(m, 'submodule')), + ...data.blobs.map(b => createEntry(b, 'blob')), + ]; + + commit(types.SET_DIRECTORY_DATA, { tree, data: formattedData }); +}; diff --git a/app/assets/javascripts/repo/stores/mutation_types.js b/app/assets/javascripts/repo/stores/mutation_types.js index 4722a7dd0df..bc3390f1506 100644 --- a/app/assets/javascripts/repo/stores/mutation_types.js +++ b/app/assets/javascripts/repo/stores/mutation_types.js @@ -4,11 +4,13 @@ export const SET_COMMIT_REF = 'SET_COMMIT_REF'; export const SET_PARENT_TREE_URL = 'SET_PARENT_TREE_URL'; export const SET_ROOT = 'SET_ROOT'; export const SET_PREVIOUS_URL = 'SET_PREVIOUS_URL'; +export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA'; // Tree mutation types export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA'; export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN'; export const CREATE_TMP_TREE = 'CREATE_TMP_TREE'; +export const SET_LAST_COMMIT_URL = 'SET_LAST_COMMIT_URL'; // File mutation types export const SET_FILE_DATA = 'SET_FILE_DATA'; diff --git a/app/assets/javascripts/repo/stores/mutations.js b/app/assets/javascripts/repo/stores/mutations.js index 2f9b038322b..ae2ba5bedf7 100644 --- a/app/assets/javascripts/repo/stores/mutations.js +++ b/app/assets/javascripts/repo/stores/mutations.js @@ -48,6 +48,13 @@ export default { previousUrl, }); }, + [types.SET_LAST_COMMIT_DATA](state, { entry, lastCommit }) { + Object.assign(entry.lastCommit, { + url: lastCommit.commit_path, + message: lastCommit.commit.message, + updatedAt: lastCommit.commit.authored_date, + }); + }, ...fileMutations, ...treeMutations, ...branchMutations, diff --git a/app/assets/javascripts/repo/stores/mutations/tree.js b/app/assets/javascripts/repo/stores/mutations/tree.js index 52be2673107..130221c9fda 100644 --- a/app/assets/javascripts/repo/stores/mutations/tree.js +++ b/app/assets/javascripts/repo/stores/mutations/tree.js @@ -1,5 +1,4 @@ import * as types from '../mutation_types'; -import * as utils from '../utils'; export default { [types.TOGGLE_TREE_OPEN](state, tree) { @@ -8,30 +7,8 @@ export default { }); }, [types.SET_DIRECTORY_DATA](state, { data, tree }) { - const level = tree.level !== undefined ? tree.level + 1 : 0; - const parentTreeUrl = data.parent_tree_url ? `${data.parent_tree_url}${data.path}` : state.endpoints.rootUrl; - Object.assign(tree, { - tree: [ - ...data.trees.map(t => utils.decorateData({ - ...t, - type: 'tree', - parentTreeUrl, - level, - }, state.project.url)), - ...data.submodules.map(m => utils.decorateData({ - ...m, - type: 'submodule', - parentTreeUrl, - level, - }, state.project.url)), - ...data.blobs.map(b => utils.decorateData({ - ...b, - type: 'blob', - parentTreeUrl, - level, - }, state.project.url)), - ], + tree: data, }); }, [types.SET_PARENT_TREE_URL](state, url) { @@ -39,6 +16,11 @@ export default { parentTreeUrl: url, }); }, + [types.SET_LAST_COMMIT_URL](state, { tree = state, url }) { + Object.assign(tree, { + lastCommitPath: url, + }); + }, [types.CREATE_TMP_TREE](state, { parent, tmpEntry }) { parent.tree.push(tmpEntry); }, diff --git a/app/assets/javascripts/repo/stores/state.js b/app/assets/javascripts/repo/stores/state.js index aab74754f02..0068834831e 100644 --- a/app/assets/javascripts/repo/stores/state.js +++ b/app/assets/javascripts/repo/stores/state.js @@ -8,6 +8,7 @@ export default () => ({ endpoints: {}, isRoot: false, isInitialRoot: false, + lastCommitPath: '', loading: false, onTopOfBranch: false, openFiles: [], diff --git a/app/assets/javascripts/repo/stores/utils.js b/app/assets/javascripts/repo/stores/utils.js index 797c2b1e5b9..fae1f4439a9 100644 --- a/app/assets/javascripts/repo/stores/utils.js +++ b/app/assets/javascripts/repo/stores/utils.js @@ -1,5 +1,6 @@ export const dataStructure = () => ({ id: '', + key: '', type: '', name: '', url: '', @@ -12,7 +13,12 @@ export const dataStructure = () => ({ opened: false, active: false, changed: false, - lastCommit: {}, + lastCommitPath: '', + lastCommit: { + url: '', + message: '', + updatedAt: '', + }, tree_url: '', blamePath: '', commitsPath: '', @@ -27,14 +33,13 @@ export const dataStructure = () => ({ base64: false, }); -export const decorateData = (entity, projectUrl = '') => { +export const decorateData = (entity) => { const { id, type, url, name, icon, - last_commit, tree_url, path, renderError, @@ -51,6 +56,7 @@ export const decorateData = (entity, projectUrl = '') => { return { ...dataStructure(), id, + key: `${name}-${type}-${id}`, type, name, url, @@ -66,12 +72,6 @@ export const decorateData = (entity, projectUrl = '') => { renderError, content, base64, - // eslint-disable-next-line camelcase - lastCommit: last_commit ? { - url: `${projectUrl}/commit/${last_commit.id}`, - message: last_commit.message, - updatedAt: last_commit.committed_date, - } : {}, }; }; @@ -106,3 +106,22 @@ export const createTemp = ({ name, path, type, level, changed, content, base64 } renderError: base64, }); }; + +export const createOrMergeEntry = ({ tree, entry, type, parentTreeUrl, level }) => { + const found = findEntry(tree, type, entry.name); + + if (found) { + return Object.assign({}, found, { + id: entry.id, + url: entry.url, + tempFile: false, + }); + } + + return decorateData({ + ...entry, + type, + parentTreeUrl, + level, + }); +}; diff --git a/app/assets/javascripts/smart_interval.js b/app/assets/javascripts/smart_interval.js index 2bf7a3a5d61..8e931995fc6 100644 --- a/app/assets/javascripts/smart_interval.js +++ b/app/assets/javascripts/smart_interval.js @@ -3,9 +3,10 @@ * and controllable by a public API. */ -class SmartInterval { +export default class SmartInterval { /** - * @param { function } opts.callback Function to be called on each iteration (required) + * @param { function } opts.callback Function that returns a promise, called on each iteration + * unless still in progress (required) * @param { milliseconds } opts.startingInterval `currentInterval` is set to this initially * @param { milliseconds } opts.maxInterval `currentInterval` will be incremented to this * @param { milliseconds } opts.hiddenInterval `currentInterval` is set to this @@ -42,13 +43,16 @@ class SmartInterval { const cfg = this.cfg; const state = this.state; - if (cfg.immediateExecution) { + if (cfg.immediateExecution && !this.isLoading) { cfg.immediateExecution = false; - cfg.callback(); + this.triggerCallback(); } state.intervalId = window.setInterval(() => { - cfg.callback(); + if (this.isLoading) { + return; + } + this.triggerCallback(); if (this.getCurrentInterval() === cfg.maxInterval) { return; @@ -76,7 +80,7 @@ class SmartInterval { // start a timer, using the existing interval resume() { - this.stopTimer(); // stop exsiting timer, in case timer was not previously stopped + this.stopTimer(); // stop existing timer, in case timer was not previously stopped this.start(); } @@ -104,6 +108,18 @@ class SmartInterval { this.initPageUnloadHandling(); } + triggerCallback() { + this.isLoading = true; + this.cfg.callback() + .then(() => { + this.isLoading = false; + }) + .catch((err) => { + this.isLoading = false; + throw err; + }); + } + initVisibilityChangeHandling() { // cancel interval when tab no longer shown (prevents cached pages from polling) document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this)); @@ -154,4 +170,3 @@ class SmartInterval { } } -window.gl.SmartInterval = SmartInterval; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js index 219ff94924e..13e4cb5717e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js @@ -1,5 +1,5 @@ import tooltip from '../../vue_shared/directives/tooltip'; -import '../../lib/utils/text_utility'; +import { pluralize } from '../../lib/utils/text_utility'; export default { name: 'MRWidgetHeader', @@ -14,7 +14,7 @@ export default { return this.mr.divergedCommitsCount > 0; }, commitsText() { - return gl.text.pluralize('commit', this.mr.divergedCommitsCount); + return pluralize('commit', this.mr.divergedCommitsCount); }, branchNameClipboardData() { // This supports code in app/assets/javascripts/copy_to_clipboard.js that diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js deleted file mode 100644 index 029832bdd27..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js +++ /dev/null @@ -1,90 +0,0 @@ -import PipelineStage from '../../pipelines/components/stage.vue'; -import ciIcon from '../../vue_shared/components/ci_icon.vue'; -import icon from '../../vue_shared/components/icon.vue'; - -export default { - name: 'MRWidgetPipeline', - props: { - mr: { type: Object, required: true }, - }, - components: { - 'pipeline-stage': PipelineStage, - ciIcon, - icon, - }, - computed: { - hasPipeline() { - return this.mr.pipeline && Object.keys(this.mr.pipeline).length > 0; - }, - hasCIError() { - const { hasCI, ciStatus } = this.mr; - - return hasCI && !ciStatus; - }, - stageText() { - return this.mr.pipeline.details.stages.length > 1 ? 'stages' : 'stage'; - }, - status() { - return this.mr.pipeline.details.status || {}; - }, - }, - template: ` - <div - v-if="hasPipeline || hasCIError" - class="mr-widget-heading"> - <div class="ci-widget media"> - <template v-if="hasCIError"> - <div class="ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-10"> - <span - aria-hidden="true"> - <icon - name="status_failed"/> - </span> - </div> - <div class="media-body"> - Could not connect to the CI server. Please check your settings and try again - </div> - </template> - <template v-else-if="hasPipeline"> - <div class="ci-status-icon append-right-10"> - <a - class="icon-link" - :href="this.status.details_path"> - <ci-icon :status="status" /> - </a> - </div> - <div class="media-body"> - <span> - Pipeline - <a - :href="mr.pipeline.path" - class="pipeline-id">#{{mr.pipeline.id}}</a> - </span> - <span class="mr-widget-pipeline-graph"> - <span class="stage-cell"> - <div - v-if="mr.pipeline.details.stages.length > 0" - v-for="stage in mr.pipeline.details.stages" - class="stage-container dropdown js-mini-pipeline-graph"> - <pipeline-stage :stage="stage" /> - </div> - </span> - </span> - <span> - {{mr.pipeline.details.status.label}} for - <a - :href="mr.pipeline.commit.commit_path" - class="commit-sha js-commit-link"> - {{mr.pipeline.commit.short_id}}</a>. - </span> - <span - v-if="mr.pipeline.coverage" - class="js-mr-coverage"> - Coverage {{mr.pipeline.coverage}}% - </span> - </div> - </template> - </div> - </div> - `, -}; 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 new file mode 100644 index 00000000000..dbc65462377 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -0,0 +1,104 @@ +<script> + import pipelineStage from '../../pipelines/components/stage.vue'; + import ciIcon from '../../vue_shared/components/ci_icon.vue'; + import icon from '../../vue_shared/components/icon.vue'; + + export default { + name: 'MRWidgetPipeline', + props: { + pipeline: { + type: Object, + required: true, + }, + // This prop needs to be camelCase, html attributes are case insensive + // https://vuejs.org/v2/guide/components.html#camelCase-vs-kebab-case + hasCi: { + type: Boolean, + required: false, + }, + ciStatus: { + type: String, + required: false, + }, + }, + components: { + pipelineStage, + ciIcon, + icon, + }, + computed: { + hasPipeline() { + return this.pipeline && Object.keys(this.pipeline).length > 0; + }, + hasCIError() { + return this.hasCi && !this.ciStatus; + }, + status() { + return this.pipeline.details && + this.pipeline.details.status ? this.pipeline.details.status : {}; + }, + hasStages() { + return this.pipeline.details && + this.pipeline.details.stages && + this.pipeline.details.stages.length; + }, + }, + }; +</script> + +<template> + <div + v-if="hasPipeline || hasCIError" + class="mr-widget-heading"> + <div class="ci-widget media"> + <template v-if="hasCIError"> + <div class="ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-10"> + <icon name="status_failed" /> + </div> + <div class="media-body"> + Could not connect to the CI server. Please check your settings and try again + </div> + </template> + <template v-else-if="hasPipeline"> + <a + class="append-right-10" + :href="this.status.details_path"> + <ci-icon :status="status" /> + </a> + + <div class="media-body"> + Pipeline + <a + :href="pipeline.path" + class="pipeline-id"> + #{{pipeline.id}} + </a> + + {{pipeline.details.status.label}} for + + <a + :href="pipeline.commit.commit_path" + class="commit-sha js-commit-link"> + {{pipeline.commit.short_id}}</a>. + + <span class="mr-widget-pipeline-graph"> + <span class="stage-cell"> + <div + v-if="hasStages" + v-for="(stage, i) in pipeline.details.stages" + :key="i" + class="stage-container dropdown js-mini-pipeline-graph"> + <pipeline-stage :stage="stage" /> + </div> + </span> + </span> + + <template v-if="pipeline.coverage"> + Coverage {{pipeline.coverage}}% + </template> + + </div> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index 49340c232c8..5bd8b99420a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -13,7 +13,7 @@ export { default as Vue } from 'vue'; export { default as SmartInterval } from '~/smart_interval'; export { default as WidgetHeader } from './components/mr_widget_header'; export { default as WidgetMergeHelp } from './components/mr_widget_merge_help'; -export { default as WidgetPipeline } from './components/mr_widget_pipeline'; +export { default as WidgetPipeline } from './components/mr_widget_pipeline.vue'; export { default as WidgetDeployment } from './components/mr_widget_deployment'; export { default as WidgetRelatedLinks } from './components/mr_widget_related_links'; export { default as MergedState } from './components/states/mr_widget_merged'; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js index 4f497b204a3..f82938aa8a9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -1,3 +1,4 @@ +import SmartInterval from '~/smart_interval'; import Flash from '../flash'; import { WidgetHeader, @@ -81,7 +82,7 @@ export default { return new MRWidgetService(endpoints); }, checkStatus(cb) { - this.service.checkStatus() + return this.service.checkStatus() .then(res => res.json()) .then((res) => { this.handleNotification(res); @@ -97,7 +98,7 @@ export default { }); }, initPolling() { - this.pollingInterval = new gl.SmartInterval({ + this.pollingInterval = new SmartInterval({ callback: this.checkStatus, startingInterval: 10000, maxInterval: 30000, @@ -106,7 +107,7 @@ export default { }); }, initDeploymentsPolling() { - this.deploymentsInterval = new gl.SmartInterval({ + this.deploymentsInterval = new SmartInterval({ callback: this.fetchDeployments, startingInterval: 30000, maxInterval: 120000, @@ -121,7 +122,7 @@ export default { } }, fetchDeployments() { - this.service.fetchDeployments() + return this.service.fetchDeployments() .then(res => res.json()) .then((res) => { if (res.length) { @@ -235,7 +236,10 @@ export default { <mr-widget-header :mr="mr" /> <mr-widget-pipeline v-if="shouldRenderPipelines" - :mr="mr" /> + :pipeline="mr.pipeline" + :ci-status="mr.ciStatus" + :has-ci="mr.hasCI" + /> <mr-widget-deployment v-if="shouldRenderDeployments" :mr="mr" diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue index 6670b554faf..0cc2653761c 100644 --- a/app/assets/javascripts/vue_shared/components/loading_button.vue +++ b/app/assets/javascripts/vue_shared/components/loading_button.vue @@ -26,6 +26,11 @@ export default { required: false, default: false, }, + disabled: { + type: Boolean, + required: false, + default: false, + }, label: { type: String, required: false, @@ -47,7 +52,7 @@ export default { class="btn btn-align-content" @click="onClick" type="button" - :disabled="loading" + :disabled="loading || disabled" > <transition name="fade"> <loading-icon diff --git a/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue b/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue new file mode 100644 index 00000000000..b06493e6c66 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue @@ -0,0 +1,37 @@ +<script> + export default { + props: { + small: { + type: Boolean, + required: false, + default: false, + }, + lines: { + type: Number, + required: false, + default: 6, + }, + }, + computed: { + lineClasses() { + return new Array(this.lines).fill().map((_, i) => `skeleton-line-${i + 1}`); + }, + }, + }; +</script> + +<template> + <div + class="animation-container" + :class="{ + 'animation-container-small': small, + }" + > + <div + v-for="(css, index) in lineClasses" + :key="index" + :class="css" + > + </div> + </div> +</template> diff --git a/app/assets/javascripts/wikis.js b/app/assets/javascripts/wikis.js index a0025ddb598..7a865587444 100644 --- a/app/assets/javascripts/wikis.js +++ b/app/assets/javascripts/wikis.js @@ -1,4 +1,5 @@ import bp from './breakpoints'; +import { slugify } from './lib/utils/text_utility'; export default class Wikis { constructor() { @@ -23,7 +24,7 @@ export default class Wikis { if (!this.newWikiForm) return; const slugInput = this.newWikiForm.querySelector('#new_wiki_path'); - const slug = gl.text.slugify(slugInput.value); + const slug = slugify(slugInput.value); if (slug.length > 0) { const wikisPath = slugInput.getAttribute('data-wikis-path'); diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index c334f39f416..66212be1b8f 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -34,6 +34,7 @@ @import "framework/modal"; @import "framework/pagination"; @import "framework/panels"; +@import "framework/popup"; @import "framework/secondary-navigation-elements"; @import "framework/selects"; @import "framework/sidebar"; diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 1b944831082..374988bb590 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -23,16 +23,6 @@ @include webkit-prefix(animation-duration, 2s); } - &.spin-cw { - transform-origin: center; - animation: spin 4s linear infinite; - } - - &.spin-ccw { - transform-origin: center; - animation: spin 4s linear infinite reverse; - } - &.flipOutX, &.flipOutY, &.bounceIn, @@ -281,9 +271,3 @@ a { transform: translateX(468px); } } - -@keyframes spin { - 100% { - transform: rotate(360deg); - } -} diff --git a/app/assets/stylesheets/framework/blank.scss b/app/assets/stylesheets/framework/blank.scss index 6bb096fc5bd..10f9e9b70b0 100644 --- a/app/assets/stylesheets/framework/blank.scss +++ b/app/assets/stylesheets/framework/blank.scss @@ -7,29 +7,67 @@ width: 100%; height: 100%; padding-bottom: 25px; - border: 1px solid $border-color; border-radius: $border-radius-default; } } -.blank-state { - padding-top: 20px; - padding-bottom: 20px; +.blank-state-row { + display: flex; + flex-wrap: wrap; + justify-content: space-around; + height: 100%; +} + +.blank-state-welcome { text-align: center; + padding: 20px 0 40px; + + .blank-state-welcome-title { + font-size: 24px; + } + + .blank-state-text { + margin-bottom: 0; + } +} - &.blank-state-welcome { - .blank-state-welcome-title { - font-size: 24px; +.blank-state-link { + display: block; + color: $gl-text-color; + flex: 0 0 100%; + margin-bottom: 15px; + + @media (min-width: $screen-sm-min) { + flex: 0 0 49%; + + &:nth-child(odd) { + margin-right: 5px; } - .blank-state-text { - margin-bottom: 0; + &:nth-child(even) { + margin-left: 5px; } } - .blank-state-icon { - padding-bottom: 20px; + &:hover { + background-color: $gray-light; + text-decoration: none; + color: $gl-text-color; + } +} + +.blank-state { + padding: 20px; + border: 1px solid $border-color; + border-radius: $border-radius-default; + + @media (min-width: $screen-sm-min) { + display: flex; + align-items: center; + padding: 50px 30px; + } + .blank-state-icon { svg { display: block; margin: auto; @@ -38,13 +76,17 @@ .blank-state-title { margin-top: 0; - margin-bottom: 10px; font-size: 18px; } - .blank-state-text { - max-width: $container-text-max-width; - margin: 0 auto $gl-padding; - font-size: 14px; + .blank-state-body { + @media (max-width: $screen-xs-max) { + text-align: center; + margin-top: 20px; + } + + @media (min-width: $screen-sm-min) { + padding-left: 20px; + } } } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index c4a95afc4d2..b2f26cf7159 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -294,6 +294,7 @@ .btn-align-content { display: flex; + justify-content: center; align-items: center; } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 1cdfa904374..f0139b5f33a 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -401,10 +401,13 @@ .breadcrumbs-list { display: -webkit-flex; display: flex; - flex-wrap: wrap; margin-bottom: 0; line-height: 16px; + @media (max-width: $screen-xs-max) { + flex-wrap: wrap; + } + > li { display: flex; align-items: center; @@ -412,24 +415,35 @@ padding: 2px 0; &:not(:last-child) { - margin-right: 20px; + padding-right: 20px; + + &:not(.dropdown) { + overflow: hidden; + } } > a { font-size: 12px; color: currentColor; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 0 1 auto; } } } .breadcrumb-item-text { - @include str-truncated(128px); text-decoration: inherit; + + @media (max-width: $screen-xs-max) { + @include str-truncated(128px); + } } .breadcrumbs-list-angle { position: absolute; - right: -12px; + right: 7px; top: 50%; color: $gl-text-color-tertiary; transform: translateY(-50%); diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 16d5edde61e..33012133b66 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -180,3 +180,31 @@ display: none; } } + +@mixin triangle($color, $border-color, $size, $border-size) { + &::before, + &::after { + bottom: 100%; + left: 50%; + border: solid transparent; + content: ''; + height: 0; + width: 0; + position: absolute; + pointer-events: none; + } + + &::before { + border-color: transparent; + border-bottom-color: $border-color; + border-width: ($size + $border-size); + margin-left: -($size + $border-size); + } + + &::after { + border-color: transparent; + border-bottom-color: $color; + border-width: $size; + margin-left: -$size; + } +} diff --git a/app/assets/stylesheets/framework/popup.scss b/app/assets/stylesheets/framework/popup.scss new file mode 100644 index 00000000000..5c76205095f --- /dev/null +++ b/app/assets/stylesheets/framework/popup.scss @@ -0,0 +1,15 @@ +.popup { + @include triangle( + $gray-lighter, + $gray-darker, + $popup-triangle-size, + $popup-triangle-border-size + ); + + padding: $gl-padding; + background-color: $gray-lighter; + border: 1px solid $gray-darker; + border-radius: $border-radius-default; + box-shadow: 0 5px 8px $popup-box-shadow-color; + position: relative; +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 8ab48e4844f..2dafd1ce47c 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -719,3 +719,10 @@ Image Commenting cursor */ $image-comment-cursor-left-offset: 12; $image-comment-cursor-top-offset: 30; + +/* +Popup +*/ +$popup-triangle-size: 15px; +$popup-triangle-border-size: 1px; +$popup-box-shadow-color: rgba(90, 90, 90, 0.05); diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss index 5f9756bf58a..68824ff8418 100644 --- a/app/assets/stylesheets/framework/wells.scss +++ b/app/assets/stylesheets/framework/wells.scss @@ -52,6 +52,37 @@ .label.label-gray { background-color: $well-expand-item; } + + .branches { + display: inline; + } + + .branch-link { + margin-bottom: 2px; + } + + .limit-box { + cursor: pointer; + display: inline-flex; + align-items: center; + background-color: $red-100; + border-radius: $border-radius-default; + text-align: center; + + &:hover { + background-color: $red-200; + } + + .limit-icon { + margin: 0 8px; + } + + .limit-message { + line-height: 16px; + margin-right: 8px; + font-size: 12px; + } + } } .light-well { diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index 5c91579c69c..e5b9e1f2de6 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -3,3 +3,8 @@ background-color: $white-light; } } + +.cluster-applications-table { + // Wait for the Vue to kick-in and render the applications block + min-height: 302px; +} diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index fee4638e20f..d93c51d5448 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -298,3 +298,7 @@ width: 100%; } } + +.multi-file-table-col-name { + width: 350px; +} diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 8b9b47a41bc..5d630c7d61e 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -249,3 +249,22 @@ } } } + +.modal-doorkeepr-auth, +.doorkeeper-app-form { + .scope-description { + color: $theme-gray-700; + } +} + +.modal-doorkeepr-auth { + .modal-body { + padding: $gl-padding; + } +} + +.doorkeeper-app-form { + .scope-description { + margin: 0 0 5px 17px; + } +} diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 9c222549cdc..072dffaff7a 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -9,9 +9,7 @@ module IssuableActions def show respond_to do |format| - format.html do - render show_view - end + format.html format.json do render json: serializer.represent(issuable, serializer: params[:serializer]) end @@ -152,10 +150,6 @@ module IssuableActions end end - def show_view - 'show' - end - def serializer raise NotImplementedError end diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 3181f517087..2b011bc87b0 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -4,58 +4,44 @@ module IssuableCollections include Gitlab::IssuableMetadata included do - helper_method :issues_finder - helper_method :merge_requests_finder + helper_method :finder end private - def set_issues_index - @collection_type = "Issue" - @issues = issues_collection - @issues = @issues.page(params[:page]) - @issuable_meta_data = issuable_meta_data(@issues, @collection_type) - @total_pages = issues_page_count(@issues) + def set_issuables_index + @issuables = issuables_collection + @issuables = @issuables.page(params[:page]) + @issuable_meta_data = issuable_meta_data(@issuables, collection_type) + @total_pages = issuable_page_count - return if redirect_out_of_range(@issues, @total_pages) + return if redirect_out_of_range(@total_pages) if params[:label_name].present? - @labels = LabelsFinder.new(current_user, project_id: @project.id, title: params[:label_name]).execute + labels_params = { project_id: @project.id, title: params[:label_name] } + @labels = LabelsFinder.new(current_user, labels_params).execute end @users = [] - end - - def issues_collection - issues_finder.execute.preload(:project, :author, :assignees, :labels, :milestone, project: :namespace) - end - - def merge_requests_collection - merge_requests_finder.execute.preload( - :source_project, - :target_project, - :author, - :assignee, - :labels, - :milestone, - head_pipeline: :project, - target_project: :namespace, - merge_request_diff: :merge_request_diff_commits - ) - end + if params[:assignee_id].present? + assignee = User.find_by_id(params[:assignee_id]) + @users.push(assignee) if assignee + end - def issues_finder - @issues_finder ||= issuable_finder_for(IssuesFinder) + if params[:author_id].present? + author = User.find_by_id(params[:author_id]) + @users.push(author) if author + end end - def merge_requests_finder - @merge_requests_finder ||= issuable_finder_for(MergeRequestsFinder) + def issuables_collection + finder.execute.preload(preload_for_collection) end - def redirect_out_of_range(relation, total_pages) + def redirect_out_of_range(total_pages) return false if total_pages.zero? - out_of_range = relation.current_page > total_pages + out_of_range = @issuables.current_page > total_pages if out_of_range redirect_to(url_for(params.merge(page: total_pages, only_path: true))) @@ -64,12 +50,8 @@ module IssuableCollections out_of_range end - def issues_page_count(relation) - page_count_for_relation(relation, issues_finder.row_count) - end - - def merge_requests_page_count(relation) - page_count_for_relation(relation, merge_requests_finder.row_count) + def issuable_page_count + page_count_for_relation(@issuables, finder.row_count) end def page_count_for_relation(relation, row_count) @@ -145,4 +127,31 @@ module IssuableCollections else value end end + + def finder + return @finder if defined?(@finder) + + @finder = issuable_finder_for(@finder_type) + end + + def collection_type + @collection_type ||= case finder + when IssuesFinder + 'Issue' + when MergeRequestsFinder + 'MergeRequest' + end + end + + def preload_for_collection + @preload_for_collection ||= case collection_type + when 'Issue' + [:project, :author, :assignees, :labels, :milestone, project: :namespace] + when 'MergeRequest' + [ + :source_project, :target_project, :author, :assignee, :labels, :milestone, + head_pipeline: :project, target_project: :namespace, merge_request_diff: :merge_request_diff_commits + ] + end + end end diff --git a/app/controllers/concerns/issues_action.rb b/app/controllers/concerns/issues_action.rb index 404559c8707..ad594903331 100644 --- a/app/controllers/concerns/issues_action.rb +++ b/app/controllers/concerns/issues_action.rb @@ -3,14 +3,14 @@ module IssuesAction include IssuableCollections def issues - @label = issues_finder.labels.first + @finder_type = IssuesFinder + @label = finder.labels.first - @issues = issues_collection + @issues = issuables_collection .non_archived .page(params[:page]) - @collection_type = "Issue" - @issuable_meta_data = issuable_meta_data(@issues, @collection_type) + @issuable_meta_data = issuable_meta_data(@issues, collection_type) respond_to do |format| format.html diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index 738afd612f0..4311f9d4db9 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -74,8 +74,9 @@ module LfsRequest def lfs_upload_access? return false unless project.lfs_enabled? + return false unless has_authentication_ability?(:push_code) - has_authentication_ability?(:push_code) && can?(user, :push_code, project) + lfs_deploy_token? || can?(user, :push_code, project) end def lfs_deploy_token? @@ -91,15 +92,7 @@ module LfsRequest end def storage_project - @storage_project ||= begin - result = project - - # TODO: Make this go to the fork_network root immeadiatly - # dependant on the discussion in: https://gitlab.com/gitlab-org/gitlab-ce/issues/39769 - result = result.fork_source while result.forked? - - result - end + @storage_project ||= project.lfs_storage_project end def objects diff --git a/app/controllers/concerns/merge_requests_action.rb b/app/controllers/concerns/merge_requests_action.rb index d3c8e4888bc..8b569a01afd 100644 --- a/app/controllers/concerns/merge_requests_action.rb +++ b/app/controllers/concerns/merge_requests_action.rb @@ -3,13 +3,12 @@ module MergeRequestsAction include IssuableCollections def merge_requests - @label = merge_requests_finder.labels.first + @finder_type = MergeRequestsFinder + @label = finder.labels.first - @merge_requests = merge_requests_collection - .page(params[:page]) + @merge_requests = issuables_collection.page(params[:page]) - @collection_type = "MergeRequest" - @issuable_meta_data = issuable_meta_data(@merge_requests, @collection_type) + @issuable_meta_data = issuable_meta_data(@merge_requests, collection_type) end private diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 02c5857eea7..e89eaf7edda 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -76,7 +76,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController def redirect_out_of_range(todos) total_pages = if todo_params.except(:sort, :page).empty? - (current_user.todos_pending_count / todos.limit_value).ceil + (current_user.todos_pending_count.to_f / todos.limit_value).ceil else todos.total_pages end diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index ab18d86dcae..b8ba7921613 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -43,7 +43,7 @@ class Import::GithubController < Import::BaseController @target_namespace = find_or_create_namespace(namespace_path, current_user.namespace_path) if can?(current_user, :create_projects, @target_namespace) - @project = Gitlab::GithubImport::ProjectCreator.new(repo, @project_name, @target_namespace, current_user, access_params, type: provider).execute + @project = Gitlab::LegacyGithubImport::ProjectCreator.new(repo, @project_name, @target_namespace, current_user, access_params, type: provider).execute else render 'unauthorized' end @@ -52,7 +52,7 @@ class Import::GithubController < Import::BaseController private def client - @client ||= Gitlab::GithubImport::Client.new(session[access_token_key], client_options) + @client ||= Gitlab::LegacyGithubImport::Client.new(session[access_token_key], client_options) end def verify_import_enabled diff --git a/app/controllers/projects/clusters/applications_controller.rb b/app/controllers/projects/clusters/applications_controller.rb new file mode 100644 index 00000000000..90c7fa62216 --- /dev/null +++ b/app/controllers/projects/clusters/applications_controller.rb @@ -0,0 +1,25 @@ +class Projects::Clusters::ApplicationsController < Projects::ApplicationController + before_action :cluster + before_action :application_class, only: [:create] + before_action :authorize_read_cluster! + before_action :authorize_create_cluster!, only: [:create] + + def create + Clusters::Applications::ScheduleInstallationService.new(project, current_user, + application_class: @application_class, + cluster: @cluster).execute + head :no_content + rescue StandardError + head :bad_request + end + + private + + def cluster + @cluster ||= project.clusters.find(params[:id]) || render_404 + end + + def application_class + @application_class ||= Clusters::Cluster::APPLICATIONS[params[:application]] || render_404 + end +end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index a62f05db7db..494d412b532 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -16,6 +16,8 @@ class Projects::CommitController < Projects::ApplicationController before_action :define_note_vars, only: [:show, :diff_for_path] before_action :authorize_edit_tree!, only: [:revert, :cherry_pick] + BRANCH_SEARCH_LIMIT = 1000 + def show apply_diff_view_cookie! @@ -56,8 +58,14 @@ class Projects::CommitController < Projects::ApplicationController end def branches - @branches = @project.repository.branch_names_contains(commit.id) - @tags = @project.repository.tag_names_contains(commit.id) + # branch_names_contains/tag_names_contains can take a long time when there are thousands of + # branches/tags - each `git branch --contains xxx` request can consume a cpu core. + # so only do the query when there are a manageable number of branches/tags + @branches_limit_exceeded = @project.repository.branch_count > BRANCH_SEARCH_LIMIT + @branches = @branches_limit_exceeded ? [] : @project.repository.branch_names_contains(commit.id) + + @tags_limit_exceeded = @project.repository.tag_count > BRANCH_SEARCH_LIMIT + @tags = @tags_limit_exceeded ? [] : @project.repository.tag_names_contains(commit.id) render layout: false end diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb index d48284a4429..28920877635 100644 --- a/app/controllers/projects/commits_controller.rb +++ b/app/controllers/projects/commits_controller.rb @@ -10,9 +10,6 @@ class Projects::CommitsController < Projects::ApplicationController before_action :set_commits def show - @note_counts = project.notes.where(commit_id: @commits.map(&:id)) - .group(:commit_id).count - @merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened .find_by(source_project: @project, source_branch: @ref, target_branch: @repository.root_ref) diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index d4e763aa5b8..dbc9106ba6d 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -10,7 +10,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action :check_issues_available! before_action :issue, except: [:index, :new, :create, :bulk_update] - before_action :set_issues_index, only: [:index] + before_action :set_issuables_index, only: [:index] # Allow write(create) issue before_action :authorize_create_issue!, only: [:new, :create] @@ -24,15 +24,7 @@ class Projects::IssuesController < Projects::ApplicationController respond_to :html def index - if params[:assignee_id].present? - assignee = User.find_by_id(params[:assignee_id]) - @users.push(assignee) if assignee - end - - if params[:author_id].present? - author = User.find_by_id(params[:author_id]) - @users.push(author) if author - end + @issues = @issuables respond_to do |format| format.html @@ -252,4 +244,9 @@ class Projects::IssuesController < Projects::ApplicationController update_params = issue_params.merge(spammable_params) Issues::UpdateService.new(project, current_user, update_params) end + + def set_issuables_index + @finder_type = IssuesFinder + super + end end diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 129682f64aa..764a9c7111e 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -110,9 +110,6 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap @commits = prepare_commits_for_rendering(@merge_request.commits) @commit = @merge_request.diff_head_commit - @note_counts = Note.where(commit_id: @commits.map(&:id)) - .group(:commit_id).count - @labels = LabelsFinder.new(current_user, project_id: @project.id).execute set_pipeline_variables diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index c86acae8fe4..22de6680511 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -10,33 +10,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort] + before_action :set_issuables_index, only: [:index] + before_action :authenticate_user!, only: [:assign_related_issues] def index - @collection_type = "MergeRequest" - @merge_requests = merge_requests_collection - @merge_requests = @merge_requests.page(params[:page]) - @merge_requests = @merge_requests.preload(merge_request_diff: :merge_request) - @issuable_meta_data = issuable_meta_data(@merge_requests, @collection_type) - @total_pages = merge_requests_page_count(@merge_requests) - - return if redirect_out_of_range(@merge_requests, @total_pages) - - if params[:label_name].present? - labels_params = { project_id: @project.id, title: params[:label_name] } - @labels = LabelsFinder.new(current_user, labels_params).execute - end - - @users = [] - if params[:assignee_id].present? - assignee = User.find_by_id(params[:assignee_id]) - @users.push(assignee) if assignee - end - - if params[:author_id].present? - author = User.find_by_id(params[:author_id]) - @users.push(author) if author - end + @merge_requests = @issuables respond_to do |format| format.html @@ -102,8 +81,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo # Get commits from repository # or from cache if already merged @commits = prepare_commits_for_rendering(@merge_request.commits) - @note_counts = Note.where(commit_id: @commits.map(&:id)) - .group(:commit_id).count render json: { html: view_to_html_string('projects/merge_requests/_commits') } end @@ -338,4 +315,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @target_project = @merge_request.target_project @target_branches = @merge_request.target_project.repository.branch_names end + + def set_issuables_index + @finder_type = MergeRequestsFinder + super + end end diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb index 2fd015df688..2376f469213 100644 --- a/app/controllers/projects/refs_controller.rb +++ b/app/controllers/projects/refs_controller.rb @@ -56,9 +56,12 @@ class Projects::RefsController < Projects::ApplicationController contents[@offset, @limit].to_a.map do |content| file = @path ? File.join(@path, content.name) : content.name last_commit = @repo.last_commit_for_path(@commit.id, file) + commit_path = project_commit_path(@project, last_commit) if last_commit { file_name: content.name, - commit: last_commit + commit: last_commit, + type: content.type, + commit_path: commit_path } end end @@ -70,6 +73,11 @@ class Projects::RefsController < Projects::ApplicationController respond_to do |format| format.html { render_404 } + format.json do + response.headers["More-Logs-Url"] = @more_log_url + + render json: @logs + end format.js end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 1688121e27e..2a473ec0cec 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -275,7 +275,8 @@ class ProjectsController < Projects::ApplicationController @project_wiki = @project.wiki @wiki_home = @project_wiki.find_page('home', params[:version_id]) elsif @project.feature_available?(:issues, current_user) - @issues = issues_collection.page(params[:page]) + @finder_type = IssuesFinder + @issues = issuables_collection.page(params[:page]) @collection_type = 'Issue' @issuable_meta_data = issuable_meta_data(@issues, @collection_type) end diff --git a/app/finders/autocomplete_users_finder.rb b/app/finders/autocomplete_users_finder.rb index b8f52e31926..c3f5358b577 100644 --- a/app/finders/autocomplete_users_finder.rb +++ b/app/finders/autocomplete_users_finder.rb @@ -45,7 +45,7 @@ class AutocompleteUsersFinder def find_users return users_from_project if project - return group.users if group + return group.users_with_parents if group return User.all if current_user User.none diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index ef22cafc2e2..f9a666fa1e6 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -60,23 +60,33 @@ module CommitsHelper branches.include?(project.default_branch) ? branches.delete(project.default_branch) : branches.pop end + # Returns a link formatted as a commit branch link + def commit_branch_link(url, text) + link_to(url, class: 'label label-gray ref-name branch-link') do + icon('code-fork') + " #{text}" + end + end + # Returns the sorted alphabetically links to branches, separated by a comma def commit_branches_links(project, branches) branches.sort.map do |branch| - link_to(project_ref_path(project, branch), class: "label label-gray ref-name") do - icon('code-fork') + " #{branch}" - end - end.join(" ").html_safe + commit_branch_link(project_ref_path(project, branch), branch) + end.join(' ').html_safe + end + + # Returns a link formatted as a commit tag link + def commit_tag_link(url, text) + link_to(url, class: 'label label-gray ref-name') do + icon('tag') + " #{text}" + end end # Returns the sorted links to tags, separated by a comma def commit_tags_links(project, tags) sorted = VersionSorter.rsort(tags) sorted.map do |tag| - link_to(project_ref_path(project, tag), class: "label label-gray ref-name") do - icon('tag') + " #{tag}" - end - end.join(" ").html_safe + commit_tag_link(project_ref_path(project, tag), tag) + end.join(' ').html_safe end def link_to_browse_code(project, commit) diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index ec779c1c447..c6a83f21ceb 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -23,10 +23,17 @@ module IconsHelper render "shared/icons/#{icon_name}.svg", size: size end + def sprite_icon_path + # SVG Sprites currently don't work across domains, so in the case of a CDN + # we have to set the current path deliberately to prevent addition of asset_host + sprite_base_url = Gitlab.config.gitlab.url if ActionController::Base.asset_host + ActionController::Base.helpers.image_path('icons.svg', host: sprite_base_url) + end + def sprite_icon(icon_name, size: nil, css_class: nil) css_classes = size ? "s#{size}" : "" css_classes << " #{css_class}" unless css_class.blank? - content_tag(:svg, content_tag(:use, "", { "xlink:href" => "#{image_path('icons.svg')}##{icon_name}" } ), class: css_classes.empty? ? nil : css_classes) + content_tag(:svg, content_tag(:use, "", { "xlink:href" => "#{sprite_icon_path}##{icon_name}" } ), class: css_classes.empty? ? nil : css_classes) end def audit_icon(names, options = {}) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 85407e38532..a9840d19178 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -249,8 +249,6 @@ module IssuablesHelper end def issuables_count_for_state(issuable_type, state) - finder = public_send("#{issuable_type}_finder") # rubocop:disable GitlabSecurity/PublicSend - Gitlab::IssuablesCountForState.new(finder)[state] end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index ca65e81f27a..19814864e50 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -66,8 +66,8 @@ module Ci state_machine :status, initial: :created do event :enqueue do - transition created: :pending - transition [:success, :failed, :canceled, :skipped] => :running + transition [:created, :skipped] => :pending + transition [:success, :failed, :canceled] => :running end event :run do @@ -409,7 +409,7 @@ module Ci end def notes - Note.for_commit_id(sha) + project.notes.for_commit_id(sha) end def process! diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb new file mode 100644 index 00000000000..c7949d11ef8 --- /dev/null +++ b/app/models/clusters/applications/helm.rb @@ -0,0 +1,35 @@ +module Clusters + module Applications + class Helm < ActiveRecord::Base + self.table_name = 'clusters_applications_helm' + + include ::Clusters::Concerns::ApplicationStatus + + belongs_to :cluster, class_name: 'Clusters::Cluster', foreign_key: :cluster_id + + default_value_for :version, Gitlab::Kubernetes::Helm::HELM_VERSION + + validates :cluster, presence: true + + after_initialize :set_initial_status + + def self.application_name + self.to_s.demodulize.underscore + end + + def set_initial_status + return unless not_installable? + + self.status = 'installable' if cluster&.platform_kubernetes_active? + end + + def name + self.class.application_name + end + + def install_command + Gitlab::Kubernetes::Helm::InstallCommand.new(name, true) + end + end + end +end diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb new file mode 100644 index 00000000000..44bd979741e --- /dev/null +++ b/app/models/clusters/applications/ingress.rb @@ -0,0 +1,44 @@ +module Clusters + module Applications + class Ingress < ActiveRecord::Base + self.table_name = 'clusters_applications_ingress' + + include ::Clusters::Concerns::ApplicationStatus + + belongs_to :cluster, class_name: 'Clusters::Cluster', foreign_key: :cluster_id + + validates :cluster, presence: true + + default_value_for :ingress_type, :nginx + default_value_for :version, :nginx + + after_initialize :set_initial_status + + enum ingress_type: { + nginx: 1 + } + + def self.application_name + self.to_s.demodulize.underscore + end + + def set_initial_status + return unless not_installable? + + self.status = 'installable' if cluster&.application_helm_installed? + end + + def name + self.class.application_name + end + + def chart + 'stable/nginx-ingress' + end + + def install_command + Gitlab::Kubernetes::Helm::InstallCommand.new(name, false, chart) + end + end + end +end diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 955dba51745..185d9473aab 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -4,6 +4,11 @@ module Clusters self.table_name = 'clusters' + APPLICATIONS = { + Applications::Helm.application_name => Applications::Helm, + Applications::Ingress.application_name => Applications::Ingress + }.freeze + belongs_to :user has_many :cluster_projects, class_name: 'Clusters::Project' @@ -15,6 +20,9 @@ module Clusters # We have to ":destroy" it today to ensure that we clean also the Kubernetes Integration has_one :platform_kubernetes, class_name: 'Clusters::Platforms::Kubernetes', autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_one :application_helm, class_name: 'Clusters::Applications::Helm' + has_one :application_ingress, class_name: 'Clusters::Applications::Ingress' + accepts_nested_attributes_for :provider_gcp, update_only: true accepts_nested_attributes_for :platform_kubernetes, update_only: true @@ -28,10 +36,12 @@ module Clusters delegate :status, to: :provider, allow_nil: true delegate :status_reason, to: :provider, allow_nil: true - delegate :status_name, to: :provider, allow_nil: true delegate :on_creation?, to: :provider, allow_nil: true delegate :update_kubernetes_integration!, to: :platform, allow_nil: true + delegate :active?, to: :platform_kubernetes, prefix: true, allow_nil: true + delegate :installed?, to: :application_helm, prefix: true, allow_nil: true + enum platform_type: { kubernetes: 1 } @@ -44,6 +54,21 @@ module Clusters scope :enabled, -> { where(enabled: true) } scope :disabled, -> { where(enabled: false) } + def status_name + if provider + provider.status_name + else + :created + end + end + + def applications + [ + application_helm || build_application_helm, + application_ingress || build_application_ingress + ] + end + def provider return provider_gcp if gcp? end @@ -59,6 +84,10 @@ module Clusters end alias_method :project, :first_project + def kubeclient + platform_kubernetes.kubeclient if kubernetes? + end + private def restrict_modification diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb new file mode 100644 index 00000000000..7b7c8eac773 --- /dev/null +++ b/app/models/clusters/concerns/application_status.rb @@ -0,0 +1,43 @@ +module Clusters + module Concerns + module ApplicationStatus + extend ActiveSupport::Concern + + included do + state_machine :status, initial: :not_installable do + state :not_installable, value: -2 + state :errored, value: -1 + state :installable, value: 0 + state :scheduled, value: 1 + state :installing, value: 2 + state :installed, value: 3 + + event :make_scheduled do + transition [:installable, :errored] => :scheduled + end + + event :make_installing do + transition [:scheduled] => :installing + end + + event :make_installed do + transition [:installing] => :installed + end + + event :make_errored do + transition any => :errored + end + + before_transition any => [:scheduled] do |app_status, _| + app_status.status_reason = nil + end + + before_transition any => [:errored] do |app_status, transition| + status_reason = transition.args.first + app_status.status_reason = status_reason if status_reason + end + end + end + end + end +end diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index b11701797c2..6dc1ee810d3 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -55,6 +55,10 @@ module Clusters self.class.namespace_for_project(project) if project end + def kubeclient + @kubeclient ||= kubernetes_service.kubeclient if manages_kubernetes_service? + end + def update_kubernetes_integration! raise 'Kubernetes service already configured' unless manages_kubernetes_service? @@ -70,6 +74,10 @@ module Clusters ) end + def active? + manages_kubernetes_service? + end + private def enforce_namespace_to_lower_case diff --git a/app/models/clusters/providers/gcp.rb b/app/models/clusters/providers/gcp.rb index c4391729dd7..ee2e43ee9dd 100644 --- a/app/models/clusters/providers/gcp.rb +++ b/app/models/clusters/providers/gcp.rb @@ -7,7 +7,7 @@ module Clusters default_value_for :zone, 'us-central1-a' default_value_for :num_nodes, 3 - default_value_for :machine_type, 'n1-standard-4' + default_value_for :machine_type, 'n1-standard-2' attr_encrypted :access_token, mode: :per_attribute_iv, diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index f3888528940..6b07dbdf3ea 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -14,7 +14,6 @@ class CommitStatus < ActiveRecord::Base delegate :sha, :short_sha, to: :pipeline validates :pipeline, presence: true, unless: :importing? - validates :name, presence: true, unless: :importing? alias_attribute :author, :user @@ -46,6 +45,17 @@ class CommitStatus < ActiveRecord::Base runner_system_failure: 4 } + ## + # We still create some CommitStatuses outside of CreatePipelineService. + # + # These are pages deployments and external statuses. + # + before_create unless: :importing? do + Ci::EnsureStageService.new(project, user).execute(self) do |stage| + self.run_after_commit { StageUpdateWorker.perform_async(stage.id) } + end + end + state_machine :status do event :process do transition [:skipped, :manual] => :created diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index 2ec70203710..10659030910 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -4,15 +4,26 @@ module Avatarable def avatar_path(only_path: true) return unless self[:avatar].present? - # If only_path is true then use the relative path of avatar. - # Otherwise use full path (including host). asset_host = ActionController::Base.asset_host - gitlab_host = only_path ? gitlab_config.relative_url_root : gitlab_config.url + use_asset_host = asset_host.present? - # If asset_host is set then it is expected that assets are handled by a standalone host. - # That means we do not want to get GitLab's relative_url_root option anymore. - host = (asset_host.present? && (!respond_to?(:public?) || public?)) ? asset_host : gitlab_host + # Avatars for private and internal groups and projects require authentication to be viewed, + # which means they can only be served by Rails, on the regular GitLab host. + # If an asset host is configured, we need to return the fully qualified URL + # instead of only the avatar path, so that Rails doesn't prefix it with the asset host. + if use_asset_host && respond_to?(:public?) && !public? + use_asset_host = false + only_path = false + end - [host, avatar.url].join + url_base = "" + if use_asset_host + url_base << asset_host unless only_path + else + url_base << gitlab_config.base_url unless only_path + url_base << gitlab_config.relative_url_root + end + + url_base + avatar.url end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index a928b9d6367..c008fb91a16 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -17,6 +17,8 @@ module Issuable include Importable include Editable include AfterCommitQueue + include Sortable + include CreatedAtFilterable # This object is used to gather issuable meta data for displaying # upvotes, downvotes, notes and closing merge requests count for issues and merge requests diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index 710fc1ed647..7026f565706 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -86,6 +86,14 @@ module Milestoneish false end + def total_issue_time_spent + @total_issue_time_spent ||= issues.joins(:timelogs).sum(:time_spent) + end + + def human_total_issue_time_spent + Gitlab::TimeTrackingFormatter.output(total_issue_time_spent) + end + private def count_issues_by_state(user) diff --git a/app/models/external_issue.rb b/app/models/external_issue.rb index 0bf18e529f0..9ff56f229bc 100644 --- a/app/models/external_issue.rb +++ b/app/models/external_issue.rb @@ -47,4 +47,8 @@ class ExternalIssue id end + + def notes + Note.none + end end diff --git a/app/models/issue.rb b/app/models/issue.rb index fc590f9257e..b5abc8f57b0 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -5,11 +5,9 @@ class Issue < ActiveRecord::Base include Issuable include Noteable include Referable - include Sortable include Spammable include FasterCacheKeys include RelativePositioning - include CreatedAtFilterable include TimeTrackable DueDateStruct = Struct.new(:title, :name).freeze @@ -264,10 +262,6 @@ class Issue < ActiveRecord::Base true end - def update_project_counter_caches? - state_changed? || confidential_changed? - end - def update_project_counter_caches Projects::OpenIssuesCountService.new(project).refresh_cache end diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index b7cf96abe83..fc586fa216e 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -6,16 +6,8 @@ class LfsObject < ActiveRecord::Base mount_uploader :file, LfsObjectUploader - def storage_project(project) - if project && project.forked? - storage_project(project.forked_from_project) - else - project - end - end - def project_allowed_access?(project) - projects.exists?(storage_project(project).id) + projects.exists?(project.lfs_storage_project.id) end def self.destroy_unreferenced diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index f80601f3484..f1a5cc73e83 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -3,9 +3,7 @@ class MergeRequest < ActiveRecord::Base include Issuable include Noteable include Referable - include Sortable include IgnorableColumn - include CreatedAtFilterable include TimeTrackable ignore_column :locked_at, @@ -578,7 +576,7 @@ class MergeRequest < ActiveRecord::Base commit_notes = Note .except(:order) .where(project_id: [source_project_id, target_project_id]) - .where(noteable_type: 'Commit', commit_id: commit_ids) + .for_commit_id(commit_ids) # We're using a UNION ALL here since this results in better performance # compared to using OR statements. We're using UNION ALL since the queries @@ -867,7 +865,19 @@ class MergeRequest < ActiveRecord::Base # def all_commit_shas if persisted? - column_shas = MergeRequestDiffCommit.where(merge_request_diff: merge_request_diffs).limit(10_000).pluck('sha') + # MySQL doesn't support LIMIT in a subquery. + diffs_relation = + if Gitlab::Database.postgresql? + merge_request_diffs.order(id: :desc).limit(100) + else + merge_request_diffs + end + + column_shas = MergeRequestDiffCommit + .where(merge_request_diff: diffs_relation) + .limit(10_000) + .pluck('sha') + serialised_shas = merge_request_diffs.where.not(st_commits: nil).flat_map(&:commit_shas) (column_shas + serialised_shas).uniq @@ -948,10 +958,6 @@ class MergeRequest < ActiveRecord::Base true end - def update_project_counter_caches? - state_changed? - end - def update_project_counter_caches Projects::OpenMergeRequestsCountService.new(target_project).refresh_cache end diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 2e824cda525..43c77f3f2a2 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -69,6 +69,10 @@ class PagesDomain < ActiveRecord::Base current < x509.not_before || x509.not_after < current end + def expiration + x509&.not_after + end + def subject return unless x509 x509.subject.to_s diff --git a/app/models/project.rb b/app/models/project.rb index 379bab27d17..853f6bc504a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -189,6 +189,7 @@ class Project < ActiveRecord::Base has_one :cluster_project, class_name: 'Clusters::Project' has_one :cluster, through: :cluster_project, class_name: 'Clusters::Cluster' + has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster' # Container repositories need to remove data from the container registry, # which is not managed by the DB. Hence we're still using dependent: :destroy @@ -364,6 +365,7 @@ class Project < ActiveRecord::Base scope :abandoned, -> { where('projects.last_activity_at < ?', 6.months.ago) } scope :excluding_project, ->(project) { where.not(id: project) } + scope :import_started, -> { where(import_status: 'started') } state_machine :import_status, initial: :none do event :import_schedule do @@ -1045,6 +1047,18 @@ class Project < ActiveRecord::Base forked_from_project || fork_network&.root_project end + def lfs_storage_project + @lfs_storage_project ||= begin + result = self + + # TODO: Make this go to the fork_network root immeadiatly + # dependant on the discussion in: https://gitlab.com/gitlab-org/gitlab-ce/issues/39769 + result = result.fork_source while result&.forked? + + result || self + end + end + def personal? !group end @@ -1189,6 +1203,10 @@ class Project < ActiveRecord::Base !!repository.exists? end + def wiki_repository_exists? + wiki.repository_exists? + end + # update visibility_level of forks def update_forks_visibility_level return unless visibility_level < visibility_level_was @@ -1432,6 +1450,31 @@ class Project < ActiveRecord::Base reload_repository! end + def after_import + repository.after_import + import_finish + remove_import_jid + update_project_counter_caches + end + + def update_project_counter_caches + classes = [ + Projects::OpenIssuesCountService, + Projects::OpenMergeRequestsCountService + ] + + classes.each do |klass| + klass.new(self).refresh_cache + end + end + + def remove_import_jid + return unless import_jid + + Gitlab::SidekiqStatus.unset(import_jid) + update_column(:import_jid, nil) + end + def running_or_pending_build_count(force: false) Rails.cache.fetch(['projects', id, 'running_or_pending_build_count'], force: force) do builds.running_or_pending.count(:all) @@ -1689,6 +1732,17 @@ class Project < ActiveRecord::Base Gitlab::ReferenceCounter.new(gl_repository(is_wiki: wiki)) end + # Refreshes the expiration time of the associated import job ID. + # + # This method can be used by asynchronous importers to refresh the status, + # preventing the StuckImportJobsWorker from marking the import as failed. + def refresh_import_jid_expiration + return unless import_jid + + Gitlab::SidekiqStatus + .set(import_jid, StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION) + end + private def storage diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index 5c0b3338a62..5080acffb3c 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -136,6 +136,10 @@ class KubernetesService < DeploymentService { pods: read_pods } end + def kubeclient + @kubeclient ||= build_kubeclient! + end + TEMPLATE_PLACEHOLDER = 'Kubernetes namespace'.freeze private diff --git a/app/models/repository.rb b/app/models/repository.rb index eb7766d040c..3a89fa9264b 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -973,6 +973,10 @@ class Repository raw_repository.fetch_source_branch!(source_repository.raw_repository, source_branch, local_ref) end + def remote_exists?(name) + raw_repository.remote_exists?(name) + end + def compare_source_branch(target_branch_name, source_repository, source_branch_name, straight:) raw_repository.compare_source_branch(target_branch_name, source_repository.raw_repository, source_branch_name, straight: straight) end @@ -1058,6 +1062,10 @@ class Repository blob_data_at(sha, path) end + def fetch_ref(source_repository, source_ref:, target_ref:) + raw_repository.fetch_ref(source_repository.raw_repository, source_ref: source_ref, target_ref: target_ref) + end + private # TODO Generice finder, later split this on finders by Ref or Oid diff --git a/app/models/user.rb b/app/models/user.rb index aa88cda4dc0..f436efd604f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -267,18 +267,23 @@ class User < ActiveRecord::Base end end + def for_github_id(id) + joins(:identities) + .where(identities: { provider: :github, extern_uid: id.to_s }) + end + # Find a User by their primary email or any associated secondary email def find_by_any_email(email) - sql = 'SELECT * - FROM users - WHERE id IN ( - SELECT id FROM users WHERE email = :email - UNION - SELECT emails.user_id FROM emails WHERE email = :email - ) - LIMIT 1;' + by_any_email(email).take + end + + # Returns a relation containing all the users for the given Email address + def by_any_email(email) + users = where(email: email) + emails = joins(:emails).where(emails: { email: email }) + union = Gitlab::SQL::Union.new([users, emails]) - User.find_by_sql([sql, { email: email }]).first + from("(#{union.to_sql}) #{table_name}") end def filter(filter_name) diff --git a/app/serializers/blob_entity.rb b/app/serializers/blob_entity.rb index 56f173e5a27..ad039a2623d 100644 --- a/app/serializers/blob_entity.rb +++ b/app/serializers/blob_entity.rb @@ -3,10 +3,6 @@ class BlobEntity < Grape::Entity expose :id, :path, :name, :mode - expose :last_commit do |blob| - request.project.repository.last_commit_for_path(blob.commit_id, blob.path) - end - expose :icon do |blob| IconsHelper.file_type_icon_class('file', blob.mode, blob.name) end diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb new file mode 100644 index 00000000000..3f9a275ad08 --- /dev/null +++ b/app/serializers/cluster_application_entity.rb @@ -0,0 +1,5 @@ +class ClusterApplicationEntity < Grape::Entity + expose :name + expose :status_name, as: :status + expose :status_reason +end diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb index 08a113c4d8a..7e5b0997878 100644 --- a/app/serializers/cluster_entity.rb +++ b/app/serializers/cluster_entity.rb @@ -3,4 +3,5 @@ class ClusterEntity < Grape::Entity expose :status_name, as: :status expose :status_reason + expose :applications, using: ClusterApplicationEntity end diff --git a/app/serializers/cluster_serializer.rb b/app/serializers/cluster_serializer.rb index 2c87202a105..2e13c1501e7 100644 --- a/app/serializers/cluster_serializer.rb +++ b/app/serializers/cluster_serializer.rb @@ -2,6 +2,6 @@ class ClusterSerializer < BaseSerializer entity ClusterEntity def represent_status(resource) - represent(resource, { only: [:status, :status_reason] }) + represent(resource, { only: [:status, :status_reason, :applications] }) end end diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index 5f47592e4ad..9d52b8d9752 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -3,7 +3,6 @@ class IssueEntity < IssuableEntity expose :state expose :deleted_at - expose :branch_name expose :confidential expose :discussion_locked expose :assignees, using: API::Entities::UserBasic diff --git a/app/serializers/tree_entity.rb b/app/serializers/tree_entity.rb index 555e5cf83bd..9f1b485347f 100644 --- a/app/serializers/tree_entity.rb +++ b/app/serializers/tree_entity.rb @@ -3,10 +3,6 @@ class TreeEntity < Grape::Entity expose :id, :path, :name, :mode - expose :last_commit do |tree| - request.project.repository.last_commit_for_path(tree.commit_id, tree.path) - end - expose :icon do |tree| IconsHelper.file_type_icon_class('folder', tree.mode, tree.name) end diff --git a/app/serializers/tree_root_entity.rb b/app/serializers/tree_root_entity.rb index 69702ae1493..496f070ddbd 100644 --- a/app/serializers/tree_root_entity.rb +++ b/app/serializers/tree_root_entity.rb @@ -18,4 +18,8 @@ class TreeRootEntity < Grape::Entity project_tree_path(request.project, File.join(request.ref, parent_tree_path)) end + + expose :last_commit_path do |tree| + logs_file_project_ref_path(request.project, request.ref, tree.path) + end end diff --git a/app/services/ci/ensure_stage_service.rb b/app/services/ci/ensure_stage_service.rb new file mode 100644 index 00000000000..dc2f49e8db1 --- /dev/null +++ b/app/services/ci/ensure_stage_service.rb @@ -0,0 +1,39 @@ +module Ci + ## + # We call this service everytime we persist a CI/CD job. + # + # In most cases a job should already have a stage assigned, but in cases it + # doesn't have we need to either find existing one or create a brand new + # stage. + # + class EnsureStageService < BaseService + def execute(build) + @build = build + + return if build.stage_id.present? + return if build.invalid? + + ensure_stage.tap do |stage| + build.stage_id = stage.id + + yield stage if block_given? + end + end + + private + + def ensure_stage + find_stage || create_stage + end + + def find_stage + @build.pipeline.stages.find_by(name: @build.stage) + end + + def create_stage + Ci::Stage.create!(name: @build.stage, + pipeline: @build.pipeline, + project: @build.project) + end + end +end diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb index 120af8c1e61..a9813d774bb 100644 --- a/app/services/ci/pipeline_trigger_service.rb +++ b/app/services/ci/pipeline_trigger_service.rb @@ -1,5 +1,7 @@ module Ci class PipelineTriggerService < BaseService + include Gitlab::Utils::StrongMemoize + def execute if trigger_from_token create_pipeline_from_trigger(trigger_from_token) @@ -26,9 +28,9 @@ module Ci end def trigger_from_token - return @trigger if defined?(@trigger) - - @trigger = Ci::Trigger.find_by_token(params[:token].to_s) + strong_memoize(:trigger) do + Ci::Trigger.find_by_token(params[:token].to_s) + end end def create_pipeline_variables!(pipeline) diff --git a/app/services/clusters/applications/base_helm_service.rb b/app/services/clusters/applications/base_helm_service.rb new file mode 100644 index 00000000000..9a4ce31cb39 --- /dev/null +++ b/app/services/clusters/applications/base_helm_service.rb @@ -0,0 +1,29 @@ +module Clusters + module Applications + class BaseHelmService + attr_accessor :app + + def initialize(app) + @app = app + end + + protected + + def cluster + app.cluster + end + + def kubeclient + cluster.kubeclient + end + + def helm_api + @helm_api ||= Gitlab::Kubernetes::Helm.new(kubeclient) + end + + def install_command + @install_command ||= app.install_command + end + end + end +end diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb new file mode 100644 index 00000000000..bde090eaeec --- /dev/null +++ b/app/services/clusters/applications/check_installation_progress_service.rb @@ -0,0 +1,65 @@ +module Clusters + module Applications + class CheckInstallationProgressService < BaseHelmService + def execute + return unless app.installing? + + case installation_phase + when Gitlab::Kubernetes::Pod::SUCCEEDED + on_success + when Gitlab::Kubernetes::Pod::FAILED + on_failed + else + check_timeout + end + rescue KubeException => ke + app.make_errored!("Kubernetes error: #{ke.message}") unless app.errored? + end + + private + + def on_success + app.make_installed! + ensure + remove_installation_pod + end + + def on_failed + app.make_errored!(installation_errors || 'Installation silently failed') + ensure + remove_installation_pod + end + + def check_timeout + if timeouted? + begin + app.make_errored!('Installation timeouted') + ensure + remove_installation_pod + end + else + ClusterWaitForAppInstallationWorker.perform_in( + ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) + end + end + + def timeouted? + Time.now.utc - app.updated_at.to_time.utc > ClusterWaitForAppInstallationWorker::TIMEOUT + end + + def remove_installation_pod + helm_api.delete_installation_pod!(install_command.pod_name) + rescue + # no-op + end + + def installation_phase + helm_api.installation_status(install_command.pod_name) + end + + def installation_errors + helm_api.installation_log(install_command.pod_name) + end + end + end +end diff --git a/app/services/clusters/applications/install_service.rb b/app/services/clusters/applications/install_service.rb new file mode 100644 index 00000000000..8ceeec687cd --- /dev/null +++ b/app/services/clusters/applications/install_service.rb @@ -0,0 +1,21 @@ +module Clusters + module Applications + class InstallService < BaseHelmService + def execute + return unless app.scheduled? + + begin + app.make_installing! + helm_api.install(install_command) + + ClusterWaitForAppInstallationWorker.perform_in( + ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) + rescue KubeException => ke + app.make_errored!("Kubernetes error: #{ke.message}") + rescue StandardError + app.make_errored!("Can't start installation process") + end + end + end + end +end diff --git a/app/services/clusters/applications/schedule_installation_service.rb b/app/services/clusters/applications/schedule_installation_service.rb new file mode 100644 index 00000000000..eb8caa68ef7 --- /dev/null +++ b/app/services/clusters/applications/schedule_installation_service.rb @@ -0,0 +1,22 @@ +module Clusters + module Applications + class ScheduleInstallationService < ::BaseService + def execute + application_class.find_or_create_by!(cluster: cluster).try do |application| + application.make_scheduled! + ClusterInstallAppWorker.perform_async(application.name, application.id) + end + end + + private + + def application_class + params[:application_class] + end + + def cluster + params[:cluster] + end + end + end +end diff --git a/app/services/delete_merged_branches_service.rb b/app/services/delete_merged_branches_service.rb index 077268b2388..cb235a85daf 100644 --- a/app/services/delete_merged_branches_service.rb +++ b/app/services/delete_merged_branches_service.rb @@ -13,7 +13,7 @@ class DeleteMergedBranchesService < BaseService # Prevent deletion of branches relevant to open merge requests branches -= merge_request_branch_names # Prevent deletion of protected branches - branches = branches.reject { |branch| project.protected_for?(branch) } + branches = branches.reject { |branch| ProtectedBranch.protected?(project, branch) } branches.each do |branch| DeleteBranchService.new(project, current_user).execute(branch) diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 68b49d880f7..90865867ff0 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -187,7 +187,7 @@ class IssuableBaseService < BaseService # We have to perform this check before saving the issuable as Rails resets # the changed fields upon calling #save. - update_project_counters = issuable.project && issuable.update_project_counter_caches? + update_project_counters = issuable.project && update_project_counter_caches?(issuable) if issuable.with_transaction_returning_status { issuable.save } # We do not touch as it will affect a update on updated_at field @@ -288,4 +288,8 @@ class IssuableBaseService < BaseService # override if needed def execute_hooks(issuable, action = 'open', params = {}) end + + def update_project_counter_caches?(issuable) + issuable.state_changed? + end end diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index 735257c4779..b680eaf5a49 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -45,5 +45,9 @@ module Issues params.delete(:assignee_ids) end end + + def update_project_counter_caches?(issue) + super || issue.confidential_changed? + end end end diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 156e7b2f078..1da4dbd9e96 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -18,15 +18,7 @@ module MergeRequests @merge_request = merge_request - unless @merge_request.mergeable? - return handle_merge_error(log_message: 'Merge request is not mergeable', save_message_on_model: true) - end - - @source = find_merge_source - - unless @source - return handle_merge_error(log_message: 'No source for merge', save_message_on_model: true) - end + error_check! merge_request.in_locked_state do if commit @@ -41,6 +33,19 @@ module MergeRequests private + def error_check! + error = + if @merge_request.should_be_rebased? + 'Only fast-forward merge is allowed for your project. Please update your source branch' + elsif !@merge_request.mergeable? + 'Merge request is not mergeable' + elsif !source + 'No source for merge' + end + + raise MergeError, error if error + end + def commit message = params[:commit_message] || merge_request.merge_commit_message @@ -91,8 +96,8 @@ module MergeRequests merge_request.to_reference(full: true) end - def find_merge_source - merge_request.diff_head_sha + def source + @source ||= @merge_request.diff_head_sha end end end diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index 455b302d819..c950da44aba 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -4,6 +4,18 @@ module Projects Error = Class.new(StandardError) + # Returns true if this importer is supposed to perform its work in the + # background. + # + # This method will only return `true` if async importing is explicitly + # supported by an importer class (`Gitlab::GithubImport::ParallelImporter` + # for example). + def async? + return false unless has_importer? + + !!importer_class.try(:async?) + end + def execute add_repository_to_project unless project.gitlab_project_import? @@ -75,12 +87,16 @@ module Projects end end + def importer_class + Gitlab::ImportSources.importer(project.import_type) + end + def has_importer? Gitlab::ImportSources.importer_names.include?(project.import_type) end def importer - Gitlab::ImportSources.importer(project.import_type).new(project) + importer_class.new(project) end def unknown_url? diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 69bd19c1977..e946218824c 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -481,17 +481,7 @@ module SystemNoteService # # Returns Boolean def cross_reference_exists?(noteable, mentioner) - # Initial scope should be system notes of this noteable type - notes = Note.system.where(noteable_type: noteable.class) - - notes = - if noteable.is_a?(Commit) - # Commits have non-integer IDs, so they're stored in `commit_id` - notes.where(commit_id: noteable.id) - else - notes.where(noteable_id: noteable.id) - end - + notes = noteable.notes.system notes_for_mentioner(mentioner, noteable, notes).exists? end diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index 4965dffab9d..4f60be698e9 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -64,7 +64,7 @@ %th Projects %th Jobs %th Tags - %th Last contact + %th= link_to 'Last contact', admin_runners_path(params.slice(:search).merge(sort: 'contacted_asc')) %th - @runners.each do |runner| diff --git a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml index 57544559824..573a4b93d67 100644 --- a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml +++ b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml @@ -1,33 +1,41 @@ -.blank-state - .blank-state-icon - = custom_icon("add_new_user", size: 50) - .blank-state-body - %h3.blank-state-title - Add user - %p.blank-state-text - Add your team members and others to GitLab. - = link_to new_admin_user_path, class: "btn btn-new" do - New user +.blank-state-row + = link_to new_project_path, class: "blank-state-link" do + .blank-state + .blank-state-icon + = custom_icon("add_new_project", size: 50) + .blank-state-body + %h3.blank-state-title + Create a project + %p.blank-state-text + Projects are where you store your code, access issues, wiki and other features of GitLab. -.blank-state - .blank-state-icon - = custom_icon("configure_server", size: 50) - .blank-state-body - %h3.blank-state-title - Configure GitLab - %p.blank-state-text - Make adjustments to how your GitLab instance is set up. - = link_to admin_root_path, class: "btn btn-new" do - Configure + - if current_user.can_create_group? + = link_to admin_root_path, class: "blank-state-link" do + .blank-state + .blank-state-icon + = custom_icon("add_new_group", size: 50) + .blank-state-body + %h3.blank-state-title + Create a group + %p.blank-state-text + Groups are a great way to organize projects and people. -- if current_user.can_create_group? - .blank-state - .blank-state-icon - = custom_icon("add_new_group", size: 50) - .blank-state-body - %h3.blank-state-title - Create a group - %p.blank-state-text - Groups are a great way to organize projects and people. - = link_to new_group_path, class: "btn btn-new" do - New group + = link_to new_admin_user_path, class: "blank-state-link" do + .blank-state + .blank-state-icon + = custom_icon("add_new_user", size: 50) + .blank-state-body + %h3.blank-state-title + Add people + %p.blank-state-text + Add your team members and others to GitLab. + + = link_to admin_root_path, class: "blank-state-link" do + .blank-state + .blank-state-icon + = custom_icon("configure_server", size: 50) + .blank-state-body + %h3.blank-state-title + Configure GitLab + %p.blank-state-text + Make adjustments to how your GitLab instance is set up. diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml index a93a3415ee1..8d5bddbb288 100644 --- a/app/views/dashboard/projects/_blank_state_welcome.html.haml +++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml @@ -1,48 +1,58 @@ - public_project_count = ProjectsFinder.new(current_user: current_user).execute.count -- if current_user.can_create_group? - .blank-state - .blank-state-icon - = custom_icon("add_new_group", size: 50) - .blank-state-body - %h3.blank-state-title - Create a group for several dependent projects. - %p.blank-state-text - Groups are the best way to manage projects and members. - = link_to new_group_path, class: "btn btn-new" do - New group +.blank-state-row + - if current_user.can_create_project? + = link_to new_project_path, class: "blank-state-link" do + .blank-state + .blank-state-icon + = custom_icon("add_new_project", size: 50) + .blank-state-body + %h3.blank-state-title + Create a project + %p.blank-state-text + Projects are where you store your code, access issues, wiki and other features of GitLab. + - else + .blank-state + .blank-state-icon + = custom_icon("add_new_project", size: 50) + .blank-state-body + %h3.blank-state-title + Create a project + %p.blank-state-text + If you are added to a project, it will be displayed here. -.blank-state - .blank-state-icon - = custom_icon("add_new_project", size: 50) - .blank-state-body - %h3.blank-state-title - Create a project - %p.blank-state-text - - if current_user.can_create_project? - You don't have access to any projects right now. - You can create up to - %strong= number_with_delimiter(current_user.projects_limit) - = succeed "." do - = "project".pluralize(current_user.projects_limit) - - else - If you are added to a project, it will be displayed here. - - if current_user.can_create_project? - = link_to new_project_path, class: "btn btn-new" do - New project + - if current_user.can_create_group? + = link_to new_group_path, class: "blank-state-link" do + .blank-state + .blank-state-icon + = custom_icon("add_new_group", size: 50) + .blank-state-body + %h3.blank-state-title + Create a group + %p.blank-state-text + Groups are the best way to manage projects and members. -- if public_project_count > 0 - .blank-state - .blank-state-icon - = custom_icon("globe", size: 50) - .blank-state-body - %h3.blank-state-title - Explore public projects - %p.blank-state-text - There are - = number_with_delimiter(public_project_count) - public projects on this server. - Public projects are an easy way to allow - everyone to have read-only access. - = link_to trending_explore_projects_path, class: "btn btn-new" do - Browse projects + - if public_project_count > 0 + = link_to trending_explore_projects_path, class: "blank-state-link" do + .blank-state + .blank-state-icon + = custom_icon("globe", size: 50) + .blank-state-body + %h3.blank-state-title + Explore public projects + %p.blank-state-text + There are + = number_with_delimiter(public_project_count) + public projects on this server. + Public projects are an easy way to allow + everyone to have read-only access. + + = link_to "https://docs.gitlab.com/", class: "blank-state-link" do + .blank-state + .blank-state-icon + = custom_icon("lightbulb", size: 50) + .blank-state-body + %h3.blank-state-title + Learn more about GitLab + %p.blank-state-text + Take a look at the documentation to discover all of GitLab's capabilities. diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml index ad3fac6d164..18a82feb189 100644 --- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml +++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml @@ -1,12 +1,13 @@ -.row.blank-state-parent-container +.blank-state-parent-container .section-container.section-welcome{ class: "#{ 'section-admin-welcome' if current_user.admin? }" } .container.section-body - .blank-state.blank-state-welcome - %h2.blank-state-welcome-title - Welcome to GitLab - %p.blank-state-text - Code, test, and deploy together - - if current_user.admin? - = render "blank_state_admin_welcome" - - else - = render "blank_state_welcome" + .row + .blank-state-welcome + %h2.blank-state-welcome-title + Welcome to GitLab + %p.blank-state-text + Code, test, and deploy together + - if current_user.admin? + = render "blank_state_admin_welcome" + - else + = render "blank_state_welcome" diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml index b3313c7c985..cf0e0de1ca4 100644 --- a/app/views/doorkeeper/applications/_form.html.haml +++ b/app/views/doorkeeper/applications/_form.html.haml @@ -1,4 +1,4 @@ -= form_for application, url: doorkeeper_submit_path(application), html: {role: 'form'} do |f| += form_for application, url: doorkeeper_submit_path(application), html: { role: 'form', class: 'doorkeeper-app-form' } do |f| = form_errors(application) .form-group diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml index 8ba88906714..6d9c6b5572a 100644 --- a/app/views/doorkeeper/authorizations/new.html.haml +++ b/app/views/doorkeeper/authorizations/new.html.haml @@ -1,5 +1,5 @@ %main{ :role => "main" } - .modal-no-backdrop + .modal-no-backdrop.modal-doorkeepr-auth .modal-content .modal-header %h3.page-title @@ -16,14 +16,26 @@ %strong= @pre_auth.client.name will allow them to interact with GitLab as an admin as well. Proceed with caution. %p - You are about to authorize + An application called = link_to @pre_auth.client.name, @pre_auth.redirect_uri, target: '_blank', rel: 'noopener noreferrer' - to use your account. - - if @pre_auth.scopes + is requesting access to your GitLab account. + + - auth_app_owner = @pre_auth.client.application.owner + - if auth_app_owner + This application was created by + = succeed "." do + = link_to auth_app_owner.name, user_path(auth_app_owner) + + Please note that this application is not provided by GitLab and you should verify its authenticity before + allowing access. + - if @pre_auth.scopes + %p This application will be able to: %ul - @pre_auth.scopes.each do |scope| - %li= t scope, scope: [:doorkeeper, :scopes] + %li + %strong= t scope, scope: [:doorkeeper, :scopes] + .scope-description= t scope, scope: [:doorkeeper, :scope_desc] .form-actions.text-right = form_tag oauth_authorization_path, method: :delete, class: 'inline' do = hidden_field_tag :client_id, @pre_auth.client.uid diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 5ff6ac5fc00..1eca412aff9 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -61,7 +61,7 @@ = link_to "Help", help_path %li.divider %li - = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link" + = link_to "Sign out", destroy_user_session_path, class: "sign-out-link" - if session[:impersonator_id] %li.impersonation = link_to admin_impersonation_path, class: 'impersonation-btn', method: :delete, title: "Stop impersonation", aria: { label: 'Stop impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml index a80518f7986..3e36da31ea3 100644 --- a/app/views/notify/_note_email.html.haml +++ b/app/views/notify/_note_email.html.haml @@ -1,10 +1,15 @@ - discussion = @note.discussion if @note.part_of_discussion? +- diff_discussion = discussion&.diff_discussion? +- on_image = discussion.on_image? if diff_discussion + - if discussion + - phrase_end_char = on_image ? "." : ":" + %p.details - = succeed ':' do + = succeed phrase_end_char do = link_to @note.author_name, user_url(@note.author) - - if discussion.diff_discussion? + - if diff_discussion - if discussion.new_discussion? started a new discussion - else @@ -21,7 +26,7 @@ %p.details #{link_to @note.author_name, user_url(@note.author)} commented: -- if discussion&.diff_discussion? +- if diff_discussion && !on_image = content_for :head do = stylesheet_link_tag 'mailers/highlighted_diff_email' diff --git a/app/views/projects/clusters/_form.html.haml b/app/views/projects/clusters/_form.html.haml index d8e5b55bb88..1f8ae463d0f 100644 --- a/app/views/projects/clusters/_form.html.haml +++ b/app/views/projects/clusters/_form.html.haml @@ -29,7 +29,7 @@ .form-group = provider_gcp_field.label :machine_type, s_('ClusterIntegration|Machine type') = link_to(s_('ClusterIntegration|See machine types'), 'https://cloud.google.com/compute/docs/machine-types', target: '_blank', rel: 'noopener noreferrer') - = provider_gcp_field.text_field :machine_type, class: 'form-control', placeholder: 'n1-standard-4' + = provider_gcp_field.text_field :machine_type, class: 'form-control', placeholder: 'n1-standard-2' .form-group = field.submit s_('ClusterIntegration|Create cluster'), class: 'btn btn-save' diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml index ebb9383ca12..b7671f5e3c4 100644 --- a/app/views/projects/clusters/show.html.haml +++ b/app/views/projects/clusters/show.html.haml @@ -4,11 +4,18 @@ - expanded = Rails.env.test? -- status_path = status_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster) && @cluster.on_creation? +- status_path = status_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster) .edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path, + install_helm_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :helm), + install_ingress_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :ingress), toggle_status: @cluster.enabled? ? 'true': 'false', cluster_status: @cluster.status_name, - cluster_status_reason: @cluster.status_reason } } + cluster_status_reason: @cluster.status_reason, + help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications') } } + + + .js-cluster-application-notice + .flash-container %section.settings.no-animate.expanded %h4= s_('ClusterIntegration|Enable cluster integration') @@ -49,7 +56,9 @@ .form-group = field.submit _('Save'), class: 'btn btn-success' - %section.settings.no-animate#js-cluster-details{ class: ('expanded' if expanded) } + .cluster-applications-table#js-cluster-applications + + %section.settings#js-cluster-details .settings-header %h4= s_('ClusterIntegration|Cluster details') %button.btn.js-settings-toggle @@ -59,7 +68,7 @@ .settings-content .form_group.append-bottom-20 - %label.append-bottom-10{ for: 'cluter-name' } + %label.append-bottom-10{ for: 'cluster-name' } = s_('ClusterIntegration|Cluster name') .input-group %input.form-control.cluster-name{ value: @cluster.name, disabled: true } diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index ff17372fdd9..8b9c1bbb602 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -61,13 +61,13 @@ %span.cgray= n_('parent', 'parents', @commit.parents.count) - @commit.parents.each do |parent| = link_to parent.short_id, project_commit_path(@project, parent), class: "commit-sha" - %span.commit-info.branches + .commit-info.branches %i.fa.fa-spinner.fa-spin - if @commit.last_pipeline - last_pipeline = @commit.last_pipeline .well-segment.pipeline-info - .status-icon-container{ class: "ci-status-icon-#{@commit.status}" } + .status-icon-container{ class: "ci-status-icon-#{last_pipeline.status}" } = link_to project_pipeline_path(@project, last_pipeline.id) do = ci_icon_for_status(last_pipeline.status) #{ _('Pipeline') } diff --git a/app/views/projects/commit/_limit_exceeded_message.html.haml b/app/views/projects/commit/_limit_exceeded_message.html.haml new file mode 100644 index 00000000000..84a52d49487 --- /dev/null +++ b/app/views/projects/commit/_limit_exceeded_message.html.haml @@ -0,0 +1,8 @@ +.has-tooltip{ class: "limit-box limit-box-#{objects} prepend-left-5", data: { title: "Project has too many #{label_for_message} to search"} } + .limit-icon + - if objects == :branch + = icon('code-fork') + - else + = icon('tag') + .limit-message + %span #{label_for_message.capitalize} unavailable diff --git a/app/views/projects/commit/branches.html.haml b/app/views/projects/commit/branches.html.haml index 911c9ddce06..8611129b356 100644 --- a/app/views/projects/commit/branches.html.haml +++ b/app/views/projects/commit/branches.html.haml @@ -1,15 +1,15 @@ -- if @branches.any? || @tags.any? +- if @branches_limit_exceeded + = render 'limit_exceeded_message', objects: :branch, label_for_message: "branches" +- elsif @branches.any? - branch = commit_default_branch(@project, @branches) - = link_to(project_ref_path(@project, branch), class: "label label-gray ref-name") do - = icon('code-fork') - = branch + = commit_branch_link(project_ref_path(@project, branch), branch) - -# `commit_default_branch` deletes the default branch from `@branches`, - -# so only render this if we have more branches left - - if @branches.any? || @tags.any? - %span - = link_to "…", "#", class: "js-details-expand label label-gray" - - %span.js-details-content.hide - = commit_branches_links(@project, @branches) if @branches.any? - = commit_tags_links(@project, @tags) if @tags.any? +- if @branches.any? || @tags.any? || @tags_limit_exceeded + %span + = link_to "…", "#", class: "js-details-expand label label-gray" + %span.js-details-content.hide + = commit_branches_links(@project, @branches) + - if @tags_limit_exceeded + = render 'limit_exceeded_message', objects: :tag, label_for_message: "tags" + - else + = commit_tags_links(@project, @tags) diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index a16ffb433a5..a66177f20e9 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -1,11 +1,6 @@ - ref = local_assigns.fetch(:ref) -- if @note_counts - - note_count = @note_counts.fetch(commit.id, 0) -- else - - notes = commit.notes - - note_count = notes.user.count -- cache_key = [project.full_path, commit.id, current_application_settings, note_count, @path.presence, current_controller?(:commits), I18n.locale] +- cache_key = [project.full_path, commit.id, current_application_settings, @path.presence, current_controller?(:commits), I18n.locale] - cache_key.push(commit.status(ref)) if commit.status(ref) = cache(cache_key, expires_in: 1.day) do diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml index 13809da6523..0d39edb7bfd 100644 --- a/app/views/projects/issues/_nav_btns.html.haml +++ b/app/views/projects/issues/_nav_btns.html.haml @@ -3,8 +3,8 @@ - if @can_bulk_update = button_tag "Edit issues", class: "btn btn-default append-right-10 js-bulk-update-toggle" = link_to "New issue", new_project_issue_path(@project, - issue: { assignee_id: issues_finder.assignee.try(:id), - milestone_id: issues_finder.milestones.first.try(:id) }), + issue: { assignee_id: finder.assignee.try(:id), + milestone_id: finder.milestones.first.try(:id) }), class: "btn btn-new", title: "New issue", id: "new_issue_link" diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml index ea91e8af70e..f53b81cada6 100644 --- a/app/views/projects/protected_tags/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml @@ -2,7 +2,7 @@ .create_access_levels-container = dropdown_tag('Select', options: { toggle_class: 'js-allowed-to-create wide', - dropdown_class: 'dropdown-menu-selectable', + dropdown_class: 'dropdown-menu-selectable capitalize-header', data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes' }}) = render 'projects/protected_tags/shared/create_protected_tag' diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index 3e99e0e8234..031efa903c5 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -33,7 +33,7 @@ = s_('TagsPage|Optionally, add a message to the tag.') %hr .form-group - = label_tag :release_description, 'Release notes', class: 'control-label' + = label_tag :release_description, s_('TagsPage|Release notes'), class: 'control-label' .col-sm-10 = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files here...'), current_text: @release_description @@ -41,6 +41,6 @@ .help-block = s_('TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page.') .form-actions - = button_tag 'Create tag', class: 'btn btn-create', tabindex: 3 - = link_to 'Cancel', project_tags_path(@project), class: 'btn btn-cancel' + = button_tag s_('TagsPage|Create tag'), class: 'btn btn-create', tabindex: 3 + = link_to s_('TagsPage|Cancel'), project_tags_path(@project), class: 'btn btn-cancel' %script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe diff --git a/app/views/shared/icons/_add_new_project.svg b/app/views/shared/icons/_add_new_project.svg index 3c1e15453df..cf8762944ca 100644 --- a/app/views/shared/icons/_add_new_project.svg +++ b/app/views/shared/icons/_add_new_project.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76 19.575 76 3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><path fill="#FEE1D3" fill-rule="nonzero" d="M30 24a4 4 0 0 0-4 4v22a4 4 0 0 0 4 4h18a4 4 0 0 0 4-4V28a4 4 0 0 0-4-4H30zm0-4h18a8 8 0 0 1 8 8v22a8 8 0 0 1-8 8H30a8 8 0 0 1-8-8V28a8 8 0 0 1 8-8z"/><path fill="#FC6D26" d="M33 30h8a2 2 0 1 1 0 4h-8a2 2 0 1 1 0-4zm0 7h12a2 2 0 1 1 0 4H33a2 2 0 1 1 0-4zm0 7h12a2 2 0 1 1 0 4H33a2 2 0 1 1 0-4z"/></g></svg>
\ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76S3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><path fill="#E1DBF2" fill-rule="nonzero" d="M30 24c-2.21 0-4 1.79-4 4v22c0 2.21 1.79 4 4 4h18c2.21 0 4-1.79 4-4V28c0-2.21-1.79-4-4-4H30zm0-4h18c4.418 0 8 3.582 8 8v22c0 4.418-3.582 8-8 8H30c-4.418 0-8-3.582-8-8V28c0-4.418 3.582-8 8-8z"/><path fill="#6B4FBB" d="M33 30h8c1.105 0 2 .895 2 2s-.895 2-2 2h-8c-1.105 0-2-.895-2-2s.895-2 2-2zm0 7h12c1.105 0 2 .895 2 2s-.895 2-2 2H33c-1.105 0-2-.895-2-2s.895-2 2-2zm0 7h12c1.105 0 2 .895 2 2s-.895 2-2 2H33c-1.105 0-2-.895-2-2s.895-2 2-2z"/></g></svg> diff --git a/app/views/shared/icons/_icon_autodevops.svg b/app/views/shared/icons/_icon_autodevops.svg index 423ca6d760d..7e47c084bde 100644 --- a/app/views/shared/icons/_icon_autodevops.svg +++ b/app/views/shared/icons/_icon_autodevops.svg @@ -29,7 +29,7 @@ </g> <g fill-rule="nonzero" transform="rotate(15 -315.035 277.714)"> <path fill="#FFFFFF" d="M12.275,10.57 C13.986216,9.15630755 15.921048,8.03765363 18,7.26 L18,5.5 C18,2.463 20.47,0 23.493,0 L26.507,0 C27.9648848,0.000530018716 29.3628038,0.580386367 30.3930274,1.61192286 C31.4232511,2.64345935 32.0013267,4.04211574 32,5.5 L32,7.26 C34.098,8.043 36.03,9.17 37.725,10.57 L39.253,9.688 C41.8816141,8.17268496 45.2407537,9.07039379 46.763,11.695 L48.27,14.305 C48.9984289,15.5678669 49.1951495,17.0684426 48.8168566,18.4763972 C48.4385638,19.8843518 47.5162683,21.0842673 46.253,21.812 L44.728,22.693 C44.907,23.769 45,24.873 45,26 C45,27.127 44.907,28.231 44.728,29.307 L46.253,30.187 C48.8800379,31.705769 49.7822744,35.0642181 48.27,37.695 L46.763,40.305 C46.0335844,41.5673849 44.8323832,42.4881439 43.4238487,42.8645658 C42.0153143,43.2409877 40.5149245,43.0422119 39.253,42.312 L37.725,41.43 C36.013784,42.8436924 34.078952,43.9623464 32,44.74 L32,46.5 C32,49.537 29.53,52 26.507,52 L23.493,52 C22.0351152,51.99947 20.6371962,51.4196136 19.6069726,50.3880771 C18.5767489,49.3565406 17.9986733,47.9578843 18,46.5 L18,44.74 C15.921048,43.9623464 13.986216,42.8436924 12.275,41.43 L10.747,42.312 C8.11838594,43.827315 4.75924629,42.9296062 3.237,40.305 L1.73,37.695 C1.00157113,36.4321331 0.804850523,34.9315574 1.18314337,33.5236028 C1.56143621,32.1156482 2.48373172,30.9157327 3.747,30.188 L5.272,29.307 C5.09051204,28.2140265 4.9995366,27.107939 5,26 C5,24.873 5.093,23.769 5.272,22.693 L3.747,21.813 C1.11996213,20.294231 0.217725591,16.9357819 1.73,14.305 L3.237,11.695 C3.96641559,10.4326151 5.16761682,9.51185609 6.57615125,9.13543417 C7.98468568,8.75901226 9.48507553,8.95778814 10.747,9.688 L12.275,10.57 Z"/> - <path class="animated spin-cw infinite" fill="#E1DBF1" d="M17.9996486,7.25963195 L18.0000013,5.49772675 C18.0034459,2.46713881 20.4561478,0.00952173148 23.493,0 L26.507,0 C29.542757,0 32,2.46161709 32,5.5 L32,7.25850184 C34.0799663,8.03664754 36.0149544,9.15559094 37.7260175,10.5694605 L39.2547869,9.68691874 C41.8812087,8.17416302 45.2363972,9.06948854 46.7630175,11.6949424 L48.270687,14.3061027 C48.9989901,15.569417 49.1952874,17.0704122 48.816349,18.4785295 C48.4374106,19.8866468 47.5143145,21.0864021 46.2530682,21.8120114 L44.7278655,22.6926677 C44.9091017,23.7802451 45,24.8850821 45,26 C45,27.1144218 44.9091826,28.218078 44.7278653,29.3073326 L46.2547984,30.1889888 C48.8778516,31.7070439 49.7801588,35.0599752 48.2700175,37.6950576 L46.7625317,40.3058986 C46.0327098,41.5684739 44.8309328,42.4891542 43.4219037,42.8651509 C42.0128746,43.2411475 40.512172,43.0416186 39.2533538,42.312255 L37.7244858,41.4299789 C36.013753,42.8435912 34.0794396,43.9622923 32.0003514,44.7403681 L31.9999987,46.5022733 C31.9965541,49.5328612 29.5438522,51.9904783 26.507,52 L23.493,52 C20.457243,52 18,49.5383829 18,46.5 L18,44.7414988 C15.9200337,43.9633525 13.9850456,42.8444091 12.2739825,41.4305395 L10.7452131,42.3130813 C8.11879127,43.825837 4.76360277,42.9305115 3.23698247,40.3050576 L1.72931303,37.6938973 C1.0010099,36.430583 0.804712603,34.9295878 1.18365098,33.5214705 C1.56258936,32.1133532 2.48568546,30.9135979 3.74693178,30.1879886 L5.27213454,29.3073323 C5.09089825,28.2197549 5,27.114918 5.00000019,26.0008761 C4.99951488,24.8930059 5.0904571,23.7869854 5.27213502,22.6926675 L3.74520157,21.8110112 C1.12214836,20.2929561 0.219841192,16.9400248 1.72998247,14.3049424 L3.23746831,11.6941014 C3.96729024,10.4315261 5.16906725,9.51084579 6.5780963,9.13484913 C7.98712536,8.75885247 9.48782803,8.95838137 10.7466462,9.687745 L12.2748018,10.56961 C14.0209791,9.13635584 15.9392199,8.03072455 17.9996486,7.25963195 Z M13.7518374,14.537862 C13.108069,15.069723 12.2016163,15.1456339 11.4783538,14.728255 L8.74433999,13.1505123 C8.40103903,12.9516035 7.99274958,12.8973186 7.60940137,12.9996143 C7.22605315,13.10191 6.89909107,13.3523954 6.70101753,13.6950576 L5.19724591,16.2994454 C4.78547321,17.0179634 5.03203388,17.9341714 5.74706822,18.3479886 L8.47306822,19.9219886 C9.19530115,20.3390079 9.58295216,21.1604138 9.44574883,21.983032 L9.21798321,23.3486236 C9.07251948,24.2246212 8.99961081,25.111131 9,26 C9,26.8953847 9.0728258,27.7804297 9.21774883,28.649968 L9.44574883,30.016968 C9.58295216,30.8395862 9.19530115,31.6609921 8.47306822,32.0780114 L5.74435077,33.6535776 C5.40046982,33.851417 5.14932721,34.1778291 5.04623114,34.5609292 C4.94313508,34.9440294 4.9965408,35.3523984 5.19401753,35.6949424 L6.69795587,38.2996585 C7.11427713,39.0156351 8.03110189,39.260288 8.7470791,38.8479035 L11.4770791,37.2719035 C12.200376,36.8543519 13.1069795,36.9302031 13.7508374,37.462138 L14.8210499,38.3463136 C16.1898549,39.4774943 17.737648,40.3725891 19.3990866,40.9941596 L20.6990866,41.4791596 C21.4813437,41.7710017 22,42.5180761 22,43.353 L22,46.5 C22,47.3308348 22.6679761,48 23.493,48 L26.5007228,48.0000099 C27.328845,47.9974107 27.99906,47.3258525 28,46.5 L28,43.353 C28,42.5185702 28.5180515,41.771829 29.2996486,41.4796319 L30.599003,40.9938734 C32.261836,40.3715765 33.8093225,39.4764853 35.1790197,38.3444304 L36.2490197,37.4614304 C36.8927697,36.9301861 37.798736,36.8545694 38.5216462,37.271745 L41.25566,38.8494877 C41.598961,39.0483965 42.0072504,39.1026814 42.3905986,39.0003857 C42.7739468,38.89809 43.1009089,38.6476046 43.2989825,38.3049424 L44.8027541,35.7005546 C45.2145268,34.9820366 44.9679661,34.0658286 44.2529318,33.6520114 L41.5269318,32.0780114 C40.8046988,31.6609921 40.4170478,30.8395862 40.5542512,30.016968 L40.7821577,28.6505288 C40.9272286,27.7792134 41,26.8950523 41,26 C41,25.1046153 40.9271742,24.2195703 40.7822512,23.350032 L40.5542512,21.983032 C40.4170478,21.1604138 40.8046988,20.3390079 41.5269318,19.9219886 L44.2556492,18.3464224 C44.5995302,18.148583 44.8506728,17.8221709 44.9537689,17.4390708 C45.0568649,17.0559706 45.0034592,16.6476016 44.8059825,16.3050576 L43.3020441,13.7003415 C42.8857229,12.9843649 41.9688981,12.739712 41.2529209,13.1520965 L38.5229209,14.7280965 C37.799624,15.1456481 36.8930205,15.0697969 36.2491626,14.537862 L35.1789501,13.6536864 C33.8101451,12.5225057 32.262352,11.6274109 30.6021792,11.0063122 L29.3021792,10.5223122 C28.5192618,10.230826 28,9.48341836 28,8.648 L28,5.5 C28,4.66916515 27.3320239,4 26.507,4 L23.4992772,3.99999015 C22.671155,4.00258933 22.00094,4.67414748 22,5.5 L22,8.647 C22,9.48142977 21.4819485,10.228171 20.7003514,10.5203681 L19.400997,11.0061266 C17.738164,11.6284235 16.1906775,12.5235147 14.822142,13.6546103 C14.8121128,13.6628994 14.4553446,13.9573166 13.7518374,14.537862 Z"/> + <path fill="#E1DBF1" d="M17.9996486,7.25963195 L18.0000013,5.49772675 C18.0034459,2.46713881 20.4561478,0.00952173148 23.493,0 L26.507,0 C29.542757,0 32,2.46161709 32,5.5 L32,7.25850184 C34.0799663,8.03664754 36.0149544,9.15559094 37.7260175,10.5694605 L39.2547869,9.68691874 C41.8812087,8.17416302 45.2363972,9.06948854 46.7630175,11.6949424 L48.270687,14.3061027 C48.9989901,15.569417 49.1952874,17.0704122 48.816349,18.4785295 C48.4374106,19.8866468 47.5143145,21.0864021 46.2530682,21.8120114 L44.7278655,22.6926677 C44.9091017,23.7802451 45,24.8850821 45,26 C45,27.1144218 44.9091826,28.218078 44.7278653,29.3073326 L46.2547984,30.1889888 C48.8778516,31.7070439 49.7801588,35.0599752 48.2700175,37.6950576 L46.7625317,40.3058986 C46.0327098,41.5684739 44.8309328,42.4891542 43.4219037,42.8651509 C42.0128746,43.2411475 40.512172,43.0416186 39.2533538,42.312255 L37.7244858,41.4299789 C36.013753,42.8435912 34.0794396,43.9622923 32.0003514,44.7403681 L31.9999987,46.5022733 C31.9965541,49.5328612 29.5438522,51.9904783 26.507,52 L23.493,52 C20.457243,52 18,49.5383829 18,46.5 L18,44.7414988 C15.9200337,43.9633525 13.9850456,42.8444091 12.2739825,41.4305395 L10.7452131,42.3130813 C8.11879127,43.825837 4.76360277,42.9305115 3.23698247,40.3050576 L1.72931303,37.6938973 C1.0010099,36.430583 0.804712603,34.9295878 1.18365098,33.5214705 C1.56258936,32.1133532 2.48568546,30.9135979 3.74693178,30.1879886 L5.27213454,29.3073323 C5.09089825,28.2197549 5,27.114918 5.00000019,26.0008761 C4.99951488,24.8930059 5.0904571,23.7869854 5.27213502,22.6926675 L3.74520157,21.8110112 C1.12214836,20.2929561 0.219841192,16.9400248 1.72998247,14.3049424 L3.23746831,11.6941014 C3.96729024,10.4315261 5.16906725,9.51084579 6.5780963,9.13484913 C7.98712536,8.75885247 9.48782803,8.95838137 10.7466462,9.687745 L12.2748018,10.56961 C14.0209791,9.13635584 15.9392199,8.03072455 17.9996486,7.25963195 Z M13.7518374,14.537862 C13.108069,15.069723 12.2016163,15.1456339 11.4783538,14.728255 L8.74433999,13.1505123 C8.40103903,12.9516035 7.99274958,12.8973186 7.60940137,12.9996143 C7.22605315,13.10191 6.89909107,13.3523954 6.70101753,13.6950576 L5.19724591,16.2994454 C4.78547321,17.0179634 5.03203388,17.9341714 5.74706822,18.3479886 L8.47306822,19.9219886 C9.19530115,20.3390079 9.58295216,21.1604138 9.44574883,21.983032 L9.21798321,23.3486236 C9.07251948,24.2246212 8.99961081,25.111131 9,26 C9,26.8953847 9.0728258,27.7804297 9.21774883,28.649968 L9.44574883,30.016968 C9.58295216,30.8395862 9.19530115,31.6609921 8.47306822,32.0780114 L5.74435077,33.6535776 C5.40046982,33.851417 5.14932721,34.1778291 5.04623114,34.5609292 C4.94313508,34.9440294 4.9965408,35.3523984 5.19401753,35.6949424 L6.69795587,38.2996585 C7.11427713,39.0156351 8.03110189,39.260288 8.7470791,38.8479035 L11.4770791,37.2719035 C12.200376,36.8543519 13.1069795,36.9302031 13.7508374,37.462138 L14.8210499,38.3463136 C16.1898549,39.4774943 17.737648,40.3725891 19.3990866,40.9941596 L20.6990866,41.4791596 C21.4813437,41.7710017 22,42.5180761 22,43.353 L22,46.5 C22,47.3308348 22.6679761,48 23.493,48 L26.5007228,48.0000099 C27.328845,47.9974107 27.99906,47.3258525 28,46.5 L28,43.353 C28,42.5185702 28.5180515,41.771829 29.2996486,41.4796319 L30.599003,40.9938734 C32.261836,40.3715765 33.8093225,39.4764853 35.1790197,38.3444304 L36.2490197,37.4614304 C36.8927697,36.9301861 37.798736,36.8545694 38.5216462,37.271745 L41.25566,38.8494877 C41.598961,39.0483965 42.0072504,39.1026814 42.3905986,39.0003857 C42.7739468,38.89809 43.1009089,38.6476046 43.2989825,38.3049424 L44.8027541,35.7005546 C45.2145268,34.9820366 44.9679661,34.0658286 44.2529318,33.6520114 L41.5269318,32.0780114 C40.8046988,31.6609921 40.4170478,30.8395862 40.5542512,30.016968 L40.7821577,28.6505288 C40.9272286,27.7792134 41,26.8950523 41,26 C41,25.1046153 40.9271742,24.2195703 40.7822512,23.350032 L40.5542512,21.983032 C40.4170478,21.1604138 40.8046988,20.3390079 41.5269318,19.9219886 L44.2556492,18.3464224 C44.5995302,18.148583 44.8506728,17.8221709 44.9537689,17.4390708 C45.0568649,17.0559706 45.0034592,16.6476016 44.8059825,16.3050576 L43.3020441,13.7003415 C42.8857229,12.9843649 41.9688981,12.739712 41.2529209,13.1520965 L38.5229209,14.7280965 C37.799624,15.1456481 36.8930205,15.0697969 36.2491626,14.537862 L35.1789501,13.6536864 C33.8101451,12.5225057 32.262352,11.6274109 30.6021792,11.0063122 L29.3021792,10.5223122 C28.5192618,10.230826 28,9.48341836 28,8.648 L28,5.5 C28,4.66916515 27.3320239,4 26.507,4 L23.4992772,3.99999015 C22.671155,4.00258933 22.00094,4.67414748 22,5.5 L22,8.647 C22,9.48142977 21.4819485,10.228171 20.7003514,10.5203681 L19.400997,11.0061266 C17.738164,11.6284235 16.1906775,12.5235147 14.822142,13.6546103 C14.8121128,13.6628994 14.4553446,13.9573166 13.7518374,14.537862 Z"/> <g transform="rotate(15 -59.137 82.348)"> <circle cx="8" cy="8" r="8" fill="#FFFFFF" transform="translate(.035 6.008)"/> <path fill="#6B4FBB" d="M7.40192379,14.7679492 C2.98364579,14.7679492 -0.598076211,11.1862272 -0.598076211,6.76794919 C-0.598076211,2.34967119 2.98364579,-1.23205081 7.40192379,-1.23205081 C11.8202018,-1.23205081 15.4019238,2.34967119 15.4019238,6.76794919 C15.4019238,11.1862272 11.8202018,14.7679492 7.40192379,14.7679492 Z M7.40192379,10.7679492 C9.61106279,10.7679492 11.4019238,8.97708819 11.4019238,6.76794919 C11.4019238,4.55881019 9.61106279,2.76794919 7.40192379,2.76794919 C5.19278479,2.76794919 3.40192379,4.55881019 3.40192379,6.76794919 C3.40192379,8.97708819 5.19278479,10.7679492 7.40192379,10.7679492 Z"/> @@ -37,7 +37,7 @@ </g> <g fill-rule="nonzero" transform="rotate(15 -402.968 460.884)"> <path fill="#FFFFFF" d="M9.82,8.53730769 C11.1889728,7.39547918 12.7368384,6.49195101 14.4,5.86384615 L14.4,4.44230769 C14.4,1.98934615 16.376,0 18.7944,0 L21.2056,0 C22.3719078,0.00042809204 23.4902431,0.468773604 24.314422,1.30193769 C25.1386009,2.13510179 25.6010613,3.26478579 25.6,4.44230769 L25.6,5.86384615 C27.2784,6.49626923 28.824,7.40653846 30.18,8.53730769 L31.4024,7.82492308 C33.5052912,6.60101478 36.192603,7.32608729 37.4104,9.44596154 L38.616,11.5540385 C39.1987431,12.5740464 39.3561196,13.7860498 39.0534853,14.9232439 C38.750851,16.060438 38.0130146,17.0296006 37.0024,17.6173846 L35.7824,18.3289615 C35.9256,19.1980385 36,20.0897308 36,21 C36,21.9102692 35.9256,22.8019615 35.7824,23.6710385 L37.0024,24.3818077 C39.1040303,25.6085057 39.8258195,28.3210992 38.616,30.4459615 L37.4104,32.5540385 C36.8268675,33.573657 35.8659065,34.317347 34.739079,34.6213801 C33.6122515,34.9254132 32.4119396,34.7648634 31.4024,34.1750769 L30.18,33.4626923 C28.8110272,34.6045208 27.2631616,35.508049 25.6,36.1361538 L25.6,37.5576923 C25.6,40.0106538 23.624,42 21.2056,42 L18.7944,42 C17.6280922,41.9995719 16.5097569,41.5312264 15.685578,40.6980623 C14.8613991,39.8648982 14.3989387,38.7352142 14.4,37.5576923 L14.4,36.1361538 C12.7368384,35.508049 11.1889728,34.6045208 9.82,33.4626923 L8.5976,34.1750769 C6.49470875,35.3989852 3.80739703,34.6739127 2.5896,32.5540385 L1.384,30.4459615 C0.8012569,29.4259536 0.643880418,28.2139502 0.946514692,27.0767561 C1.24914897,25.939562 1.98698538,24.9703994 2.9976,24.3826154 L4.2176,23.6710385 C4.07240963,22.7882521 3.99962928,21.8948738 4,21 C4,20.0897308 4.0744,19.1980385 4.2176,18.3289615 L2.9976,17.6181923 C0.895969702,16.3914943 0.174180473,13.6789008 1.384,11.5540385 L2.5896,9.44596154 C3.17313247,8.42634297 4.13409345,7.682653 5.260921,7.37861991 C6.38774855,7.07458682 7.58806043,7.23513658 8.5976,7.82492308 L9.82,8.53730769 Z"/> - <path class="animated spin-ccw infinite" fill="#FEE1D3" d="M14.0000007,5.6038043 L14.0000013,4.44005609 C14.0029906,1.78475013 16.1390906,-0.376211234 18.7944,-0.384615385 L21.2056,-0.384615385 C23.8595941,-0.384615385 26,1.78021801 26,4.44230769 L26,5.60295806 C27.5208716,6.20655954 28.9434678,7.03621848 30.2204219,8.06411282 L31.1970056,7.49492104 C33.4941909,6.15907529 36.4301298,6.95005805 37.7609369,9.26076474 L38.9671983,11.3699991 C39.5988409,12.4761812 39.768854,13.7886936 39.4405746,15.0202941 C39.1116282,16.2543969 38.308799,17.3078735 37.2096539,17.946304 L36.2175721,18.5246428 C36.3390841,19.3401617 36.4,20.1667594 36.4,21 C36.4,21.8329668 36.339124,22.6588262 36.2175401,23.4753391 L37.2113882,24.0547082 C39.4944154,25.3886826 40.276605,28.3232105 38.9665369,30.6311583 L37.7604568,32.7400742 C37.1252608,33.8495148 36.0768547,34.6604208 34.8452776,34.9922248 C33.6111681,35.324711 32.2964469,35.1482289 31.195569,34.5042428 L30.2192355,33.9354047 C28.9426535,34.9630196 27.5206806,35.7924453 25.9999993,36.3961957 L25.9999987,37.5599439 C25.9970094,40.2152499 23.8609094,42.3762112 21.2056,42.3846154 L18.7944,42.3846154 C16.1404059,42.3846154 14,40.219782 14,37.5576923 L14,36.3970419 C12.4791284,35.7934405 11.0565322,34.9637815 9.77957815,33.9358872 L8.80299442,34.505079 C6.50580915,35.8409247 3.56987021,35.049942 2.23906313,32.7392353 L1.03280169,30.6300009 C0.401159146,29.5238188 0.231145999,28.2113064 0.559425405,26.9797059 C0.888371786,25.7456031 1.69120101,24.6921265 2.79034606,24.053696 L3.78242779,23.4753573 C3.66091587,22.6598457 3.60000002,21.8333228 3.60000019,21.0008678 C3.59964068,20.1722851 3.66061719,19.3449468 3.78254167,18.5247085 L2.78861183,17.9452918 C0.505584602,16.6113174 -0.276605002,13.6767895 1.03346313,11.3688417 L2.23954317,9.25992583 C2.87473915,8.15048519 3.92314533,7.33957919 5.15472238,7.00777521 C6.38883187,6.67528896 7.70355311,6.85177112 8.80443097,7.49575721 L9.78076186,8.06459377 C11.0573465,7.03698045 12.4793194,6.20755475 14.0000007,5.6038043 Z M11.2634746,12.0326234 C10.617233,12.5716613 9.7026973,12.6485026 8.97556903,12.2248582 L6.78774825,10.9501716 C6.60754053,10.8447551 6.39506809,10.8162338 6.19527576,10.8700606 C5.99295099,10.9245697 5.8183659,11.0596053 5.71133687,11.246543 L4.50892658,13.3490215 C4.28085652,13.7508163 4.41776119,14.2644394 4.80485394,14.4906191 L6.98565394,15.7619268 C7.70254629,16.1798426 8.08690703,16.9970357 7.95165511,17.8157512 L7.76948523,18.9184706 C7.65638664,19.6061109 7.59969735,20.3020342 7.6,21 C7.6,21.7031066 7.65662064,22.3978283 7.76925511,23.0801334 L7.95165511,24.1842488 C8.08690703,25.0029643 7.70254629,25.8201574 6.98565394,26.2380732 L4.80213007,27.5109659 C4.61772321,27.6180778 4.48116147,27.7972748 4.42448029,28.0099246 C4.36713215,28.2250767 4.39688141,28.454743 4.50573687,28.6453801 L5.70831165,30.7481858 C5.93243371,31.1373303 6.41410538,31.2670993 6.79049373,31.0482253 L8.97449373,29.7753023 C9.7016554,29.3514832 10.6163433,29.4282639 11.2626746,29.9673766 L12.1188867,30.6815536 C13.1796505,31.566598 14.3786867,32.2666727 15.6649769,32.7525215 L16.7049769,33.1442523 C17.4841581,33.4377419 18,34.1832625 18,35.0158846 L18,37.5576923 C18,38.02074 18.3597694,38.3846154 18.7944,38.3846154 L21.1992624,38.3846254 C21.6372484,38.3832375 21.9994819,38.0167881 22,37.5576923 L22,35.0158846 C22,34.18376 22.5152346,33.4385758 23.2937506,33.1447321 L24.3331012,32.7524389 C25.620867,32.2658727 26.8196661,31.5658006 27.8813806,30.679856 L28.7373806,29.9666637 C29.3836087,29.4282468 30.2976553,29.3517028 31.024431,29.7751418 L33.2122517,31.0498284 C33.3924595,31.1552449 33.6049319,31.1837662 33.8047242,31.1299394 C34.007049,31.0754303 34.1816341,30.9403947 34.2886631,30.753457 L35.4910734,28.6509785 C35.7191435,28.2491837 35.5822388,27.7355606 35.1951461,27.5093809 L33.0143461,26.2380732 C32.2974537,25.8201574 31.913093,25.0029643 32.0483449,24.1842488 L32.2306531,23.0806893 C32.3434217,22.3968737 32.4,21.7028459 32.4,21 C32.4,20.2968934 32.3433794,19.6021717 32.2307449,18.9198666 L32.0483449,17.8157512 C31.913093,16.9970357 32.2974537,16.1798426 33.0143461,15.7619268 L35.1978699,14.4890341 C35.3822768,14.3819222 35.5188385,14.2027252 35.5755197,13.9900754 C35.6328679,13.7749233 35.6031186,13.545257 35.4942631,13.3546199 L34.2916883,11.2518142 C34.0675663,10.8626697 33.5858946,10.7329007 33.2095063,10.9517747 L31.0255063,12.2246977 C30.2983446,12.6485168 29.3836567,12.5717361 28.7373254,12.0326234 L27.8811133,11.3184464 C26.8203495,10.433402 25.6213133,9.73332732 24.3362966,9.24795765 L23.2962966,8.85703457 C22.5164499,8.56389992 22,7.81804293 22,6.98492308 L22,4.44230769 C22,3.97925995 21.6402306,3.61538462 21.2056,3.61538462 L18.8007376,3.61537457 C18.3627516,3.61676247 18.0005181,3.98321188 18,4.44230769 L18,6.98411538 C18,7.81623999 17.4847654,8.56142419 16.7062494,8.85526793 L15.6668988,9.24756113 C14.379133,9.73412728 13.1803339,10.4341994 12.1197785,11.3191775 C12.1108094,11.3266617 11.8253748,11.564477 11.2634746,12.0326234 Z"/> + <path fill="#FEE1D3" d="M14.0000007,5.6038043 L14.0000013,4.44005609 C14.0029906,1.78475013 16.1390906,-0.376211234 18.7944,-0.384615385 L21.2056,-0.384615385 C23.8595941,-0.384615385 26,1.78021801 26,4.44230769 L26,5.60295806 C27.5208716,6.20655954 28.9434678,7.03621848 30.2204219,8.06411282 L31.1970056,7.49492104 C33.4941909,6.15907529 36.4301298,6.95005805 37.7609369,9.26076474 L38.9671983,11.3699991 C39.5988409,12.4761812 39.768854,13.7886936 39.4405746,15.0202941 C39.1116282,16.2543969 38.308799,17.3078735 37.2096539,17.946304 L36.2175721,18.5246428 C36.3390841,19.3401617 36.4,20.1667594 36.4,21 C36.4,21.8329668 36.339124,22.6588262 36.2175401,23.4753391 L37.2113882,24.0547082 C39.4944154,25.3886826 40.276605,28.3232105 38.9665369,30.6311583 L37.7604568,32.7400742 C37.1252608,33.8495148 36.0768547,34.6604208 34.8452776,34.9922248 C33.6111681,35.324711 32.2964469,35.1482289 31.195569,34.5042428 L30.2192355,33.9354047 C28.9426535,34.9630196 27.5206806,35.7924453 25.9999993,36.3961957 L25.9999987,37.5599439 C25.9970094,40.2152499 23.8609094,42.3762112 21.2056,42.3846154 L18.7944,42.3846154 C16.1404059,42.3846154 14,40.219782 14,37.5576923 L14,36.3970419 C12.4791284,35.7934405 11.0565322,34.9637815 9.77957815,33.9358872 L8.80299442,34.505079 C6.50580915,35.8409247 3.56987021,35.049942 2.23906313,32.7392353 L1.03280169,30.6300009 C0.401159146,29.5238188 0.231145999,28.2113064 0.559425405,26.9797059 C0.888371786,25.7456031 1.69120101,24.6921265 2.79034606,24.053696 L3.78242779,23.4753573 C3.66091587,22.6598457 3.60000002,21.8333228 3.60000019,21.0008678 C3.59964068,20.1722851 3.66061719,19.3449468 3.78254167,18.5247085 L2.78861183,17.9452918 C0.505584602,16.6113174 -0.276605002,13.6767895 1.03346313,11.3688417 L2.23954317,9.25992583 C2.87473915,8.15048519 3.92314533,7.33957919 5.15472238,7.00777521 C6.38883187,6.67528896 7.70355311,6.85177112 8.80443097,7.49575721 L9.78076186,8.06459377 C11.0573465,7.03698045 12.4793194,6.20755475 14.0000007,5.6038043 Z M11.2634746,12.0326234 C10.617233,12.5716613 9.7026973,12.6485026 8.97556903,12.2248582 L6.78774825,10.9501716 C6.60754053,10.8447551 6.39506809,10.8162338 6.19527576,10.8700606 C5.99295099,10.9245697 5.8183659,11.0596053 5.71133687,11.246543 L4.50892658,13.3490215 C4.28085652,13.7508163 4.41776119,14.2644394 4.80485394,14.4906191 L6.98565394,15.7619268 C7.70254629,16.1798426 8.08690703,16.9970357 7.95165511,17.8157512 L7.76948523,18.9184706 C7.65638664,19.6061109 7.59969735,20.3020342 7.6,21 C7.6,21.7031066 7.65662064,22.3978283 7.76925511,23.0801334 L7.95165511,24.1842488 C8.08690703,25.0029643 7.70254629,25.8201574 6.98565394,26.2380732 L4.80213007,27.5109659 C4.61772321,27.6180778 4.48116147,27.7972748 4.42448029,28.0099246 C4.36713215,28.2250767 4.39688141,28.454743 4.50573687,28.6453801 L5.70831165,30.7481858 C5.93243371,31.1373303 6.41410538,31.2670993 6.79049373,31.0482253 L8.97449373,29.7753023 C9.7016554,29.3514832 10.6163433,29.4282639 11.2626746,29.9673766 L12.1188867,30.6815536 C13.1796505,31.566598 14.3786867,32.2666727 15.6649769,32.7525215 L16.7049769,33.1442523 C17.4841581,33.4377419 18,34.1832625 18,35.0158846 L18,37.5576923 C18,38.02074 18.3597694,38.3846154 18.7944,38.3846154 L21.1992624,38.3846254 C21.6372484,38.3832375 21.9994819,38.0167881 22,37.5576923 L22,35.0158846 C22,34.18376 22.5152346,33.4385758 23.2937506,33.1447321 L24.3331012,32.7524389 C25.620867,32.2658727 26.8196661,31.5658006 27.8813806,30.679856 L28.7373806,29.9666637 C29.3836087,29.4282468 30.2976553,29.3517028 31.024431,29.7751418 L33.2122517,31.0498284 C33.3924595,31.1552449 33.6049319,31.1837662 33.8047242,31.1299394 C34.007049,31.0754303 34.1816341,30.9403947 34.2886631,30.753457 L35.4910734,28.6509785 C35.7191435,28.2491837 35.5822388,27.7355606 35.1951461,27.5093809 L33.0143461,26.2380732 C32.2974537,25.8201574 31.913093,25.0029643 32.0483449,24.1842488 L32.2306531,23.0806893 C32.3434217,22.3968737 32.4,21.7028459 32.4,21 C32.4,20.2968934 32.3433794,19.6021717 32.2307449,18.9198666 L32.0483449,17.8157512 C31.913093,16.9970357 32.2974537,16.1798426 33.0143461,15.7619268 L35.1978699,14.4890341 C35.3822768,14.3819222 35.5188385,14.2027252 35.5755197,13.9900754 C35.6328679,13.7749233 35.6031186,13.545257 35.4942631,13.3546199 L34.2916883,11.2518142 C34.0675663,10.8626697 33.5858946,10.7329007 33.2095063,10.9517747 L31.0255063,12.2246977 C30.2983446,12.6485168 29.3836567,12.5717361 28.7373254,12.0326234 L27.8811133,11.3184464 C26.8203495,10.433402 25.6213133,9.73332732 24.3362966,9.24795765 L23.2962966,8.85703457 C22.5164499,8.56389992 22,7.81804293 22,6.98492308 L22,4.44230769 C22,3.97925995 21.6402306,3.61538462 21.2056,3.61538462 L18.8007376,3.61537457 C18.3627516,3.61676247 18.0005181,3.98321188 18,4.44230769 L18,6.98411538 C18,7.81623999 17.4847654,8.56142419 16.7062494,8.85526793 L15.6668988,9.24756113 C14.379133,9.73412728 13.1803339,10.4341994 12.1197785,11.3191775 C12.1108094,11.3266617 11.8253748,11.564477 11.2634746,12.0326234 Z"/> <g transform="rotate(15 -47.892 66.043)"> <ellipse cx="6.4" cy="6.462" fill="#FFFFFF" rx="6.4" ry="6.462" transform="translate(.028 4.853)"/> <path fill="#FC6D26" d="M5.92153903,11.9125743 C2.3834711,11.9125743 -0.478460969,9.0231237 -0.478460969,5.4664205 C-0.478460969,1.9097173 2.3834711,-0.979733345 5.92153903,-0.979733345 C9.45960696,-0.979733345 12.321539,1.9097173 12.321539,5.4664205 C12.321539,9.0231237 9.45960696,11.9125743 5.92153903,11.9125743 Z M5.92153903,8.71257435 C7.6854047,8.71257435 9.12153903,7.26263103 9.12153903,5.4664205 C9.12153903,3.67020997 7.6854047,2.22026666 5.92153903,2.22026666 C4.15767337,2.22026666 2.72153903,3.67020997 2.72153903,5.4664205 C2.72153903,7.26263103 4.15767337,8.71257435 5.92153903,8.71257435 Z"/> diff --git a/app/views/shared/icons/_icon_hourglass.svg b/app/views/shared/icons/_icon_hourglass.svg new file mode 100644 index 00000000000..fe7e497ce13 --- /dev/null +++ b/app/views/shared/icons/_icon_hourglass.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.331 4.889A2.988 2.988 0 0 0 11 3V2H5v1c0 .362.064.709.182 1.03l5.15.859zM3 14v-1c0-1.78.93-3.342 2.33-4.228.447-.327.67-.582.67-.764 0-.19-.242-.46-.725-.815A4.996 4.996 0 0 1 3 3V2H2a1 1 0 1 1 0-2h12a1 1 0 0 1 0 2h-1v1a4.997 4.997 0 0 1-2.39 4.266c-.407.3-.61.545-.61.734 0 .19.203.434.61.734A4.997 4.997 0 0 1 13 13v1h1a1 1 0 0 1 0 2H2a1 1 0 0 1 0-2h1zm8 0v-1a3 3 0 0 0-6 0v1h6z"/></svg> diff --git a/app/views/shared/icons/_lightbulb.svg b/app/views/shared/icons/_lightbulb.svg new file mode 100644 index 00000000000..2fcc4c65f99 --- /dev/null +++ b/app/views/shared/icons/_lightbulb.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76S3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><path fill="#6B4FBB" d="M33 52h12c1.105 0 2 .895 2 2s-.895 2-2 2H33c-1.105 0-2-.895-2-2s.895-2 2-2zm1 5h10c1.105 0 2 .895 2 2s-.895 2-2 2H34c-1.105 0-2-.895-2-2s.895-2 2-2z"/><path fill="#E1DBF2" fill-rule="nonzero" d="M45.542 46.932l.346-2.36c.198-1.348.737-2.623 1.566-3.705 3.025-3.946 4.485-7.29 4.547-9.96C52.153 24.41 46.843 20 39 20c-7.777 0-13 4.374-13 11 0 2.4 1.462 5.73 4.573 9.846.815 1.08 1.343 2.345 1.536 3.683l.353 2.456 13.08-.054zm-17.038.624L28.15 45.1c-.097-.67-.36-1.303-.768-1.842C23.794 38.51 22 34.424 22 31c0-9.39 7.61-15 17-15s17.218 5.614 17 15c-.085 3.64-1.875 7.74-5.37 12.3-.416.54-.685 1.18-.784 1.853l-.346 2.36c-.288 1.958-1.963 3.41-3.942 3.42l-13.08.053c-1.994.008-3.69-1.455-3.974-3.43z"/><path fill="#6B4FBB" d="M41 38.732c-.598-.345-1-.992-1-1.732 0-1.105.895-2 2-2s2 .895 2 2c0 .74-.402 1.387-1 1.732V42c0 .552-.448 1-1 1s-1-.448-1-1v-3.268zm-6 0c-.598-.345-1-.992-1-1.732 0-1.105.895-2 2-2s2 .895 2 2c0 .74-.402 1.387-1 1.732V42c0 .552-.448 1-1 1s-1-.448-1-1v-3.268z"/></g></svg> diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index d3f0aa2d339..8442d7ff4a2 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -1,4 +1,3 @@ -- finder = controller.controller_name == 'issues' ? issues_finder : merge_requests_finder - boards_page = controller.controller_name == 'boards' .issues-filters diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 951b4dd7b36..2c27dd638a7 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -104,7 +104,6 @@ class: 'btn btn-remove prepend-left-10' - else = link_to member, - remote: true, method: :delete, data: { confirm: remove_member_message(member) }, class: 'btn btn-remove prepend-left-10', diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index f03e0ab154c..4f51455c26e 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -85,6 +85,22 @@ Closed: = milestone.issues_visible_to_user(current_user).closed.count + .block.time_spent + .sidebar-collapsed-icon + = custom_icon('icon_hourglass') + %span.collapsed-milestone-total-time-spent + - if milestone.human_total_issue_time_spent + = milestone.human_total_issue_time_spent + - else + = _("None") + .title.hide-collapsed + = _("Total issue time spent") + .value.hide-collapsed + - if milestone.human_total_issue_time_spent + %span.bold= milestone.human_total_issue_time_spent + - else + %span.no-value= _("No time spent") + .block.merge-requests .sidebar-collapsed-icon %strong diff --git a/app/views/shared/tokens/_scopes_form.html.haml b/app/views/shared/tokens/_scopes_form.html.haml index 8bbaf431536..ae437dd16d6 100644 --- a/app/views/shared/tokens/_scopes_form.html.haml +++ b/app/views/shared/tokens/_scopes_form.html.haml @@ -7,3 +7,4 @@ = check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}" = label_tag ("#{prefix}_scopes_#{scope}"), scope %span= t(scope, scope: [:doorkeeper, :scopes]) + .scope-description= t scope, scope: [:doorkeeper, :scope_desc] diff --git a/app/workers/cluster_install_app_worker.rb b/app/workers/cluster_install_app_worker.rb new file mode 100644 index 00000000000..899aed904e4 --- /dev/null +++ b/app/workers/cluster_install_app_worker.rb @@ -0,0 +1,11 @@ +class ClusterInstallAppWorker + include Sidekiq::Worker + include ClusterQueue + include ClusterApplications + + def perform(app_name, app_id) + find_application(app_name, app_id) do |app| + Clusters::Applications::InstallService.new(app).execute + end + end +end diff --git a/app/workers/cluster_wait_for_app_installation_worker.rb b/app/workers/cluster_wait_for_app_installation_worker.rb new file mode 100644 index 00000000000..4bb8c293e5d --- /dev/null +++ b/app/workers/cluster_wait_for_app_installation_worker.rb @@ -0,0 +1,14 @@ +class ClusterWaitForAppInstallationWorker + include Sidekiq::Worker + include ClusterQueue + include ClusterApplications + + INTERVAL = 10.seconds + TIMEOUT = 20.minutes + + def perform(app_name, app_id) + find_application(app_name, app_id) do |app| + Clusters::Applications::CheckInstallationProgressService.new(app).execute + end + end +end diff --git a/app/workers/concerns/cluster_applications.rb b/app/workers/concerns/cluster_applications.rb new file mode 100644 index 00000000000..24ecaa0b52f --- /dev/null +++ b/app/workers/concerns/cluster_applications.rb @@ -0,0 +1,9 @@ +module ClusterApplications + extend ActiveSupport::Concern + + included do + def find_application(app_name, id, &blk) + Clusters::Cluster::APPLICATIONS[app_name].find(id).try(&blk) + end + end +end diff --git a/app/workers/concerns/gitlab/github_import/notify_upon_death.rb b/app/workers/concerns/gitlab/github_import/notify_upon_death.rb new file mode 100644 index 00000000000..3d7120665b6 --- /dev/null +++ b/app/workers/concerns/gitlab/github_import/notify_upon_death.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + # NotifyUponDeath can be included into a GitHub worker class if it should + # notify any JobWaiter instances upon being moved to the Sidekiq dead queue. + # + # Note that this will only notify the waiter upon graceful termination, a + # SIGKILL will still result in the waiter _not_ being notified. + # + # Workers including this module must have jobs passed where the last + # argument is the key to notify, as a String. + module NotifyUponDeath + extend ActiveSupport::Concern + + included do + # If a job is being exhausted we still want to notify the + # AdvanceStageWorker. This prevents the entire import from getting stuck + # just because 1 job threw too many errors. + sidekiq_retries_exhausted do |job| + args = job['args'] + jid = job['jid'] + + if args.length == 3 && (key = args.last) && key.is_a?(String) + JobWaiter.notify(key, jid) + end + end + end + end + end +end diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb new file mode 100644 index 00000000000..67e36c811de --- /dev/null +++ b/app/workers/concerns/gitlab/github_import/object_importer.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + # ObjectImporter defines the base behaviour for every Sidekiq worker that + # imports a single resource such as a note or pull request. + module ObjectImporter + extend ActiveSupport::Concern + + included do + include Sidekiq::Worker + include GithubImport::Queue + include ReschedulingMethods + include NotifyUponDeath + end + + # project - An instance of `Project` to import the data into. + # client - An instance of `Gitlab::GithubImport::Client` + # hash - A Hash containing the details of the object to import. + def import(project, client, hash) + object = representation_class.from_json_hash(hash) + + importer_class.new(object, project, client).execute + + counter.increment(project: project.path_with_namespace) + end + + def counter + @counter ||= Gitlab::Metrics.counter(counter_name, counter_description) + end + + # Returns the representation class to use for the object. This class must + # define the class method `from_json_hash`. + def representation_class + raise NotImplementedError + end + + # Returns the class to use for importing the object. + def importer_class + raise NotImplementedError + end + + # Returns the name (as a Symbol) of the Prometheus counter. + def counter_name + raise NotImplementedError + end + + # Returns the description (as a String) of the Prometheus counter. + def counter_description + raise NotImplementedError + end + end + end +end diff --git a/app/workers/concerns/gitlab/github_import/queue.rb b/app/workers/concerns/gitlab/github_import/queue.rb new file mode 100644 index 00000000000..a2bee361b86 --- /dev/null +++ b/app/workers/concerns/gitlab/github_import/queue.rb @@ -0,0 +1,16 @@ +module Gitlab + module GithubImport + module Queue + extend ActiveSupport::Concern + + included do + # If a job produces an error it may block a stage from advancing + # forever. To prevent this from happening we prevent jobs from going to + # the dead queue. This does mean some resources may not be imported, but + # this is better than a project being stuck in the "import" state + # forever. + sidekiq_options queue: 'github_importer', dead: false, retry: 5 + end + end + end +end diff --git a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb new file mode 100644 index 00000000000..692ca6b7f42 --- /dev/null +++ b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + # Module that provides methods shared by the various workers used for + # importing GitHub projects. + module ReschedulingMethods + # project_id - The ID of the GitLab project to import the note into. + # hash - A Hash containing the details of the GitHub object to imoprt. + # notify_key - The Redis key to notify upon completion, if any. + def perform(project_id, hash, notify_key = nil) + project = Project.find_by(id: project_id) + + return notify_waiter(notify_key) unless project + + client = GithubImport.new_client_for(project, parallel: true) + + if try_import(project, client, hash) + notify_waiter(notify_key) + else + # In the event of hitting the rate limit we want to reschedule the job + # so its retried after our rate limit has been reset. + self.class + .perform_in(client.rate_limit_resets_in, project.id, hash, notify_key) + end + end + + def try_import(*args) + import(*args) + true + rescue RateLimitError + false + end + + def notify_waiter(key = nil) + JobWaiter.notify(key, jid) if key + end + end + end +end diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb new file mode 100644 index 00000000000..147c8c8d683 --- /dev/null +++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + module StageMethods + # project_id - The ID of the GitLab project to import the data into. + def perform(project_id) + return unless (project = find_project(project_id)) + + client = GithubImport.new_client_for(project) + + try_import(client, project) + end + + # client - An instance of Gitlab::GithubImport::Client. + # project - An instance of Project. + def try_import(client, project) + import(client, project) + rescue RateLimitError + self.class.perform_in(client.rate_limit_resets_in, project.id) + end + + def find_project(id) + # If the project has been marked as failed we want to bail out + # automatically. + Project.import_started.find_by(id: id) + end + end + end +end diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb new file mode 100644 index 00000000000..877f88c043f --- /dev/null +++ b/app/workers/gitlab/github_import/advance_stage_worker.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + # AdvanceStageWorker is a worker used by the GitHub importer to wait for a + # number of jobs to complete, without blocking a thread. Once all jobs have + # been completed this worker will advance the import process to the next + # stage. + class AdvanceStageWorker + include Sidekiq::Worker + + sidekiq_options queue: 'github_importer_advance_stage', dead: false + + INTERVAL = 30.seconds.to_i + + # The number of seconds to wait (while blocking the thread) before + # continueing to the next waiter. + BLOCKING_WAIT_TIME = 5 + + # The known importer stages and their corresponding Sidekiq workers. + STAGES = { + issues_and_diff_notes: Stage::ImportIssuesAndDiffNotesWorker, + notes: Stage::ImportNotesWorker, + finish: Stage::FinishImportWorker + }.freeze + + # project_id - The ID of the project being imported. + # waiters - A Hash mapping Gitlab::JobWaiter keys to the number of + # remaining jobs. + # next_stage - The name of the next stage to start when all jobs have been + # completed. + def perform(project_id, waiters, next_stage) + return unless (project = find_project(project_id)) + + new_waiters = wait_for_jobs(waiters) + + if new_waiters.empty? + # We refresh the import JID here so workers importing individual + # resources (e.g. notes) don't have to do this all the time, reducing + # the pressure on Redis. We _only_ do this once all jobs are done so + # we don't get stuck forever if one or more jobs failed to notify the + # JobWaiter. + project.refresh_import_jid_expiration + + STAGES.fetch(next_stage.to_sym).perform_async(project_id) + else + self.class.perform_in(INTERVAL, project_id, new_waiters, next_stage) + end + end + + def wait_for_jobs(waiters) + waiters.each_with_object({}) do |(key, remaining), new_waiters| + waiter = JobWaiter.new(remaining, key) + + # We wait for a brief moment of time so we don't reschedule if we can + # complete the work fast enough. + waiter.wait(BLOCKING_WAIT_TIME) + + next unless waiter.jobs_remaining.positive? + + new_waiters[waiter.key] = waiter.jobs_remaining + end + end + + def find_project(id) + # We only care about the import JID so we can refresh it. We also only + # want the project if it hasn't been marked as failed yet. It's possible + # the import gets marked as stuck when jobs of the current stage failed + # somehow. + Project.select(:import_jid).import_started.find_by(id: id) + end + end + end +end diff --git a/app/workers/gitlab/github_import/import_diff_note_worker.rb b/app/workers/gitlab/github_import/import_diff_note_worker.rb new file mode 100644 index 00000000000..ef2a74c51c5 --- /dev/null +++ b/app/workers/gitlab/github_import/import_diff_note_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + class ImportDiffNoteWorker + include ObjectImporter + + def representation_class + Representation::DiffNote + end + + def importer_class + Importer::DiffNoteImporter + end + + def counter_name + :github_importer_imported_diff_notes + end + + def counter_description + 'The number of imported GitHub pull request review comments' + end + end + end +end diff --git a/app/workers/gitlab/github_import/import_issue_worker.rb b/app/workers/gitlab/github_import/import_issue_worker.rb new file mode 100644 index 00000000000..1b081ae5966 --- /dev/null +++ b/app/workers/gitlab/github_import/import_issue_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + class ImportIssueWorker + include ObjectImporter + + def representation_class + Representation::Issue + end + + def importer_class + Importer::IssueAndLabelLinksImporter + end + + def counter_name + :github_importer_imported_issues + end + + def counter_description + 'The number of imported GitHub issues' + end + end + end +end diff --git a/app/workers/gitlab/github_import/import_note_worker.rb b/app/workers/gitlab/github_import/import_note_worker.rb new file mode 100644 index 00000000000..d2b4c36a5b9 --- /dev/null +++ b/app/workers/gitlab/github_import/import_note_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + class ImportNoteWorker + include ObjectImporter + + def representation_class + Representation::Note + end + + def importer_class + Importer::NoteImporter + end + + def counter_name + :github_importer_imported_notes + end + + def counter_description + 'The number of imported GitHub comments' + end + end + end +end diff --git a/app/workers/gitlab/github_import/import_pull_request_worker.rb b/app/workers/gitlab/github_import/import_pull_request_worker.rb new file mode 100644 index 00000000000..62a6da152a3 --- /dev/null +++ b/app/workers/gitlab/github_import/import_pull_request_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + class ImportPullRequestWorker + include ObjectImporter + + def representation_class + Representation::PullRequest + end + + def importer_class + Importer::PullRequestImporter + end + + def counter_name + :github_importer_imported_pull_requests + end + + def counter_description + 'The number of imported GitHub pull requests' + end + end + end +end diff --git a/app/workers/gitlab/github_import/refresh_import_jid_worker.rb b/app/workers/gitlab/github_import/refresh_import_jid_worker.rb new file mode 100644 index 00000000000..45a38927225 --- /dev/null +++ b/app/workers/gitlab/github_import/refresh_import_jid_worker.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + class RefreshImportJidWorker + include Sidekiq::Worker + include GithubImport::Queue + + # The interval to schedule new instances of this job at. + INTERVAL = 1.minute.to_i + + def self.perform_in_the_future(*args) + perform_in(INTERVAL, *args) + end + + # project_id - The ID of the project that is being imported. + # check_job_id - The ID of the job for which to check the status. + def perform(project_id, check_job_id) + return unless (project = find_project(project_id)) + + if SidekiqStatus.running?(check_job_id) + # As long as the repository is being cloned we want to keep refreshing + # the import JID status. + project.refresh_import_jid_expiration + self.class.perform_in_the_future(project_id, check_job_id) + end + + # If the job is no longer running there's nothing else we need to do. If + # the clone job completed successfully it will have scheduled the next + # stage, if it died there's nothing we can do anyway. + end + + def find_project(id) + Project.select(:import_jid).import_started.find_by(id: id) + end + end + end +end diff --git a/app/workers/gitlab/github_import/stage/finish_import_worker.rb b/app/workers/gitlab/github_import/stage/finish_import_worker.rb new file mode 100644 index 00000000000..1a09497780a --- /dev/null +++ b/app/workers/gitlab/github_import/stage/finish_import_worker.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + module Stage + class FinishImportWorker + include Sidekiq::Worker + include GithubImport::Queue + include StageMethods + + # project - An instance of Project. + def import(_, project) + project.after_import + report_import_time(project) + end + + def report_import_time(project) + duration = Time.zone.now - project.created_at + path = project.path_with_namespace + + histogram.observe({ project: path }, duration) + counter.increment + + logger.info("GitHub importer finished for #{path} in #{duration.round(2)} seconds") + end + + def histogram + @histogram ||= Gitlab::Metrics.histogram( + :github_importer_total_duration_seconds, + 'Total time spent importing GitHub projects, in seconds' + ) + end + + def counter + @counter ||= Gitlab::Metrics.counter( + :github_importer_imported_projects, + 'The number of imported GitHub projects' + ) + end + end + end + end +end diff --git a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb new file mode 100644 index 00000000000..f8a3684c6ba --- /dev/null +++ b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + module Stage + class ImportBaseDataWorker + include Sidekiq::Worker + include GithubImport::Queue + include StageMethods + + # These importers are fast enough that we can just run them in the same + # thread. + IMPORTERS = [ + Importer::LabelsImporter, + Importer::MilestonesImporter, + Importer::ReleasesImporter + ].freeze + + # client - An instance of Gitlab::GithubImport::Client. + # project - An instance of Project. + def import(client, project) + IMPORTERS.each do |klass| + klass.new(project, client).execute + end + + project.refresh_import_jid_expiration + + ImportPullRequestsWorker.perform_async(project.id) + end + end + end + end +end diff --git a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb new file mode 100644 index 00000000000..e110b7c1c36 --- /dev/null +++ b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + module Stage + class ImportIssuesAndDiffNotesWorker + include Sidekiq::Worker + include GithubImport::Queue + include StageMethods + + # The importers to run in this stage. Issues can't be imported earlier + # on as we also use these to enrich pull requests with assigned labels. + IMPORTERS = [ + Importer::IssuesImporter, + Importer::DiffNotesImporter + ].freeze + + # client - An instance of Gitlab::GithubImport::Client. + # project - An instance of Project. + def import(client, project) + waiters = IMPORTERS.each_with_object({}) do |klass, hash| + waiter = klass.new(project, client).execute + hash[waiter.key] = waiter.jobs_remaining + end + + AdvanceStageWorker.perform_async(project.id, waiters, :notes) + end + end + end + end +end diff --git a/app/workers/gitlab/github_import/stage/import_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_notes_worker.rb new file mode 100644 index 00000000000..9810ed25cf9 --- /dev/null +++ b/app/workers/gitlab/github_import/stage/import_notes_worker.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + module Stage + class ImportNotesWorker + include Sidekiq::Worker + include GithubImport::Queue + include StageMethods + + # client - An instance of Gitlab::GithubImport::Client. + # project - An instance of Project. + def import(client, project) + waiter = Importer::NotesImporter + .new(project, client) + .execute + + AdvanceStageWorker.perform_async( + project.id, + { waiter.key => waiter.jobs_remaining }, + :finish + ) + end + end + end + end +end diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb new file mode 100644 index 00000000000..c531f26e897 --- /dev/null +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + module Stage + class ImportPullRequestsWorker + include Sidekiq::Worker + include GithubImport::Queue + include StageMethods + + # client - An instance of Gitlab::GithubImport::Client. + # project - An instance of Project. + def import(client, project) + waiter = Importer::PullRequestsImporter + .new(project, client) + .execute + + project.refresh_import_jid_expiration + + AdvanceStageWorker.perform_async( + project.id, + { waiter.key => waiter.jobs_remaining }, + :issues_and_diff_notes + ) + end + end + end + end +end diff --git a/app/workers/gitlab/github_import/stage/import_repository_worker.rb b/app/workers/gitlab/github_import/stage/import_repository_worker.rb new file mode 100644 index 00000000000..aa5762e773d --- /dev/null +++ b/app/workers/gitlab/github_import/stage/import_repository_worker.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + module Stage + class ImportRepositoryWorker + include Sidekiq::Worker + include GithubImport::Queue + include StageMethods + + # client - An instance of Gitlab::GithubImport::Client. + # project - An instance of Project. + def import(client, project) + # In extreme cases it's possible for a clone to take more than the + # import job expiration time. To work around this we schedule a + # separate job that will periodically run and refresh the import + # expiration time. + RefreshImportJidWorker.perform_in_the_future(project.id, jid) + + importer = Importer::RepositoryImporter.new(project, client) + + return unless importer.execute + + counter.increment + + ImportBaseDataWorker.perform_async(project.id) + end + + def counter + Gitlab::Metrics.counter( + :github_importer_imported_repositories, + 'The number of imported GitHub repositories' + ) + end + end + end + end +end diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index d7c0043d3b6..4e90b137b26 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -17,11 +17,16 @@ class RepositoryImportWorker import_url: project.import_url, path: project.full_path) - result = Projects::ImportService.new(project, project.creator).execute + service = Projects::ImportService.new(project, project.creator) + result = service.execute + + # Some importers may perform their work asynchronously. In this case it's up + # to those importers to mark the import process as complete. + return if service.async? + raise ImportError, result[:message] if result[:status] == :error - project.repository.after_import - project.import_finish + project.after_import rescue ImportError => ex fail_import(project, ex.message) raise diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb index 150788ca611..89ae17cef37 100644 --- a/app/workers/update_merge_requests_worker.rb +++ b/app/workers/update_merge_requests_worker.rb @@ -2,10 +2,6 @@ class UpdateMergeRequestsWorker include Sidekiq::Worker include DedicatedSidekiqQueue - def metrics_tags - @metrics_tags || {} - end - def perform(project_id, user_id, oldrev, newrev, ref) project = Project.find_by(id: project_id) return unless project @@ -13,11 +9,6 @@ class UpdateMergeRequestsWorker user = User.find_by(id: user_id) return unless user - @metrics_tags = { - project_id: project_id, - user_id: user_id - } - MergeRequests::RefreshService.new(project, user).execute(oldrev, newrev, ref) end end |