diff options
Diffstat (limited to 'app')
315 files changed, 11960 insertions, 2544 deletions
diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index 7e98e04303a..56293d5f96f 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -7,27 +7,24 @@ export default function installGlEmojiElement() { const GlEmojiElementProto = Object.create(HTMLElement.prototype); GlEmojiElementProto.createdCallback = function createdCallback() { const emojiUnicode = this.textContent.trim(); - const { - name, - unicodeVersion, - fallbackSrc, - fallbackSpriteClass, - } = this.dataset; + const { name, unicodeVersion, fallbackSrc, fallbackSpriteClass } = this.dataset; - const isEmojiUnicode = this.childNodes && Array.prototype.every.call( - this.childNodes, - childNode => childNode.nodeType === 3, - ); + const isEmojiUnicode = + this.childNodes && + Array.prototype.every.call(this.childNodes, childNode => childNode.nodeType === 3); const hasImageFallback = fallbackSrc && fallbackSrc.length > 0; const hasCssSpriteFalback = fallbackSpriteClass && fallbackSpriteClass.length > 0; - if ( - emojiUnicode && - isEmojiUnicode && - !isEmojiUnicodeSupported(emojiUnicode, unicodeVersion) - ) { + if (emojiUnicode && isEmojiUnicode && !isEmojiUnicodeSupported(emojiUnicode, unicodeVersion)) { // CSS sprite fallback takes precedence over image fallback if (hasCssSpriteFalback) { + if (!gon.emoji_sprites_css_added && gon.emoji_sprites_css_path) { + const emojiSpriteLinkTag = document.createElement('link'); + emojiSpriteLinkTag.setAttribute('rel', 'stylesheet'); + emojiSpriteLinkTag.setAttribute('href', gon.emoji_sprites_css_path); + document.head.appendChild(emojiSpriteLinkTag); + gon.emoji_sprites_css_added = true; + } // IE 11 doesn't like adding multiple at once :( this.classList.add('emoji-icon'); this.classList.add(fallbackSpriteClass); diff --git a/app/assets/javascripts/clusters/clusters_index.js b/app/assets/javascripts/clusters/clusters_index.js index 2e3ad244375..1e5c733d151 100644 --- a/app/assets/javascripts/clusters/clusters_index.js +++ b/app/assets/javascripts/clusters/clusters_index.js @@ -1,20 +1,24 @@ -import Flash from '../flash'; -import { s__ } from '../locale'; -import setupToggleButtons from '../toggle_buttons'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import setupToggleButtons from '~/toggle_buttons'; +import gcpSignupOffer from '~/clusters/components/gcp_signup_offer'; + import ClustersService from './services/clusters_service'; export default () => { const clusterList = document.querySelector('.js-clusters-list'); + + gcpSignupOffer(); + // The empty state won't have a clusterList if (clusterList) { - setupToggleButtons( - document.querySelector('.js-clusters-list'), - (value, toggle) => - ClustersService.updateCluster(toggle.dataset.endpoint, { cluster: { enabled: value } }) - .catch((err) => { - Flash(s__('ClusterIntegration|Something went wrong on our end.')); - throw err; - }), + setupToggleButtons(document.querySelector('.js-clusters-list'), (value, toggle) => + ClustersService.updateCluster(toggle.dataset.endpoint, { cluster: { enabled: value } }).catch( + err => { + createFlash(__('Something went wrong on our end.')); + throw err; + }, + ), ); } }; diff --git a/app/assets/javascripts/clusters/components/gcp_signup_offer.js b/app/assets/javascripts/clusters/components/gcp_signup_offer.js new file mode 100644 index 00000000000..8bc20a1c09f --- /dev/null +++ b/app/assets/javascripts/clusters/components/gcp_signup_offer.js @@ -0,0 +1,27 @@ +import $ from 'jquery'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import Flash from '~/flash'; + +export default function gcpSignupOffer() { + const alertEl = document.querySelector('.gcp-signup-offer'); + if (!alertEl) { + return; + } + + const closeButtonEl = alertEl.getElementsByClassName('close')[0]; + const { dismissEndpoint, featureId } = closeButtonEl.dataset; + + closeButtonEl.addEventListener('click', () => { + axios + .post(dismissEndpoint, { + feature_name: featureId, + }) + .then(() => { + $(alertEl).alert('close'); + }) + .catch(() => { + Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.')); + }); + }); +} diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index 260c91cac24..9c88466e576 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -4,8 +4,9 @@ import $ from 'jquery'; import { __ } from './locale'; import axios from './lib/utils/axios_utils'; import flash from './flash'; +import { capitalizeFirstCharacter } from './lib/utils/text_utility'; -export default function initCompareAutocomplete() { +export default function initCompareAutocomplete(limitTo = null, clickHandler = () => {}) { $('.js-compare-dropdown').each(function() { var $dropdown, selected; $dropdown = $(this); @@ -15,14 +16,27 @@ export default function initCompareAutocomplete() { const $filterInput = $('input[type="search"]', $dropdownContainer); $dropdown.glDropdown({ data: function(term, callback) { - axios.get($dropdown.data('refsUrl'), { - params: { - ref: $dropdown.data('ref'), - search: term, - }, - }).then(({ data }) => { - callback(data); - }).catch(() => flash(__('Error fetching refs'))); + const params = { + ref: $dropdown.data('ref'), + search: term, + }; + + if (limitTo) { + params.find = limitTo; + } + + axios + .get($dropdown.data('refsUrl'), { + params, + }) + .then(({ data }) => { + if (limitTo) { + callback(data[capitalizeFirstCharacter(limitTo)] || []); + } else { + callback(data); + } + }) + .catch(() => flash(__('Error fetching refs'))); }, selectable: true, filterable: true, @@ -32,9 +46,15 @@ export default function initCompareAutocomplete() { renderRow: function(ref) { var link; if (ref.header != null) { - return $('<li />').addClass('dropdown-header').text(ref.header); + return $('<li />') + .addClass('dropdown-header') + .text(ref.header); } else { - link = $('<a />').attr('href', '#').addClass(ref === selected ? 'is-active' : '').text(ref).attr('data-ref', escape(ref)); + link = $('<a />') + .attr('href', '#') + .addClass(ref === selected ? 'is-active' : '') + .text(ref) + .attr('data-ref', escape(ref)); return $('<li />').append(link); } }, @@ -43,9 +63,10 @@ export default function initCompareAutocomplete() { }, toggleLabel: function(obj, $el) { return $el.text().trim(); - } + }, + clicked: () => clickHandler($dropdown), }); - $filterInput.on('keyup', (e) => { + $filterInput.on('keyup', e => { const keyCode = e.keyCode || e.which; if (keyCode !== 13) return; const text = $filterInput.val(); @@ -54,7 +75,7 @@ export default function initCompareAutocomplete() { $dropdownContainer.removeClass('open'); }); - $dropdownContainer.on('click', '.dropdown-content a', (e) => { + $dropdownContainer.on('click', '.dropdown-content a', e => { $dropdown.prop('title', e.target.text.replace(/_+?/g, '-')); if ($dropdown.hasClass('has-tooltip')) { $dropdown.tooltip('fixTitle'); diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index 87f8854f940..1c43fc3cdc7 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -82,7 +82,6 @@ export default () => { this.service .fetchCycleAnalyticsData(fetchOptions) - .then(resp => resp.json()) .then((response) => { this.store.setCycleAnalyticsData(response); this.selectDefaultStage(); @@ -116,7 +115,6 @@ export default () => { stage, startDate: this.startDate, }) - .then(resp => resp.json()) .then((response) => { this.isEmptyStage = !response.events.length; this.store.setStageEvents(response.events, stage); diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js index f496c38208d..4cf416c50e5 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js @@ -1,16 +1,20 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; - -Vue.use(VueResource); +import axios from '~/lib/utils/axios_utils'; export default class CycleAnalyticsService { constructor(options) { - this.requestPath = options.requestPath; - this.cycleAnalytics = Vue.resource(this.requestPath); + this.axios = axios.create({ + baseURL: options.requestPath, + }); } fetchCycleAnalyticsData(options = { startDate: 30 }) { - return this.cycleAnalytics.get({ cycle_analytics: { start_date: options.startDate } }); + return this.axios + .get('', { + params: { + 'cycle_analytics[start_date]': options.startDate, + }, + }) + .then(x => x.data); } fetchStageData(options) { @@ -19,12 +23,12 @@ export default class CycleAnalyticsService { startDate, } = options; - return Vue.http.get(`${this.requestPath}/events/${stage.name}.json`, { - params: { - cycle_analytics: { - start_date: startDate, + return this.axios + .get(`events/${stage.name}.json`, { + params: { + 'cycle_analytics[start_date]': startDate, }, - }, - }); + }) + .then(x => x.data); } } diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue index b839b9f286f..67dda0e29cb 100644 --- a/app/assets/javascripts/deploy_keys/components/action_btn.vue +++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue @@ -1,55 +1,50 @@ <script> - import eventHub from '../eventhub'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import loadingIcon from '~/vue_shared/components/loading_icon.vue'; +import eventHub from '../eventhub'; - export default { - components: { - loadingIcon, +export default { + components: { + loadingIcon, + }, + props: { + deployKey: { + type: Object, + required: true, }, - props: { - deployKey: { - type: Object, - required: true, - }, - type: { - type: String, - required: true, - }, - btnCssClass: { - type: String, - required: false, - default: 'btn-default', - }, + type: { + type: String, + required: true, }, - data() { - return { - isLoading: false, - }; + btnCssClass: { + type: String, + required: false, + default: 'btn-default', }, - computed: { - text() { - return `${this.type.charAt(0).toUpperCase()}${this.type.slice(1)}`; - }, - }, - methods: { - doAction() { - this.isLoading = true; + }, + data() { + return { + isLoading: false, + }; + }, + methods: { + doAction() { + this.isLoading = true; - eventHub.$emit(`${this.type}.key`, this.deployKey, () => { - this.isLoading = false; - }); - }, + eventHub.$emit(`${this.type}.key`, this.deployKey, () => { + this.isLoading = false; + }); }, - }; + }, +}; </script> <template> <button - class="btn btn-sm prepend-left-10" + class="btn" :class="[{ disabled: isLoading }, btnCssClass]" :disabled="isLoading" @click="doAction"> - {{ text }} + <slot></slot> <loading-icon v-if="isLoading" :inline="true" diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index 5a782237b7d..c41fe55db63 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -1,80 +1,115 @@ <script> - import Flash from '../../flash'; - import eventHub from '../eventhub'; - import DeployKeysService from '../service'; - import DeployKeysStore from '../store'; - import keysPanel from './keys_panel.vue'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import { s__ } from '~/locale'; +import Flash from '~/flash'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; +import eventHub from '../eventhub'; +import DeployKeysService from '../service'; +import DeployKeysStore from '../store'; +import KeysPanel from './keys_panel.vue'; - export default { - components: { - keysPanel, - loadingIcon, +export default { + components: { + KeysPanel, + LoadingIcon, + NavigationTabs, + }, + props: { + endpoint: { + type: String, + required: true, }, - props: { - endpoint: { - type: String, - required: true, - }, + projectId: { + type: String, + required: true, }, - data() { - return { - isLoading: false, - store: new DeployKeysStore(), - }; + }, + data() { + return { + currentTab: 'enabled_keys', + isLoading: false, + store: new DeployKeysStore(), + }; + }, + scopes: { + enabled_keys: s__('DeployKeys|Enabled deploy keys'), + available_project_keys: s__('DeployKeys|Privately accessible deploy keys'), + public_keys: s__('DeployKeys|Publicly accessible deploy keys'), + }, + computed: { + tabs() { + return Object.keys(this.$options.scopes).map(scope => { + const count = Array.isArray(this.keys[scope]) ? this.keys[scope].length : null; + + return { + name: this.$options.scopes[scope], + scope, + isActive: scope === this.currentTab, + count, + }; + }); + }, + hasKeys() { + return Object.keys(this.keys).length; }, - computed: { - hasKeys() { - return Object.keys(this.keys).length; - }, - keys() { - return this.store.keys; - }, + keys() { + return this.store.keys; }, - created() { - this.service = new DeployKeysService(this.endpoint); + }, + created() { + this.service = new DeployKeysService(this.endpoint); - eventHub.$on('enable.key', this.enableKey); - eventHub.$on('remove.key', this.disableKey); - eventHub.$on('disable.key', this.disableKey); + eventHub.$on('enable.key', this.enableKey); + eventHub.$on('remove.key', this.disableKey); + eventHub.$on('disable.key', this.disableKey); + }, + mounted() { + this.fetchKeys(); + }, + beforeDestroy() { + eventHub.$off('enable.key', this.enableKey); + eventHub.$off('remove.key', this.disableKey); + eventHub.$off('disable.key', this.disableKey); + }, + methods: { + onChangeTab(tab) { + this.currentTab = tab; }, - mounted() { - this.fetchKeys(); + fetchKeys() { + this.isLoading = true; + + return this.service + .getKeys() + .then(data => { + this.isLoading = false; + this.store.keys = data; + }) + .catch(() => { + this.isLoading = false; + this.store.keys = {}; + return new Flash(s__('DeployKeys|Error getting deploy keys')); + }); }, - beforeDestroy() { - eventHub.$off('enable.key', this.enableKey); - eventHub.$off('remove.key', this.disableKey); - eventHub.$off('disable.key', this.disableKey); + enableKey(deployKey) { + this.service + .enableKey(deployKey.id) + .then(this.fetchKeys) + .catch(() => new Flash(s__('DeployKeys|Error enabling deploy key'))); }, - methods: { - fetchKeys() { - this.isLoading = true; - - this.service.getKeys() - .then((data) => { - this.isLoading = false; - this.store.keys = data; - }) - .catch(() => new Flash('Error getting deploy keys')); - }, - enableKey(deployKey) { - this.service.enableKey(deployKey.id) - .then(() => this.fetchKeys()) - .catch(() => new Flash('Error enabling deploy key')); - }, - disableKey(deployKey, callback) { - // eslint-disable-next-line no-alert - if (confirm('You are going to remove this deploy key. Are you sure?')) { - this.service.disableKey(deployKey.id) - .then(() => this.fetchKeys()) - .then(callback) - .catch(() => new Flash('Error removing deploy key')); - } else { - callback(); - } - }, + disableKey(deployKey, callback) { + // eslint-disable-next-line no-alert + if (confirm(s__('DeployKeys|You are going to remove this deploy key. Are you sure?'))) { + this.service + .disableKey(deployKey.id) + .then(this.fetchKeys) + .then(callback) + .catch(() => new Flash(s__('DeployKeys|Error removing deploy key'))); + } else { + callback(); + } }, - }; + }, +}; </script> <template> @@ -82,29 +117,38 @@ <loading-icon v-if="isLoading && !hasKeys" size="2" - label="Loading deploy keys" + :label="s__('DeployKeys|Loading deploy keys')" /> - <div v-else-if="hasKeys"> + <template v-else-if="hasKeys"> + <div class="top-area scrolling-tabs-container inner-page-scroll-tabs"> + <div class="fade-left"> + <i + class="fa fa-angle-left" + aria-hidden="true" + > + </i> + </div> + <div class="fade-right"> + <i + class="fa fa-angle-right" + aria-hidden="true" + > + </i> + </div> + + <navigation-tabs + :tabs="tabs" + @onChangeTab="onChangeTab" + scope="deployKeys" + /> + </div> <keys-panel - title="Enabled deploy keys for this project" class="qa-project-deploy-keys" - :keys="keys.enabled_keys" - :store="store" - :endpoint="endpoint" - /> - <keys-panel - title="Deploy keys from projects you have access to" - :keys="keys.available_project_keys" - :store="store" - :endpoint="endpoint" - /> - <keys-panel - v-if="keys.public_keys.length" - title="Public deploy keys available to any project" - :keys="keys.public_keys" + :project-id="projectId" + :keys="keys[currentTab]" :store="store" :endpoint="endpoint" /> - </div> + </template> </div> </template> diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index c6091efd62f..6c2af7fa768 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -1,111 +1,235 @@ <script> - import actionBtn from './action_btn.vue'; - import { getTimeago } from '../../lib/utils/datetime_utility'; - import tooltip from '../../vue_shared/directives/tooltip'; +import _ from 'underscore'; +import { s__, sprintf } from '~/locale'; +import icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; - export default { - components: { - actionBtn, - }, - directives: { - tooltip, - }, - props: { - deployKey: { - type: Object, - required: true, - }, - store: { - type: Object, - required: true, - }, - endpoint: { - type: String, - required: true, - }, - }, - computed: { - timeagoDate() { - return getTimeago().format(this.deployKey.created_at); - }, - editDeployKeyPath() { - return `${this.endpoint}/${this.deployKey.id}/edit`; - }, - }, - methods: { - isEnabled(id) { - return this.store.findEnabledKey(id) !== undefined; - }, - tooltipTitle(project) { - return project.can_push ? 'Write access allowed' : 'Read access only'; - }, - }, - }; +import actionBtn from './action_btn.vue'; + +export default { + components: { + actionBtn, + icon, + }, + directives: { + tooltip, + }, + mixins: [timeagoMixin], + props: { + deployKey: { + type: Object, + required: true, + }, + store: { + type: Object, + required: true, + }, + endpoint: { + type: String, + required: true, + }, + projectId: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + projectsExpanded: false, + }; + }, + computed: { + editDeployKeyPath() { + return `${this.endpoint}/${this.deployKey.id}/edit`; + }, + projects() { + const projects = [...this.deployKey.deploy_keys_projects]; + + if (this.projectId !== null) { + const indexOfCurrentProject = _.findIndex( + projects, + project => + project && + project.project && + project.project.id && + project.project.id.toString() === this.projectId, + ); + + if (indexOfCurrentProject > -1) { + const currentProject = projects.splice(indexOfCurrentProject, 1); + currentProject[0].project.full_name = s__('DeployKeys|Current project'); + return currentProject.concat(projects); + } + } + return projects; + }, + firstProject() { + return _.head(this.projects); + }, + restProjects() { + return _.tail(this.projects); + }, + restProjectsTooltip() { + return sprintf(s__('DeployKeys|Expand %{count} other projects'), { + count: this.restProjects.length, + }); + }, + restProjectsLabel() { + return sprintf(s__('DeployKeys|+%{count} others'), { count: this.restProjects.length }); + }, + isEnabled() { + return this.store.isEnabled(this.deployKey.id); + }, + isRemovable() { + return ( + this.store.isEnabled(this.deployKey.id) && + this.deployKey.destroyed_when_orphaned && + this.deployKey.almost_orphaned + ); + }, + isExpandable() { + return !this.projectsExpanded && this.restProjects.length > 1; + }, + isExpanded() { + return this.projectsExpanded || this.restProjects.length === 1; + }, + }, + methods: { + projectTooltipTitle(project) { + return project.can_push + ? s__('DeployKeys|Write access allowed') + : s__('DeployKeys|Read access only'); + }, + toggleExpanded() { + this.projectsExpanded = !this.projectsExpanded; + }, + }, +}; </script> <template> - <div> - <div class="pull-left append-right-10 hidden-xs"> - <i - aria-hidden="true" - class="fa fa-key key-icon" - > - </i> + <div class="gl-responsive-table-row deploy-key"> + <div class="table-section section-40"> + <div + role="rowheader" + class="table-mobile-header"> + {{ s__('DeployKeys|Deploy key') }} + </div> + <div class="table-mobile-content"> + <strong class="title qa-key-title"> + {{ deployKey.title }} + </strong> + <div class="fingerprint qa-key-fingerprint"> + {{ deployKey.fingerprint }} + </div> + </div> </div> - <div class="deploy-key-content key-list-item-info"> - <strong class="title qa-key-title"> - {{ deployKey.title }} - </strong> - <div class="description qa-key-fingerprint"> - {{ deployKey.fingerprint }} + <div class="table-section section-30 section-wrap"> + <div + role="rowheader" + class="table-mobile-header"> + {{ s__('DeployKeys|Project usage') }} + </div> + <div class="table-mobile-content deploy-project-list"> + <template v-if="projects.length > 0"> + <a + class="label deploy-project-label" + :title="projectTooltipTitle(firstProject)" + v-tooltip + > + <span> + {{ firstProject.project.full_name }} + </span> + <icon :name="firstProject.can_push ? 'lock-open' : 'lock'"/> + </a> + <a + v-if="isExpandable" + class="label deploy-project-label" + @click="toggleExpanded" + :title="restProjectsTooltip" + v-tooltip + > + <span>{{ restProjectsLabel }}</span> + </a> + <a + v-else-if="isExpanded" + v-for="deployKeysProject in restProjects" + :key="deployKeysProject.project.full_path" + class="label deploy-project-label" + :href="deployKeysProject.project.full_path" + :title="projectTooltipTitle(deployKeysProject)" + v-tooltip + > + <span> + {{ deployKeysProject.project.full_name }} + </span> + <icon :name="deployKeysProject.can_push ? 'lock-open' : 'lock'"/> + </a> + </template> + <span + v-else + class="text-secondary">{{ __('None') }}</span> </div> </div> - <div class="deploy-key-content prepend-left-default deploy-key-projects"> - <a - v-for="(deployKeysProject, i) in deployKey.deploy_keys_projects" - :key="i" - class="label deploy-project-label" - :href="deployKeysProject.project.full_path" - :title="tooltipTitle(deployKeysProject)" - v-tooltip - > - {{ deployKeysProject.project.full_name }} - <i - v-if="!deployKeysProject.can_push" - aria-hidden="true" - class="fa fa-lock" - > - </i> - </a> + <div class="table-section section-15 text-right"> + <div + role="rowheader" + class="table-mobile-header"> + {{ __('Created') }} + </div> + <div class="table-mobile-content text-secondary key-created-at"> + <span + :title="tooltipTitle(deployKey.created_at)" + v-tooltip> + <icon name="calendar"/> + <span>{{ timeFormated(deployKey.created_at) }}</span> + </span> + </div> </div> - <div class="deploy-key-content"> - <span class="key-created-at"> - created {{ timeagoDate }} - </span> - <a - v-if="deployKey.can_edit" - class="btn btn-sm" - :href="editDeployKeyPath" - > - Edit - </a> - <action-btn - v-if="!isEnabled(deployKey.id)" - :deploy-key="deployKey" - type="enable" - /> - <action-btn - v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned" - :deploy-key="deployKey" - btn-css-class="btn-warning" - type="remove" - /> - <action-btn - v-else - :deploy-key="deployKey" - btn-css-class="btn-warning" - type="disable" - /> + <div class="table-section section-15 table-button-footer deploy-key-actions"> + <div class="btn-group table-action-buttons"> + <action-btn + v-if="!isEnabled" + :deploy-key="deployKey" + type="enable" + > + {{ __('Enable') }} + </action-btn> + <a + v-if="deployKey.can_edit" + class="btn btn-default text-secondary" + :href="editDeployKeyPath" + :title="__('Edit')" + data-container="body" + v-tooltip + > + <icon name="pencil"/> + </a> + <action-btn + v-if="isRemovable" + :deploy-key="deployKey" + btn-css-class="btn-danger" + type="remove" + :title="__('Remove')" + data-container="body" + v-tooltip + > + <icon name="remove"/> + </action-btn> + <action-btn + v-else-if="isEnabled" + :deploy-key="deployKey" + btn-css-class="btn-warning" + type="disable" + :title="__('Disable')" + data-container="body" + v-tooltip + > + <icon name="cancel"/> + </action-btn> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue index 822b0323156..3b146c7389a 100644 --- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue +++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue @@ -1,62 +1,68 @@ <script> - import key from './key.vue'; +import deployKey from './key.vue'; - export default { - components: { - key, +export default { + components: { + deployKey, + }, + props: { + keys: { + type: Array, + required: true, }, - props: { - title: { - type: String, - required: true, - }, - keys: { - type: Array, - required: true, - }, - showHelpBox: { - type: Boolean, - required: false, - default: true, - }, - store: { - type: Object, - required: true, - }, - endpoint: { - type: String, - required: true, - }, + store: { + type: Object, + required: true, }, - }; + endpoint: { + type: String, + required: true, + }, + projectId: { + type: String, + required: false, + default: null, + }, + }, +}; </script> <template> - <div class="deploy-keys-panel"> - <h5> - {{ title }} - ({{ keys.length }}) - </h5> - <ul - class="well-list" - v-if="keys.length" - > - <li + <div class="deploy-keys-panel table-holder"> + <template v-if="keys.length > 0"> + <div + role="row" + class="gl-responsive-table-row table-row-header"> + <div + role="rowheader" + class="table-section section-40"> + {{ s__('DeployKeys|Deploy key') }} + </div> + <div + role="rowheader" + class="table-section section-30"> + {{ s__('DeployKeys|Project usage') }} + </div> + <div + role="rowheader" + class="table-section section-15 text-right"> + {{ __('Created') }} + </div> + </div> + <deploy-key v-for="deployKey in keys" :key="deployKey.id" - > - <key - :deploy-key="deployKey" - :store="store" - :endpoint="endpoint" - /> - </li> - </ul> + :deploy-key="deployKey" + :store="store" + :endpoint="endpoint" + :project-id="projectId" + /> + </template> <div class="settings-message text-center" - v-else-if="showHelpBox" + v-else > - No deploy keys found. Create one with the form above. + {{ s__('DeployKeys|No deploy keys found. Create one with the form above.') }} </div> </div> </template> diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js index b727261648c..6e439be42ae 100644 --- a/app/assets/javascripts/deploy_keys/index.js +++ b/app/assets/javascripts/deploy_keys/index.js @@ -1,21 +1,24 @@ import Vue from 'vue'; import deployKeysApp from './components/app.vue'; -export default () => new Vue({ - el: document.getElementById('js-deploy-keys'), - components: { - deployKeysApp, - }, - data() { - return { - endpoint: this.$options.el.dataset.endpoint, - }; - }, - render(createElement) { - return createElement('deploy-keys-app', { - props: { - endpoint: this.endpoint, - }, - }); - }, -}); +export default () => + new Vue({ + el: document.getElementById('js-deploy-keys'), + components: { + deployKeysApp, + }, + data() { + return { + endpoint: this.$options.el.dataset.endpoint, + projectId: this.$options.el.dataset.projectId, + }; + }, + render(createElement) { + return createElement('deploy-keys-app', { + props: { + endpoint: this.endpoint, + projectId: this.projectId, + }, + }); + }, + }); diff --git a/app/assets/javascripts/deploy_keys/service/index.js b/app/assets/javascripts/deploy_keys/service/index.js index fe6dbaa9498..9dc3b21f6f6 100644 --- a/app/assets/javascripts/deploy_keys/service/index.js +++ b/app/assets/javascripts/deploy_keys/service/index.js @@ -1,34 +1,24 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; - -Vue.use(VueResource); +import axios from '~/lib/utils/axios_utils'; export default class DeployKeysService { constructor(endpoint) { - this.endpoint = endpoint; - - this.resource = Vue.resource(`${this.endpoint}{/id}`, {}, { - enable: { - method: 'PUT', - url: `${this.endpoint}{/id}/enable`, - }, - disable: { - method: 'PUT', - url: `${this.endpoint}{/id}/disable`, - }, + this.axios = axios.create({ + baseURL: endpoint, }); } getKeys() { - return this.resource.get() - .then(response => response.json()); + return this.axios.get() + .then(response => response.data); } enableKey(id) { - return this.resource.enable({ id }, {}); + return this.axios.put(`${id}/enable`) + .then(response => response.data); } disableKey(id) { - return this.resource.disable({ id }, {}); + return this.axios.put(`${id}/disable`) + .then(response => response.data); } } diff --git a/app/assets/javascripts/deploy_keys/store/index.js b/app/assets/javascripts/deploy_keys/store/index.js index 6210361af26..a350bc99a70 100644 --- a/app/assets/javascripts/deploy_keys/store/index.js +++ b/app/assets/javascripts/deploy_keys/store/index.js @@ -3,7 +3,7 @@ export default class DeployKeysStore { this.keys = {}; } - findEnabledKey(id) { - return this.keys.enabled_keys.find(key => key.id === id); + isEnabled(id) { + return this.keys.enabled_keys.some(key => key.id === id); } } diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index dc7672560ea..cd8dff40b88 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -34,7 +34,7 @@ export function getEmojiCategoryMap() { symbols: [], flags: [], }; - Object.keys(emojiMap).forEach((name) => { + Object.keys(emojiMap).forEach(name => { const emoji = emojiMap[name]; if (emojiCategoryMap[emoji.category]) { emojiCategoryMap[emoji.category].push(name); @@ -79,7 +79,9 @@ export function glEmojiTag(inputName, options) { classList.push(fallbackSpriteClass); } const classAttribute = classList.length > 0 ? `class="${classList.join(' ')}"` : ''; - const fallbackSpriteAttribute = opts.sprite ? `data-fallback-sprite-class="${fallbackSpriteClass}"` : ''; + const fallbackSpriteAttribute = opts.sprite + ? `data-fallback-sprite-class="${fallbackSpriteClass}"` + : ''; let contents = emojiInfo.moji; if (opts.forceFallback && !opts.sprite) { contents = emojiImageTag(name, fallbackImageSrc); diff --git a/app/assets/javascripts/emoji/support/unicode_support_map.js b/app/assets/javascripts/emoji/support/unicode_support_map.js index c18d07dad43..8c1861c56db 100644 --- a/app/assets/javascripts/emoji/support/unicode_support_map.js +++ b/app/assets/javascripts/emoji/support/unicode_support_map.js @@ -54,7 +54,8 @@ const unicodeSupportTestMap = { function checkPixelInImageDataArray(pixelOffset, imageDataArray) { // `4 *` because RGBA const indexOffset = 4 * pixelOffset; - const hasColor = imageDataArray[indexOffset + 0] || + const hasColor = + imageDataArray[indexOffset + 0] || imageDataArray[indexOffset + 1] || imageDataArray[indexOffset + 2]; const isVisible = imageDataArray[indexOffset + 3]; @@ -75,23 +76,23 @@ const chromeVersion = chromeMatches && chromeMatches[1] && parseInt(chromeMatche const fontSize = 16; function generateUnicodeSupportMap(testMap) { const testMapKeys = Object.keys(testMap); - const numTestEntries = testMapKeys - .reduce((list, testKey) => list.concat(testMap[testKey]), []).length; + const numTestEntries = testMapKeys.reduce((list, testKey) => list.concat(testMap[testKey]), []) + .length; const canvas = document.createElement('canvas'); (window.gl || window).testEmojiUnicodeSupportMapCanvas = canvas; const ctx = canvas.getContext('2d'); - canvas.width = (2 * fontSize); - canvas.height = (numTestEntries * fontSize); + canvas.width = 2 * fontSize; + canvas.height = numTestEntries * fontSize; ctx.fillStyle = '#000000'; ctx.textBaseline = 'middle'; ctx.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"`; // Write each emoji to the canvas vertically let writeIndex = 0; - testMapKeys.forEach((testKey) => { + testMapKeys.forEach(testKey => { const testEntry = testMap[testKey]; - [].concat(testEntry).forEach((emojiUnicode) => { - ctx.fillText(emojiUnicode, 0, (writeIndex * fontSize) + (fontSize / 2)); + [].concat(testEntry).forEach(emojiUnicode => { + ctx.fillText(emojiUnicode, 0, writeIndex * fontSize + fontSize / 2); writeIndex += 1; }); }); @@ -99,29 +100,25 @@ function generateUnicodeSupportMap(testMap) { // Read from the canvas const resultMap = {}; let readIndex = 0; - testMapKeys.forEach((testKey) => { + testMapKeys.forEach(testKey => { const testEntry = testMap[testKey]; // This needs to be a `reduce` instead of `every` because we need to // keep the `readIndex` in sync from the writes by running all entries - const isTestSatisfied = [].concat(testEntry).reduce((isSatisfied) => { + const isTestSatisfied = [].concat(testEntry).reduce(isSatisfied => { // Sample along the vertical-middle for a couple of characters - const imageData = ctx.getImageData( - 0, - (readIndex * fontSize) + (fontSize / 2), - 2 * fontSize, - 1, - ).data; + const imageData = ctx.getImageData(0, readIndex * fontSize + fontSize / 2, 2 * fontSize, 1) + .data; let isValidEmoji = false; for (let currentPixel = 0; currentPixel < 64; currentPixel += 1) { const isLookingAtFirstChar = currentPixel < fontSize; - const isLookingAtSecondChar = currentPixel >= (fontSize + (fontSize / 2)); + const isLookingAtSecondChar = currentPixel >= fontSize + fontSize / 2; // Check for the emoji somewhere along the row if (isLookingAtFirstChar && checkPixelInImageDataArray(currentPixel, imageData)) { isValidEmoji = true; - // Check to see that nothing is rendered next to the first character - // to ensure that the ZWJ sequence rendered as one piece + // Check to see that nothing is rendered next to the first character + // to ensure that the ZWJ sequence rendered as one piece } else if (isLookingAtSecondChar && checkPixelInImageDataArray(currentPixel, imageData)) { isValidEmoji = false; break; @@ -170,7 +167,10 @@ export default function getUnicodeSupportMap() { if (isLocalStorageAvailable) { window.localStorage.setItem('gl-emoji-version', GL_EMOJI_VERSION); window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent); - window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap)); + window.localStorage.setItem( + 'gl-emoji-unicode-support-map', + JSON.stringify(unicodeSupportMap), + ); } } diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index dbee81fa320..6bd7c6b49cb 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -43,6 +43,7 @@ <div class="environments-container"> <loading-icon + class="prepend-top-default" label="Loading environments" v-if="isLoading" size="3" diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 16bd2f5feb3..ab9e22037d0 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -1,5 +1,5 @@ <script> - import playIconSvg from 'icons/_icon_play.svg'; + import Icon from '~/vue_shared/components/icon.vue'; import eventHub from '../event_hub'; import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import tooltip from '../../vue_shared/directives/tooltip'; @@ -8,9 +8,9 @@ directives: { tooltip, }, - components: { loadingIcon, + Icon, }, props: { actions: { @@ -19,20 +19,16 @@ default: () => [], }, }, - data() { return { - playIconSvg, isLoading: false, }; }, - computed: { title() { return 'Deploy to...'; }, }, - methods: { onClickAction(endpoint) { this.isLoading = true; @@ -65,7 +61,10 @@ :disabled="isLoading" > <span> - <span v-html="playIconSvg"></span> + <icon + name="play" + :size="12" + /> <i class="fa fa-caret-down" aria-hidden="true" @@ -86,7 +85,10 @@ :class="{ disabled: isActionDisabled(action) }" :disabled="isActionDisabled(action)" > - <span v-html="playIconSvg"></span> + <icon + name="play" + :size="12" + /> <span> {{ action.name }} </span> diff --git a/app/assets/javascripts/environments/components/environment_external_url.vue b/app/assets/javascripts/environments/components/environment_external_url.vue index c9a68cface6..ea6f1168c68 100644 --- a/app/assets/javascripts/environments/components/environment_external_url.vue +++ b/app/assets/javascripts/environments/components/environment_external_url.vue @@ -1,4 +1,5 @@ <script> + import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '../../vue_shared/directives/tooltip'; import { s__ } from '../../locale'; @@ -6,6 +7,9 @@ * Renders the external url link in environments table. */ export default { + components: { + Icon, + }, directives: { tooltip, }, @@ -15,7 +19,6 @@ required: true, }, }, - computed: { title() { return s__('Environments|Open'); @@ -34,10 +37,9 @@ :aria-label="title" :href="externalUrl" > - <i - class="fa fa-external-link" - aria-hidden="true" - > - </i> + <icon + name="external-link" + :size="12" + /> </a> </template> diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue index 081537cf218..deada134b27 100644 --- a/app/assets/javascripts/environments/components/environment_monitoring.vue +++ b/app/assets/javascripts/environments/components/environment_monitoring.vue @@ -2,20 +2,22 @@ /** * Renders the Monitoring (Metrics) link in environments table. */ + import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '../../vue_shared/directives/tooltip'; export default { + components: { + Icon, + }, directives: { tooltip, }, - props: { monitoringUrl: { type: String, required: true, }, }, - computed: { title() { return 'Monitoring'; @@ -33,10 +35,9 @@ :title="title" :aria-label="title" > - <i - class="fa fa-area-chart" - aria-hidden="true" - > - </i> + <icon + name="chart" + :size="12" + /> </a> </template> diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index 605a88e997e..c822fb1574c 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -12,7 +12,6 @@ components: { loadingIcon, }, - props: { retryUrl: { type: String, @@ -24,13 +23,11 @@ default: true, }, }, - data() { return { isLoading: false, }; }, - methods: { onClick() { this.isLoading = true; diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue index 407d5333c0e..e8469d088ef 100644 --- a/app/assets/javascripts/environments/components/environment_terminal_button.vue +++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue @@ -3,14 +3,16 @@ * Renders a terminal button to open a web terminal. * Used in environments table. */ - import terminalIconSvg from 'icons/_icon_terminal.svg'; + import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '../../vue_shared/directives/tooltip'; export default { + components: { + Icon, + }, directives: { tooltip, }, - props: { terminalPath: { type: String, @@ -18,13 +20,6 @@ default: '', }, }, - - data() { - return { - terminalIconSvg, - }; - }, - computed: { title() { return 'Terminal'; @@ -40,7 +35,10 @@ :title="title" :aria-label="title" :href="terminalPath" - v-html="terminalIconSvg" > + <icon + name="terminal" + :size="12" + /> </a> </template> diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 7e9770a9ea2..9de57db48fd 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -408,7 +408,10 @@ class GfmAutoComplete { fetchData($input, at) { if (this.isLoadingData[at]) return; + this.isLoadingData[at] = true; + const dataSource = this.dataSources[GfmAutoComplete.atTypeMap[at]]; + if (this.cachedData[at]) { this.loadData($input, at, this.cachedData[at]); } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { @@ -418,12 +421,14 @@ class GfmAutoComplete { GfmAutoComplete.glEmojiTag = glEmojiTag; }) .catch(() => { this.isLoadingData[at] = false; }); - } else { - AjaxCache.retrieve(this.dataSources[GfmAutoComplete.atTypeMap[at]], true) + } else if (dataSource) { + AjaxCache.retrieve(dataSource, true) .then((data) => { this.loadData($input, at, data); }) .catch(() => { this.isLoadingData[at] = false; }); + } else { + this.isLoadingData[at] = false; } } diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js index 502e3569321..029fd6a67d4 100644 --- a/app/assets/javascripts/gpg_badges.js +++ b/app/assets/javascripts/gpg_badges.js @@ -7,12 +7,12 @@ import { __ } from '~/locale'; export default class GpgBadges { static fetch() { const badges = $('.js-loading-gpg-badge'); - const form = $('.commits-search-form'); + const tag = $('.js-signature-container'); badges.html('<i class="fa fa-spinner fa-spin"></i>'); - const params = parseQueryStringIntoObject(form.serialize()); - return axios.get(form.data('signaturesPath'), { params }) + const params = parseQueryStringIntoObject(tag.serialize()); + return axios.get(tag.data('signaturesPath'), { params }) .then(({ data }) => { data.signatures.forEach((signature) => { badges.filter(`[data-commit-sha="${signature.commit_sha}"]`).replaceWith(signature.html); diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue new file mode 100644 index 00000000000..05dbc1410de --- /dev/null +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -0,0 +1,106 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import { activityBarViews } from '../constants'; + +export default { + components: { + Icon, + }, + directives: { + tooltip, + }, + computed: { + ...mapGetters(['currentProject', 'hasChanges']), + ...mapState(['currentActivityView']), + goBackUrl() { + return document.referrer || this.currentProject.web_url; + }, + }, + methods: { + ...mapActions(['updateActivityBarView']), + }, + activityBarViews, +}; +</script> + +<template> + <nav class="ide-activity-bar"> + <ul class="list-unstyled"> + <li v-once> + <a + v-tooltip + data-container="body" + data-placement="right" + :href="goBackUrl" + class="ide-sidebar-link" + :title="s__('IDE|Go back')" + :aria-label="s__('IDE|Go back')" + > + <icon + :size="16" + name="go-back" + /> + </a> + </li> + <li> + <button + v-tooltip + data-container="body" + data-placement="right" + type="button" + class="ide-sidebar-link js-ide-edit-mode" + :class="{ + active: currentActivityView === $options.activityBarViews.edit + }" + @click.prevent="updateActivityBarView($options.activityBarViews.edit)" + :title="s__('IDE|Edit')" + :aria-label="s__('IDE|Edit')" + > + <icon + name="code" + /> + </button> + </li> + <li> + <button + v-tooltip + data-container="body" + data-placement="right" + type="button" + class="ide-sidebar-link js-ide-review-mode" + :class="{ + active: currentActivityView === $options.activityBarViews.review + }" + @click.prevent="updateActivityBarView($options.activityBarViews.review)" + :title="s__('IDE|Review')" + :aria-label="s__('IDE|Review')" + > + <icon + name="file-modified" + /> + </button> + </li> + <li v-show="hasChanges"> + <button + v-tooltip + data-container="body" + data-placement="right" + type="button" + class="ide-sidebar-link js-ide-commit-mode" + :class="{ + active: currentActivityView === $options.activityBarViews.commit + }" + @click.prevent="updateActivityBarView($options.activityBarViews.commit)" + :title="s__('IDE|Commit')" + :aria-label="s__('IDE|Commit')" + > + <icon + name="commit" + /> + </button> + </li> + </ul> + </nav> +</template> diff --git a/app/assets/javascripts/ide/components/changed_file_icon.vue b/app/assets/javascripts/ide/components/changed_file_icon.vue index fdbc14a4b8f..1cec84706fc 100644 --- a/app/assets/javascripts/ide/components/changed_file_icon.vue +++ b/app/assets/javascripts/ide/components/changed_file_icon.vue @@ -43,7 +43,7 @@ export default { return `${this.changedIcon}-solid`; }, changedIconClass() { - return `multi-${this.changedIcon} prepend-left-5 pull-left`; + return `multi-${this.changedIcon} pull-left`; }, tooltipTitle() { if (!this.showTooltip) return undefined; @@ -79,13 +79,7 @@ export default { class="ide-file-changed-icon" > <icon - v-if="file.staged && showStagedIcon" - :name="stagedIcon" - :size="12" - :css-classes="changedIconClass" - /> - <icon - v-if="file.changed || file.tempFile || (file.staged && !showStagedIcon)" + v-if="file.changed || file.tempFile || file.staged" :name="changedIcon" :size="12" :css-classes="changedIconClass" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index 45321df191c..b4f3778d946 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -1,5 +1,5 @@ <script> -import { mapState } from 'vuex'; +import { mapActions, mapState, mapGetters } from 'vuex'; import { sprintf, __ } from '~/locale'; import * as consts from '../../stores/modules/commit/constants'; import RadioGroup from './radio_group.vue'; @@ -9,7 +9,8 @@ export default { RadioGroup, }, computed: { - ...mapState(['currentBranchId']), + ...mapState(['currentBranchId', 'changedFiles', 'stagedFiles']), + ...mapGetters(['currentProject']), commitToCurrentBranchText() { return sprintf( __('Commit to %{branchName} branch'), @@ -17,6 +18,17 @@ export default { false, ); }, + disableMergeRequestRadio() { + return this.changedFiles.length > 0 && this.stagedFiles.length > 0; + }, + }, + mounted() { + if (this.disableMergeRequestRadio) { + this.updateCommitAction(consts.COMMIT_TO_CURRENT_BRANCH); + } + }, + methods: { + ...mapActions('commit', ['updateCommitAction']), }, commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH, commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH, @@ -41,9 +53,11 @@ export default { :show-input="true" /> <radio-group + v-if="currentProject.merge_requests_enabled" :value="$options.commitToNewBranchMR" :label="__('Create a new branch and merge request')" :show-input="true" + :disabled="disableMergeRequestRadio" /> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue index 6424b93ce54..d0a60d647e5 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue @@ -1,75 +1,27 @@ <script> -import { mapActions, mapState, mapGetters } from 'vuex'; -import Icon from '~/vue_shared/components/icon.vue'; -import tooltip from '~/vue_shared/directives/tooltip'; +import { mapState } from 'vuex'; export default { - components: { - Icon, - }, - directives: { - tooltip, - }, - props: { - noChangesStateSvgPath: { - type: String, - required: true, - }, - committedStateSvgPath: { - type: String, - required: true, - }, - }, computed: { - ...mapState(['lastCommitMsg', 'rightPanelCollapsed']), - ...mapGetters(['collapseButtonIcon', 'collapseButtonTooltip']), - statusSvg() { - return this.lastCommitMsg ? this.committedStateSvgPath : this.noChangesStateSvgPath; - }, - }, - methods: { - ...mapActions(['toggleRightPanelCollapsed']), + ...mapState(['lastCommitMsg', 'noChangesStateSvgPath']), }, }; </script> <template> <div + v-if="!lastCommitMsg" class="multi-file-commit-panel-section ide-commit-empty-state js-empty-state" > - <header - class="multi-file-commit-panel-header" - :class="{ - 'is-collapsed': rightPanelCollapsed, - }" - > - <button - v-tooltip - :title="collapseButtonTooltip" - data-container="body" - data-placement="left" - type="button" - class="btn btn-transparent multi-file-commit-panel-collapse-btn" - :aria-label="__('Toggle sidebar')" - @click.stop="toggleRightPanelCollapsed" - > - <icon - :name="collapseButtonIcon" - :size="18" - /> - </button> - </header> <div class="ide-commit-empty-state-container" - v-if="!rightPanelCollapsed" > <div class="svg-content svg-80"> - <img :src="statusSvg" /> + <img :src="noChangesStateSvgPath" /> </div> <div class="append-right-default prepend-left-default"> <div class="text-content text-center" - v-if="!lastCommitMsg" > <h4> {{ __('No changes') }} @@ -78,15 +30,6 @@ export default { {{ __('Edit files in the editor and commit changes here') }} </p> </div> - <div - class="text-content text-center" - v-else - > - <h4> - {{ __('All changes are committed') }} - </h4> - <p v-html="lastCommitMsg"></p> - </div> </div> </div> </div> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue new file mode 100644 index 00000000000..4a645c827ad --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -0,0 +1,171 @@ +<script> +import { mapState, mapActions, mapGetters } from 'vuex'; +import { sprintf, __ } from '~/locale'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import CommitMessageField from './message_field.vue'; +import Actions from './actions.vue'; +import SuccessMessage from './success_message.vue'; +import { activityBarViews, MAX_WINDOW_HEIGHT_COMPACT, COMMIT_ITEM_PADDING } from '../../constants'; + +export default { + components: { + Actions, + LoadingButton, + CommitMessageField, + SuccessMessage, + }, + data() { + return { + isCompact: true, + componentHeight: null, + }; + }, + computed: { + ...mapState(['changedFiles', 'stagedFiles', 'currentActivityView', 'lastCommitMsg']), + ...mapState('commit', ['commitMessage', 'submitCommitLoading']), + ...mapGetters(['hasChanges']), + ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']), + overviewText() { + return sprintf( + __( + '<strong>%{changedFilesLength} unstaged</strong> and <strong>%{stagedFilesLength} staged</strong> changes', + ), + { + stagedFilesLength: this.stagedFiles.length, + changedFilesLength: this.changedFiles.length, + }, + ); + }, + }, + watch: { + currentActivityView() { + if (this.lastCommitMsg) { + this.isCompact = false; + } else { + this.isCompact = !( + this.currentActivityView === activityBarViews.commit && + window.innerHeight >= MAX_WINDOW_HEIGHT_COMPACT + ); + } + }, + lastCommitMsg() { + this.isCompact = + this.currentActivityView !== activityBarViews.commit && this.lastCommitMsg === ''; + }, + }, + methods: { + ...mapActions(['updateActivityBarView']), + ...mapActions('commit', ['updateCommitMessage', 'discardDraft', 'commitChanges']), + toggleIsSmall() { + this.updateActivityBarView(activityBarViews.commit) + .then(() => { + this.isCompact = !this.isCompact; + }) + .catch(e => { + throw e; + }); + }, + beforeEnterTransition() { + const elHeight = this.isCompact + ? this.$refs.formEl && this.$refs.formEl.offsetHeight + : this.$refs.compactEl && this.$refs.compactEl.offsetHeight; + + this.componentHeight = elHeight + COMMIT_ITEM_PADDING; + }, + enterTransition() { + this.$nextTick(() => { + const elHeight = this.isCompact + ? this.$refs.compactEl && this.$refs.compactEl.offsetHeight + : this.$refs.formEl && this.$refs.formEl.offsetHeight; + + this.componentHeight = elHeight + COMMIT_ITEM_PADDING; + }); + }, + afterEndTransition() { + this.componentHeight = null; + }, + }, + activityBarViews, +}; +</script> + +<template> + <div + class="multi-file-commit-form" + :class="{ + 'is-compact': isCompact, + 'is-full': !isCompact + }" + :style="{ + height: componentHeight ? `${componentHeight}px` : null, + }" + > + <transition + name="commit-form-slide-up" + @before-enter="beforeEnterTransition" + @enter="enterTransition" + @after-enter="afterEndTransition" + > + <div + v-if="isCompact" + class="commit-form-compact" + ref="compactEl" + > + <button + type="button" + :disabled="!hasChanges" + class="btn btn-primary btn-sm btn-block" + @click="toggleIsSmall" + > + {{ __('Commit') }} + </button> + <p + class="text-center" + v-html="overviewText" + ></p> + </div> + <form + v-if="!isCompact" + class="form-horizontal" + @submit.prevent.stop="commitChanges" + ref="formEl" + > + <transition name="fade"> + <success-message + v-show="lastCommitMsg" + /> + </transition> + <commit-message-field + :text="commitMessage" + @input="updateCommitMessage" + /> + <div class="clearfix prepend-top-15"> + <actions /> + <loading-button + :loading="submitCommitLoading" + :disabled="commitButtonDisabled" + container-class="btn btn-success btn-sm pull-left" + :label="__('Commit')" + @click="commitChanges" + /> + <button + v-if="!discardDraftButtonDisabled" + type="button" + class="btn btn-default btn-sm pull-right" + @click="discardDraft" + > + {{ __('Discard draft') }} + </button> + <button + v-else + type="button" + class="btn btn-default btn-sm pull-right" + @click="toggleIsSmall" + > + {{ __('Collapse') }} + </button> + </div> + </form> + </transition> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index ff05ee8682a..c3ac18bfb83 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -1,16 +1,14 @@ <script> -import { mapActions, mapState, mapGetters } from 'vuex'; +import { mapActions } from 'vuex'; import { __, sprintf } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import ListItem from './list_item.vue'; -import ListCollapsed from './list_collapsed.vue'; export default { components: { Icon, ListItem, - ListCollapsed, }, directives: { tooltip, @@ -24,11 +22,6 @@ export default { type: Array, required: true, }, - showToggle: { - type: Boolean, - required: false, - default: true, - }, iconName: { type: String, required: true, @@ -51,9 +44,12 @@ export default { default: false, }, }, + data() { + return { + showActionButton: false, + }; + }, computed: { - ...mapState(['rightPanelCollapsed']), - ...mapGetters(['collapseButtonIcon', 'collapseButtonTooltip']), titleText() { return sprintf(__('%{title} changes'), { title: this.title, @@ -61,10 +57,13 @@ export default { }, }, methods: { - ...mapActions(['toggleRightPanelCollapsed', 'stageAllChanges', 'unstageAllChanges']), + ...mapActions(['stageAllChanges', 'unstageAllChanges']), actionBtnClicked() { this[this.action](); }, + setShowActionButton(show) { + this.showActionButton = show; + }, }, }; </script> @@ -72,19 +71,14 @@ export default { <template> <div class="ide-commit-list-container" - :class="{ - 'is-collapsed': rightPanelCollapsed, - }" > <header class="multi-file-commit-panel-header" + @mouseenter="setShowActionButton(true)" + @mouseleave="setShowActionButton(false)" > <div - v-if="!rightPanelCollapsed" class="multi-file-commit-panel-header-title" - :class="{ - 'append-right-10': showToggle, - }" > <icon v-once @@ -92,7 +86,14 @@ export default { :size="18" /> {{ titleText }} + <span + v-show="!showActionButton" + class="ide-commit-file-count" + > + {{ fileList.length }} + </span> <button + v-show="showActionButton" type="button" class="btn btn-blank btn-link ide-staged-action-btn" @click="actionBtnClicked" @@ -100,52 +101,28 @@ export default { {{ actionBtnText }} </button> </div> - <button - v-if="showToggle" - v-tooltip - :title="collapseButtonTooltip" - data-container="body" - data-placement="left" - type="button" - class="btn btn-transparent multi-file-commit-panel-collapse-btn" - :aria-label="__('Toggle sidebar')" - @click.stop="toggleRightPanelCollapsed" - > - <icon - :name="collapseButtonIcon" - :size="18" - /> - </button> </header> - <list-collapsed - v-if="rightPanelCollapsed" - :files="fileList" - :icon-name="iconName" - :title="title" - /> - <template v-else> - <ul - v-if="fileList.length" - class="multi-file-commit-list list-unstyled append-bottom-0" - > - <li - v-for="file in fileList" - :key="file.key" - > - <list-item - :file="file" - :action-component="itemActionComponent" - :key-prefix="title" - :staged-list="stagedList" - /> - </li> - </ul> - <p - v-else - class="multi-file-commit-list help-block" + <ul + v-if="fileList.length" + class="multi-file-commit-list list-unstyled append-bottom-0" + > + <li + v-for="file in fileList" + :key="file.key" > - {{ __('No changes') }} - </p> - </template> + <list-item + :file="file" + :action-component="itemActionComponent" + :key-prefix="title" + :staged-list="stagedList" + /> + </li> + </ul> + <p + v-else + class="multi-file-commit-list help-block" + > + {{ __('No changes') }} + </p> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index ad4713c40d5..03f3e4de83c 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -3,6 +3,7 @@ import { mapActions } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; import StageButton from './stage_button.vue'; import UnstageButton from './unstage_button.vue'; +import { viewerTypes } from '../../constants'; export default { components: { @@ -36,7 +37,7 @@ export default { return this.file.tempFile ? `file-addition${prefix}` : `file-modified${prefix}`; }, iconClass() { - return `multi-file-${this.file.tempFile ? 'additions' : 'modified'} append-right-8`; + return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; }, }, methods: { @@ -53,7 +54,7 @@ export default { keyPrefix: this.keyPrefix.toLowerCase(), }).then(changeViewer => { if (changeViewer) { - this.updateViewer('diff'); + this.updateViewer(viewerTypes.diff); } }); }, diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue index b660a2961cb..00f2312ae51 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue @@ -1,5 +1,6 @@ <script> import { mapActions, mapState, mapGetters } from 'vuex'; +import { __ } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; export default { @@ -26,10 +27,20 @@ export default { required: false, default: false, }, + disabled: { + type: Boolean, + required: false, + default: false, + }, }, computed: { ...mapState('commit', ['commitAction']), ...mapGetters('commit', ['newBranchName']), + tooltipTitle() { + return this.disabled + ? __('This option is disabled while you still have unstaged changes') + : ''; + }, }, methods: { ...mapActions('commit', ['updateCommitAction', 'updateBranchName']), @@ -39,19 +50,28 @@ export default { <template> <fieldset> - <label> + <label + v-tooltip + :title="tooltipTitle" + :class="{ + 'is-disabled': disabled + }" + > <input type="radio" name="commit-action" :value="value" @change="updateCommitAction($event.target.value)" - :checked="checked" - v-once + :checked="commitAction === value" + :disabled="disabled" /> <span class="prepend-left-10"> - <template v-if="label"> + <span + v-if="label" + class="ide-radio-label" + > {{ label }} - </template> + </span> <slot v-else></slot> </span> </label> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue new file mode 100644 index 00000000000..a6df91b79c2 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue @@ -0,0 +1,33 @@ +<script> +import { mapState } from 'vuex'; + +export default { + computed: { + ...mapState(['lastCommitMsg', 'committedStateSvgPath']), + }, +}; +</script> + +<template> + <div + class="multi-file-commit-panel-success-message" + aria-live="assertive" + > + <div class="svg-content svg-80"> + <img + :src="committedStateSvgPath" + alt="" + /> + </div> + <div class="append-right-default prepend-left-default"> + <div + class="text-content text-center" + > + <h4> + {{ __('All changes are committed') }} + </h4> + <p v-html="lastCommitMsg"></p> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue index 0c44a755f56..b9af4d27145 100644 --- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue +++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue @@ -1,28 +1,15 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; import { __, sprintf } from '~/locale'; +import { viewerTypes } from '../constants'; export default { - components: { - Icon, - }, props: { - hasChanges: { - type: Boolean, - required: false, - default: false, - }, - mergeRequestId: { - type: String, - required: false, - default: '', - }, viewer: { type: String, required: true, }, - showShadow: { - type: Boolean, + mergeRequestId: { + type: Number, required: true, }, }, @@ -38,84 +25,45 @@ export default { this.$emit('click', mode); }, }, + viewerTypes, }; </script> <template> <div class="dropdown" - :class="{ - shadow: showShadow, - }" > <button type="button" - class="btn btn-primary btn-sm" - :class="{ - 'btn-inverted': hasChanges, - }" + class="btn btn-link" data-toggle="dropdown" > - <template v-if="viewer === 'mrdiff' && mergeRequestId"> - {{ mergeReviewLine }} - </template> - <template v-else-if="viewer === 'editor'"> - {{ __('Editing') }} - </template> - <template v-else> - {{ __('Reviewing') }} - </template> - <icon - name="angle-down" - :size="12" - css-classes="caret-down" - /> + {{ __('Edit') }} </button> <div class="dropdown-menu dropdown-menu-selectable dropdown-open-left"> <ul> - <template v-if="mergeRequestId"> - <li> - <a - href="#" - @click.prevent="changeMode('mrdiff')" - :class="{ - 'is-active': viewer === 'mrdiff', - }" - > - <strong class="dropdown-menu-inner-title"> - {{ mergeReviewLine }} - </strong> - <span class="dropdown-menu-inner-content"> - {{ __('Compare changes with the merge request target branch') }} - </span> - </a> - </li> - <li - role="separator" - class="divider" - > - </li> - </template> <li> <a href="#" - @click.prevent="changeMode('editor')" + @click.prevent="changeMode($options.viewerTypes.mr)" :class="{ - 'is-active': viewer === 'editor', + 'is-active': viewer === $options.viewerTypes.mr, }" > - <strong class="dropdown-menu-inner-title">{{ __('Editing') }}</strong> + <strong class="dropdown-menu-inner-title"> + {{ mergeReviewLine }} + </strong> <span class="dropdown-menu-inner-content"> - {{ __('View and edit lines') }} + {{ __('Compare changes with the merge request target branch') }} </span> </a> </li> <li> <a href="#" - @click.prevent="changeMode('diff')" + @click.prevent="changeMode($options.viewerTypes.diff)" :class="{ - 'is-active': viewer === 'diff', + 'is-active': viewer === $options.viewerTypes.diff, }" > <strong class="dropdown-menu-inner-title">{{ __('Reviewing') }}</strong> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 0274fc7d299..6c373a92776 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,144 +1,127 @@ <script> - import { mapActions, mapState, mapGetters } from 'vuex'; - import Mousetrap from 'mousetrap'; - import ideSidebar from './ide_side_bar.vue'; - import ideContextbar from './ide_context_bar.vue'; - import repoTabs from './repo_tabs.vue'; - import ideStatusBar from './ide_status_bar.vue'; - import repoEditor from './repo_editor.vue'; - import FindFile from './file_finder/index.vue'; +import Mousetrap from 'mousetrap'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import IdeSidebar from './ide_side_bar.vue'; +import RepoTabs from './repo_tabs.vue'; +import IdeStatusBar from './ide_status_bar.vue'; +import RepoEditor from './repo_editor.vue'; +import FindFile from './file_finder/index.vue'; - const originalStopCallback = Mousetrap.stopCallback; +const originalStopCallback = Mousetrap.stopCallback; - export default { - components: { - ideSidebar, - ideContextbar, - repoTabs, - ideStatusBar, - repoEditor, - FindFile, - }, - props: { - emptyStateSvgPath: { - type: String, - required: true, - }, - noChangesStateSvgPath: { - type: String, - required: true, - }, - committedStateSvgPath: { - type: String, - required: true, - }, - }, - computed: { - ...mapState([ - 'changedFiles', - 'openFiles', - 'viewer', - 'currentMergeRequestId', - 'fileFindVisible', - ]), - ...mapGetters(['activeFile', 'hasChanges']), - }, - mounted() { - const returnValue = 'Are you sure you want to lose unsaved changes?'; - window.onbeforeunload = e => { - if (!this.changedFiles.length) return undefined; +export default { + components: { + IdeSidebar, + RepoTabs, + IdeStatusBar, + RepoEditor, + FindFile, + }, + computed: { + ...mapState([ + 'changedFiles', + 'openFiles', + 'viewer', + 'currentMergeRequestId', + 'fileFindVisible', + 'emptyStateSvgPath', + ]), + ...mapGetters(['activeFile', 'hasChanges']), + }, + mounted() { + const returnValue = 'Are you sure you want to lose unsaved changes?'; + window.onbeforeunload = e => { + if (!this.changedFiles.length) return undefined; - Object.assign(e, { - returnValue, - }); - return returnValue; - }; + Object.assign(e, { + returnValue, + }); + return returnValue; + }; - Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => { - if (e.preventDefault) { - e.preventDefault(); - } + Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => { + if (e.preventDefault) { + e.preventDefault(); + } - this.toggleFileFinder(!this.fileFindVisible); - }); + this.toggleFileFinder(!this.fileFindVisible); + }); - Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo); - }, - methods: { - ...mapActions(['toggleFileFinder']), - mousetrapStopCallback(e, el, combo) { - if (combo === 't' && el.classList.contains('dropdown-input-field')) { - return true; - } else if (combo === 'command+p' || combo === 'ctrl+p') { - return false; - } + Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo); + }, + methods: { + ...mapActions(['toggleFileFinder']), + mousetrapStopCallback(e, el, combo) { + if (combo === 't' && el.classList.contains('dropdown-input-field')) { + return true; + } else if (combo === 'command+p' || combo === 'ctrl+p') { + return false; + } - return originalStopCallback(e, el, combo); - }, + return originalStopCallback(e, el, combo); }, - }; + }, +}; </script> <template> - <div - class="ide-view" - > - <find-file - v-show="fileFindVisible" - /> - <ide-sidebar /> + <article class="ide"> <div - class="multi-file-edit-pane" + class="ide-view" > - <template - v-if="activeFile" - > - <repo-tabs - :active-file="activeFile" - :files="openFiles" - :viewer="viewer" - :has-changes="hasChanges" - :merge-request-id="currentMergeRequestId" - /> - <repo-editor - class="multi-file-edit-pane-content" - :file="activeFile" - /> - <ide-status-bar - :file="activeFile" - /> - </template> - <template - v-else + <find-file + v-show="fileFindVisible" + /> + <ide-sidebar /> + <div + class="multi-file-edit-pane" > - <div - v-once - class="ide-empty-state" + <template + v-if="activeFile" > - <div class="row js-empty-state"> - <div class="col-xs-12"> - <div class="svg-content svg-250"> - <img :src="emptyStateSvgPath" /> + <repo-tabs + :active-file="activeFile" + :files="openFiles" + :viewer="viewer" + :has-changes="hasChanges" + :merge-request-id="currentMergeRequestId" + /> + <repo-editor + class="multi-file-edit-pane-content" + :file="activeFile" + /> + </template> + <template + v-else + > + <div + v-once + class="ide-empty-state" + > + <div class="row js-empty-state"> + <div class="col-xs-12"> + <div class="svg-content svg-250"> + <img :src="emptyStateSvgPath" /> + </div> </div> - </div> - <div class="col-xs-12"> - <div class="text-content text-center"> - <h4> - Welcome to the GitLab IDE - </h4> - <p> - You can select a file in the left sidebar to begin - editing and use the right sidebar to commit your changes. - </p> + <div class="col-xs-12"> + <div class="text-content text-center"> + <h4> + Welcome to the GitLab IDE + </h4> + <p> + Select a file from the left sidebar to begin editing. + Afterwards, you'll be able to commit your changes. + </p> + </div> </div> </div> </div> - </div> - </template> + </template> + </div> </div> - <ide-contextbar - :no-changes-state-svg-path="noChangesStateSvgPath" - :committed-state-svg-path="committedStateSvgPath" + <ide-status-bar + :file="activeFile" /> - </div> + </article> </template> diff --git a/app/assets/javascripts/ide/components/ide_review.vue b/app/assets/javascripts/ide/components/ide_review.vue new file mode 100644 index 00000000000..0c9ec3b00f0 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_review.vue @@ -0,0 +1,62 @@ +<script> +import { mapGetters, mapState, mapActions } from 'vuex'; +import IdeTreeList from './ide_tree_list.vue'; +import EditorModeDropdown from './editor_mode_dropdown.vue'; +import { viewerTypes } from '../constants'; + +export default { + components: { + IdeTreeList, + EditorModeDropdown, + }, + computed: { + ...mapGetters(['currentMergeRequest']), + ...mapState(['viewer']), + showLatestChangesText() { + return !this.currentMergeRequest || this.viewer === viewerTypes.diff; + }, + showMergeRequestText() { + return this.currentMergeRequest && this.viewer === viewerTypes.mr; + }, + }, + mounted() { + this.$nextTick(() => { + this.updateViewer(this.currentMergeRequest ? viewerTypes.mr : viewerTypes.diff); + }); + }, + methods: { + ...mapActions(['updateViewer']), + }, +}; +</script> + +<template> + <ide-tree-list + :viewer-type="viewer" + header-class="ide-review-header" + :disable-action-dropdown="true" + > + <template + slot="header" + > + <div class="ide-review-button-holder"> + {{ __('Review') }} + <editor-mode-dropdown + v-if="currentMergeRequest" + :viewer="viewer" + :merge-request-id="currentMergeRequest.iid" + @click="updateViewer" + /> + </div> + <div class="prepend-top-5 ide-review-sub-header"> + <template v-if="showLatestChangesText"> + {{ __('Latest changes') }} + </template> + <template v-else-if="showMergeRequestText"> + {{ __('Merge request') }} + (<a :href="currentMergeRequest.web_url">!{{ currentMergeRequest.iid }}</a>) + </template> + </div> + </template> + </ide-tree-list> +</template> diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue index 8cf1ccb4fce..3f980203911 100644 --- a/app/assets/javascripts/ide/components/ide_side_bar.vue +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -1,36 +1,82 @@ <script> - import { mapState, mapGetters } from 'vuex'; - import icon from '~/vue_shared/components/icon.vue'; - import panelResizer from '~/vue_shared/components/panel_resizer.vue'; - import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; - import projectTree from './ide_project_tree.vue'; - import ResizablePanel from './resizable_panel.vue'; +import { mapState, mapGetters } from 'vuex'; +import ProjectAvatarImage from '~/vue_shared/components/project_avatar/image.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; +import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; +import Identicon from '../../vue_shared/components/identicon.vue'; +import IdeTree from './ide_tree.vue'; +import ResizablePanel from './resizable_panel.vue'; +import ActivityBar from './activity_bar.vue'; +import CommitSection from './repo_commit_section.vue'; +import CommitForm from './commit_sidebar/form.vue'; +import IdeReview from './ide_review.vue'; +import SuccessMessage from './commit_sidebar/success_message.vue'; +import { activityBarViews } from '../constants'; - export default { - components: { - projectTree, - icon, - panelResizer, - skeletonLoadingContainer, - ResizablePanel, +export default { + directives: { + tooltip, + }, + components: { + Icon, + PanelResizer, + SkeletonLoadingContainer, + ResizablePanel, + ActivityBar, + ProjectAvatarImage, + Identicon, + CommitSection, + IdeTree, + CommitForm, + IdeReview, + SuccessMessage, + }, + data() { + return { + showTooltip: false, + }; + }, + computed: { + ...mapState([ + 'loading', + 'currentBranchId', + 'currentActivityView', + 'changedFiles', + 'stagedFiles', + 'lastCommitMsg', + ]), + ...mapGetters(['currentProject', 'someUncommitedChanges']), + showSuccessMessage() { + return ( + this.currentActivityView === activityBarViews.edit && + (this.lastCommitMsg && !this.someUncommitedChanges) + ); }, - computed: { - ...mapState([ - 'loading', - ]), - ...mapGetters([ - 'projectsWithTrees', - ]), + branchTooltipTitle() { + return this.showTooltip ? this.currentBranchId : undefined; }, - }; + }, + watch: { + currentBranchId() { + this.$nextTick(() => { + this.showTooltip = this.$refs.branchId.scrollWidth > this.$refs.branchId.offsetWidth; + }); + }, + }, +}; </script> <template> <resizable-panel :collapsible="false" - :initial-width="290" + :initial-width="340" side="left" > + <activity-bar + v-if="!loading" + /> <div class="multi-file-commit-panel-inner"> <template v-if="loading"> <div @@ -41,11 +87,54 @@ <skeleton-loading-container /> </div> </template> - <project-tree - v-for="project in projectsWithTrees" - :key="project.id" - :project="project" - /> + <template v-else> + <div class="context-header ide-context-header"> + <a + :href="currentProject.web_url" + > + <div + v-if="currentProject.avatar_url" + class="avatar-container s40 project-avatar" + > + <project-avatar-image + class="avatar-container project-avatar" + :link-href="currentProject.path" + :img-src="currentProject.avatar_url" + :img-alt="currentProject.name" + :img-size="40" + /> + </div> + <identicon + v-else + size-class="s40" + :entity-id="currentProject.id" + :entity-name="currentProject.name" + /> + <div class="ide-sidebar-project-title"> + <div class="sidebar-context-title"> + {{ currentProject.name }} + </div> + <div + class="sidebar-context-title ide-sidebar-branch-title" + ref="branchId" + v-tooltip + :title="branchTooltipTitle" + > + <icon + name="branch" + css-classes="append-right-5" + />{{ currentBranchId }} + </div> + </div> + </a> + </div> + <div class="multi-file-commit-panel-inner-scroll"> + <component + :is="currentActivityView" + /> + </div> + <commit-form /> + </template> </div> </resizable-panel> </template> diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index c13eeeace3f..70c6d53c3ab 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -1,11 +1,14 @@ <script> +import { mapGetters } from 'vuex'; import icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import timeAgoMixin from '~/vue_shared/mixins/timeago'; +import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; export default { components: { icon, + userAvatarImage, }, directives: { tooltip, @@ -14,40 +17,93 @@ export default { props: { file: { type: Object, - required: true, + required: false, + default: null, + }, + }, + data() { + return { + lastCommitFormatedAge: null, + }; + }, + computed: { + ...mapGetters(['currentProject', 'lastCommit']), + }, + mounted() { + this.startTimer(); + }, + beforeDestroy() { + if (this.intervalId) { + clearInterval(this.intervalId); + } + }, + methods: { + startTimer() { + this.intervalId = setInterval(() => { + this.commitAgeUpdate(); + }, 1000); + }, + commitAgeUpdate() { + if (this.lastCommit) { + this.lastCommitFormatedAge = this.timeFormated(this.lastCommit.committed_date); + } + }, + getCommitPath(shortSha) { + return `${this.currentProject.web_url}/commit/${shortSha}`; }, }, }; </script> <template> - <div class="ide-status-bar"> - <div> - <div v-if="file.lastCommit && file.lastCommit.id"> - Last commit: - <a - v-tooltip - :title="file.lastCommit.message" - :href="file.lastCommit.url" - > - {{ timeFormated(file.lastCommit.updatedAt) }} by - {{ file.lastCommit.author }} - </a> - </div> + <footer class="ide-status-bar"> + <div + class="ide-status-branch" + v-if="lastCommit && lastCommitFormatedAge" + > + <icon + name="commit" + /> + <a + v-tooltip + class="commit-sha" + :title="lastCommit.message" + :href="getCommitPath(lastCommit.short_id)" + >{{ lastCommit.short_id }}</a> + by + {{ lastCommit.author_name }} + <time + v-tooltip + data-placement="top" + data-container="body" + :datetime="lastCommit.committed_date" + :title="tooltipTitle(lastCommit.committed_date)" + > + {{ lastCommitFormatedAge }} + </time> </div> - <div class="text-right"> + <div + v-if="file" + class="ide-status-file" + > {{ file.name }} </div> - <div class="text-right"> + <div + v-if="file" + class="ide-status-file" + > {{ file.eol }} </div> <div - class="text-right" - v-if="!file.binary"> + class="ide-status-file" + v-if="file && !file.binary"> {{ file.editorRow }}:{{ file.editorColumn }} </div> - <div class="text-right"> + <div + v-if="file" + class="ide-status-file" + > {{ file.fileLanguage }} </div> - </div> + </footer> </template> diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue new file mode 100644 index 00000000000..8fc4ebe6ca6 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_tree.vue @@ -0,0 +1,42 @@ +<script> +import { mapState, mapGetters, mapActions } from 'vuex'; +import NewDropdown from './new_dropdown/index.vue'; +import IdeTreeList from './ide_tree_list.vue'; + +export default { + components: { + NewDropdown, + IdeTreeList, + }, + computed: { + ...mapState(['currentBranchId']), + ...mapGetters(['currentProject', 'currentTree', 'activeFile']), + }, + mounted() { + if (this.activeFile && this.activeFile.pending) { + this.$router.push(`/project${this.activeFile.url}`, () => { + this.updateViewer('editor'); + }); + } + }, + methods: { + ...mapActions(['updateViewer']), + }, +}; +</script> + +<template> + <ide-tree-list + viewer-type="editor" + > + <template + slot="header" + > + {{ __('Edit') }} + <new-dropdown + :project-id="currentProject.name_with_namespace" + :branch="currentBranchId" + /> + </template> + </ide-tree-list> +</template> diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue new file mode 100644 index 00000000000..e64a09fcc90 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -0,0 +1,76 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; +import RepoFile from './repo_file.vue'; +import NewDropdown from './new_dropdown/index.vue'; + +export default { + components: { + Icon, + RepoFile, + SkeletonLoadingContainer, + NewDropdown, + }, + props: { + viewerType: { + type: String, + required: true, + }, + headerClass: { + type: String, + required: false, + default: null, + }, + disableActionDropdown: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapState(['currentBranchId']), + ...mapGetters(['currentProject', 'currentTree']), + showLoading() { + return !this.currentTree || this.currentTree.loading; + }, + }, + mounted() { + this.updateViewer(this.viewerType); + }, + methods: { + ...mapActions(['updateViewer']), + }, +}; +</script> + +<template> + <div + class="ide-file-list" + > + <template v-if="showLoading"> + <div + class="multi-file-loading-container" + v-for="n in 3" + :key="n" + > + <skeleton-loading-container /> + </div> + </template> + <template v-else> + <header + class="ide-tree-header" + :class="headerClass" + > + <slot name="header"></slot> + </header> + <repo-file + v-for="file in currentTree.tree" + :key="file.key" + :file="file" + :level="0" + :disable-action-dropdown="disableActionDropdown" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/mr_file_icon.vue b/app/assets/javascripts/ide/components/mr_file_icon.vue index 8a440902dfc..179a589d1ac 100644 --- a/app/assets/javascripts/ide/components/mr_file_icon.vue +++ b/app/assets/javascripts/ide/components/mr_file_icon.vue @@ -16,8 +16,8 @@ export default { <icon name="git-merge" v-tooltip - title="__('Part of merge request changes')" - css-classes="ide-file-changed-icon" + :title="__('Part of merge request changes')" + css-classes="append-right-8" :size="12" /> </template> diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index b1b5c0d4a28..a0ce1c9dac7 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -17,7 +17,8 @@ export default { }, path: { type: String, - required: true, + required: false, + default: '', }, }, data() { diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 877d1b5e026..c5092d8e04d 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -3,12 +3,10 @@ import { mapState, mapActions, mapGetters } from 'vuex'; import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import CommitFilesList from './commit_sidebar/list.vue'; import EmptyState from './commit_sidebar/empty_state.vue'; -import CommitMessageField from './commit_sidebar/message_field.vue'; import * as consts from '../stores/modules/commit/constants'; -import Actions from './commit_sidebar/actions.vue'; +import { activityBarViews } from '../constants'; export default { components: { @@ -16,35 +14,50 @@ export default { Icon, CommitFilesList, EmptyState, - Actions, - LoadingButton, - CommitMessageField, }, directives: { tooltip, }, - props: { - noChangesStateSvgPath: { - type: String, - required: true, + computed: { + ...mapState([ + 'changedFiles', + 'stagedFiles', + 'rightPanelCollapsed', + 'lastCommitMsg', + 'unusedSeal', + ]), + ...mapState('commit', ['commitMessage', 'submitCommitLoading']), + ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommitedChanges']), + ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']), + showStageUnstageArea() { + return !!(this.someUncommitedChanges || this.lastCommitMsg || !this.unusedSeal); }, - committedStateSvgPath: { - type: String, - required: true, + }, + watch: { + hasChanges() { + if (!this.hasChanges) { + this.updateActivityBarView(activityBarViews.edit); + } }, }, - computed: { - ...mapState(['changedFiles', 'stagedFiles', 'rightPanelCollapsed']), - ...mapState('commit', ['commitMessage', 'submitCommitLoading']), - ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled', 'branchName']), + mounted() { + if (this.lastOpenedFile) { + this.openPendingTab({ + file: this.lastOpenedFile, + }) + .then(changeViewer => { + if (changeViewer) { + this.updateViewer('diff'); + } + }) + .catch(e => { + throw e; + }); + } }, methods: { - ...mapActions('commit', [ - 'updateCommitMessage', - 'discardDraft', - 'commitChanges', - 'updateCommitAction', - ]), + ...mapActions(['openPendingTab', 'updateViewer', 'updateActivityBarView']), + ...mapActions('commit', ['commitChanges', 'updateCommitAction']), forceCreateNewBranch() { return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges()); }, @@ -69,9 +82,10 @@ export default { </template> </deprecated-modal> <template - v-if="changedFiles.length || stagedFiles.length" + v-if="showStageUnstageArea" > <commit-files-list + class="is-first" icon-name="unstaged" :title="__('Unstaged')" :file-list="changedFiles" @@ -86,42 +100,11 @@ export default { action="unstageAllChanges" :action-btn-text="__('Unstage all')" item-action-component="unstage-button" - :show-toggle="false" :staged-list="true" /> - <form - class="form-horizontal multi-file-commit-form" - @submit.prevent.stop="commitChanges" - v-if="!rightPanelCollapsed" - > - <commit-message-field - :text="commitMessage" - @input="updateCommitMessage" - /> - <div class="clearfix prepend-top-15"> - <actions /> - <loading-button - :loading="submitCommitLoading" - :disabled="commitButtonDisabled" - container-class="btn btn-success btn-sm pull-left" - :label="__('Commit')" - @click="commitChanges" - /> - <button - v-if="!discardDraftButtonDisabled" - type="button" - class="btn btn-default btn-sm pull-right" - @click="discardDraft" - > - {{ __('Discard draft') }} - </button> - </div> - </form> </template> <empty-state - v-else - :no-changes-state-svg-path="noChangesStateSvgPath" - :committed-state-svg-path="committedStateSvgPath" + v-if="unusedSeal" /> </div> </template> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 3a04cdd8e46..f8678b602ac 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -3,6 +3,7 @@ import { mapState, mapGetters, mapActions } from 'vuex'; import flash from '~/flash'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; +import { activityBarViews, viewerTypes } from '../constants'; import monacoLoader from '../monaco_loader'; import Editor from '../lib/editor'; import IdeFileButtons from './ide_file_buttons.vue'; @@ -19,8 +20,14 @@ export default { }, }, computed: { - ...mapState(['rightPanelCollapsed', 'viewer', 'delayViewerUpdated', 'panelResizing']), - ...mapGetters(['currentMergeRequest', 'getStagedFile']), + ...mapState(['rightPanelCollapsed', 'viewer', 'panelResizing', 'currentActivityView']), + ...mapGetters([ + 'currentMergeRequest', + 'getStagedFile', + 'isEditModeActive', + 'isCommitModeActive', + 'isReviewModeActive', + ]), shouldHideEditor() { return this.file && this.file.binary && !this.file.content; }, @@ -40,6 +47,21 @@ export default { // Compare key to allow for files opened in review mode to be cached differently if (newVal.key !== this.file.key) { this.initMonaco(); + + if (this.currentActivityView !== activityBarViews.edit) { + this.setFileViewMode({ + file: this.file, + viewMode: 'edit', + }); + } + } + }, + currentActivityView() { + if (this.currentActivityView !== activityBarViews.edit) { + this.setFileViewMode({ + file: this.file, + viewMode: 'edit', + }); } }, rightPanelCollapsed() { @@ -77,7 +99,6 @@ export default { 'setFileViewMode', 'setFileEOL', 'updateViewer', - 'updateDelayViewerUpdated', ]), initMonaco() { if (this.shouldHideEditor) return; @@ -89,14 +110,6 @@ export default { baseSha: this.currentMergeRequest ? this.currentMergeRequest.baseCommitSha : '', }) .then(() => { - const viewerPromise = this.delayViewerUpdated - ? this.updateViewer(this.file.pending ? 'diff' : 'editor') - : Promise.resolve(); - - return viewerPromise; - }) - .then(() => { - this.updateDelayViewerUpdated(false); this.createEditorInstance(); }) .catch(err => { @@ -108,10 +121,10 @@ export default { this.editor.dispose(); this.$nextTick(() => { - if (this.viewer === 'editor') { + if (this.viewer === viewerTypes.edit) { this.editor.createInstance(this.$refs.editor); } else { - this.editor.createDiffInstance(this.$refs.editor); + this.editor.createDiffInstance(this.$refs.editor, !this.isReviewModeActive); } this.setupEditor(); @@ -127,7 +140,7 @@ export default { this.file.staged && this.file.key.indexOf('unstaged-') === 0 ? head : null, ); - if (this.viewer === 'mrdiff') { + if (this.viewer === viewerTypes.mr && this.file.mrChange) { this.editor.attachMergeRequestModel(this.model); } else { this.editor.attachModel(this.model); @@ -168,6 +181,7 @@ export default { }); }, }, + viewerTypes, }; </script> @@ -176,16 +190,17 @@ export default { id="ide" class="blob-viewer-container blob-editor-container" > - <div class="ide-mode-tabs clearfix"> + <div class="ide-mode-tabs clearfix" > <ul class="nav-links pull-left" - v-if="!shouldHideEditor"> + v-if="!shouldHideEditor && isEditModeActive" + > <li :class="editTabCSS"> <a href="javascript:void(0);" role="button" @click.prevent="setFileViewMode({ file, viewMode: 'edit' })"> - <template v-if="viewer === 'editor'"> + <template v-if="viewer === $options.viewerTypes.edit"> {{ __('Edit') }} </template> <template v-else> @@ -212,6 +227,9 @@ export default { v-show="!shouldHideEditor && file.viewMode === 'edit'" ref="editor" class="multi-file-editor-holder" + :class="{ + 'is-readonly': isCommitModeActive, + }" > </div> <content-viewer diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue index e86db2da4a6..14946f8c9fa 100644 --- a/app/assets/javascripts/ide/components/repo_file.vue +++ b/app/assets/javascripts/ide/components/repo_file.vue @@ -1,22 +1,29 @@ <script> -import { mapActions } from 'vuex'; -import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; -import fileIcon from '~/vue_shared/components/file_icon.vue'; +import { mapActions, mapGetters } from 'vuex'; +import { n__, __, sprintf } from '~/locale'; +import tooltip from '~/vue_shared/directives/tooltip'; +import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; import router from '../ide_router'; -import newDropdown from './new_dropdown/index.vue'; -import fileStatusIcon from './repo_file_status_icon.vue'; -import changedFileIcon from './changed_file_icon.vue'; -import mrFileIcon from './mr_file_icon.vue'; +import NewDropdown from './new_dropdown/index.vue'; +import FileStatusIcon from './repo_file_status_icon.vue'; +import ChangedFileIcon from './changed_file_icon.vue'; +import MrFileIcon from './mr_file_icon.vue'; export default { name: 'RepoFile', + directives: { + tooltip, + }, components: { - skeletonLoadingContainer, - newDropdown, - fileStatusIcon, - fileIcon, - changedFileIcon, - mrFileIcon, + SkeletonLoadingContainer, + NewDropdown, + FileStatusIcon, + FileIcon, + ChangedFileIcon, + MrFileIcon, + Icon, }, props: { file: { @@ -27,8 +34,41 @@ export default { type: Number, required: true, }, + disableActionDropdown: { + type: Boolean, + required: false, + default: false, + }, }, computed: { + ...mapGetters([ + 'getChangesInFolder', + 'getUnstagedFilesCountForPath', + 'getStagedFilesCountForPath', + ]), + folderUnstagedCount() { + return this.getUnstagedFilesCountForPath(this.file.path); + }, + folderStagedCount() { + return this.getStagedFilesCountForPath(this.file.path); + }, + changesCount() { + return this.getChangesInFolder(this.file.path); + }, + folderChangesTooltip() { + if (this.changesCount === 0) return undefined; + + if (this.folderUnstagedCount > 0 && this.folderStagedCount === 0) { + return n__('%d unstaged change', '%d unstaged changes', this.folderUnstagedCount); + } else if (this.folderUnstagedCount === 0 && this.folderStagedCount > 0) { + return n__('%d staged change', '%d staged changes', this.folderStagedCount); + } + + return sprintf(__('%{unstaged} unstaged and %{staged} staged changes'), { + unstaged: this.folderUnstagedCount, + staged: this.folderStagedCount, + }); + }, isTree() { return this.file.type === 'tree'; }, @@ -48,23 +88,30 @@ export default { 'is-open': this.file.opened, }; }, + showTreeChangesCount() { + return this.isTree && this.changesCount > 0 && !this.file.opened; + }, + showChangedFileIcon() { + return this.file.changed || this.file.tempFile || this.file.staged; + }, }, updated() { if (this.file.type === 'blob' && this.file.active) { - this.$el.scrollIntoView(); + this.$el.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); } }, methods: { - ...mapActions(['toggleTreeOpen', 'updateDelayViewerUpdated']), + ...mapActions(['toggleTreeOpen']), clickFile() { // Manual Action if a tree is selected/opened if (this.isTree && this.$router.currentRoute.path === `/project${this.file.url}`) { this.toggleTreeOpen(this.file.path); } - return this.updateDelayViewerUpdated(true).then(() => { - router.push(`/project${this.file.url}`); - }); + router.push(`/project${this.file.url}`); }, }, }; @@ -101,8 +148,23 @@ export default { <mr-file-icon v-if="file.mrChange" /> + <span + v-if="showTreeChangesCount" + class="ide-tree-changes" + > + {{ changesCount }} + <icon + v-tooltip + :title="folderChangesTooltip" + data-container="body" + data-placement="right" + name="file-modified" + :size="12" + css-classes="prepend-left-5 multi-file-modified" + /> + </span> <changed-file-icon - v-if="file.changed || file.tempFile || file.staged" + v-else-if="showChangedFileIcon" :file="file" :show-tooltip="true" :show-staged-icon="true" @@ -111,7 +173,7 @@ export default { /> </span> <new-dropdown - v-if="isTree" + v-if="isTree && !disableActionDropdown" :project-id="file.projectId" :branch="file.branchId" :path="file.path" diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index a3ee3184c19..fb26b973236 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -32,6 +32,8 @@ export default { return `Close ${this.tab.name}`; }, showChangedIcon() { + if (this.tab.pending) return true; + return this.fileHasChanged ? !this.tabMouseOver : false; }, fileHasChanged() { @@ -66,15 +68,32 @@ export default { <template> <li + :class="{ + active: tab.active + }" @click="clickFile(tab)" @mouseover="mouseOverTab" @mouseout="mouseOutTab" > + <div + class="multi-file-tab" + :title="tab.url" + > + <file-icon + :file-name="tab.name" + :size="16" + /> + {{ tab.name }} + <file-status-icon + :file="tab" + /> + </div> <button type="button" class="multi-file-tab-close" @click.stop.prevent="closeFile(tab)" :aria-label="closeLabel" + :disabled="tab.pending" > <icon v-if="!showChangedIcon" @@ -87,22 +106,5 @@ export default { :force-modified-icon="true" /> </button> - - <div - class="multi-file-tab" - :class="{ - active: tab.active - }" - :title="tab.url" - > - <file-icon - :file-name="tab.name" - :size="16" - /> - {{ tab.name }} - <file-status-icon - :file="tab" - /> - </div> </li> </template> diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue index 7bd646ba9b0..99e51097e12 100644 --- a/app/assets/javascripts/ide/components/repo_tabs.vue +++ b/app/assets/javascripts/ide/components/repo_tabs.vue @@ -32,16 +32,6 @@ export default { default: '', }, }, - data() { - return { - showShadow: false, - }; - }, - updated() { - if (!this.$refs.tabsScroller) return; - - this.showShadow = this.$refs.tabsScroller.scrollWidth > this.$refs.tabsScroller.offsetWidth; - }, methods: { ...mapActions(['updateViewer', 'removePendingTab']), openFileViewer(viewer) { @@ -71,12 +61,5 @@ export default { :tab="tab" /> </ul> - <editor-mode - :viewer="viewer" - :show-shadow="showShadow" - :has-changes="hasChanges" - :merge-request-id="mergeRequestId" - @click="openFileViewer" - /> </div> </template> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index b06da9f95d1..48d4cc43198 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -3,6 +3,22 @@ export const MAX_FILE_FINDER_RESULTS = 40; export const FILE_FINDER_ROW_HEIGHT = 55; export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33; +export const MAX_WINDOW_HEIGHT_COMPACT = 750; + +export const COMMIT_ITEM_PADDING = 32; + // Commit message textarea export const MAX_TITLE_LENGTH = 50; export const MAX_BODY_LENGTH = 72; + +export const activityBarViews = { + edit: 'ide-tree', + commit: 'commit-section', + review: 'ide-review', +}; + +export const viewerTypes = { + mr: 'mrdiff', + edit: 'editor', + diff: 'diff', +}; diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index 4a0a303d5a6..adca85dc65b 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; import flash from '~/flash'; import store from './stores'; +import { activityBarViews } from './constants'; Vue.use(VueRouter); @@ -63,6 +64,8 @@ router.beforeEach((to, from, next) => { const fullProjectId = `${to.params.namespace}/${to.params.project}`; if (to.params.branch) { + store.dispatch('setCurrentBranchId', to.params.branch); + store.dispatch('getBranchData', { projectId: fullProjectId, branchId: to.params.branch, @@ -99,14 +102,14 @@ router.beforeEach((to, from, next) => { throw e; }); } else if (to.params.mrid) { - store.dispatch('updateViewer', 'mrdiff'); - store .dispatch('getMergeRequestData', { projectId: fullProjectId, mergeRequestId: to.params.mrid, }) .then(mr => { + store.dispatch('updateActivityBarView', activityBarViews.review); + store.dispatch('getBranchData', { projectId: fullProjectId, branchId: mr.source_branch, diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index cbfb3dc54f2..c5835cd3b06 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -4,7 +4,9 @@ import ide from './components/ide.vue'; import store from './stores'; import router from './ide_router'; -function initIde(el) { +Vue.use(Translate); + +export function initIde(el) { if (!el) return null; return new Vue({ @@ -14,20 +16,25 @@ function initIde(el) { components: { ide, }, - render(createElement) { - return createElement('ide', { - props: { - emptyStateSvgPath: el.dataset.emptyStateSvgPath, - noChangesStateSvgPath: el.dataset.noChangesStateSvgPath, - committedStateSvgPath: el.dataset.committedStateSvgPath, - }, + created() { + this.$store.dispatch('setEmptyStateSvgs', { + emptyStateSvgPath: el.dataset.emptyStateSvgPath, + noChangesStateSvgPath: el.dataset.noChangesStateSvgPath, + committedStateSvgPath: el.dataset.committedStateSvgPath, }); }, + render(createElement) { + return createElement('ide'); + }, }); } -const ideElement = document.getElementById('ide'); - -Vue.use(Translate); - -initIde(ideElement); +// tell webpack to load assets from origin so that web workers don't break +export function resetServiceWorkersPublicPath() { + // __webpack_public_path__ is a global variable that can be used to adjust + // the webpack publicPath setting at runtime. + // see: https://webpack.js.org/guides/public-path/ + const relativeRootPath = (gon && gon.relative_url_root) || ''; + const webpackAssetPath = `${relativeRootPath}/assets/webpack/`; + __webpack_public_path__ = webpackAssetPath; // eslint-disable-line camelcase +} diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index 016dcda1fa1..b1e43a1e38c 100644 --- a/app/assets/javascripts/ide/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -14,12 +14,12 @@ export default class Model { (this.originalModel = this.monaco.editor.createModel( head ? head.content : this.file.raw, undefined, - new this.monaco.Uri(null, null, `original/${this.file.key}`), + new this.monaco.Uri(null, null, `original/${this.path}`), )), (this.model = this.monaco.editor.createModel( this.content, undefined, - new this.monaco.Uri(null, null, this.file.key), + new this.monaco.Uri(null, null, this.path), )), ); if (this.file.mrChange) { @@ -27,7 +27,7 @@ export default class Model { (this.baseModel = this.monaco.editor.createModel( this.file.baseRaw, undefined, - new this.monaco.Uri(null, null, `target/${this.file.path}`), + new this.monaco.Uri(null, null, `target/${this.path}`), )), ); } diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index b65d9c68a0b..9c3bb9cc17d 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -61,19 +61,19 @@ export default class Editor { } } - createDiffInstance(domElement) { + createDiffInstance(domElement, readOnly = true) { if (!this.instance) { clearDomElement(domElement); this.disposable.add( (this.instance = this.monaco.editor.createDiffEditor(domElement, { ...defaultEditorOptions, - readOnly: true, quickSuggestions: false, occurrencesHighlight: false, - renderLineHighlight: 'none', - hideCursorInOverviewRuler: true, renderSideBySide: Editor.renderSideBySide(domElement), + readOnly, + renderLineHighlight: readOnly ? 'all' : 'none', + hideCursorInOverviewRuler: !readOnly, })), ); diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 4c8c997e376..1a98b42761e 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -123,6 +123,8 @@ export const scrollToTab = () => { }; export const stageAllChanges = ({ state, commit }) => { + commit(types.SET_LAST_COMMIT_MSG, ''); + state.changedFiles.forEach(file => commit(types.STAGE_CHANGE, file.path)); }; @@ -138,6 +140,18 @@ export const updateDelayViewerUpdated = ({ commit }, delay) => { commit(types.UPDATE_DELAY_VIEWER_CHANGE, delay); }; +export const updateActivityBarView = ({ commit }, view) => { + commit(types.UPDATE_ACTIVITY_BAR_VIEW, view); +}; + +export const setEmptyStateSvgs = ({ commit }, svgs) => { + commit(types.SET_EMPTY_STATE_SVGS, svgs); +}; + +export const setCurrentBranchId = ({ commit }, currentBranchId) => { + commit(types.SET_CURRENT_BRANCH, currentBranchId); +}; + export const updateTempFlagForEntry = ({ commit, dispatch, state }, { file, tempFile }) => { commit(types.UPDATE_TEMP_FLAG, { path: file.path, tempFile }); @@ -149,6 +163,12 @@ export const updateTempFlagForEntry = ({ commit, dispatch, state }, { file, temp export const toggleFileFinder = ({ commit }, fileFindVisible) => commit(types.TOGGLE_FILE_FINDER, fileFindVisible); +export const burstUnusedSeal = ({ state, commit }) => { + if (state.unusedSeal) { + commit(types.BURST_UNUSED_SEAL); + } +}; + export * from './actions/tree'; export * from './actions/file'; export * from './actions/project'; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index fcdb3b753b2..b6baa693104 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -5,6 +5,7 @@ import service from '../../services'; import * as types from '../mutation_types'; import router from '../../ide_router'; import { setPageTitle } from '../utils'; +import { viewerTypes } from '../../constants'; export const closeFile = ({ commit, state, dispatch }, file) => { const path = file.path; @@ -23,13 +24,12 @@ export const closeFile = ({ commit, state, dispatch }, file) => { const nextFileToOpen = state.openFiles[nextIndexToOpen]; if (nextFileToOpen.pending) { - dispatch('updateViewer', 'diff'); + dispatch('updateViewer', viewerTypes.diff); dispatch('openPendingTab', { file: nextFileToOpen, keyPrefix: nextFileToOpen.staged ? 'staged' : 'unstaged', }); } else { - dispatch('updateDelayViewerUpdated', true); router.push(`/project${nextFileToOpen.url}`); } } else if (!state.openFiles.length) { @@ -117,7 +117,7 @@ export const getRawFileData = ({ state, commit, dispatch }, { path, baseSha }) = }); }; -export const changeFileContent = ({ state, commit }, { path, content }) => { +export const changeFileContent = ({ commit, dispatch, state }, { path, content }) => { const file = state.entries[path]; commit(types.UPDATE_FILE_CONTENT, { path, content }); @@ -128,6 +128,8 @@ export const changeFileContent = ({ state, commit }, { path, content }) => { } else if (!file.changed && indexOfChangedFile !== -1) { commit(types.REMOVE_FILE_FROM_CHANGED, path); } + + dispatch('burstUnusedSeal', {}, { root: true }); }; export const setFileLanguage = ({ getters, commit }, { fileLanguage }) => { @@ -182,6 +184,7 @@ export const stageChange = ({ commit, state }, path) => { const stagedFile = state.stagedFiles.find(f => f.path === path); commit(types.STAGE_CHANGE, path); + commit(types.SET_LAST_COMMIT_MSG, ''); if (stagedFile) { eventHub.$emit(`editor.update.model.new.content.staged-${stagedFile.key}`, stagedFile.content); @@ -193,9 +196,9 @@ export const unstageChange = ({ commit }, path) => { }; export const openPendingTab = ({ commit, getters, dispatch, state }, { file, keyPrefix }) => { - if (getters.activeFile && getters.activeFile === file && state.viewer === 'diff') { - return false; - } + if (getters.activeFile && getters.activeFile.key === `${keyPrefix}-${file.key}`) return false; + + state.openFiles.forEach(f => eventHub.$emit(`editor.update.model.dispose.${f.key}`)); commit(types.ADD_PENDING_TAB, { file, keyPrefix }); diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index 4eb23b2ee0f..eff9bc03651 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -55,7 +55,6 @@ export const getBranchData = ( branch: data, }); commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id }); - commit(types.SET_CURRENT_BRANCH, branchId); resolve(data); }) .catch(() => { @@ -73,3 +72,26 @@ export const getBranchData = ( resolve(state.projects[`${projectId}`].branches[branchId]); } }); + +export const refreshLastCommitData = ( + { commit, state, dispatch }, + { projectId, branchId } = {}, +) => service + .getBranchData(projectId, branchId) + .then(({ data }) => { + commit(types.SET_BRANCH_COMMIT, { + projectId, + branchId, + commit: data.commit, + }); + }) + .catch(() => { + flash( + 'Error loading last commit.', + 'alert', + document, + null, + false, + true, + ); + }); diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index ec1ea155aee..b239a605371 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -1,4 +1,5 @@ -import { __ } from '~/locale'; +import { getChangesCountForFiles, filePathMatches } from './utils'; +import { activityBarViews } from '../constants'; export const activeFile = state => state.openFiles.find(file => file.active) || null; @@ -30,15 +31,12 @@ export const currentMergeRequest = state => { return null; }; -// eslint-disable-next-line no-confusing-arrow -export const collapseButtonIcon = state => - state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right'; +export const currentProject = state => state.projects[state.currentProjectId]; -export const hasChanges = state => !!state.changedFiles.length || !!state.stagedFiles.length; +export const currentTree = state => + state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; -// eslint-disable-next-line no-confusing-arrow -export const collapseButtonTooltip = state => - state.rightPanelCollapsed ? __('Expand sidebar') : __('Collapse sidebar'); +export const hasChanges = state => !!state.changedFiles.length || !!state.stagedFiles.length; export const hasMergeRequest = state => !!state.currentMergeRequestId; @@ -55,7 +53,39 @@ export const allBlobs = state => }, []) .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); +export const getChangedFile = state => path => state.changedFiles.find(f => f.path === path); export const getStagedFile = state => path => state.stagedFiles.find(f => f.path === path); +export const lastOpenedFile = state => + [...state.changedFiles, ...state.stagedFiles].sort((a, b) => b.lastOpenedAt - a.lastOpenedAt)[0]; + +export const isEditModeActive = state => state.currentActivityView === activityBarViews.edit; +export const isCommitModeActive = state => state.currentActivityView === activityBarViews.commit; +export const isReviewModeActive = state => state.currentActivityView === activityBarViews.review; + +export const someUncommitedChanges = state => + !!(state.changedFiles.length || state.stagedFiles.length); + +export const getChangesInFolder = state => path => { + const changedFilesCount = state.changedFiles.filter(f => filePathMatches(f, path)).length; + const stagedFilesCount = state.stagedFiles.filter( + f => filePathMatches(f, path) && !getChangedFile(state)(f.path), + ).length; + + return changedFilesCount + stagedFilesCount; +}; + +export const getUnstagedFilesCountForPath = state => path => + getChangesCountForFiles(state.changedFiles, path); + +export const getStagedFilesCountForPath = state => path => + getChangesCountForFiles(state.stagedFiles, path); + +export const lastCommit = (state, getters) => { + const branch = getters.currentProject && getters.currentProject.branches[state.currentBranchId]; + + return branch ? branch.commit : null; +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 349ff68f1e3..b85246b2502 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -8,6 +8,7 @@ import router from '../../../ide_router'; import service from '../../../services'; import * as types from './mutation_types'; import * as consts from './constants'; +import { activityBarViews } from '../../../constants'; import eventHub from '../../../eventhub'; export const updateCommitMessage = ({ commit }, message) => { @@ -75,7 +76,7 @@ export const checkCommitStatus = ({ rootState }) => export const updateFilesAfterCommit = ( { commit, dispatch, state, rootState, rootGetters }, - { data, branch }, + { data }, ) => { const selectedProject = rootState.projects[rootState.currentProjectId]; const lastCommit = { @@ -126,15 +127,9 @@ export const updateFilesAfterCommit = ( changed: !!changedFile, }); }); - - if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH && rootGetters.activeFile) { - router.push( - `/project/${rootState.currentProjectId}/blob/${branch}/${rootGetters.activeFile.path}`, - ); - } }; -export const commitChanges = ({ commit, state, getters, dispatch, rootState }) => { +export const commitChanges = ({ commit, state, getters, dispatch, rootState, rootGetters }) => { const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH; const payload = createCommitPayload(getters.branchName, newBranch, state, rootState); const getCommitStatus = newBranch ? Promise.resolve(false) : dispatch('checkCommitStatus'); @@ -182,8 +177,44 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState }) = } commit(rootTypes.CLEAR_STAGED_CHANGES, null, { root: true }); + + setTimeout(() => { + commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true }); + }, 5000); + }) + .then(() => { + if (rootGetters.lastOpenedFile) { + dispatch( + 'openPendingTab', + { + file: rootGetters.lastOpenedFile, + }, + { root: true }, + ) + .then(changeViewer => { + if (changeViewer) { + dispatch('updateViewer', 'diff', { root: true }); + } + }) + .catch(e => { + throw e; + }); + } else { + dispatch('updateActivityBarView', activityBarViews.edit, { root: true }); + dispatch('updateViewer', 'editor', { root: true }); + + router.push( + `/project/${rootState.currentProjectId}/blob/${getters.branchName}/${ + rootGetters.activeFile.path + }`, + ); + } }) - .then(() => dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH)); + .then(() => dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH)) + .then(() => dispatch('refreshLastCommitData', { + projectId: rootState.currentProjectId, + branchId: rootState.currentBranchId, + }, { root: true })); }) .catch(err => { let errMsg = __('Error committing changes. Please try again.'); diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index f5c12db6db0..a3fb3232f1d 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -5,6 +5,7 @@ export const SET_LAST_COMMIT_MSG = 'SET_LAST_COMMIT_MSG'; export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED'; export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED'; export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS'; +export const SET_EMPTY_STATE_SVGS = 'SET_EMPTY_STATE_SVGS'; // Project Mutation Types export const SET_PROJECT = 'SET_PROJECT'; @@ -19,6 +20,7 @@ export const SET_MERGE_REQUEST_VERSIONS = 'SET_MERGE_REQUEST_VERSIONS'; // Branch Mutation Types export const SET_BRANCH = 'SET_BRANCH'; +export const SET_BRANCH_COMMIT = 'SET_BRANCH_COMMIT'; export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE'; export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN'; @@ -59,5 +61,7 @@ export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT'; export const ADD_PENDING_TAB = 'ADD_PENDING_TAB'; export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB'; +export const UPDATE_ACTIVITY_BAR_VIEW = 'UPDATE_ACTIVITY_BAR_VIEW'; export const UPDATE_TEMP_FLAG = 'UPDATE_TEMP_FLAG'; export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER'; +export const BURST_UNUSED_SEAL = 'BURST_UNUSED_SEAL'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index 0c1d720df09..a257e2ef025 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -107,6 +107,21 @@ export default { delayViewerUpdated, }); }, + [types.UPDATE_ACTIVITY_BAR_VIEW](state, currentActivityView) { + Object.assign(state, { + currentActivityView, + }); + }, + [types.SET_EMPTY_STATE_SVGS]( + state, + { emptyStateSvgPath, noChangesStateSvgPath, committedStateSvgPath }, + ) { + Object.assign(state, { + emptyStateSvgPath, + noChangesStateSvgPath, + committedStateSvgPath, + }); + }, [types.TOGGLE_FILE_FINDER](state, fileFindVisible) { Object.assign(state, { fileFindVisible, @@ -128,6 +143,11 @@ export default { }), }); }, + [types.BURST_UNUSED_SEAL](state) { + Object.assign(state, { + unusedSeal: false, + }); + }, ...projectMutations, ...mergeRequestMutation, ...fileMutations, diff --git a/app/assets/javascripts/ide/stores/mutations/branch.js b/app/assets/javascripts/ide/stores/mutations/branch.js index 2972ba5e38e..e09f88878f4 100644 --- a/app/assets/javascripts/ide/stores/mutations/branch.js +++ b/app/assets/javascripts/ide/stores/mutations/branch.js @@ -23,4 +23,9 @@ export default { workingReference: reference, }); }, + [types.SET_BRANCH_COMMIT](state, { projectId, branchId, commit }) { + Object.assign(state.projects[projectId].branches[branchId], { + commit, + }); + }, }; diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index c3041c77199..13f123b6630 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -1,3 +1,4 @@ +/* eslint-disable no-param-reassign */ import * as types from '../mutation_types'; export default { @@ -169,32 +170,24 @@ export default { }); }, [types.ADD_PENDING_TAB](state, { file, keyPrefix = 'pending' }) { - const key = `${keyPrefix}-${file.key}`; - const pendingTab = state.openFiles.find(f => f.key === key && f.pending); - let openFiles = state.openFiles.map(f => Object.assign(f, { active: false, opened: false })); - - if (!pendingTab) { - const openFile = openFiles.find(f => f.path === file.path); - - openFiles = openFiles.concat(openFile ? null : file).reduce((acc, f) => { - if (!f) return acc; - - if (f.path === file.path) { - return acc.concat({ - ...f, - content: file.content, - active: true, - pending: true, - opened: true, - key, - }); - } - - return acc.concat(f); - }, []); - } - - Object.assign(state, { openFiles }); + state.entries[file.path].opened = false; + state.entries[file.path].active = false; + state.entries[file.path].lastOpenedAt = new Date().getTime(); + state.openFiles.forEach(f => + Object.assign(f, { + opened: false, + active: false, + }), + ); + state.openFiles = [ + { + ...file, + key: `${keyPrefix}-${file.key}`, + pending: true, + opened: true, + active: true, + }, + ]; }, [types.REMOVE_PENDING_TAB](state, file) { Object.assign(state, { diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 3470bb9aec0..e7411f16a4f 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -1,3 +1,5 @@ +import { activityBarViews, viewerTypes } from '../constants'; + export default () => ({ currentProjectId: '', currentBranchId: '', @@ -16,7 +18,9 @@ export default () => ({ rightPanelCollapsed: false, panelResizing: false, entries: {}, - viewer: 'editor', + viewer: viewerTypes.edit, delayViewerUpdated: false, + currentActivityView: activityBarViews.edit, + unusedSeal: true, fileFindVisible: false, }); diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 59185f8f0ad..e0b9766fbee 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -33,7 +33,6 @@ export const dataStructure = () => ({ raw: '', content: '', parentTreeUrl: '', - parentPath: '', renderError: false, base64: false, editorRow: 1, @@ -43,7 +42,9 @@ export const dataStructure = () => ({ viewMode: 'edit', previewMode: null, size: 0, + parentPath: null, lastOpenedAt: 0, + mrChange: null, }); export const decorateData = entity => { @@ -83,7 +84,6 @@ export const decorateData = entity => { opened, active, parentTreeUrl, - parentPath, changed, renderError, content, @@ -91,6 +91,7 @@ export const decorateData = entity => { previewMode, file_lock, html, + parentPath, }; }; @@ -137,3 +138,9 @@ export const sortTree = sortedTree => }), ) .sort(sortTreesByTypeAndName); + +export const filePathMatches = (f, path) => + f.path.replace(new RegExp(`${f.name}$`), '').indexOf(`${path}/`) === 0; + +export const getChangesCountForFiles = (files, path) => + files.filter(f => filePathMatches(f, path)).length; diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index bb8b3d91e40..90d4e19e90b 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -30,7 +30,7 @@ export default class IssuableForm { } this.initAutosave(); - this.form.on('submit', this.handleSubmit); + this.form.on('submit:success', this.handleSubmit); this.form.on('click', '.btn-cancel', this.resetAutosave); this.initWip(); diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index f9a4453fb15..5f25c6ce1ae 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -82,7 +82,11 @@ export function capitalizeFirstCharacter(text) { * @param {*} replace * @returns {String} */ -export const stripHtml = (string, replace = '') => string.replace(/<[^>]*>/g, replace); +export const stripHtml = (string, replace = '') => { + if (!string) return string; + + return string.replace(/<[^>]*>/g, replace); +}; /** * Converts snake_case string to camelCase diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 2c80baba10b..247aeb481c6 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -1,22 +1,19 @@ -/* eslint-disable import/first */ /* global $ */ import jQuery from 'jquery'; import Cookies from 'js-cookie'; import svg4everybody from 'svg4everybody'; -// expose common libraries as globals (TODO: remove these) -window.jQuery = jQuery; -window.$ = jQuery; +// bootstrap webpack, common libs, polyfills, and behaviors +import './webpack'; +import './commons'; +import './behaviors'; // lib/utils import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils'; import { localTimeAgo } from './lib/utils/datetime_utility'; import { getLocationHash, visitUrl } from './lib/utils/url_utility'; -// behaviors -import './behaviors/'; - // everything else import loadAwardsHandler from './awards_handler'; import bp from './breakpoints'; @@ -31,9 +28,12 @@ import initLogoAnimation from './logo'; import './milestone_select'; import './projects_dropdown'; import initBreadcrumbs from './breadcrumb'; - import initDispatcher from './dispatcher'; +// expose jQuery as global (TODO: remove these) +window.jQuery = jQuery; +window.$ = jQuery; + // inject test utilities if necessary if (process.env.NODE_ENV !== 'production' && gon && gon.test_env) { $.fx.off = true; @@ -52,10 +52,14 @@ document.addEventListener('beforeunload', () => { }); window.addEventListener('hashchange', handleLocationHash); -window.addEventListener('load', function onLoad() { - window.removeEventListener('load', onLoad, false); - handleLocationHash(); -}, false); +window.addEventListener( + 'load', + function onLoad() { + window.removeEventListener('load', onLoad, false); + handleLocationHash(); + }, + false, +); gl.lazyLoader = new LazyLoader({ scrollContainer: window, @@ -89,9 +93,7 @@ document.addEventListener('DOMContentLoaded', () => { if (bootstrapBreakpoint === 'xs') { const $rightSidebar = $('aside.right-sidebar, .layout-page'); - $rightSidebar - .removeClass('right-sidebar-expanded') - .addClass('right-sidebar-collapsed'); + $rightSidebar.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); } // prevent default action for disabled buttons @@ -108,7 +110,8 @@ document.addEventListener('DOMContentLoaded', () => { addSelectOnFocusBehaviour('.js-select-on-focus'); $('.remove-row').on('ajax:success', function removeRowAjaxSuccessCallback() { - $(this).tooltip('destroy') + $(this) + .tooltip('destroy') .closest('li') .fadeOut(); }); @@ -118,7 +121,9 @@ document.addEventListener('DOMContentLoaded', () => { }); $('.js-remove-tr').on('ajax:success', function removeTRAjaxSuccessCallback() { - $(this).closest('tr').fadeOut(); + $(this) + .closest('tr') + .fadeOut(); }); // Initialize select2 selects @@ -155,7 +160,9 @@ document.addEventListener('DOMContentLoaded', () => { // Form submitter $('.trigger-submit').on('change', function triggerSubmitCallback() { - $(this).parents('form').submit(); + $(this) + .parents('form') + .submit(); }); localTimeAgo($('abbr.timeago, .js-timeago'), true); @@ -204,9 +211,15 @@ document.addEventListener('DOMContentLoaded', () => { $this.toggleClass('active'); if ($this.hasClass('active')) { - notesHolders.show().find('.hide, .content').show(); + notesHolders + .show() + .find('.hide, .content') + .show(); } else { - notesHolders.hide().find('.content').hide(); + notesHolders + .hide() + .find('.content') + .hide(); } $(document).trigger('toggle.comments'); @@ -247,9 +260,11 @@ document.addEventListener('DOMContentLoaded', () => { const flashContainer = document.querySelector('.flash-container'); if (flashContainer && flashContainer.children.length) { - flashContainer.querySelectorAll('.flash-alert, .flash-notice, .flash-success').forEach((flashEl) => { - removeFlashClickListener(flashEl); - }); + flashContainer + .querySelectorAll('.flash-alert, .flash-notice, .flash-success') + .forEach(flashEl => { + removeFlashClickListener(flashEl); + }); } initDispatcher(); diff --git a/app/assets/javascripts/mini_pipeline_graph_dropdown.js b/app/assets/javascripts/mini_pipeline_graph_dropdown.js index 01399de4c62..f8257b6abab 100644 --- a/app/assets/javascripts/mini_pipeline_graph_dropdown.js +++ b/app/assets/javascripts/mini_pipeline_graph_dropdown.js @@ -1,5 +1,3 @@ -/* eslint-disable no-new */ - import $ from 'jquery'; import flash from './flash'; import axios from './lib/utils/axios_utils'; @@ -62,7 +60,7 @@ export default class MiniPipelineGraph { */ renderBuildsList(stageContainer, data) { const dropdownContainer = stageContainer.parentElement.querySelector( - `${this.dropdownListSelector} .js-builds-dropdown-list`, + `${this.dropdownListSelector} .js-builds-dropdown-list ul`, ); dropdownContainer.innerHTML = data; diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index f93b1da4f58..de6755e0414 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -81,9 +81,8 @@ export default { time: new Date(), value: 0, }, - currentDataIndex: 0, currentXCoordinate: 0, - currentFlagPosition: 0, + currentCoordinates: [], showFlag: false, showFlagContent: false, timeSeries: [], @@ -273,6 +272,9 @@ export default { :line-style="path.lineStyle" :line-color="path.lineColor" :area-color="path.areaColor" + :current-coordinates="currentCoordinates[index]" + :current-time-series-index="index" + :show-dot="showFlagContent" /> <graph-deployment :deployment-data="reducedDeploymentData" @@ -298,9 +300,9 @@ export default { :show-flag-content="showFlagContent" :time-series="timeSeries" :unit-of-display="unitOfDisplay" - :current-data-index="currentDataIndex" :legend-title="legendTitle" :deployment-flag-data="deploymentFlagData" + :current-coordinates="currentCoordinates" /> </div> <graph-legend diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue index b8202e25685..8a771107de8 100644 --- a/app/assets/javascripts/monitoring/components/graph/flag.vue +++ b/app/assets/javascripts/monitoring/components/graph/flag.vue @@ -47,14 +47,14 @@ export default { type: String, required: true, }, - currentDataIndex: { - type: Number, - required: true, - }, legendTitle: { type: String, required: true, }, + currentCoordinates: { + type: Array, + required: true, + }, }, computed: { formatTime() { @@ -90,10 +90,12 @@ export default { }, }, methods: { - seriesMetricValue(series) { + seriesMetricValue(seriesIndex, series) { + const indexFromCoordinates = this.currentCoordinates[seriesIndex] + ? this.currentCoordinates[seriesIndex].currentDataIndex : 0; const index = this.deploymentFlagData ? this.deploymentFlagData.seriesIndex - : this.currentDataIndex; + : indexFromCoordinates; const value = series.values[index] && series.values[index].value; if (isNaN(value)) { return '-'; @@ -128,7 +130,7 @@ export default { <h5 v-if="deploymentFlagData"> Deployed </h5> - {{ formatDate }} at + {{ formatDate }} <strong>{{ formatTime }}</strong> </div> <div @@ -163,9 +165,11 @@ export default { :key="index" > <track-line :track="series"/> - <td>{{ series.track }} {{ seriesMetricLabel(index, series) }}</td> <td> - <strong>{{ seriesMetricValue(series) }}</strong> + {{ series.track }} {{ seriesMetricLabel(index, series) }} + </td> + <td> + <strong>{{ seriesMetricValue(index, series) }}</strong> </td> </tr> </table> diff --git a/app/assets/javascripts/monitoring/components/graph/path.vue b/app/assets/javascripts/monitoring/components/graph/path.vue index 881560124a5..52f8aa2ee3f 100644 --- a/app/assets/javascripts/monitoring/components/graph/path.vue +++ b/app/assets/javascripts/monitoring/components/graph/path.vue @@ -22,6 +22,15 @@ export default { type: String, required: true, }, + currentCoordinates: { + type: Object, + required: false, + default: () => ({ currentX: 0, currentY: 0 }), + }, + showDot: { + type: Boolean, + required: true, + }, }, computed: { strokeDashArray() { @@ -33,12 +42,20 @@ export default { }; </script> <template> - <g> + <g transform="translate(-5, 20)"> + <circle + class="circle-path" + :cx="currentCoordinates.currentX" + :cy="currentCoordinates.currentY" + :fill="lineColor" + :stroke="lineColor" + r="3" + v-if="showDot" + /> <path class="metric-area" :d="generatedAreaPath" :fill="areaColor" - transform="translate(-5, 20)" /> <path class="metric-line" @@ -47,7 +64,6 @@ export default { fill="none" stroke-width="1" :stroke-dasharray="strokeDashArray" - transform="translate(-5, 20)" /> </g> </template> diff --git a/app/assets/javascripts/monitoring/components/graph/track_line.vue b/app/assets/javascripts/monitoring/components/graph/track_line.vue index 79b322e2e42..18be65fd1ef 100644 --- a/app/assets/javascripts/monitoring/components/graph/track_line.vue +++ b/app/assets/javascripts/monitoring/components/graph/track_line.vue @@ -19,16 +19,16 @@ export default { <template> <td> <svg - width="15" - height="6"> + width="16" + height="8"> <line :stroke-dasharray="stylizedLine" :stroke="track.lineColor" stroke-width="4" :x1="0" - :x2="15" - :y1="2" - :y2="2" + :x2="16" + :y1="4" + :y2="4" /> </svg> </td> diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js index 6cc67ba57ee..4f23814ff3e 100644 --- a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js +++ b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js @@ -52,14 +52,22 @@ const mixins = { positionFlag() { const timeSeries = this.timeSeries[0]; const hoveredDataIndex = bisectDate(timeSeries.values, this.hoverData.hoveredDate, 1); + this.currentData = timeSeries.values[hoveredDataIndex]; - this.currentDataIndex = hoveredDataIndex; this.currentXCoordinate = Math.floor(timeSeries.timeSeriesScaleX(this.currentData.time)); - if (this.currentXCoordinate > (this.graphWidth - 200)) { - this.currentFlagPosition = this.currentXCoordinate - 103; - } else { - this.currentFlagPosition = this.currentXCoordinate; - } + + this.currentCoordinates = this.timeSeries.map((series) => { + const currentDataIndex = bisectDate(series.values, this.hoverData.hoveredDate, 1); + const currentData = series.values[currentDataIndex]; + const currentX = Math.floor(series.timeSeriesScaleX(currentData.time)); + const currentY = Math.floor(series.timeSeriesScaleY(currentData.value)); + + return { + currentX, + currentY, + currentDataIndex, + }; + }); if (this.hoverData.currentDeployXPos) { this.showFlag = false; diff --git a/app/assets/javascripts/monitoring/utils/date_time_formatters.js b/app/assets/javascripts/monitoring/utils/date_time_formatters.js index f3c9acdd93e..d88c13609dc 100644 --- a/app/assets/javascripts/monitoring/utils/date_time_formatters.js +++ b/app/assets/javascripts/monitoring/utils/date_time_formatters.js @@ -14,7 +14,7 @@ const d3 = { timeYear, }; -export const dateFormat = d3.time('%a, %b %-d'); +export const dateFormat = d3.time('%d %b %Y, '); export const timeFormat = d3.time('%-I:%M%p'); export const dateFormatWithName = d3.time('%a, %b %-d'); export const bisectDate = d3.bisector(d => d.time).left; diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js index 8a93c7e6bae..4d3f1f1a7cc 100644 --- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js +++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js @@ -123,6 +123,7 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom linePath: lineFunction(timeSeries.values), areaPath: areaFunction(timeSeries.values), timeSeriesScaleX, + timeSeriesScaleY, values: timeSeries.values, max: maximumValue, average: accum / timeSeries.values.length, diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 775b16dda79..ce1f4562b72 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -98,10 +98,6 @@ export default { 'js-note-target-reopen': !this.isOpen, }; }, - supportQuickActions() { - // Disable quick actions support for Epics - return this.noteableType !== constants.EPIC_NOTEABLE_TYPE; - }, markdownDocsPath() { return this.getNotesData.markdownDocsPath; }, @@ -354,7 +350,7 @@ Please check your network connection and try again.`; name="note[note]" class="note-textarea js-vue-comment-form js-gfm-input js-autosize markdown-area js-vue-textarea" - :data-supports-quick-actions="supportQuickActions" + data-supports-quick-actions="true" aria-label="Description" v-model="note" ref="textarea" diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 2e153e1e96d..2464f95b3c6 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -107,7 +107,7 @@ export default { action-text="Edited" /> <note-awards-list - v-if="note.award_emoji && note.award_emoji.length" + v-if="note.award_emoji.length" :note-id="note.id" :note-author-id="note.author.id" :awards="note.award_emoji" diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue index a909b712e28..6b0c91bee28 100644 --- a/app/assets/javascripts/notes/components/note_edited_text.vue +++ b/app/assets/javascripts/notes/components/note_edited_text.vue @@ -33,18 +33,17 @@ export default { <template> <div :class="className"> {{ actionText }} - <time-ago-tooltip - v-if="editedAt" - :time="editedAt" - tooltip-placement="bottom" - /> <template v-if="editedBy"> - by + {{ s__('ByAuthor|by') }} <a :href="editedBy.path" class="js-vue-author author_link"> {{ editedBy.name }} </a> </template> + <time-ago-tooltip + :time="editedAt" + tooltip-placement="bottom" + /> </div> </template> diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 351b4ca4ba6..5e9c12a9ed8 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -128,7 +128,7 @@ export default { <template> <div ref="editNoteForm" - class="note-edit-form current-note-edit-form js-discussion-note-form"> + class="note-edit-form current-note-edit-form"> <div v-if="conflictWhileEditing" class="js-conflict-edit-warning alert alert-danger"> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index c3d1ef1fcc6..7183d0b50b2 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -62,6 +62,21 @@ export default { <template> <div class="note-header-info"> + <div + v-if="includeToggle" + class="discussion-actions"> + <button + @click="handleToggle" + class="note-action-button discussion-toggle-button js-vue-toggle-button" + type="button"> + <i + :class="toggleChevronClass" + class="fa" + aria-hidden="true"> + </i> + {{ __('Toggle discussion') }} + </button> + </div> <a :href="author.path"> <span class="note-header-author-name">{{ author.name }}</span> <span class="note-headline-light"> @@ -95,20 +110,5 @@ export default { </i> </span> </span> - <div - v-if="includeToggle" - class="discussion-actions"> - <button - @click="handleToggle" - class="note-action-button discussion-toggle-button js-vue-toggle-button" - type="button"> - <i - :class="toggleChevronClass" - class="fa" - aria-hidden="true"> - </i> - Toggle discussion - </button> - </div> </div> </template> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index c135f0bc960..6a921c9908a 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -274,21 +274,7 @@ Please check your network connection and try again.`; :action-text-html="actionTextHtml" /> <note-edited-text - v-if="discussion.resolved && discussion.resolved_by_push" - :edited-at="discussion.resolved_at" - :edited-by="discussion.resolved_by" - action-text="Automatically resolved with a push" - class-name="discussion-headline-light js-discussion-headline" - /> - <note-edited-text - v-if="discussion.resolved && !discussion.resolved_by_push" - :edited-at="discussion.resolved_at" - :edited-by="discussion.resolved_by" - action-text="Resolved" - class-name="discussion-headline-light js-discussion-headline" - /> - <note-edited-text - v-if="lastUpdatedAt && !discussion.resolved" + v-if="lastUpdatedAt" :edited-at="lastUpdatedAt" :edited-by="lastUpdatedBy" action-text="Last updated" @@ -296,7 +282,7 @@ Please check your network connection and try again.`; /> </div> <div - v-show="note.expanded || alwaysExpanded" + v-if="note.expanded || alwaysExpanded" class="discussion-body"> <component :is="wrapperComponent" diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index 04a0d8117cc..d3b2656743d 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -1,6 +1,10 @@ +import initSettingsPanels from '~/settings_panels'; import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; document.addEventListener('DOMContentLoaded', () => { + // Initialize expandable settings panels + initSettingsPanels(); + const variableListEl = document.querySelector('.js-ci-variable-list-section'); // eslint-disable-next-line no-new new AjaxVariableList({ diff --git a/app/assets/javascripts/pages/ide/index.js b/app/assets/javascripts/pages/ide/index.js new file mode 100644 index 00000000000..efadf6967aa --- /dev/null +++ b/app/assets/javascripts/pages/ide/index.js @@ -0,0 +1,9 @@ +import { initIde, resetServiceWorkersPublicPath } from '~/ide/index'; + +document.addEventListener('DOMContentLoaded', () => { + const ideElement = document.getElementById('ide'); + if (ideElement) { + resetServiceWorkersPublicPath(); + initIde(ideElement); + } +}); diff --git a/app/assets/javascripts/pages/projects/clusters/gcp/login/index.js b/app/assets/javascripts/pages/projects/clusters/gcp/login/index.js new file mode 100644 index 00000000000..0c2d7d7c96a --- /dev/null +++ b/app/assets/javascripts/pages/projects/clusters/gcp/login/index.js @@ -0,0 +1,3 @@ +import gcpSignupOffer from '~/clusters/components/gcp_signup_offer'; + +gcpSignupOffer(); diff --git a/app/assets/javascripts/pages/projects/clusters/new/index.js b/app/assets/javascripts/pages/projects/clusters/new/index.js new file mode 100644 index 00000000000..0c2d7d7c96a --- /dev/null +++ b/app/assets/javascripts/pages/projects/clusters/new/index.js @@ -0,0 +1,3 @@ +import gcpSignupOffer from '~/clusters/components/gcp_signup_offer'; + +gcpSignupOffer(); diff --git a/app/assets/javascripts/pages/projects/compare/index.js b/app/assets/javascripts/pages/projects/compare/index.js index d1c78bd61db..768da8fb236 100644 --- a/app/assets/javascripts/pages/projects/compare/index.js +++ b/app/assets/javascripts/pages/projects/compare/index.js @@ -1,3 +1,3 @@ import initCompareAutocomplete from '~/compare_autocomplete'; -document.addEventListener('DOMContentLoaded', initCompareAutocomplete); +document.addEventListener('DOMContentLoaded', () => initCompareAutocomplete()); diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js index 2b4fd3c47c0..a626ed2d30b 100644 --- a/app/assets/javascripts/pages/projects/compare/show/index.js +++ b/app/assets/javascripts/pages/projects/compare/show/index.js @@ -1,8 +1,10 @@ import Diff from '~/diff'; import initChangesDropdown from '~/init_changes_dropdown'; +import GpgBadges from '~/gpg_badges'; document.addEventListener('DOMContentLoaded', () => { new Diff(); // eslint-disable-line no-new const paddingTop = 16; initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop); + GpgBadges.fetch(); }); diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js new file mode 100644 index 00000000000..46f3f55a400 --- /dev/null +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js @@ -0,0 +1,60 @@ +import $ from 'jquery'; +import { localTimeAgo } from '~/lib/utils/datetime_utility'; +import axios from '~/lib/utils/axios_utils'; +import initCompareAutocomplete from '~/compare_autocomplete'; +import initTargetProjectDropdown from './target_project_dropdown'; + +const updateCommitList = (url, $loadingIndicator, $commitList, params) => { + $loadingIndicator.show(); + $commitList.empty(); + + return axios + .get(url, { + params, + }) + .then(({ data }) => { + $loadingIndicator.hide(); + $commitList.html(data); + localTimeAgo($('.js-timeago', $commitList)); + }); +}; + +export default mrNewCompareNode => { + const { sourceBranchUrl, targetBranchUrl } = mrNewCompareNode.dataset; + initTargetProjectDropdown(); + + const updateSourceBranchCommitList = () => + updateCommitList( + sourceBranchUrl, + $(mrNewCompareNode).find('.js-source-loading'), + $(mrNewCompareNode).find('.mr_source_commit'), + { + ref: $(mrNewCompareNode) + .find("input[name='merge_request[source_branch]']") + .val(), + }, + ); + const updateTargetBranchCommitList = () => + updateCommitList( + targetBranchUrl, + $(mrNewCompareNode).find('.js-target-loading'), + $(mrNewCompareNode).find('.mr_target_commit'), + { + target_project_id: $(mrNewCompareNode) + .find("input[name='merge_request[target_project_id]']") + .val(), + ref: $(mrNewCompareNode) + .find("input[name='merge_request[target_branch]']") + .val(), + }, + ); + initCompareAutocomplete('branches', $dropdown => { + if ($dropdown.is('.js-target-branch')) { + updateTargetBranchCommitList(); + } else if ($dropdown.is('.js-source-branch')) { + updateSourceBranchCommitList(); + } + }); + updateSourceBranchCommitList(); + updateTargetBranchCommitList(); +}; diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js index 6c9afddefac..01a0b4870c1 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js @@ -1,18 +1,15 @@ -import Compare from '~/compare'; import MergeRequest from '~/merge_request'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; +import initCompare from './compare'; document.addEventListener('DOMContentLoaded', () => { const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare'); if (mrNewCompareNode) { - new Compare({ // eslint-disable-line no-new - targetProjectUrl: mrNewCompareNode.dataset.targetProjectUrl, - sourceBranchUrl: mrNewCompareNode.dataset.sourceBranchUrl, - targetBranchUrl: mrNewCompareNode.dataset.targetBranchUrl, - }); + initCompare(mrNewCompareNode); } else { const mrNewSubmitNode = document.querySelector('.js-merge-request-new-submit'); - new MergeRequest({ // eslint-disable-line no-new + // eslint-disable-next-line no-new + new MergeRequest({ action: mrNewSubmitNode.dataset.mrSubmitAction, }); initPipelines(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/target_project_dropdown.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/target_project_dropdown.js new file mode 100644 index 00000000000..b72fe6681df --- /dev/null +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/target_project_dropdown.js @@ -0,0 +1,22 @@ +import $ from 'jquery'; + +export default () => { + const $targetProjectDropdown = $('.js-target-project'); + $targetProjectDropdown.glDropdown({ + selectable: true, + fieldName: $targetProjectDropdown.data('fieldName'), + filterable: true, + id(obj, $el) { + return $el.data('id'); + }, + toggleLabel(obj, $el) { + return $el.text().trim(); + }, + clicked({ $el }) { + $('.mr_target_commit').empty(); + const $targetBranchDropdown = $('.js-target-branch'); + $targetBranchDropdown.data('refsUrl', $el.data('refsUrl')); + $targetBranchDropdown.data('glDropdown').clearMenu(); + }, + }); +}; diff --git a/app/assets/javascripts/pages/projects/pipelines/new/index.js b/app/assets/javascripts/pages/projects/pipelines/new/index.js index 9aa8945e268..b0b077a5e4c 100644 --- a/app/assets/javascripts/pages/projects/pipelines/new/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/new/index.js @@ -1,6 +1,12 @@ import $ from 'jquery'; import NewBranchForm from '~/new_branch_form'; +import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list'; document.addEventListener('DOMContentLoaded', () => { new NewBranchForm($('.js-new-pipeline-form')); // eslint-disable-line no-new + + setupNativeFormVariableList({ + container: $('.js-ci-variable-list-section'), + formField: 'variables_attributes', + }); }); diff --git a/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue b/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue new file mode 100644 index 00000000000..df21e2f8771 --- /dev/null +++ b/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue @@ -0,0 +1,77 @@ +<script> +import _ from 'underscore'; +import GlModal from '~/vue_shared/components/gl_modal.vue'; +import { s__, sprintf } from '~/locale'; + +export default { + components: { + GlModal, + }, + props: { + deleteWikiUrl: { + type: String, + required: true, + default: '', + }, + pageTitle: { + type: String, + required: true, + default: '', + }, + csrfToken: { + type: String, + required: true, + default: '', + }, + }, + computed: { + message() { + return s__('WikiPageConfirmDelete|Are you sure you want to delete this page?'); + }, + title() { + return sprintf( + s__('WikiPageConfirmDelete|Delete page %{pageTitle}?'), + { + pageTitle: _.escape(this.pageTitle), + }, + false, + ); + }, + }, + methods: { + onSubmit() { + this.$refs.form.submit(); + }, + }, +}; +</script> + +<template> + <gl-modal + id="delete-wiki-modal" + :header-title-text="title" + footer-primary-button-variant="danger" + :footer-primary-button-text="s__('WikiPageConfirmDelete|Delete page')" + @submit="onSubmit" + > + {{ message }} + <form + ref="form" + :action="deleteWikiUrl" + method="post" + class="form-horizontal js-requires-input" + > + <input + ref="method" + type="hidden" + name="_method" + value="delete" + /> + <input + type="hidden" + name="authenticity_token" + :value="csrfToken" + /> + </form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/pages/projects/wikis/index.js b/app/assets/javascripts/pages/projects/wikis/index.js index ec01c66ffda..0295653cb29 100644 --- a/app/assets/javascripts/pages/projects/wikis/index.js +++ b/app/assets/javascripts/pages/projects/wikis/index.js @@ -1,12 +1,40 @@ import $ from 'jquery'; +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import csrf from '~/lib/utils/csrf'; import Wikis from './wikis'; import ShortcutsWiki from '../../../shortcuts_wiki'; import ZenMode from '../../../zen_mode'; import GLForm from '../../../gl_form'; +import deleteWikiModal from './components/delete_wiki_modal.vue'; document.addEventListener('DOMContentLoaded', () => { new Wikis(); // eslint-disable-line no-new new ShortcutsWiki(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new new GLForm($('.wiki-form'), true); // eslint-disable-line no-new + + const deleteWikiButton = document.getElementById('delete-wiki-button'); + + if (deleteWikiButton) { + Vue.use(Translate); + + const { deleteWikiUrl, pageTitle } = deleteWikiButton.dataset; + const deleteWikiModalEl = document.getElementById('delete-wiki-modal'); + const deleteModal = new Vue({ // eslint-disable-line + el: deleteWikiModalEl, + data: { + deleteWikiUrl: '', + }, + render(createElement) { + return createElement(deleteWikiModal, { + props: { + pageTitle, + deleteWikiUrl, + csrfToken: csrf.token, + }, + }); + }, + }); + } }); diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index 29ee73a2a6f..fd3491c7fe0 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -61,7 +61,7 @@ export default { methods: { onClickAction() { $(this.$el).tooltip('hide'); - eventHub.$emit('graphAction', this.link); + eventHub.$emit('postAction', this.link); this.linkRequested = this.link; this.isDisabled = true; }, diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue index 43121dd38f3..4027d26098f 100644 --- a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue @@ -87,7 +87,8 @@ export default { data-toggle="dropdown" data-container="body" class="dropdown-menu-toggle build-content" - :title="tooltipText"> + :title="tooltipText" + > <job-name-component :name="job.name" @@ -104,7 +105,8 @@ export default { <ul> <li v-for="(item, i) in job.jobs" - :key="i"> + :key="i" + > <job-component :job="item" css-class-job-name="mini-pipeline-graph-dropdown-item" diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue index 4fcd4b79f4a..c1f0f051b63 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue @@ -108,7 +108,7 @@ export default { <div v-else v-tooltip - class="js-job-component-tooltip" + class="js-job-component-tooltip non-details-job-component" :title="tooltipText" :class="cssClassJobName" data-html="true" diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_table.vue index 714aed1333e..41986b827cd 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table.vue @@ -1,7 +1,7 @@ <script> - import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; + import Modal from '~/vue_shared/components/gl_modal.vue'; import { s__, sprintf } from '~/locale'; - import pipelinesTableRowComponent from './pipelines_table_row.vue'; + import PipelinesTableRowComponent from './pipelines_table_row.vue'; import eventHub from '../event_hub'; /** @@ -11,8 +11,8 @@ */ export default { components: { - pipelinesTableRowComponent, - DeprecatedModal, + PipelinesTableRowComponent, + Modal, }, props: { pipelines: { @@ -37,30 +37,18 @@ return { pipelineId: '', endpoint: '', - type: '', }; }, computed: { modalTitle() { - return this.type === 'stop' ? - sprintf(s__('Pipeline|Stop pipeline #%{pipelineId}?'), { - pipelineId: `'${this.pipelineId}'`, - }, false) : - sprintf(s__('Pipeline|Retry pipeline #%{pipelineId}?'), { - pipelineId: `'${this.pipelineId}'`, - }, false); + return sprintf(s__('Pipeline|Stop pipeline #%{pipelineId}?'), { + pipelineId: `${this.pipelineId}`, + }, false); }, modalText() { - return this.type === 'stop' ? - sprintf(s__('Pipeline|You’re about to stop pipeline %{pipelineId}.'), { - pipelineId: `<strong>#${this.pipelineId}</strong>`, - }, false) : - sprintf(s__('Pipeline|You’re about to retry pipeline %{pipelineId}.'), { - pipelineId: `<strong>#${this.pipelineId}</strong>`, - }, false); - }, - primaryButtonLabel() { - return this.type === 'stop' ? s__('Pipeline|Stop pipeline') : s__('Pipeline|Retry pipeline'); + return sprintf(s__('Pipeline|You’re about to stop pipeline %{pipelineId}.'), { + pipelineId: `<strong>#${this.pipelineId}</strong>`, + }, false); }, }, created() { @@ -73,7 +61,6 @@ setModalData(data) { this.pipelineId = data.pipelineId; this.endpoint = data.endpoint; - this.type = data.type; }, onSubmit() { eventHub.$emit('postAction', this.endpoint); @@ -120,20 +107,16 @@ :auto-devops-help-path="autoDevopsHelpPath" :view-type="viewType" /> - <deprecated-modal + + <modal id="confirmation-modal" - :title="modalTitle" - :text="modalText" - kind="danger" - :primary-button-label="primaryButtonLabel" + :header-title-text="modalTitle" + footer-primary-button-variant="danger" + :footer-primary-button-text="s__('Pipeline|Stop pipeline')" @submit="onSubmit" > - <template - slot="body" - slot-scope="props" - > - <p v-html="props.text"></p> - </template> - </deprecated-modal> + <span v-html="modalText"></span> + </modal> + </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue index 4cbd67e0372..498a97851fa 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue @@ -1,13 +1,14 @@ <script> - /* eslint-disable no-param-reassign */ - import asyncButtonComponent from './async_button.vue'; - import pipelinesActionsComponent from './pipelines_actions.vue'; - import pipelinesArtifactsComponent from './pipelines_artifacts.vue'; - import ciBadge from '../../vue_shared/components/ci_badge_link.vue'; - import pipelineStage from './stage.vue'; - import pipelineUrl from './pipeline_url.vue'; - import pipelinesTimeago from './time_ago.vue'; - import commitComponent from '../../vue_shared/components/commit.vue'; + import eventHub from '../event_hub'; + import PipelinesActionsComponent from './pipelines_actions.vue'; + import PipelinesArtifactsComponent from './pipelines_artifacts.vue'; + import CiBadge from '../../vue_shared/components/ci_badge_link.vue'; + import PipelineStage from './stage.vue'; + import PipelineUrl from './pipeline_url.vue'; + import PipelinesTimeago from './time_ago.vue'; + import CommitComponent from '../../vue_shared/components/commit.vue'; + import LoadingButton from '../../vue_shared/components/loading_button.vue'; + import Icon from '../../vue_shared/components/icon.vue'; /** * Pipeline table row. @@ -16,14 +17,15 @@ */ export default { components: { - asyncButtonComponent, - pipelinesActionsComponent, - pipelinesArtifactsComponent, - commitComponent, - pipelineStage, - pipelineUrl, - ciBadge, - pipelinesTimeago, + PipelinesActionsComponent, + PipelinesArtifactsComponent, + CommitComponent, + PipelineStage, + PipelineUrl, + CiBadge, + PipelinesTimeago, + LoadingButton, + Icon, }, props: { pipeline: { @@ -44,6 +46,12 @@ required: true, }, }, + data() { + return { + isRetrying: false, + isCancelling: false, + }; + }, computed: { /** * If provided, returns the commit tag. @@ -119,8 +127,10 @@ if (this.pipeline.ref) { return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => { if (prop === 'path') { + // eslint-disable-next-line no-param-reassign accumulator.ref_url = this.pipeline.ref[prop]; } else { + // eslint-disable-next-line no-param-reassign accumulator[prop] = this.pipeline.ref[prop]; } return accumulator; @@ -216,6 +226,21 @@ return this.viewType === 'child'; }, }, + + methods: { + handleCancelClick() { + this.isCancelling = true; + + eventHub.$emit('openConfirmationModal', { + pipelineId: this.pipeline.id, + endpoint: this.pipeline.cancel_path, + }); + }, + handleRetryClick() { + this.isRetrying = true; + eventHub.$emit('retryPipeline', this.pipeline.retry_path); + }, + }, }; </script> <template> @@ -287,7 +312,8 @@ <div v-if="displayPipelineActions" - class="table-section section-20 table-button-footer pipeline-actions"> + class="table-section section-20 table-button-footer pipeline-actions" + > <div class="btn-group table-action-buttons"> <pipelines-actions-component v-if="pipeline.details.manual_actions.length" @@ -300,29 +326,27 @@ :artifacts="pipeline.details.artifacts" /> - <async-button-component + <loading-button v-if="pipeline.flags.retryable" - :endpoint="pipeline.retry_path" - css-class="js-pipelines-retry-button btn-default btn-retry" - title="Retry" - icon="repeat" - :pipeline-id="pipeline.id" - data-toggle="modal" - data-target="#confirmation-modal" - type="retry" - /> + @click="handleRetryClick" + container-class="js-pipelines-retry-button btn btn-default btn-retry" + :loading="isRetrying" + :disabled="isRetrying" + > + <icon name="repeat" /> + </loading-button> - <async-button-component + <loading-button v-if="pipeline.flags.cancelable" - :endpoint="pipeline.cancel_path" - css-class="js-pipelines-cancel-button btn-remove" - title="Stop" - icon="close" - :pipeline-id="pipeline.id" + @click="handleCancelClick" data-toggle="modal" data-target="#confirmation-modal" - type="stop" - /> + container-class="js-pipelines-cancel-button btn btn-remove" + :loading="isCancelling" + :disabled="isCancelling" + > + <icon name="close" /> + </loading-button> </div> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue index 32cf3dba3c3..a65485c05eb 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -1,135 +1,140 @@ <script> - - /** - * Renders each stage of the pipeline mini graph. - * - * Given the provided endpoint will make a request to - * fetch the dropdown data when the stage is clicked. - * - * Request is made inside this component to make it reusable between: - * 1. Pipelines main table - * 2. Pipelines table in commit and Merge request views - * 3. Merge request widget - * 4. Commit widget - */ - - import $ from 'jquery'; - import Flash from '../../flash'; - import axios from '../../lib/utils/axios_utils'; - import eventHub from '../event_hub'; - import Icon from '../../vue_shared/components/icon.vue'; - import LoadingIcon from '../../vue_shared/components/loading_icon.vue'; - import tooltip from '../../vue_shared/directives/tooltip'; - - export default { - components: { - LoadingIcon, - Icon, +/** + * Renders each stage of the pipeline mini graph. + * + * Given the provided endpoint will make a request to + * fetch the dropdown data when the stage is clicked. + * + * Request is made inside this component to make it reusable between: + * 1. Pipelines main table + * 2. Pipelines table in commit and Merge request views + * 3. Merge request widget + * 4. Commit widget + */ + +import $ from 'jquery'; +import { __ } from '../../locale'; +import Flash from '../../flash'; +import axios from '../../lib/utils/axios_utils'; +import eventHub from '../event_hub'; +import Icon from '../../vue_shared/components/icon.vue'; +import LoadingIcon from '../../vue_shared/components/loading_icon.vue'; +import JobComponent from './graph/job_component.vue'; +import tooltip from '../../vue_shared/directives/tooltip'; + +export default { + components: { + LoadingIcon, + Icon, + JobComponent, + }, + + directives: { + tooltip, + }, + + props: { + stage: { + type: Object, + required: true, }, - directives: { - tooltip, + updateDropdown: { + type: Boolean, + required: false, + default: false, }, - - props: { - stage: { - type: Object, - required: true, - }, - - updateDropdown: { - type: Boolean, - required: false, - default: false, - }, + }, + + data() { + return { + isLoading: false, + dropdownContent: '', + }; + }, + + computed: { + dropdownClass() { + return this.dropdownContent.length > 0 + ? 'js-builds-dropdown-container' + : 'js-builds-dropdown-loading'; }, - data() { - return { - isLoading: false, - dropdownContent: '', - }; + triggerButtonClass() { + return `ci-status-icon-${this.stage.status.group}`; }, - computed: { - dropdownClass() { - return this.dropdownContent.length > 0 ? 'js-builds-dropdown-container' : 'js-builds-dropdown-loading'; - }, + borderlessIcon() { + return `${this.stage.status.icon}_borderless`; + }, + }, - triggerButtonClass() { - return `ci-status-icon-${this.stage.status.group}`; - }, + watch: { + updateDropdown() { + if (this.updateDropdown && this.isDropdownOpen() && !this.isLoading) { + this.fetchJobs(); + } + }, + }, + + updated() { + if (this.dropdownContent.length > 0) { + this.stopDropdownClickPropagation(); + } + }, + + methods: { + onClickStage() { + if (!this.isDropdownOpen()) { + eventHub.$emit('clickedDropdown'); + this.isLoading = true; + this.fetchJobs(); + } + }, - borderlessIcon() { - return `${this.stage.status.icon}_borderless`; - }, + fetchJobs() { + axios + .get(this.stage.dropdown_path) + .then(({ data }) => { + this.dropdownContent = data.latest_statuses; + this.isLoading = false; + }) + .catch(() => { + this.closeDropdown(); + this.isLoading = false; + + Flash(__('Something went wrong on our end.')); + }); }, - watch: { - updateDropdown() { - if (this.updateDropdown && - this.isDropdownOpen() && - !this.isLoading) { - this.fetchJobs(); - } - }, + /** + * When the user right clicks or cmd/ctrl + click in the job name + * the dropdown should not be closed and the link should open in another tab, + * so we stop propagation of the click event inside the dropdown. + * + * Since this component is rendered multiple times per page we need to guarantee we only + * target the click event of this component. + */ + stopDropdownClickPropagation() { + $( + '.js-builds-dropdown-list button, .js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item', + this.$el, + ).on('click', e => { + e.stopPropagation(); + }); }, - updated() { - if (this.dropdownContent.length > 0) { - this.stopDropdownClickPropagation(); + closeDropdown() { + if (this.isDropdownOpen()) { + $(this.$refs.dropdown).dropdown('toggle'); } }, - methods: { - onClickStage() { - if (!this.isDropdownOpen()) { - eventHub.$emit('clickedDropdown'); - this.isLoading = true; - this.fetchJobs(); - } - }, - - fetchJobs() { - axios.get(this.stage.dropdown_path) - .then(({ data }) => { - this.dropdownContent = data.html; - this.isLoading = false; - }) - .catch(() => { - this.closeDropdown(); - this.isLoading = false; - - Flash('Something went wrong on our end.'); - }); - }, - - /** - * When the user right clicks or cmd/ctrl + click in the job name - * the dropdown should not be closed and the link should open in another tab, - * so we stop propagation of the click event inside the dropdown. - * - * Since this component is rendered multiple times per page we need to guarantee we only - * target the click event of this component. - */ - stopDropdownClickPropagation() { - $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item')) - .on('click', (e) => { - e.stopPropagation(); - }); - }, - - closeDropdown() { - if (this.isDropdownOpen()) { - $(this.$refs.dropdown).dropdown('toggle'); - } - }, - - isDropdownOpen() { - return this.$el.classList.contains('open'); - }, + isDropdownOpen() { + return this.$el.classList.contains('open'); }, - }; + }, +}; </script> <template> @@ -168,7 +173,6 @@ > <li - :class="dropdownClass" class="js-builds-dropdown-list scrollable-menu" > @@ -176,8 +180,16 @@ <ul v-else - v-html="dropdownContent" > + <li + v-for="job in dropdownContent" + :key="job.id" + > + <job-component + :job="job" + css-class-job-name="mini-pipeline-graph-dropdown-item" + /> + </li> </ul> </li> </ul> diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 6d87f75ae8e..de0faf181e5 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -53,10 +53,12 @@ export default { }); eventHub.$on('postAction', this.postAction); + eventHub.$on('retryPipeline', this.postAction); eventHub.$on('clickedDropdown', this.updateTable); }, beforeDestroy() { eventHub.$off('postAction', this.postAction); + eventHub.$off('retryPipeline', this.postAction); eventHub.$off('clickedDropdown', this.updateTable); }, destroyed() { diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 6584f96130b..04fe7958fe6 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -29,10 +29,10 @@ export default () => { }; }, created() { - eventHub.$on('graphAction', this.postAction); + eventHub.$on('postAction', this.postAction); }, beforeDestroy() { - eventHub.$off('graphAction', this.postAction); + eventHub.$off('postAction', this.postAction); }, methods: { postAction(action) { diff --git a/app/assets/javascripts/projects_dropdown/components/app.vue b/app/assets/javascripts/projects_dropdown/components/app.vue index 34a60dd574b..0bbd8a41753 100644 --- a/app/assets/javascripts/projects_dropdown/components/app.vue +++ b/app/assets/javascripts/projects_dropdown/components/app.vue @@ -100,9 +100,10 @@ export default { fetchSearchedProjects(searchQuery) { this.searchQuery = searchQuery; this.toggleLoader(true); - this.service.getSearchedProjects(this.searchQuery) + this.service + .getSearchedProjects(this.searchQuery) .then(res => res.json()) - .then((results) => { + .then(results => { this.toggleSearchProjectsList(true); this.store.setSearchedProjects(results); }) diff --git a/app/assets/javascripts/projects_dropdown/service/projects_service.js b/app/assets/javascripts/projects_dropdown/service/projects_service.js index 7231f520933..ed1c3deead2 100644 --- a/app/assets/javascripts/projects_dropdown/service/projects_service.js +++ b/app/assets/javascripts/projects_dropdown/service/projects_service.js @@ -50,7 +50,7 @@ export default class ProjectsService { } else { // Check if project is already present in frequents list // When found, update metadata of it. - storedFrequentProjects = JSON.parse(storedRawProjects).map((projectItem) => { + storedFrequentProjects = JSON.parse(storedRawProjects).map(projectItem => { if (projectItem.id === project.id) { matchFound = true; const diff = Math.abs(project.lastAccessedOn - projectItem.lastAccessedOn) / HOUR_IN_MS; @@ -104,13 +104,17 @@ export default class ProjectsService { return []; } - if (bp.getBreakpointSize() === 'sm' || - bp.getBreakpointSize() === 'xs') { + if (bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'xs') { frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_MOBILE; } - const frequentProjects = storedFrequentProjects - .filter(project => project.frequency >= FREQUENT_PROJECTS.ELIGIBLE_FREQUENCY); + const frequentProjects = storedFrequentProjects.filter( + project => project.frequency >= FREQUENT_PROJECTS.ELIGIBLE_FREQUENCY, + ); + + if (!frequentProjects || frequentProjects.length === 0) { + return []; + } // Sort all frequent projects in decending order of frequency // and then by lastAccessedOn with recent most first diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue index a03180e80e6..2ce43ef0125 100644 --- a/app/assets/javascripts/registry/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -28,11 +28,6 @@ isOpen: false, }; }, - computed: { - clipboardText() { - return `docker pull ${this.repo.location}`; - }, - }, methods: { ...mapActions([ 'fetchRepos', @@ -84,7 +79,7 @@ <clipboard-button v-if="repo.location" - :text="clipboardText" + :text="repo.location" :title="repo.location" css-class="btn-default btn-transparent btn-clipboard" /> diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index ee4eb3581f3..673b1db6769 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -56,10 +56,6 @@ .catch(() => this.showError(errorMessagesTypes.FETCH_REGISTRY)); }, - clipboardText(text) { - return `docker pull ${text}`; - }, - showError(message) { Flash(errorMessages[message]); }, @@ -89,7 +85,7 @@ <clipboard-button v-if="item.location" :title="item.location" - :text="clipboardText(item.location)" + :text="item.location" css-class="btn-default btn-transparent btn-clipboard" /> </td> @@ -111,7 +107,13 @@ </td> <td> - {{ timeFormated(item.createdAt) }} + <span + v-tooltip + :title="tooltipTitle(item.createdAt)" + data-placement="bottom" + > + {{ timeFormated(item.createdAt) }} + </span> </td> <td class="content"> diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js index e31e067033f..99c71d6524a 100644 --- a/app/assets/javascripts/shortcuts.js +++ b/app/assets/javascripts/shortcuts.js @@ -85,6 +85,7 @@ export default class Shortcuts { if ($modal.length) { $modal.modal('toggle'); + return null; } return axios.get(gon.shortcuts_path, { diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index 6d95153af28..8f9e6761d20 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -70,6 +70,9 @@ toggleMoreParticipants() { this.isShowingMoreParticipants = !this.isShowingMoreParticipants; }, + onClickCollapsedIcon() { + this.$emit('toggleSidebar'); + }, }, }; </script> @@ -82,6 +85,7 @@ data-container="body" data-placement="left" :title="participantLabel" + @click="onClickCollapsedIcon" > <i class="fa fa-users" diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue index 3e8cc7a6630..385717e7c1e 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue @@ -1,6 +1,5 @@ <script> import Store from '../../stores/sidebar_store'; -import eventHub from '../../event_hub'; import Flash from '../../../flash'; import { __ } from '../../../locale'; import subscriptions from './subscriptions.vue'; @@ -20,12 +19,6 @@ export default { store: new Store(), }; }, - created() { - eventHub.$on('toggleSubscription', this.onToggleSubscription); - }, - beforeDestroy() { - eventHub.$off('toggleSubscription', this.onToggleSubscription); - }, methods: { onToggleSubscription() { this.mediator.toggleSubscription() @@ -42,6 +35,7 @@ export default { <subscriptions :loading="store.isFetching.subscriptions" :subscribed="store.subscribed" + @toggleSubscription="onToggleSubscription" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue index d69d100a26c..f0df759ef7a 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -47,8 +47,25 @@ }, }, methods: { + /** + * We need to emit this event on both component & eventHub + * for 2 dependencies; + * + * 1. eventHub: This component is used in Issue Boards sidebar + * where component template is part of HAML + * and event listeners are tied to app's eventHub. + * 2. Component: This compone is also used in Epics in EE + * where listeners are tied to component event. + */ toggleSubscription() { + // App's eventHub event emission. eventHub.$emit('toggleSubscription', this.id); + + // Component event emission. + this.$emit('toggleSubscription', this.id); + }, + onClickCollapsedIcon() { + this.$emit('toggleSidebar'); }, }, }; @@ -56,7 +73,10 @@ <template> <div> - <div class="sidebar-collapsed-icon"> + <div + class="sidebar-collapsed-icon" + @click="onClickCollapsedIcon" + > <span v-tooltip :title="notificationTooltip" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue new file mode 100644 index 00000000000..59cd99f8f14 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue @@ -0,0 +1,18 @@ +<script> +export default { + name: 'TimeTrackingSpentOnlyPane', + props: { + timeSpentHumanReadable: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="time-tracking-spend-only-pane"> + <span class="bold">Spent:</span> + {{ timeSpentHumanReadable }} + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 9c003aa9f8a..8f5d0bee107 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -1,7 +1,7 @@ <script> import TimeTrackingHelpState from './help_state.vue'; import TimeTrackingCollapsedState from './collapsed_state.vue'; -import timeTrackingSpentOnlyPane from './spent_only_pane'; +import TimeTrackingSpentOnlyPane from './spent_only_pane.vue'; import TimeTrackingNoTrackingPane from './no_tracking_pane.vue'; import TimeTrackingEstimateOnlyPane from './estimate_only_pane.vue'; import TimeTrackingComparisonPane from './comparison_pane.vue'; @@ -13,7 +13,7 @@ export default { components: { TimeTrackingCollapsedState, TimeTrackingEstimateOnlyPane, - 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane, + TimeTrackingSpentOnlyPane, TimeTrackingNoTrackingPane, TimeTrackingComparisonPane, TimeTrackingHelpState, diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js index 97d5cf96bcb..96dfff77859 100644 --- a/app/assets/javascripts/user_callout.js +++ b/app/assets/javascripts/user_callout.js @@ -15,7 +15,7 @@ export default class UserCallout { init() { if (!this.isCalloutDismissed || this.isCalloutDismissed === 'false') { - $('.js-close-callout').on('click', e => this.dismissCallout(e)); + this.userCalloutBody.find('.js-close-callout').on('click', e => this.dismissCallout(e)); } } @@ -23,12 +23,15 @@ export default class UserCallout { const $currentTarget = $(e.currentTarget); if (this.options.setCalloutPerProject) { - Cookies.set(this.cookieName, 'true', { expires: 365, path: this.userCalloutBody.data('projectPath') }); + Cookies.set(this.cookieName, 'true', { + expires: 365, + path: this.userCalloutBody.data('projectPath'), + }); } else { Cookies.set(this.cookieName, 'true', { expires: 365 }); } - if ($currentTarget.hasClass('close')) { + if ($currentTarget.hasClass('close') || $currentTarget.hasClass('js-close')) { this.userCalloutBody.remove(); } } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue index 7bef2e97349..1fea231c816 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -109,12 +109,12 @@ export default { rel="noopener noreferrer nofollow" class="deploy-link js-deploy-url" > + {{ deployment.external_url_formatted }} <i class="fa fa-external-link" aria-hidden="true" > </i> - {{ deployment.external_url_formatted }} </a> </template> <span diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index c1618bc6ea0..3e36a3a10f9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -3,6 +3,7 @@ import tooltip from '~/vue_shared/directives/tooltip'; import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import { s__, __ } from '~/locale'; + import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import mrWidgetAuthorTime from '../../components/mr_widget_author_time.vue'; import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; @@ -16,6 +17,7 @@ mrWidgetAuthorTime, loadingIcon, statusIcon, + ClipboardButton, }, props: { mr: { @@ -162,6 +164,18 @@ <span class="label-branch"> <a :href="mr.targetBranchPath">{{ mr.targetBranch }}</a> </span> + with + <a + :href="mr.mergeCommitPath" + class="commit-sha js-mr-merged-commit-sha" + > + {{ mr.shortMergeCommitSha }} + </a> + <clipboard-button + :title="__('Copy commit SHA to clipboard')" + :text="mr.shortMergeCommitSha" + css-class="btn-default btn-transparent btn-clipboard js-mr-merged-copy-sha" + /> </p> <p v-if="mr.sourceBranchRemoved"> {{ s__("mrWidget|The source branch has been removed") }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.vue new file mode 100644 index 00000000000..926a3172412 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.vue @@ -0,0 +1,15 @@ +/* +The squash-before-merge button is EE only, but it's located right in the middle +of the readyToMerge state component template. + +If we didn't declare this component in CE, we'd need to maintain a separate copy +of the readyToMergeState template in EE, which is pretty big and likely to change. + +Instead, in CE, we declare the component, but it's hidden and is configured to do nothing. +In EE, the configuration extends this object to add a functioning squash-before-merge +button. +*/ + +<script> + export default {}; +</script> diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index 7f5f28091da..15097fa2a3f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -15,7 +15,6 @@ export { default as WidgetHeader } from './components/mr_widget_header.vue'; export { default as WidgetMergeHelp } from './components/mr_widget_merge_help.vue'; export { default as WidgetPipeline } from './components/mr_widget_pipeline.vue'; export { default as Deployment } from './components/deployment.vue'; -export { default as WidgetMaintainerEdit } from './components/mr_widget_maintainer_edit.vue'; export { default as WidgetRelatedLinks } from './components/mr_widget_related_links.vue'; export { default as MergedState } from './components/states/mr_widget_merged.vue'; export { default as FailedToMerge } from './components/states/mr_widget_failed_to_merge.vue'; @@ -41,8 +40,8 @@ export { default as MRWidgetService } from './services/mr_widget_service'; export { default as eventHub } from './event_hub'; export { default as getStateKey } from './stores/get_state_key'; export { default as stateMaps } from './stores/state_maps'; -export { default as SquashBeforeMerge } from './components/states/mr_widget_squash_before_merge'; +export { default as SquashBeforeMerge } from './components/states/mr_widget_squash_before_merge.vue'; export { default as notify } from '../lib/utils/notify'; export { default as SourceBranchRemovalStatus } from './components/source_branch_removal_status.vue'; -export { default as mrWidgetOptions } from './mr_widget_options'; +export { default as mrWidgetOptions } from './mr_widget_options.vue'; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue new file mode 100644 index 00000000000..f69fe03fcb3 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -0,0 +1,291 @@ +<script> + +import Project from '~/pages/projects/project'; +import SmartInterval from '~/smart_interval'; +import createFlash from '../flash'; +import { + WidgetHeader, + WidgetMergeHelp, + WidgetPipeline, + Deployment, + WidgetRelatedLinks, + MergedState, + ClosedState, + MergingState, + RebaseState, + WorkInProgressState, + ArchivedState, + ConflictsState, + NothingToMergeState, + MissingBranchState, + NotAllowedState, + ReadyToMergeState, + ShaMismatchState, + UnresolvedDiscussionsState, + PipelineBlockedState, + PipelineFailedState, + FailedToMerge, + MergeWhenPipelineSucceedsState, + AutoMergeFailed, + CheckingState, + MRWidgetStore, + MRWidgetService, + eventHub, + stateMaps, + SquashBeforeMerge, + notify, + SourceBranchRemovalStatus, +} from './dependencies'; +import { setFavicon } from '../lib/utils/common_utils'; + +export default { + el: '#js-vue-mr-widget', + name: 'MRWidget', + components: { + 'mr-widget-header': WidgetHeader, + 'mr-widget-merge-help': WidgetMergeHelp, + 'mr-widget-pipeline': WidgetPipeline, + Deployment, + 'mr-widget-related-links': WidgetRelatedLinks, + 'mr-widget-merged': MergedState, + 'mr-widget-closed': ClosedState, + 'mr-widget-merging': MergingState, + 'mr-widget-failed-to-merge': FailedToMerge, + 'mr-widget-wip': WorkInProgressState, + 'mr-widget-archived': ArchivedState, + 'mr-widget-conflicts': ConflictsState, + 'mr-widget-nothing-to-merge': NothingToMergeState, + 'mr-widget-not-allowed': NotAllowedState, + 'mr-widget-missing-branch': MissingBranchState, + 'mr-widget-ready-to-merge': ReadyToMergeState, + 'sha-mismatch': ShaMismatchState, + 'mr-widget-squash-before-merge': SquashBeforeMerge, + 'mr-widget-checking': CheckingState, + 'mr-widget-unresolved-discussions': UnresolvedDiscussionsState, + 'mr-widget-pipeline-blocked': PipelineBlockedState, + 'mr-widget-pipeline-failed': PipelineFailedState, + 'mr-widget-merge-when-pipeline-succeeds': MergeWhenPipelineSucceedsState, + 'mr-widget-auto-merge-failed': AutoMergeFailed, + 'mr-widget-rebase': RebaseState, + SourceBranchRemovalStatus, + }, + props: { + mrData: { + type: Object, + required: false, + default: null, + }, + }, + data() { + const store = new MRWidgetStore(this.mrData || window.gl.mrWidgetData); + const service = this.createService(store); + return { + mr: store, + service, + }; + }, + computed: { + componentName() { + return stateMaps.stateToComponentMap[this.mr.state]; + }, + shouldRenderMergeHelp() { + return stateMaps.statesToShowHelpWidget.indexOf(this.mr.state) > -1; + }, + shouldRenderPipelines() { + return this.mr.hasCI; + }, + shouldRenderRelatedLinks() { + return !!this.mr.relatedLinks && !this.mr.isNothingToMergeState; + }, + shouldRenderSourceBranchRemovalStatus() { + return !this.mr.canRemoveSourceBranch && this.mr.shouldRemoveSourceBranch && + (!this.mr.isNothingToMergeState && !this.mr.isMergedState); + }, + }, + created() { + this.initPolling(); + this.bindEventHubListeners(); + }, + mounted() { + this.handleMounted(); + }, + methods: { + createService(store) { + const endpoints = { + mergePath: store.mergePath, + mergeCheckPath: store.mergeCheckPath, + cancelAutoMergePath: store.cancelAutoMergePath, + removeWIPPath: store.removeWIPPath, + sourceBranchPath: store.sourceBranchPath, + ciEnvironmentsStatusPath: store.ciEnvironmentsStatusPath, + statusPath: store.statusPath, + mergeActionsContentPath: store.mergeActionsContentPath, + rebasePath: store.rebasePath, + }; + return new MRWidgetService(endpoints); + }, + checkStatus(cb) { + return this.service.checkStatus() + .then(res => res.data) + .then((data) => { + this.handleNotification(data); + this.mr.setData(data); + this.setFaviconHelper(); + + if (cb) { + cb.call(null, data); + } + }) + .catch(() => createFlash('Something went wrong. Please try again.')); + }, + initPolling() { + this.pollingInterval = new SmartInterval({ + callback: this.checkStatus, + startingInterval: 10000, + maxInterval: 30000, + hiddenInterval: 120000, + incrementByFactorOf: 5000, + }); + }, + initDeploymentsPolling() { + this.deploymentsInterval = new SmartInterval({ + callback: this.fetchDeployments, + startingInterval: 30000, + maxInterval: 120000, + hiddenInterval: 240000, + incrementByFactorOf: 15000, + immediateExecution: true, + }); + }, + setFaviconHelper() { + if (this.mr.ciStatusFaviconPath) { + setFavicon(this.mr.ciStatusFaviconPath); + } + }, + fetchDeployments() { + return this.service.fetchDeployments() + .then(res => res.data) + .then((data) => { + if (data.length) { + this.mr.deployments = data; + } + }) + .catch(() => { + createFlash('Something went wrong while fetching the environments for this merge request. Please try again.'); // eslint-disable-line + }); + }, + fetchActionsContent() { + this.service.fetchMergeActionsContent() + .then((res) => { + if (res.data) { + const el = document.createElement('div'); + el.innerHTML = res.data; + document.body.appendChild(el); + Project.initRefSwitcher(); + } + }) + .catch(() => createFlash('Something went wrong. Please try again.')); + }, + handleNotification(data) { + if (data.ci_status === this.mr.ciStatus) return; + if (!data.pipeline) return; + + const label = data.pipeline.details.status.label; + const title = `Pipeline ${label}`; + const message = `Pipeline ${label} for "${data.title}"`; + + notify.notifyMe(title, message, this.mr.gitlabLogo); + }, + resumePolling() { + this.pollingInterval.resume(); + }, + stopPolling() { + this.pollingInterval.stopTimer(); + }, + bindEventHubListeners() { + eventHub.$on('MRWidgetUpdateRequested', (cb) => { + this.checkStatus(cb); + }); + + // `params` should be an Array contains a Boolean, like `[true]` + // Passing parameter as Boolean didn't work. + eventHub.$on('SetBranchRemoveFlag', (params) => { + this.mr.isRemovingSourceBranch = params[0]; + }); + + eventHub.$on('FailedToMerge', (mergeError) => { + this.mr.state = 'failedToMerge'; + this.mr.mergeError = mergeError; + }); + + eventHub.$on('UpdateWidgetData', (data) => { + this.mr.setData(data); + }); + + eventHub.$on('FetchActionsContent', () => { + this.fetchActionsContent(); + }); + + eventHub.$on('EnablePolling', () => { + this.resumePolling(); + }); + + eventHub.$on('DisablePolling', () => { + this.stopPolling(); + }); + }, + handleMounted() { + this.setFaviconHelper(); + this.initDeploymentsPolling(); + }, + }, +}; +</script> +<template> + <div class="mr-state-widget prepend-top-default"> + <mr-widget-header + :mr="mr" + /> + <mr-widget-pipeline + v-if="shouldRenderPipelines" + :pipeline="mr.pipeline" + :ci-status="mr.ciStatus" + :has-ci="mr.hasCI" + /> + <deployment + v-for="deployment in mr.deployments" + :key="deployment.id" + :deployment="deployment" + /> + <div class="mr-widget-section"> + <component + :is="componentName" + :mr="mr" + :service="service" + /> + + <section + v-if="mr.maintainerEditAllowed" + class="mr-info-list mr-links" + > + {{ s__("mrWidget|Allows edits from maintainers") }} + </section> + + <mr-widget-related-links + v-if="shouldRenderRelatedLinks" + :state="mr.state" + :related-links="mr.relatedLinks" + /> + + <source-branch-removal-status + v-if="shouldRenderSourceBranchRemovalStatus" + /> + </div> + <div + class="mr-widget-footer" + v-if="shouldRenderMergeHelp" + > + <mr-widget-merge-help /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index a47ca9fae86..83b7b054e6f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -20,6 +20,7 @@ export default class MergeRequestStore { this.sourceBranch = data.source_branch; this.mergeStatus = data.merge_status; this.commitMessage = data.merge_commit_message; + this.shortMergeCommitSha = data.short_merge_commit_sha; this.commitMessageWithDescription = data.merge_commit_message_with_description; this.commitsCount = data.commits_count; this.divergedCommitsCount = data.diverged_commits_count; @@ -65,6 +66,7 @@ export default class MergeRequestStore { this.createIssueToResolveDiscussionsPath = data.create_issue_to_resolve_discussions_path; this.mergeCheckPath = data.merge_check_path; this.mergeActionsContentPath = data.commit_change_content_path; + this.mergeCommitPath = data.merge_commit_path; this.isRemovingSourceBranch = this.isRemovingSourceBranch || false; this.isOpen = data.state === 'opened'; this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false; diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue index 1a0df49bc29..c42c4a1fbe7 100644 --- a/app/assets/javascripts/vue_shared/components/icon.vue +++ b/app/assets/javascripts/vue_shared/components/icon.vue @@ -65,6 +65,9 @@ export default { spriteHref() { return `${gon.sprite_icons}#${this.name}`; }, + iconTestClass() { + return `ic-${this.name}`; + }, iconSizeClass() { return this.size ? `s${this.size}` : ''; }, @@ -74,7 +77,7 @@ export default { <template> <svg - :class="[iconSizeClass, cssClasses]" + :class="[iconSizeClass, iconTestClass, cssClasses]" :width="width" :height="height" :x="x" diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue index e832d94d32f..88c13a1f340 100644 --- a/app/assets/javascripts/vue_shared/components/loading_button.vue +++ b/app/assets/javascripts/vue_shared/components/loading_button.vue @@ -70,12 +70,14 @@ /> </transition> <transition name="fade"> - <span - v-if="label" - class="js-loading-button-label" - > - {{ label }} - </span> + <slot> + <span + v-if="label" + class="js-loading-button-label" + > + {{ label }} + </span> + </slot> </transition> </button> </template> diff --git a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue index b33a0101dbf..92d187e24bf 100644 --- a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue @@ -1,53 +1,53 @@ <script> - import $ from 'jquery'; +import $ from 'jquery'; - /** - * Given an array of tabs, renders non linked bootstrap tabs. - * When a tab is clicked it will trigger an event and provide the clicked scope. - * - * This component is used in apps that handle the API call. - * If you only need to change the URL this component should not be used. - * - * @example - * <navigation-tabs - * :tabs="[ - * { - * name: String, - * scope: String, - * count: Number || Undefined, - * isActive: Boolean, - * }, - * ]" - * @onChangeTab="onChangeTab" - * /> - */ - export default { - name: 'NavigationTabs', - props: { - tabs: { - type: Array, - required: true, - }, - scope: { - type: String, - required: false, - default: '', - }, +/** + * Given an array of tabs, renders non linked bootstrap tabs. + * When a tab is clicked it will trigger an event and provide the clicked scope. + * + * This component is used in apps that handle the API call. + * If you only need to change the URL this component should not be used. + * + * @example + * <navigation-tabs + * :tabs="[ + * { + * name: String, + * scope: String, + * count: Number || Undefined || Null, + * isActive: Boolean, + * }, + * ]" + * @onChangeTab="onChangeTab" + * /> + */ +export default { + name: 'NavigationTabs', + props: { + tabs: { + type: Array, + required: true, }, - mounted() { - $(document).trigger('init.scrolling-tabs'); + scope: { + type: String, + required: false, + default: '', + }, + }, + mounted() { + $(document).trigger('init.scrolling-tabs'); + }, + methods: { + shouldRenderBadge(count) { + // 0 is valid in a badge, but evaluates to false, we need to check for undefined or null + return !(count === undefined || count === null); }, - methods: { - shouldRenderBadge(count) { - // 0 is valid in a badge, but evaluates to false, we need to check for undefined - return count !== undefined; - }, - onTabClick(tab) { - this.$emit('onChangeTab', tab.scope); - }, + onTabClick(tab) { + this.$emit('onChangeTab', tab.scope); }, - }; + }, +}; </script> <template> <ul class="nav-links scrolling-tabs separator"> diff --git a/app/assets/stylesheets/emoji_sprites.scss b/app/assets/stylesheets/emoji_sprites.scss new file mode 100644 index 00000000000..8f6134c474b --- /dev/null +++ b/app/assets/stylesheets/emoji_sprites.scss @@ -0,0 +1,5403 @@ +// Automatic Prettier Formatting for this big file +// scss-lint:disable EmptyLineBetweenBlocks +.emoji-zzz { + background-position: 0 0; +} +.emoji-1234 { + background-position: -20px 0; +} +.emoji-1F627 { + background-position: 0 -20px; +} +.emoji-8ball { + background-position: -20px -20px; +} +.emoji-a { + background-position: -40px 0; +} +.emoji-ab { + background-position: -40px -20px; +} +.emoji-abc { + background-position: 0 -40px; +} +.emoji-abcd { + background-position: -20px -40px; +} +.emoji-accept { + background-position: -40px -40px; +} +.emoji-aerial_tramway { + background-position: -60px 0; +} +.emoji-airplane { + background-position: -60px -20px; +} +.emoji-airplane_arriving { + background-position: -60px -40px; +} +.emoji-airplane_departure { + background-position: 0 -60px; +} +.emoji-airplane_small { + background-position: -20px -60px; +} +.emoji-alarm_clock { + background-position: -40px -60px; +} +.emoji-alembic { + background-position: -60px -60px; +} +.emoji-alien { + background-position: -80px 0; +} +.emoji-ambulance { + background-position: -80px -20px; +} +.emoji-amphora { + background-position: -80px -40px; +} +.emoji-anchor { + background-position: -80px -60px; +} +.emoji-angel { + background-position: 0 -80px; +} +.emoji-angel_tone1 { + background-position: -20px -80px; +} +.emoji-angel_tone2 { + background-position: -40px -80px; +} +.emoji-angel_tone3 { + background-position: -60px -80px; +} +.emoji-angel_tone4 { + background-position: -80px -80px; +} +.emoji-angel_tone5 { + background-position: -100px 0; +} +.emoji-anger { + background-position: -100px -20px; +} +.emoji-anger_right { + background-position: -100px -40px; +} +.emoji-angry { + background-position: -100px -60px; +} +.emoji-ant { + background-position: -100px -80px; +} +.emoji-apple { + background-position: 0 -100px; +} +.emoji-aquarius { + background-position: -20px -100px; +} +.emoji-aries { + background-position: -40px -100px; +} +.emoji-arrow_backward { + background-position: -60px -100px; +} +.emoji-arrow_double_down { + background-position: -80px -100px; +} +.emoji-arrow_double_up { + background-position: -100px -100px; +} +.emoji-arrow_down { + background-position: -120px 0; +} +.emoji-arrow_down_small { + background-position: -120px -20px; +} +.emoji-arrow_forward { + background-position: -120px -40px; +} +.emoji-arrow_heading_down { + background-position: -120px -60px; +} +.emoji-arrow_heading_up { + background-position: -120px -80px; +} +.emoji-arrow_left { + background-position: -120px -100px; +} +.emoji-arrow_lower_left { + background-position: 0 -120px; +} +.emoji-arrow_lower_right { + background-position: -20px -120px; +} +.emoji-arrow_right { + background-position: -40px -120px; +} +.emoji-arrow_right_hook { + background-position: -60px -120px; +} +.emoji-arrow_up { + background-position: -80px -120px; +} +.emoji-arrow_up_down { + background-position: -100px -120px; +} +.emoji-arrow_up_small { + background-position: -120px -120px; +} +.emoji-arrow_upper_left { + background-position: -140px 0; +} +.emoji-arrow_upper_right { + background-position: -140px -20px; +} +.emoji-arrows_clockwise { + background-position: -140px -40px; +} +.emoji-arrows_counterclockwise { + background-position: -140px -60px; +} +.emoji-art { + background-position: -140px -80px; +} +.emoji-articulated_lorry { + background-position: -140px -100px; +} +.emoji-asterisk { + background-position: -140px -120px; +} +.emoji-astonished { + background-position: 0 -140px; +} +.emoji-athletic_shoe { + background-position: -20px -140px; +} +.emoji-atm { + background-position: -40px -140px; +} +.emoji-atom { + background-position: -60px -140px; +} +.emoji-avocado { + background-position: -80px -140px; +} +.emoji-b { + background-position: -100px -140px; +} +.emoji-baby { + background-position: -120px -140px; +} +.emoji-baby_bottle { + background-position: -140px -140px; +} +.emoji-baby_chick { + background-position: -160px 0; +} +.emoji-baby_symbol { + background-position: -160px -20px; +} +.emoji-baby_tone1 { + background-position: -160px -40px; +} +.emoji-baby_tone2 { + background-position: -160px -60px; +} +.emoji-baby_tone3 { + background-position: -160px -80px; +} +.emoji-baby_tone4 { + background-position: -160px -100px; +} +.emoji-baby_tone5 { + background-position: -160px -120px; +} +.emoji-back { + background-position: -160px -140px; +} +.emoji-bacon { + background-position: 0 -160px; +} +.emoji-badminton { + background-position: -20px -160px; +} +.emoji-baggage_claim { + background-position: -40px -160px; +} +.emoji-balloon { + background-position: -60px -160px; +} +.emoji-ballot_box { + background-position: -80px -160px; +} +.emoji-ballot_box_with_check { + background-position: -100px -160px; +} +.emoji-bamboo { + background-position: -120px -160px; +} +.emoji-banana { + background-position: -140px -160px; +} +.emoji-bangbang { + background-position: -160px -160px; +} +.emoji-bank { + background-position: -180px 0; +} +.emoji-bar_chart { + background-position: -180px -20px; +} +.emoji-barber { + background-position: -180px -40px; +} +.emoji-baseball { + background-position: -180px -60px; +} +.emoji-basketball { + background-position: -180px -80px; +} +.emoji-basketball_player { + background-position: -180px -100px; +} +.emoji-basketball_player_tone1 { + background-position: -180px -120px; +} +.emoji-basketball_player_tone2 { + background-position: -180px -140px; +} +.emoji-basketball_player_tone3 { + background-position: -180px -160px; +} +.emoji-basketball_player_tone4 { + background-position: 0 -180px; +} +.emoji-basketball_player_tone5 { + background-position: -20px -180px; +} +.emoji-bat { + background-position: -40px -180px; +} +.emoji-bath { + background-position: -60px -180px; +} +.emoji-bath_tone1 { + background-position: -80px -180px; +} +.emoji-bath_tone2 { + background-position: -100px -180px; +} +.emoji-bath_tone3 { + background-position: -120px -180px; +} +.emoji-bath_tone4 { + background-position: -140px -180px; +} +.emoji-bath_tone5 { + background-position: -160px -180px; +} +.emoji-bathtub { + background-position: -180px -180px; +} +.emoji-battery { + background-position: -200px 0; +} +.emoji-beach { + background-position: -200px -20px; +} +.emoji-beach_umbrella { + background-position: -200px -40px; +} +.emoji-bear { + background-position: -200px -60px; +} +.emoji-bed { + background-position: -200px -80px; +} +.emoji-bee { + background-position: -200px -100px; +} +.emoji-beer { + background-position: -200px -120px; +} +.emoji-beers { + background-position: -200px -140px; +} +.emoji-beetle { + background-position: -200px -160px; +} +.emoji-beginner { + background-position: -200px -180px; +} +.emoji-bell { + background-position: 0 -200px; +} +.emoji-bellhop { + background-position: -20px -200px; +} +.emoji-bento { + background-position: -40px -200px; +} +.emoji-bicyclist { + background-position: -60px -200px; +} +.emoji-bicyclist_tone1 { + background-position: -80px -200px; +} +.emoji-bicyclist_tone2 { + background-position: -100px -200px; +} +.emoji-bicyclist_tone3 { + background-position: -120px -200px; +} +.emoji-bicyclist_tone4 { + background-position: -140px -200px; +} +.emoji-bicyclist_tone5 { + background-position: -160px -200px; +} +.emoji-bike { + background-position: -180px -200px; +} +.emoji-bikini { + background-position: -200px -200px; +} +.emoji-biohazard { + background-position: -220px 0; +} +.emoji-bird { + background-position: -220px -20px; +} +.emoji-birthday { + background-position: -220px -40px; +} +.emoji-black_circle { + background-position: -220px -60px; +} +.emoji-black_heart { + background-position: -220px -80px; +} +.emoji-black_joker { + background-position: -220px -100px; +} +.emoji-black_large_square { + background-position: -220px -120px; +} +.emoji-black_medium_small_square { + background-position: -220px -140px; +} +.emoji-black_medium_square { + background-position: -220px -160px; +} +.emoji-black_nib { + background-position: -220px -180px; +} +.emoji-black_small_square { + background-position: -220px -200px; +} +.emoji-black_square_button { + background-position: 0 -220px; +} +.emoji-blossom { + background-position: -20px -220px; +} +.emoji-blowfish { + background-position: -40px -220px; +} +.emoji-blue_book { + background-position: -60px -220px; +} +.emoji-blue_car { + background-position: -80px -220px; +} +.emoji-blue_heart { + background-position: -100px -220px; +} +.emoji-blush { + background-position: -120px -220px; +} +.emoji-boar { + background-position: -140px -220px; +} +.emoji-bomb { + background-position: -160px -220px; +} +.emoji-book { + background-position: -180px -220px; +} +.emoji-bookmark { + background-position: -200px -220px; +} +.emoji-bookmark_tabs { + background-position: -220px -220px; +} +.emoji-books { + background-position: -240px 0; +} +.emoji-boom { + background-position: -240px -20px; +} +.emoji-boot { + background-position: -240px -40px; +} +.emoji-bouquet { + background-position: -240px -60px; +} +.emoji-bow { + background-position: -240px -80px; +} +.emoji-bow_and_arrow { + background-position: -240px -100px; +} +.emoji-bow_tone1 { + background-position: -240px -120px; +} +.emoji-bow_tone2 { + background-position: -240px -140px; +} +.emoji-bow_tone3 { + background-position: -240px -160px; +} +.emoji-bow_tone4 { + background-position: -240px -180px; +} +.emoji-bow_tone5 { + background-position: -240px -200px; +} +.emoji-bowling { + background-position: -240px -220px; +} +.emoji-boxing_glove { + background-position: 0 -240px; +} +.emoji-boy { + background-position: -20px -240px; +} +.emoji-boy_tone1 { + background-position: -40px -240px; +} +.emoji-boy_tone2 { + background-position: -60px -240px; +} +.emoji-boy_tone3 { + background-position: -80px -240px; +} +.emoji-boy_tone4 { + background-position: -100px -240px; +} +.emoji-boy_tone5 { + background-position: -120px -240px; +} +.emoji-bread { + background-position: -140px -240px; +} +.emoji-bride_with_veil { + background-position: -160px -240px; +} +.emoji-bride_with_veil_tone1 { + background-position: -180px -240px; +} +.emoji-bride_with_veil_tone2 { + background-position: -200px -240px; +} +.emoji-bride_with_veil_tone3 { + background-position: -220px -240px; +} +.emoji-bride_with_veil_tone4 { + background-position: -240px -240px; +} +.emoji-bride_with_veil_tone5 { + background-position: -260px 0; +} +.emoji-bridge_at_night { + background-position: -260px -20px; +} +.emoji-briefcase { + background-position: -260px -40px; +} +.emoji-broken_heart { + background-position: -260px -60px; +} +.emoji-bug { + background-position: -260px -80px; +} +.emoji-bulb { + background-position: -260px -100px; +} +.emoji-bullettrain_front { + background-position: -260px -120px; +} +.emoji-bullettrain_side { + background-position: -260px -140px; +} +.emoji-burrito { + background-position: -260px -160px; +} +.emoji-bus { + background-position: -260px -180px; +} +.emoji-busstop { + background-position: -260px -200px; +} +.emoji-bust_in_silhouette { + background-position: -260px -220px; +} +.emoji-busts_in_silhouette { + background-position: -260px -240px; +} +.emoji-butterfly { + background-position: 0 -260px; +} +.emoji-cactus { + background-position: -20px -260px; +} +.emoji-cake { + background-position: -40px -260px; +} +.emoji-calendar { + background-position: -60px -260px; +} +.emoji-calendar_spiral { + background-position: -80px -260px; +} +.emoji-call_me { + background-position: -100px -260px; +} +.emoji-call_me_tone1 { + background-position: -120px -260px; +} +.emoji-call_me_tone2 { + background-position: -140px -260px; +} +.emoji-call_me_tone3 { + background-position: -160px -260px; +} +.emoji-call_me_tone4 { + background-position: -180px -260px; +} +.emoji-call_me_tone5 { + background-position: -200px -260px; +} +.emoji-calling { + background-position: -220px -260px; +} +.emoji-camel { + background-position: -240px -260px; +} +.emoji-camera { + background-position: -260px -260px; +} +.emoji-camera_with_flash { + background-position: -280px 0; +} +.emoji-camping { + background-position: -280px -20px; +} +.emoji-cancer { + background-position: -280px -40px; +} +.emoji-candle { + background-position: -280px -60px; +} +.emoji-candy { + background-position: -280px -80px; +} +.emoji-canoe { + background-position: -280px -100px; +} +.emoji-capital_abcd { + background-position: -280px -120px; +} +.emoji-capricorn { + background-position: -280px -140px; +} +.emoji-card_box { + background-position: -280px -160px; +} +.emoji-card_index { + background-position: -280px -180px; +} +.emoji-carousel_horse { + background-position: -280px -200px; +} +.emoji-carrot { + background-position: -280px -220px; +} +.emoji-cartwheel { + background-position: -280px -240px; +} +.emoji-cartwheel_tone1 { + background-position: -280px -260px; +} +.emoji-cartwheel_tone2 { + background-position: 0 -280px; +} +.emoji-cartwheel_tone3 { + background-position: -20px -280px; +} +.emoji-cartwheel_tone4 { + background-position: -40px -280px; +} +.emoji-cartwheel_tone5 { + background-position: -60px -280px; +} +.emoji-cat { + background-position: -80px -280px; +} +.emoji-cat2 { + background-position: -100px -280px; +} +.emoji-cd { + background-position: -120px -280px; +} +.emoji-chains { + background-position: -140px -280px; +} +.emoji-champagne { + background-position: -160px -280px; +} +.emoji-champagne_glass { + background-position: -180px -280px; +} +.emoji-chart { + background-position: -200px -280px; +} +.emoji-chart_with_downwards_trend { + background-position: -220px -280px; +} +.emoji-chart_with_upwards_trend { + background-position: -240px -280px; +} +.emoji-checkered_flag { + background-position: -260px -280px; +} +.emoji-cheese { + background-position: -280px -280px; +} +.emoji-cherries { + background-position: -300px 0; +} +.emoji-cherry_blossom { + background-position: -300px -20px; +} +.emoji-chestnut { + background-position: -300px -40px; +} +.emoji-chicken { + background-position: -300px -60px; +} +.emoji-children_crossing { + background-position: -300px -80px; +} +.emoji-chipmunk { + background-position: -300px -100px; +} +.emoji-chocolate_bar { + background-position: -300px -120px; +} +.emoji-christmas_tree { + background-position: -300px -140px; +} +.emoji-church { + background-position: -300px -160px; +} +.emoji-cinema { + background-position: -300px -180px; +} +.emoji-circus_tent { + background-position: -300px -200px; +} +.emoji-city_dusk { + background-position: -300px -220px; +} +.emoji-city_sunset { + background-position: -300px -240px; +} +.emoji-cityscape { + background-position: -300px -260px; +} +.emoji-cl { + background-position: -300px -280px; +} +.emoji-clap { + background-position: 0 -300px; +} +.emoji-clap_tone1 { + background-position: -20px -300px; +} +.emoji-clap_tone2 { + background-position: -40px -300px; +} +.emoji-clap_tone3 { + background-position: -60px -300px; +} +.emoji-clap_tone4 { + background-position: -80px -300px; +} +.emoji-clap_tone5 { + background-position: -100px -300px; +} +.emoji-clapper { + background-position: -120px -300px; +} +.emoji-classical_building { + background-position: -140px -300px; +} +.emoji-clipboard { + background-position: -160px -300px; +} +.emoji-clock { + background-position: -180px -300px; +} +.emoji-clock1 { + background-position: -200px -300px; +} +.emoji-clock10 { + background-position: -220px -300px; +} +.emoji-clock1030 { + background-position: -240px -300px; +} +.emoji-clock11 { + background-position: -260px -300px; +} +.emoji-clock1130 { + background-position: -280px -300px; +} +.emoji-clock12 { + background-position: -300px -300px; +} +.emoji-clock1230 { + background-position: -320px 0; +} +.emoji-clock130 { + background-position: -320px -20px; +} +.emoji-clock2 { + background-position: -320px -40px; +} +.emoji-clock230 { + background-position: -320px -60px; +} +.emoji-clock3 { + background-position: -320px -80px; +} +.emoji-clock330 { + background-position: -320px -100px; +} +.emoji-clock4 { + background-position: -320px -120px; +} +.emoji-clock430 { + background-position: -320px -140px; +} +.emoji-clock5 { + background-position: -320px -160px; +} +.emoji-clock530 { + background-position: -320px -180px; +} +.emoji-clock6 { + background-position: -320px -200px; +} +.emoji-clock630 { + background-position: -320px -220px; +} +.emoji-clock7 { + background-position: -320px -240px; +} +.emoji-clock730 { + background-position: -320px -260px; +} +.emoji-clock8 { + background-position: -320px -280px; +} +.emoji-clock830 { + background-position: -320px -300px; +} +.emoji-clock9 { + background-position: 0 -320px; +} +.emoji-clock930 { + background-position: -20px -320px; +} +.emoji-closed_book { + background-position: -40px -320px; +} +.emoji-closed_lock_with_key { + background-position: -60px -320px; +} +.emoji-closed_umbrella { + background-position: -80px -320px; +} +.emoji-cloud { + background-position: -100px -320px; +} +.emoji-cloud_lightning { + background-position: -120px -320px; +} +.emoji-cloud_rain { + background-position: -140px -320px; +} +.emoji-cloud_snow { + background-position: -160px -320px; +} +.emoji-cloud_tornado { + background-position: -180px -320px; +} +.emoji-clown { + background-position: -200px -320px; +} +.emoji-clubs { + background-position: -220px -320px; +} +.emoji-cocktail { + background-position: -240px -320px; +} +.emoji-coffee { + background-position: -260px -320px; +} +.emoji-coffin { + background-position: -280px -320px; +} +.emoji-cold_sweat { + background-position: -300px -320px; +} +.emoji-comet { + background-position: -320px -320px; +} +.emoji-compression { + background-position: -340px 0; +} +.emoji-computer { + background-position: -340px -20px; +} +.emoji-confetti_ball { + background-position: -340px -40px; +} +.emoji-confounded { + background-position: -340px -60px; +} +.emoji-confused { + background-position: -340px -80px; +} +.emoji-congratulations { + background-position: -340px -100px; +} +.emoji-construction { + background-position: -340px -120px; +} +.emoji-construction_site { + background-position: -340px -140px; +} +.emoji-construction_worker { + background-position: -340px -160px; +} +.emoji-construction_worker_tone1 { + background-position: -340px -180px; +} +.emoji-construction_worker_tone2 { + background-position: -340px -200px; +} +.emoji-construction_worker_tone3 { + background-position: -340px -220px; +} +.emoji-construction_worker_tone4 { + background-position: -340px -240px; +} +.emoji-construction_worker_tone5 { + background-position: -340px -260px; +} +.emoji-control_knobs { + background-position: -340px -280px; +} +.emoji-convenience_store { + background-position: -340px -300px; +} +.emoji-cookie { + background-position: -340px -320px; +} +.emoji-cooking { + background-position: 0 -340px; +} +.emoji-cool { + background-position: -20px -340px; +} +.emoji-cop { + background-position: -40px -340px; +} +.emoji-cop_tone1 { + background-position: -60px -340px; +} +.emoji-cop_tone2 { + background-position: -80px -340px; +} +.emoji-cop_tone3 { + background-position: -100px -340px; +} +.emoji-cop_tone4 { + background-position: -120px -340px; +} +.emoji-cop_tone5 { + background-position: -140px -340px; +} +.emoji-copyright { + background-position: -160px -340px; +} +.emoji-corn { + background-position: -180px -340px; +} +.emoji-couch { + background-position: -200px -340px; +} +.emoji-couple { + background-position: -220px -340px; +} +.emoji-couple_mm { + background-position: -240px -340px; +} +.emoji-couple_with_heart { + background-position: -260px -340px; +} +.emoji-couple_ww { + background-position: -280px -340px; +} +.emoji-couplekiss { + background-position: -300px -340px; +} +.emoji-cow { + background-position: -320px -340px; +} +.emoji-cow2 { + background-position: -340px -340px; +} +.emoji-cowboy { + background-position: -360px 0; +} +.emoji-crab { + background-position: -360px -20px; +} +.emoji-crayon { + background-position: -360px -40px; +} +.emoji-credit_card { + background-position: -360px -60px; +} +.emoji-crescent_moon { + background-position: -360px -80px; +} +.emoji-cricket { + background-position: -360px -100px; +} +.emoji-crocodile { + background-position: -360px -120px; +} +.emoji-croissant { + background-position: -360px -140px; +} +.emoji-cross { + background-position: -360px -160px; +} +.emoji-crossed_flags { + background-position: -360px -180px; +} +.emoji-crossed_swords { + background-position: -360px -200px; +} +.emoji-crown { + background-position: -360px -220px; +} +.emoji-cruise_ship { + background-position: -360px -240px; +} +.emoji-cry { + background-position: -360px -260px; +} +.emoji-crying_cat_face { + background-position: -360px -280px; +} +.emoji-crystal_ball { + background-position: -360px -300px; +} +.emoji-cucumber { + background-position: -360px -320px; +} +.emoji-cupid { + background-position: -360px -340px; +} +.emoji-curly_loop { + background-position: 0 -360px; +} +.emoji-currency_exchange { + background-position: -20px -360px; +} +.emoji-curry { + background-position: -40px -360px; +} +.emoji-custard { + background-position: -60px -360px; +} +.emoji-customs { + background-position: -80px -360px; +} +.emoji-cyclone { + background-position: -100px -360px; +} +.emoji-dagger { + background-position: -120px -360px; +} +.emoji-dancer { + background-position: -140px -360px; +} +.emoji-dancer_tone1 { + background-position: -160px -360px; +} +.emoji-dancer_tone2 { + background-position: -180px -360px; +} +.emoji-dancer_tone3 { + background-position: -200px -360px; +} +.emoji-dancer_tone4 { + background-position: -220px -360px; +} +.emoji-dancer_tone5 { + background-position: -240px -360px; +} +.emoji-dancers { + background-position: -260px -360px; +} +.emoji-dango { + background-position: -280px -360px; +} +.emoji-dark_sunglasses { + background-position: -300px -360px; +} +.emoji-dart { + background-position: -320px -360px; +} +.emoji-dash { + background-position: -340px -360px; +} +.emoji-date { + background-position: -360px -360px; +} +.emoji-deciduous_tree { + background-position: -380px 0; +} +.emoji-deer { + background-position: -380px -20px; +} +.emoji-department_store { + background-position: -380px -40px; +} +.emoji-desert { + background-position: -380px -60px; +} +.emoji-desktop { + background-position: -380px -80px; +} +.emoji-diamond_shape_with_a_dot_inside { + background-position: -380px -100px; +} +.emoji-diamonds { + background-position: -380px -120px; +} +.emoji-disappointed { + background-position: -380px -140px; +} +.emoji-disappointed_relieved { + background-position: -380px -160px; +} +.emoji-dividers { + background-position: -380px -180px; +} +.emoji-dizzy { + background-position: -380px -200px; +} +.emoji-dizzy_face { + background-position: -380px -220px; +} +.emoji-do_not_litter { + background-position: -380px -240px; +} +.emoji-dog { + background-position: -380px -260px; +} +.emoji-dog2 { + background-position: -380px -280px; +} +.emoji-dollar { + background-position: -380px -300px; +} +.emoji-dolls { + background-position: -380px -320px; +} +.emoji-dolphin { + background-position: -380px -340px; +} +.emoji-door { + background-position: -380px -360px; +} +.emoji-doughnut { + background-position: 0 -380px; +} +.emoji-dove { + background-position: -20px -380px; +} +.emoji-dragon { + background-position: -40px -380px; +} +.emoji-dragon_face { + background-position: -60px -380px; +} +.emoji-dress { + background-position: -80px -380px; +} +.emoji-dromedary_camel { + background-position: -100px -380px; +} +.emoji-drooling_face { + background-position: -120px -380px; +} +.emoji-droplet { + background-position: -140px -380px; +} +.emoji-drum { + background-position: -160px -380px; +} +.emoji-duck { + background-position: -180px -380px; +} +.emoji-dvd { + background-position: -200px -380px; +} +.emoji-e-mail { + background-position: -220px -380px; +} +.emoji-eagle { + background-position: -240px -380px; +} +.emoji-ear { + background-position: -260px -380px; +} +.emoji-ear_of_rice { + background-position: -280px -380px; +} +.emoji-ear_tone1 { + background-position: -300px -380px; +} +.emoji-ear_tone2 { + background-position: -320px -380px; +} +.emoji-ear_tone3 { + background-position: -340px -380px; +} +.emoji-ear_tone4 { + background-position: -360px -380px; +} +.emoji-ear_tone5 { + background-position: -380px -380px; +} +.emoji-earth_africa { + background-position: -400px 0; +} +.emoji-earth_americas { + background-position: -400px -20px; +} +.emoji-earth_asia { + background-position: -400px -40px; +} +.emoji-egg { + background-position: -400px -60px; +} +.emoji-eggplant { + background-position: -400px -80px; +} +.emoji-eight { + background-position: -400px -100px; +} +.emoji-eight_pointed_black_star { + background-position: -400px -120px; +} +.emoji-eight_spoked_asterisk { + background-position: -400px -140px; +} +.emoji-eject { + background-position: -400px -160px; +} +.emoji-electric_plug { + background-position: -400px -180px; +} +.emoji-elephant { + background-position: -400px -200px; +} +.emoji-end { + background-position: -400px -220px; +} +.emoji-envelope { + background-position: -400px -240px; +} +.emoji-envelope_with_arrow { + background-position: -400px -260px; +} +.emoji-euro { + background-position: -400px -280px; +} +.emoji-european_castle { + background-position: -400px -300px; +} +.emoji-european_post_office { + background-position: -400px -320px; +} +.emoji-evergreen_tree { + background-position: -400px -340px; +} +.emoji-exclamation { + background-position: -400px -360px; +} +.emoji-expressionless { + background-position: -400px -380px; +} +.emoji-eye { + background-position: 0 -400px; +} +.emoji-eye_in_speech_bubble { + background-position: -20px -400px; +} +.emoji-eyeglasses { + background-position: -40px -400px; +} +.emoji-eyes { + background-position: -60px -400px; +} +.emoji-face_palm { + background-position: -80px -400px; +} +.emoji-face_palm_tone1 { + background-position: -100px -400px; +} +.emoji-face_palm_tone2 { + background-position: -120px -400px; +} +.emoji-face_palm_tone3 { + background-position: -140px -400px; +} +.emoji-face_palm_tone4 { + background-position: -160px -400px; +} +.emoji-face_palm_tone5 { + background-position: -180px -400px; +} +.emoji-factory { + background-position: -200px -400px; +} +.emoji-fallen_leaf { + background-position: -220px -400px; +} +.emoji-family { + background-position: -240px -400px; +} +.emoji-family_mmb { + background-position: -260px -400px; +} +.emoji-family_mmbb { + background-position: -280px -400px; +} +.emoji-family_mmg { + background-position: -300px -400px; +} +.emoji-family_mmgb { + background-position: -320px -400px; +} +.emoji-family_mmgg { + background-position: -340px -400px; +} +.emoji-family_mwbb { + background-position: -360px -400px; +} +.emoji-family_mwg { + background-position: -380px -400px; +} +.emoji-family_mwgb { + background-position: -400px -400px; +} +.emoji-family_mwgg { + background-position: -420px 0; +} +.emoji-family_wwb { + background-position: -420px -20px; +} +.emoji-family_wwbb { + background-position: -420px -40px; +} +.emoji-family_wwg { + background-position: -420px -60px; +} +.emoji-family_wwgb { + background-position: -420px -80px; +} +.emoji-family_wwgg { + background-position: -420px -100px; +} +.emoji-fast_forward { + background-position: -420px -120px; +} +.emoji-fax { + background-position: -420px -140px; +} +.emoji-fearful { + background-position: -420px -160px; +} +.emoji-feet { + background-position: -420px -180px; +} +.emoji-fencer { + background-position: -420px -200px; +} +.emoji-ferris_wheel { + background-position: -420px -220px; +} +.emoji-ferry { + background-position: -420px -240px; +} +.emoji-field_hockey { + background-position: -420px -260px; +} +.emoji-file_cabinet { + background-position: -420px -280px; +} +.emoji-file_folder { + background-position: -420px -300px; +} +.emoji-film_frames { + background-position: -420px -320px; +} +.emoji-fingers_crossed { + background-position: -420px -340px; +} +.emoji-fingers_crossed_tone1 { + background-position: -420px -360px; +} +.emoji-fingers_crossed_tone2 { + background-position: -420px -380px; +} +.emoji-fingers_crossed_tone3 { + background-position: -420px -400px; +} +.emoji-fingers_crossed_tone4 { + background-position: 0 -420px; +} +.emoji-fingers_crossed_tone5 { + background-position: -20px -420px; +} +.emoji-fire { + background-position: -40px -420px; +} +.emoji-fire_engine { + background-position: -60px -420px; +} +.emoji-fireworks { + background-position: -80px -420px; +} +.emoji-first_place { + background-position: -100px -420px; +} +.emoji-first_quarter_moon { + background-position: -120px -420px; +} +.emoji-first_quarter_moon_with_face { + background-position: -140px -420px; +} +.emoji-fish { + background-position: -160px -420px; +} +.emoji-fish_cake { + background-position: -180px -420px; +} +.emoji-fishing_pole_and_fish { + background-position: -200px -420px; +} +.emoji-fist { + background-position: -220px -420px; +} +.emoji-fist_tone1 { + background-position: -240px -420px; +} +.emoji-fist_tone2 { + background-position: -260px -420px; +} +.emoji-fist_tone3 { + background-position: -280px -420px; +} +.emoji-fist_tone4 { + background-position: -300px -420px; +} +.emoji-fist_tone5 { + background-position: -320px -420px; +} +.emoji-five { + background-position: -340px -420px; +} +.emoji-flag_ac { + background-position: -360px -420px; +} +.emoji-flag_ad { + background-position: -380px -420px; +} +.emoji-flag_ae { + background-position: -400px -420px; +} +.emoji-flag_af { + background-position: -420px -420px; +} +.emoji-flag_ag { + background-position: -440px 0; +} +.emoji-flag_ai { + background-position: -440px -20px; +} +.emoji-flag_al { + background-position: -440px -40px; +} +.emoji-flag_am { + background-position: -440px -60px; +} +.emoji-flag_ao { + background-position: -440px -80px; +} +.emoji-flag_aq { + background-position: -440px -100px; +} +.emoji-flag_ar { + background-position: -440px -120px; +} +.emoji-flag_as { + background-position: -440px -140px; +} +.emoji-flag_at { + background-position: -440px -160px; +} +.emoji-flag_au { + background-position: -440px -180px; +} +.emoji-flag_aw { + background-position: -440px -200px; +} +.emoji-flag_ax { + background-position: -440px -220px; +} +.emoji-flag_az { + background-position: -440px -240px; +} +.emoji-flag_ba { + background-position: -440px -260px; +} +.emoji-flag_bb { + background-position: -440px -280px; +} +.emoji-flag_bd { + background-position: -440px -300px; +} +.emoji-flag_be { + background-position: -440px -320px; +} +.emoji-flag_bf { + background-position: -440px -340px; +} +.emoji-flag_bg { + background-position: -440px -360px; +} +.emoji-flag_bh { + background-position: -440px -380px; +} +.emoji-flag_bi { + background-position: -440px -400px; +} +.emoji-flag_bj { + background-position: -440px -420px; +} +.emoji-flag_bl { + background-position: 0 -440px; +} +.emoji-flag_black { + background-position: -20px -440px; +} +.emoji-flag_bm { + background-position: -40px -440px; +} +.emoji-flag_bn { + background-position: -60px -440px; +} +.emoji-flag_bo { + background-position: -80px -440px; +} +.emoji-flag_bq { + background-position: -100px -440px; +} +.emoji-flag_br { + background-position: -120px -440px; +} +.emoji-flag_bs { + background-position: -140px -440px; +} +.emoji-flag_bt { + background-position: -160px -440px; +} +.emoji-flag_bv { + background-position: -180px -440px; +} +.emoji-flag_bw { + background-position: -200px -440px; +} +.emoji-flag_by { + background-position: -220px -440px; +} +.emoji-flag_bz { + background-position: -240px -440px; +} +.emoji-flag_ca { + background-position: -260px -440px; +} +.emoji-flag_cc { + background-position: -280px -440px; +} +.emoji-flag_cd { + background-position: -300px -440px; +} +.emoji-flag_cf { + background-position: -320px -440px; +} +.emoji-flag_cg { + background-position: -340px -440px; +} +.emoji-flag_ch { + background-position: -360px -440px; +} +.emoji-flag_ci { + background-position: -380px -440px; +} +.emoji-flag_ck { + background-position: -400px -440px; +} +.emoji-flag_cl { + background-position: -420px -440px; +} +.emoji-flag_cm { + background-position: -440px -440px; +} +.emoji-flag_cn { + background-position: -460px 0; +} +.emoji-flag_co { + background-position: -460px -20px; +} +.emoji-flag_cp { + background-position: -460px -40px; +} +.emoji-flag_cr { + background-position: -460px -60px; +} +.emoji-flag_cu { + background-position: -460px -80px; +} +.emoji-flag_cv { + background-position: -460px -100px; +} +.emoji-flag_cw { + background-position: -460px -120px; +} +.emoji-flag_cx { + background-position: -460px -140px; +} +.emoji-flag_cy { + background-position: -460px -160px; +} +.emoji-flag_cz { + background-position: -460px -180px; +} +.emoji-flag_de { + background-position: -460px -200px; +} +.emoji-flag_dg { + background-position: -460px -220px; +} +.emoji-flag_dj { + background-position: -460px -240px; +} +.emoji-flag_dk { + background-position: -460px -260px; +} +.emoji-flag_dm { + background-position: -460px -280px; +} +.emoji-flag_do { + background-position: -460px -300px; +} +.emoji-flag_dz { + background-position: -460px -320px; +} +.emoji-flag_ea { + background-position: -460px -340px; +} +.emoji-flag_ec { + background-position: -460px -360px; +} +.emoji-flag_ee { + background-position: -460px -380px; +} +.emoji-flag_eg { + background-position: -460px -400px; +} +.emoji-flag_eh { + background-position: -460px -420px; +} +.emoji-flag_er { + background-position: -460px -440px; +} +.emoji-flag_es { + background-position: 0 -460px; +} +.emoji-flag_et { + background-position: -20px -460px; +} +.emoji-flag_eu { + background-position: -40px -460px; +} +.emoji-flag_fi { + background-position: -60px -460px; +} +.emoji-flag_fj { + background-position: -80px -460px; +} +.emoji-flag_fk { + background-position: -100px -460px; +} +.emoji-flag_fm { + background-position: -120px -460px; +} +.emoji-flag_fo { + background-position: -140px -460px; +} +.emoji-flag_fr { + background-position: -160px -460px; +} +.emoji-flag_ga { + background-position: -180px -460px; +} +.emoji-flag_gb { + background-position: -200px -460px; +} +.emoji-flag_gd { + background-position: -220px -460px; +} +.emoji-flag_ge { + background-position: -240px -460px; +} +.emoji-flag_gf { + background-position: -260px -460px; +} +.emoji-flag_gg { + background-position: -280px -460px; +} +.emoji-flag_gh { + background-position: -300px -460px; +} +.emoji-flag_gi { + background-position: -320px -460px; +} +.emoji-flag_gl { + background-position: -340px -460px; +} +.emoji-flag_gm { + background-position: -360px -460px; +} +.emoji-flag_gn { + background-position: -380px -460px; +} +.emoji-flag_gp { + background-position: -400px -460px; +} +.emoji-flag_gq { + background-position: -420px -460px; +} +.emoji-flag_gr { + background-position: -440px -460px; +} +.emoji-flag_gs { + background-position: -460px -460px; +} +.emoji-flag_gt { + background-position: -480px 0; +} +.emoji-flag_gu { + background-position: -480px -20px; +} +.emoji-flag_gw { + background-position: -480px -40px; +} +.emoji-flag_gy { + background-position: -480px -60px; +} +.emoji-flag_hk { + background-position: -480px -80px; +} +.emoji-flag_hm { + background-position: -480px -100px; +} +.emoji-flag_hn { + background-position: -480px -120px; +} +.emoji-flag_hr { + background-position: -480px -140px; +} +.emoji-flag_ht { + background-position: -480px -160px; +} +.emoji-flag_hu { + background-position: -480px -180px; +} +.emoji-flag_ic { + background-position: -480px -200px; +} +.emoji-flag_id { + background-position: -480px -220px; +} +.emoji-flag_ie { + background-position: -480px -240px; +} +.emoji-flag_il { + background-position: -480px -260px; +} +.emoji-flag_im { + background-position: -480px -280px; +} +.emoji-flag_in { + background-position: -480px -300px; +} +.emoji-flag_io { + background-position: -480px -320px; +} +.emoji-flag_iq { + background-position: -480px -340px; +} +.emoji-flag_ir { + background-position: -480px -360px; +} +.emoji-flag_is { + background-position: -480px -380px; +} +.emoji-flag_it { + background-position: -480px -400px; +} +.emoji-flag_je { + background-position: -480px -420px; +} +.emoji-flag_jm { + background-position: -480px -440px; +} +.emoji-flag_jo { + background-position: -480px -460px; +} +.emoji-flag_jp { + background-position: 0 -480px; +} +.emoji-flag_ke { + background-position: -20px -480px; +} +.emoji-flag_kg { + background-position: -40px -480px; +} +.emoji-flag_kh { + background-position: -60px -480px; +} +.emoji-flag_ki { + background-position: -80px -480px; +} +.emoji-flag_km { + background-position: -100px -480px; +} +.emoji-flag_kn { + background-position: -120px -480px; +} +.emoji-flag_kp { + background-position: -140px -480px; +} +.emoji-flag_kr { + background-position: -160px -480px; +} +.emoji-flag_kw { + background-position: -180px -480px; +} +.emoji-flag_ky { + background-position: -200px -480px; +} +.emoji-flag_kz { + background-position: -220px -480px; +} +.emoji-flag_la { + background-position: -240px -480px; +} +.emoji-flag_lb { + background-position: -260px -480px; +} +.emoji-flag_lc { + background-position: -280px -480px; +} +.emoji-flag_li { + background-position: -300px -480px; +} +.emoji-flag_lk { + background-position: -320px -480px; +} +.emoji-flag_lr { + background-position: -340px -480px; +} +.emoji-flag_ls { + background-position: -360px -480px; +} +.emoji-flag_lt { + background-position: -380px -480px; +} +.emoji-flag_lu { + background-position: -400px -480px; +} +.emoji-flag_lv { + background-position: -420px -480px; +} +.emoji-flag_ly { + background-position: -440px -480px; +} +.emoji-flag_ma { + background-position: -460px -480px; +} +.emoji-flag_mc { + background-position: -480px -480px; +} +.emoji-flag_md { + background-position: -500px 0; +} +.emoji-flag_me { + background-position: -500px -20px; +} +.emoji-flag_mf { + background-position: -500px -40px; +} +.emoji-flag_mg { + background-position: -500px -60px; +} +.emoji-flag_mh { + background-position: -500px -80px; +} +.emoji-flag_mk { + background-position: -500px -100px; +} +.emoji-flag_ml { + background-position: -500px -120px; +} +.emoji-flag_mm { + background-position: -500px -140px; +} +.emoji-flag_mn { + background-position: -500px -160px; +} +.emoji-flag_mo { + background-position: -500px -180px; +} +.emoji-flag_mp { + background-position: -500px -200px; +} +.emoji-flag_mq { + background-position: -500px -220px; +} +.emoji-flag_mr { + background-position: -500px -240px; +} +.emoji-flag_ms { + background-position: -500px -260px; +} +.emoji-flag_mt { + background-position: -500px -280px; +} +.emoji-flag_mu { + background-position: -500px -300px; +} +.emoji-flag_mv { + background-position: -500px -320px; +} +.emoji-flag_mw { + background-position: -500px -340px; +} +.emoji-flag_mx { + background-position: -500px -360px; +} +.emoji-flag_my { + background-position: -500px -380px; +} +.emoji-flag_mz { + background-position: -500px -400px; +} +.emoji-flag_na { + background-position: -500px -420px; +} +.emoji-flag_nc { + background-position: -500px -440px; +} +.emoji-flag_ne { + background-position: -500px -460px; +} +.emoji-flag_nf { + background-position: -500px -480px; +} +.emoji-flag_ng { + background-position: 0 -500px; +} +.emoji-flag_ni { + background-position: -20px -500px; +} +.emoji-flag_nl { + background-position: -40px -500px; +} +.emoji-flag_no { + background-position: -60px -500px; +} +.emoji-flag_np { + background-position: -80px -500px; +} +.emoji-flag_nr { + background-position: -100px -500px; +} +.emoji-flag_nu { + background-position: -120px -500px; +} +.emoji-flag_nz { + background-position: -140px -500px; +} +.emoji-flag_om { + background-position: -160px -500px; +} +.emoji-flag_pa { + background-position: -180px -500px; +} +.emoji-flag_pe { + background-position: -200px -500px; +} +.emoji-flag_pf { + background-position: -220px -500px; +} +.emoji-flag_pg { + background-position: -240px -500px; +} +.emoji-flag_ph { + background-position: -260px -500px; +} +.emoji-flag_pk { + background-position: -280px -500px; +} +.emoji-flag_pl { + background-position: -300px -500px; +} +.emoji-flag_pm { + background-position: -320px -500px; +} +.emoji-flag_pn { + background-position: -340px -500px; +} +.emoji-flag_pr { + background-position: -360px -500px; +} +.emoji-flag_ps { + background-position: -380px -500px; +} +.emoji-flag_pt { + background-position: -400px -500px; +} +.emoji-flag_pw { + background-position: -420px -500px; +} +.emoji-flag_py { + background-position: -440px -500px; +} +.emoji-flag_qa { + background-position: -460px -500px; +} +.emoji-flag_re { + background-position: -480px -500px; +} +.emoji-flag_ro { + background-position: -500px -500px; +} +.emoji-flag_rs { + background-position: -520px 0; +} +.emoji-flag_ru { + background-position: -520px -20px; +} +.emoji-flag_rw { + background-position: -520px -40px; +} +.emoji-flag_sa { + background-position: -520px -60px; +} +.emoji-flag_sb { + background-position: -520px -80px; +} +.emoji-flag_sc { + background-position: -520px -100px; +} +.emoji-flag_sd { + background-position: -520px -120px; +} +.emoji-flag_se { + background-position: -520px -140px; +} +.emoji-flag_sg { + background-position: -520px -160px; +} +.emoji-flag_sh { + background-position: -520px -180px; +} +.emoji-flag_si { + background-position: -520px -200px; +} +.emoji-flag_sj { + background-position: -520px -220px; +} +.emoji-flag_sk { + background-position: -520px -240px; +} +.emoji-flag_sl { + background-position: -520px -260px; +} +.emoji-flag_sm { + background-position: -520px -280px; +} +.emoji-flag_sn { + background-position: -520px -300px; +} +.emoji-flag_so { + background-position: -520px -320px; +} +.emoji-flag_sr { + background-position: -520px -340px; +} +.emoji-flag_ss { + background-position: -520px -360px; +} +.emoji-flag_st { + background-position: -520px -380px; +} +.emoji-flag_sv { + background-position: -520px -400px; +} +.emoji-flag_sx { + background-position: -520px -420px; +} +.emoji-flag_sy { + background-position: -520px -440px; +} +.emoji-flag_sz { + background-position: -520px -460px; +} +.emoji-flag_ta { + background-position: -520px -480px; +} +.emoji-flag_tc { + background-position: -520px -500px; +} +.emoji-flag_td { + background-position: 0 -520px; +} +.emoji-flag_tf { + background-position: -20px -520px; +} +.emoji-flag_tg { + background-position: -40px -520px; +} +.emoji-flag_th { + background-position: -60px -520px; +} +.emoji-flag_tj { + background-position: -80px -520px; +} +.emoji-flag_tk { + background-position: -100px -520px; +} +.emoji-flag_tl { + background-position: -120px -520px; +} +.emoji-flag_tm { + background-position: -140px -520px; +} +.emoji-flag_tn { + background-position: -160px -520px; +} +.emoji-flag_to { + background-position: -180px -520px; +} +.emoji-flag_tr { + background-position: -200px -520px; +} +.emoji-flag_tt { + background-position: -220px -520px; +} +.emoji-flag_tv { + background-position: -240px -520px; +} +.emoji-flag_tw { + background-position: -260px -520px; +} +.emoji-flag_tz { + background-position: -280px -520px; +} +.emoji-flag_ua { + background-position: -300px -520px; +} +.emoji-flag_ug { + background-position: -320px -520px; +} +.emoji-flag_um { + background-position: -340px -520px; +} +.emoji-flag_us { + background-position: -360px -520px; +} +.emoji-flag_uy { + background-position: -380px -520px; +} +.emoji-flag_uz { + background-position: -400px -520px; +} +.emoji-flag_va { + background-position: -420px -520px; +} +.emoji-flag_vc { + background-position: -440px -520px; +} +.emoji-flag_ve { + background-position: -460px -520px; +} +.emoji-flag_vg { + background-position: -480px -520px; +} +.emoji-flag_vi { + background-position: -500px -520px; +} +.emoji-flag_vn { + background-position: -520px -520px; +} +.emoji-flag_vu { + background-position: -540px 0; +} +.emoji-flag_wf { + background-position: -540px -20px; +} +.emoji-flag_white { + background-position: -540px -40px; +} +.emoji-flag_ws { + background-position: -540px -60px; +} +.emoji-flag_xk { + background-position: -540px -80px; +} +.emoji-flag_ye { + background-position: -540px -100px; +} +.emoji-flag_yt { + background-position: -540px -120px; +} +.emoji-flag_za { + background-position: -540px -140px; +} +.emoji-flag_zm { + background-position: -540px -160px; +} +.emoji-flag_zw { + background-position: -540px -180px; +} +.emoji-flags { + background-position: -540px -200px; +} +.emoji-flashlight { + background-position: -540px -220px; +} +.emoji-fleur-de-lis { + background-position: -540px -240px; +} +.emoji-floppy_disk { + background-position: -540px -260px; +} +.emoji-flower_playing_cards { + background-position: -540px -280px; +} +.emoji-flushed { + background-position: -540px -300px; +} +.emoji-fog { + background-position: -540px -320px; +} +.emoji-foggy { + background-position: -540px -340px; +} +.emoji-football { + background-position: -540px -360px; +} +.emoji-footprints { + background-position: -540px -380px; +} +.emoji-fork_and_knife { + background-position: -540px -400px; +} +.emoji-fork_knife_plate { + background-position: -540px -420px; +} +.emoji-fountain { + background-position: -540px -440px; +} +.emoji-four { + background-position: -540px -460px; +} +.emoji-four_leaf_clover { + background-position: -540px -480px; +} +.emoji-fox { + background-position: -540px -500px; +} +.emoji-frame_photo { + background-position: -540px -520px; +} +.emoji-free { + background-position: 0 -540px; +} +.emoji-french_bread { + background-position: -20px -540px; +} +.emoji-fried_shrimp { + background-position: -40px -540px; +} +.emoji-fries { + background-position: -60px -540px; +} +.emoji-frog { + background-position: -80px -540px; +} +.emoji-frowning { + background-position: -100px -540px; +} +.emoji-frowning2 { + background-position: -120px -540px; +} +.emoji-fuelpump { + background-position: -140px -540px; +} +.emoji-full_moon { + background-position: -160px -540px; +} +.emoji-full_moon_with_face { + background-position: -180px -540px; +} +.emoji-game_die { + background-position: -200px -540px; +} +.emoji-gay_pride_flag { + background-position: -220px -540px; +} +.emoji-gear { + background-position: -240px -540px; +} +.emoji-gem { + background-position: -260px -540px; +} +.emoji-gemini { + background-position: -280px -540px; +} +.emoji-ghost { + background-position: -300px -540px; +} +.emoji-gift { + background-position: -320px -540px; +} +.emoji-gift_heart { + background-position: -340px -540px; +} +.emoji-girl { + background-position: -360px -540px; +} +.emoji-girl_tone1 { + background-position: -380px -540px; +} +.emoji-girl_tone2 { + background-position: -400px -540px; +} +.emoji-girl_tone3 { + background-position: -420px -540px; +} +.emoji-girl_tone4 { + background-position: -440px -540px; +} +.emoji-girl_tone5 { + background-position: -460px -540px; +} +.emoji-globe_with_meridians { + background-position: -480px -540px; +} +.emoji-goal { + background-position: -500px -540px; +} +.emoji-goat { + background-position: -520px -540px; +} +.emoji-golf { + background-position: -540px -540px; +} +.emoji-golfer { + background-position: -560px 0; +} +.emoji-gorilla { + background-position: -560px -20px; +} +.emoji-grapes { + background-position: -560px -40px; +} +.emoji-green_apple { + background-position: -560px -60px; +} +.emoji-green_book { + background-position: -560px -80px; +} +.emoji-green_heart { + background-position: -560px -100px; +} +.emoji-grey_exclamation { + background-position: -560px -120px; +} +.emoji-grey_question { + background-position: -560px -140px; +} +.emoji-grimacing { + background-position: -560px -160px; +} +.emoji-grin { + background-position: -560px -180px; +} +.emoji-grinning { + background-position: -560px -200px; +} +.emoji-guardsman { + background-position: -560px -220px; +} +.emoji-guardsman_tone1 { + background-position: -560px -240px; +} +.emoji-guardsman_tone2 { + background-position: -560px -260px; +} +.emoji-guardsman_tone3 { + background-position: -560px -280px; +} +.emoji-guardsman_tone4 { + background-position: -560px -300px; +} +.emoji-guardsman_tone5 { + background-position: -560px -320px; +} +.emoji-guitar { + background-position: -560px -340px; +} +.emoji-gun { + background-position: -560px -360px; +} +.emoji-haircut { + background-position: -560px -380px; +} +.emoji-haircut_tone1 { + background-position: -560px -400px; +} +.emoji-haircut_tone2 { + background-position: -560px -420px; +} +.emoji-haircut_tone3 { + background-position: -560px -440px; +} +.emoji-haircut_tone4 { + background-position: -560px -460px; +} +.emoji-haircut_tone5 { + background-position: -560px -480px; +} +.emoji-hamburger { + background-position: -560px -500px; +} +.emoji-hammer { + background-position: -560px -520px; +} +.emoji-hammer_pick { + background-position: -560px -540px; +} +.emoji-hamster { + background-position: 0 -560px; +} +.emoji-hand_splayed { + background-position: -20px -560px; +} +.emoji-hand_splayed_tone1 { + background-position: -40px -560px; +} +.emoji-hand_splayed_tone2 { + background-position: -60px -560px; +} +.emoji-hand_splayed_tone3 { + background-position: -80px -560px; +} +.emoji-hand_splayed_tone4 { + background-position: -100px -560px; +} +.emoji-hand_splayed_tone5 { + background-position: -120px -560px; +} +.emoji-handbag { + background-position: -140px -560px; +} +.emoji-handball { + background-position: -160px -560px; +} +.emoji-handball_tone1 { + background-position: -180px -560px; +} +.emoji-handball_tone2 { + background-position: -200px -560px; +} +.emoji-handball_tone3 { + background-position: -220px -560px; +} +.emoji-handball_tone4 { + background-position: -240px -560px; +} +.emoji-handball_tone5 { + background-position: -260px -560px; +} +.emoji-handshake { + background-position: -280px -560px; +} +.emoji-handshake_tone1 { + background-position: -300px -560px; +} +.emoji-handshake_tone2 { + background-position: -320px -560px; +} +.emoji-handshake_tone3 { + background-position: -340px -560px; +} +.emoji-handshake_tone4 { + background-position: -360px -560px; +} +.emoji-handshake_tone5 { + background-position: -380px -560px; +} +.emoji-hash { + background-position: -400px -560px; +} +.emoji-hatched_chick { + background-position: -420px -560px; +} +.emoji-hatching_chick { + background-position: -440px -560px; +} +.emoji-head_bandage { + background-position: -460px -560px; +} +.emoji-headphones { + background-position: -480px -560px; +} +.emoji-hear_no_evil { + background-position: -500px -560px; +} +.emoji-heart { + background-position: -520px -560px; +} +.emoji-heart_decoration { + background-position: -540px -560px; +} +.emoji-heart_exclamation { + background-position: -560px -560px; +} +.emoji-heart_eyes { + background-position: -580px 0; +} +.emoji-heart_eyes_cat { + background-position: -580px -20px; +} +.emoji-heartbeat { + background-position: -580px -40px; +} +.emoji-heartpulse { + background-position: -580px -60px; +} +.emoji-hearts { + background-position: -580px -80px; +} +.emoji-heavy_check_mark { + background-position: -580px -100px; +} +.emoji-heavy_division_sign { + background-position: -580px -120px; +} +.emoji-heavy_dollar_sign { + background-position: -580px -140px; +} +.emoji-heavy_minus_sign { + background-position: -580px -160px; +} +.emoji-heavy_multiplication_x { + background-position: -580px -180px; +} +.emoji-heavy_plus_sign { + background-position: -580px -200px; +} +.emoji-helicopter { + background-position: -580px -220px; +} +.emoji-helmet_with_cross { + background-position: -580px -240px; +} +.emoji-herb { + background-position: -580px -260px; +} +.emoji-hibiscus { + background-position: -580px -280px; +} +.emoji-high_brightness { + background-position: -580px -300px; +} +.emoji-high_heel { + background-position: -580px -320px; +} +.emoji-hockey { + background-position: -580px -340px; +} +.emoji-hole { + background-position: -580px -360px; +} +.emoji-homes { + background-position: -580px -380px; +} +.emoji-honey_pot { + background-position: -580px -400px; +} +.emoji-horse { + background-position: -580px -420px; +} +.emoji-horse_racing { + background-position: -580px -440px; +} +.emoji-horse_racing_tone1 { + background-position: -580px -460px; +} +.emoji-horse_racing_tone2 { + background-position: -580px -480px; +} +.emoji-horse_racing_tone3 { + background-position: -580px -500px; +} +.emoji-horse_racing_tone4 { + background-position: -580px -520px; +} +.emoji-horse_racing_tone5 { + background-position: -580px -540px; +} +.emoji-hospital { + background-position: -580px -560px; +} +.emoji-hot_pepper { + background-position: 0 -580px; +} +.emoji-hotdog { + background-position: -20px -580px; +} +.emoji-hotel { + background-position: -40px -580px; +} +.emoji-hotsprings { + background-position: -60px -580px; +} +.emoji-hourglass { + background-position: -80px -580px; +} +.emoji-hourglass_flowing_sand { + background-position: -100px -580px; +} +.emoji-house { + background-position: -120px -580px; +} +.emoji-house_abandoned { + background-position: -140px -580px; +} +.emoji-house_with_garden { + background-position: -160px -580px; +} +.emoji-hugging { + background-position: -180px -580px; +} +.emoji-hushed { + background-position: -200px -580px; +} +.emoji-ice_cream { + background-position: -220px -580px; +} +.emoji-ice_skate { + background-position: -240px -580px; +} +.emoji-icecream { + background-position: -260px -580px; +} +.emoji-id { + background-position: -280px -580px; +} +.emoji-ideograph_advantage { + background-position: -300px -580px; +} +.emoji-imp { + background-position: -320px -580px; +} +.emoji-inbox_tray { + background-position: -340px -580px; +} +.emoji-incoming_envelope { + background-position: -360px -580px; +} +.emoji-information_desk_person { + background-position: -380px -580px; +} +.emoji-information_desk_person_tone1 { + background-position: -400px -580px; +} +.emoji-information_desk_person_tone2 { + background-position: -420px -580px; +} +.emoji-information_desk_person_tone3 { + background-position: -440px -580px; +} +.emoji-information_desk_person_tone4 { + background-position: -460px -580px; +} +.emoji-information_desk_person_tone5 { + background-position: -480px -580px; +} +.emoji-information_source { + background-position: -500px -580px; +} +.emoji-innocent { + background-position: -520px -580px; +} +.emoji-interrobang { + background-position: -540px -580px; +} +.emoji-iphone { + background-position: -560px -580px; +} +.emoji-island { + background-position: -580px -580px; +} +.emoji-izakaya_lantern { + background-position: -600px 0; +} +.emoji-jack_o_lantern { + background-position: -600px -20px; +} +.emoji-japan { + background-position: -600px -40px; +} +.emoji-japanese_castle { + background-position: -600px -60px; +} +.emoji-japanese_goblin { + background-position: -600px -80px; +} +.emoji-japanese_ogre { + background-position: -600px -100px; +} +.emoji-jeans { + background-position: -600px -120px; +} +.emoji-joy { + background-position: -600px -140px; +} +.emoji-joy_cat { + background-position: -600px -160px; +} +.emoji-joystick { + background-position: -600px -180px; +} +.emoji-juggling { + background-position: -600px -200px; +} +.emoji-juggling_tone1 { + background-position: -600px -220px; +} +.emoji-juggling_tone2 { + background-position: -600px -240px; +} +.emoji-juggling_tone3 { + background-position: -600px -260px; +} +.emoji-juggling_tone4 { + background-position: -600px -280px; +} +.emoji-juggling_tone5 { + background-position: -600px -300px; +} +.emoji-kaaba { + background-position: -600px -320px; +} +.emoji-key { + background-position: -600px -340px; +} +.emoji-key2 { + background-position: -600px -360px; +} +.emoji-keyboard { + background-position: -600px -380px; +} +.emoji-kimono { + background-position: -600px -400px; +} +.emoji-kiss { + background-position: -600px -420px; +} +.emoji-kiss_mm { + background-position: -600px -440px; +} +.emoji-kiss_ww { + background-position: -600px -460px; +} +.emoji-kissing { + background-position: -600px -480px; +} +.emoji-kissing_cat { + background-position: -600px -500px; +} +.emoji-kissing_closed_eyes { + background-position: -600px -520px; +} +.emoji-kissing_heart { + background-position: -600px -540px; +} +.emoji-kissing_smiling_eyes { + background-position: -600px -560px; +} +.emoji-kiwi { + background-position: -600px -580px; +} +.emoji-knife { + background-position: 0 -600px; +} +.emoji-koala { + background-position: -20px -600px; +} +.emoji-koko { + background-position: -40px -600px; +} +.emoji-label { + background-position: -60px -600px; +} +.emoji-large_blue_circle { + background-position: -80px -600px; +} +.emoji-large_blue_diamond { + background-position: -100px -600px; +} +.emoji-large_orange_diamond { + background-position: -120px -600px; +} +.emoji-last_quarter_moon { + background-position: -140px -600px; +} +.emoji-last_quarter_moon_with_face { + background-position: -160px -600px; +} +.emoji-laughing { + background-position: -180px -600px; +} +.emoji-leaves { + background-position: -200px -600px; +} +.emoji-ledger { + background-position: -220px -600px; +} +.emoji-left_facing_fist { + background-position: -240px -600px; +} +.emoji-left_facing_fist_tone1 { + background-position: -260px -600px; +} +.emoji-left_facing_fist_tone2 { + background-position: -280px -600px; +} +.emoji-left_facing_fist_tone3 { + background-position: -300px -600px; +} +.emoji-left_facing_fist_tone4 { + background-position: -320px -600px; +} +.emoji-left_facing_fist_tone5 { + background-position: -340px -600px; +} +.emoji-left_luggage { + background-position: -360px -600px; +} +.emoji-left_right_arrow { + background-position: -380px -600px; +} +.emoji-leftwards_arrow_with_hook { + background-position: -400px -600px; +} +.emoji-lemon { + background-position: -420px -600px; +} +.emoji-leo { + background-position: -440px -600px; +} +.emoji-leopard { + background-position: -460px -600px; +} +.emoji-level_slider { + background-position: -480px -600px; +} +.emoji-levitate { + background-position: -500px -600px; +} +.emoji-libra { + background-position: -520px -600px; +} +.emoji-lifter { + background-position: -540px -600px; +} +.emoji-lifter_tone1 { + background-position: -560px -600px; +} +.emoji-lifter_tone2 { + background-position: -580px -600px; +} +.emoji-lifter_tone3 { + background-position: -600px -600px; +} +.emoji-lifter_tone4 { + background-position: -620px 0; +} +.emoji-lifter_tone5 { + background-position: -620px -20px; +} +.emoji-light_rail { + background-position: -620px -40px; +} +.emoji-link { + background-position: -620px -60px; +} +.emoji-lion_face { + background-position: -620px -80px; +} +.emoji-lips { + background-position: -620px -100px; +} +.emoji-lipstick { + background-position: -620px -120px; +} +.emoji-lizard { + background-position: -620px -140px; +} +.emoji-lock { + background-position: -620px -160px; +} +.emoji-lock_with_ink_pen { + background-position: -620px -180px; +} +.emoji-lollipop { + background-position: -620px -200px; +} +.emoji-loop { + background-position: -620px -220px; +} +.emoji-loud_sound { + background-position: -620px -240px; +} +.emoji-loudspeaker { + background-position: -620px -260px; +} +.emoji-love_hotel { + background-position: -620px -280px; +} +.emoji-love_letter { + background-position: -620px -300px; +} +.emoji-low_brightness { + background-position: -620px -320px; +} +.emoji-lying_face { + background-position: -620px -340px; +} +.emoji-m { + background-position: -620px -360px; +} +.emoji-mag { + background-position: -620px -380px; +} +.emoji-mag_right { + background-position: -620px -400px; +} +.emoji-mahjong { + background-position: -620px -420px; +} +.emoji-mailbox { + background-position: -620px -440px; +} +.emoji-mailbox_closed { + background-position: -620px -460px; +} +.emoji-mailbox_with_mail { + background-position: -620px -480px; +} +.emoji-mailbox_with_no_mail { + background-position: -620px -500px; +} +.emoji-man { + background-position: -620px -520px; +} +.emoji-man_dancing { + background-position: -620px -540px; +} +.emoji-man_dancing_tone1 { + background-position: -620px -560px; +} +.emoji-man_dancing_tone2 { + background-position: -620px -580px; +} +.emoji-man_dancing_tone3 { + background-position: -620px -600px; +} +.emoji-man_dancing_tone4 { + background-position: 0 -620px; +} +.emoji-man_dancing_tone5 { + background-position: -20px -620px; +} +.emoji-man_in_tuxedo { + background-position: -40px -620px; +} +.emoji-man_in_tuxedo_tone1 { + background-position: -60px -620px; +} +.emoji-man_in_tuxedo_tone2 { + background-position: -80px -620px; +} +.emoji-man_in_tuxedo_tone3 { + background-position: -100px -620px; +} +.emoji-man_in_tuxedo_tone4 { + background-position: -120px -620px; +} +.emoji-man_in_tuxedo_tone5 { + background-position: -140px -620px; +} +.emoji-man_tone1 { + background-position: -160px -620px; +} +.emoji-man_tone2 { + background-position: -180px -620px; +} +.emoji-man_tone3 { + background-position: -200px -620px; +} +.emoji-man_tone4 { + background-position: -220px -620px; +} +.emoji-man_tone5 { + background-position: -240px -620px; +} +.emoji-man_with_gua_pi_mao { + background-position: -260px -620px; +} +.emoji-man_with_gua_pi_mao_tone1 { + background-position: -280px -620px; +} +.emoji-man_with_gua_pi_mao_tone2 { + background-position: -300px -620px; +} +.emoji-man_with_gua_pi_mao_tone3 { + background-position: -320px -620px; +} +.emoji-man_with_gua_pi_mao_tone4 { + background-position: -340px -620px; +} +.emoji-man_with_gua_pi_mao_tone5 { + background-position: -360px -620px; +} +.emoji-man_with_turban { + background-position: -380px -620px; +} +.emoji-man_with_turban_tone1 { + background-position: -400px -620px; +} +.emoji-man_with_turban_tone2 { + background-position: -420px -620px; +} +.emoji-man_with_turban_tone3 { + background-position: -440px -620px; +} +.emoji-man_with_turban_tone4 { + background-position: -460px -620px; +} +.emoji-man_with_turban_tone5 { + background-position: -480px -620px; +} +.emoji-mans_shoe { + background-position: -500px -620px; +} +.emoji-map { + background-position: -520px -620px; +} +.emoji-maple_leaf { + background-position: -540px -620px; +} +.emoji-martial_arts_uniform { + background-position: -560px -620px; +} +.emoji-mask { + background-position: -580px -620px; +} +.emoji-massage { + background-position: -600px -620px; +} +.emoji-massage_tone1 { + background-position: -620px -620px; +} +.emoji-massage_tone2 { + background-position: -640px 0; +} +.emoji-massage_tone3 { + background-position: -640px -20px; +} +.emoji-massage_tone4 { + background-position: -640px -40px; +} +.emoji-massage_tone5 { + background-position: -640px -60px; +} +.emoji-meat_on_bone { + background-position: -640px -80px; +} +.emoji-medal { + background-position: -640px -100px; +} +.emoji-mega { + background-position: -640px -120px; +} +.emoji-melon { + background-position: -640px -140px; +} +.emoji-menorah { + background-position: -640px -160px; +} +.emoji-mens { + background-position: -640px -180px; +} +.emoji-metal { + background-position: -640px -200px; +} +.emoji-metal_tone1 { + background-position: -640px -220px; +} +.emoji-metal_tone2 { + background-position: -640px -240px; +} +.emoji-metal_tone3 { + background-position: -640px -260px; +} +.emoji-metal_tone4 { + background-position: -640px -280px; +} +.emoji-metal_tone5 { + background-position: -640px -300px; +} +.emoji-metro { + background-position: -640px -320px; +} +.emoji-microphone { + background-position: -640px -340px; +} +.emoji-microphone2 { + background-position: -640px -360px; +} +.emoji-microscope { + background-position: -640px -380px; +} +.emoji-middle_finger { + background-position: -640px -400px; +} +.emoji-middle_finger_tone1 { + background-position: -640px -420px; +} +.emoji-middle_finger_tone2 { + background-position: -640px -440px; +} +.emoji-middle_finger_tone3 { + background-position: -640px -460px; +} +.emoji-middle_finger_tone4 { + background-position: -640px -480px; +} +.emoji-middle_finger_tone5 { + background-position: -640px -500px; +} +.emoji-military_medal { + background-position: -640px -520px; +} +.emoji-milk { + background-position: -640px -540px; +} +.emoji-milky_way { + background-position: -640px -560px; +} +.emoji-minibus { + background-position: -640px -580px; +} +.emoji-minidisc { + background-position: -640px -600px; +} +.emoji-mobile_phone_off { + background-position: -640px -620px; +} +.emoji-money_mouth { + background-position: 0 -640px; +} +.emoji-money_with_wings { + background-position: -20px -640px; +} +.emoji-moneybag { + background-position: -40px -640px; +} +.emoji-monkey { + background-position: -60px -640px; +} +.emoji-monkey_face { + background-position: -80px -640px; +} +.emoji-monorail { + background-position: -100px -640px; +} +.emoji-mortar_board { + background-position: -120px -640px; +} +.emoji-mosque { + background-position: -140px -640px; +} +.emoji-motor_scooter { + background-position: -160px -640px; +} +.emoji-motorboat { + background-position: -180px -640px; +} +.emoji-motorcycle { + background-position: -200px -640px; +} +.emoji-motorway { + background-position: -220px -640px; +} +.emoji-mount_fuji { + background-position: -240px -640px; +} +.emoji-mountain { + background-position: -260px -640px; +} +.emoji-mountain_bicyclist { + background-position: -280px -640px; +} +.emoji-mountain_bicyclist_tone1 { + background-position: -300px -640px; +} +.emoji-mountain_bicyclist_tone2 { + background-position: -320px -640px; +} +.emoji-mountain_bicyclist_tone3 { + background-position: -340px -640px; +} +.emoji-mountain_bicyclist_tone4 { + background-position: -360px -640px; +} +.emoji-mountain_bicyclist_tone5 { + background-position: -380px -640px; +} +.emoji-mountain_cableway { + background-position: -400px -640px; +} +.emoji-mountain_railway { + background-position: -420px -640px; +} +.emoji-mountain_snow { + background-position: -440px -640px; +} +.emoji-mouse { + background-position: -460px -640px; +} +.emoji-mouse2 { + background-position: -480px -640px; +} +.emoji-mouse_three_button { + background-position: -500px -640px; +} +.emoji-movie_camera { + background-position: -520px -640px; +} +.emoji-moyai { + background-position: -540px -640px; +} +.emoji-mrs_claus { + background-position: -560px -640px; +} +.emoji-mrs_claus_tone1 { + background-position: -580px -640px; +} +.emoji-mrs_claus_tone2 { + background-position: -600px -640px; +} +.emoji-mrs_claus_tone3 { + background-position: -620px -640px; +} +.emoji-mrs_claus_tone4 { + background-position: -640px -640px; +} +.emoji-mrs_claus_tone5 { + background-position: -660px 0; +} +.emoji-muscle { + background-position: -660px -20px; +} +.emoji-muscle_tone1 { + background-position: -660px -40px; +} +.emoji-muscle_tone2 { + background-position: -660px -60px; +} +.emoji-muscle_tone3 { + background-position: -660px -80px; +} +.emoji-muscle_tone4 { + background-position: -660px -100px; +} +.emoji-muscle_tone5 { + background-position: -660px -120px; +} +.emoji-mushroom { + background-position: -660px -140px; +} +.emoji-musical_keyboard { + background-position: -660px -160px; +} +.emoji-musical_note { + background-position: -660px -180px; +} +.emoji-musical_score { + background-position: -660px -200px; +} +.emoji-mute { + background-position: -660px -220px; +} +.emoji-nail_care { + background-position: -660px -240px; +} +.emoji-nail_care_tone1 { + background-position: -660px -260px; +} +.emoji-nail_care_tone2 { + background-position: -660px -280px; +} +.emoji-nail_care_tone3 { + background-position: -660px -300px; +} +.emoji-nail_care_tone4 { + background-position: -660px -320px; +} +.emoji-nail_care_tone5 { + background-position: -660px -340px; +} +.emoji-name_badge { + background-position: -660px -360px; +} +.emoji-nauseated_face { + background-position: -660px -380px; +} +.emoji-necktie { + background-position: -660px -400px; +} +.emoji-negative_squared_cross_mark { + background-position: -660px -420px; +} +.emoji-nerd { + background-position: -660px -440px; +} +.emoji-neutral_face { + background-position: -660px -460px; +} +.emoji-new { + background-position: -660px -480px; +} +.emoji-new_moon { + background-position: -660px -500px; +} +.emoji-new_moon_with_face { + background-position: -660px -520px; +} +.emoji-newspaper { + background-position: -660px -540px; +} +.emoji-newspaper2 { + background-position: -660px -560px; +} +.emoji-ng { + background-position: -660px -580px; +} +.emoji-night_with_stars { + background-position: -660px -600px; +} +.emoji-nine { + background-position: -660px -620px; +} +.emoji-no_bell { + background-position: -660px -640px; +} +.emoji-no_bicycles { + background-position: 0 -660px; +} +.emoji-no_entry { + background-position: -20px -660px; +} +.emoji-no_entry_sign { + background-position: -40px -660px; +} +.emoji-no_good { + background-position: -60px -660px; +} +.emoji-no_good_tone1 { + background-position: -80px -660px; +} +.emoji-no_good_tone2 { + background-position: -100px -660px; +} +.emoji-no_good_tone3 { + background-position: -120px -660px; +} +.emoji-no_good_tone4 { + background-position: -140px -660px; +} +.emoji-no_good_tone5 { + background-position: -160px -660px; +} +.emoji-no_mobile_phones { + background-position: -180px -660px; +} +.emoji-no_mouth { + background-position: -200px -660px; +} +.emoji-no_pedestrians { + background-position: -220px -660px; +} +.emoji-no_smoking { + background-position: -240px -660px; +} +.emoji-non-potable_water { + background-position: -260px -660px; +} +.emoji-nose { + background-position: -280px -660px; +} +.emoji-nose_tone1 { + background-position: -300px -660px; +} +.emoji-nose_tone2 { + background-position: -320px -660px; +} +.emoji-nose_tone3 { + background-position: -340px -660px; +} +.emoji-nose_tone4 { + background-position: -360px -660px; +} +.emoji-nose_tone5 { + background-position: -380px -660px; +} +.emoji-notebook { + background-position: -400px -660px; +} +.emoji-notebook_with_decorative_cover { + background-position: -420px -660px; +} +.emoji-notepad_spiral { + background-position: -440px -660px; +} +.emoji-notes { + background-position: -460px -660px; +} +.emoji-nut_and_bolt { + background-position: -480px -660px; +} +.emoji-o { + background-position: -500px -660px; +} +.emoji-o2 { + background-position: -520px -660px; +} +.emoji-ocean { + background-position: -540px -660px; +} +.emoji-octagonal_sign { + background-position: -560px -660px; +} +.emoji-octopus { + background-position: -580px -660px; +} +.emoji-oden { + background-position: -600px -660px; +} +.emoji-office { + background-position: -620px -660px; +} +.emoji-oil { + background-position: -640px -660px; +} +.emoji-ok { + background-position: -660px -660px; +} +.emoji-ok_hand { + background-position: -680px 0; +} +.emoji-ok_hand_tone1 { + background-position: -680px -20px; +} +.emoji-ok_hand_tone2 { + background-position: -680px -40px; +} +.emoji-ok_hand_tone3 { + background-position: -680px -60px; +} +.emoji-ok_hand_tone4 { + background-position: -680px -80px; +} +.emoji-ok_hand_tone5 { + background-position: -680px -100px; +} +.emoji-ok_woman { + background-position: -680px -120px; +} +.emoji-ok_woman_tone1 { + background-position: -680px -140px; +} +.emoji-ok_woman_tone2 { + background-position: -680px -160px; +} +.emoji-ok_woman_tone3 { + background-position: -680px -180px; +} +.emoji-ok_woman_tone4 { + background-position: -680px -200px; +} +.emoji-ok_woman_tone5 { + background-position: -680px -220px; +} +.emoji-older_man { + background-position: -680px -240px; +} +.emoji-older_man_tone1 { + background-position: -680px -260px; +} +.emoji-older_man_tone2 { + background-position: -680px -280px; +} +.emoji-older_man_tone3 { + background-position: -680px -300px; +} +.emoji-older_man_tone4 { + background-position: -680px -320px; +} +.emoji-older_man_tone5 { + background-position: -680px -340px; +} +.emoji-older_woman { + background-position: -680px -360px; +} +.emoji-older_woman_tone1 { + background-position: -680px -380px; +} +.emoji-older_woman_tone2 { + background-position: -680px -400px; +} +.emoji-older_woman_tone3 { + background-position: -680px -420px; +} +.emoji-older_woman_tone4 { + background-position: -680px -440px; +} +.emoji-older_woman_tone5 { + background-position: -680px -460px; +} +.emoji-om_symbol { + background-position: -680px -480px; +} +.emoji-on { + background-position: -680px -500px; +} +.emoji-oncoming_automobile { + background-position: -680px -520px; +} +.emoji-oncoming_bus { + background-position: -680px -540px; +} +.emoji-oncoming_police_car { + background-position: -680px -560px; +} +.emoji-oncoming_taxi { + background-position: -680px -580px; +} +.emoji-one { + background-position: -680px -600px; +} +.emoji-open_file_folder { + background-position: -680px -620px; +} +.emoji-open_hands { + background-position: -680px -640px; +} +.emoji-open_hands_tone1 { + background-position: -680px -660px; +} +.emoji-open_hands_tone2 { + background-position: 0 -680px; +} +.emoji-open_hands_tone3 { + background-position: -20px -680px; +} +.emoji-open_hands_tone4 { + background-position: -40px -680px; +} +.emoji-open_hands_tone5 { + background-position: -60px -680px; +} +.emoji-open_mouth { + background-position: -80px -680px; +} +.emoji-ophiuchus { + background-position: -100px -680px; +} +.emoji-orange_book { + background-position: -120px -680px; +} +.emoji-orthodox_cross { + background-position: -140px -680px; +} +.emoji-outbox_tray { + background-position: -160px -680px; +} +.emoji-owl { + background-position: -180px -680px; +} +.emoji-ox { + background-position: -200px -680px; +} +.emoji-package { + background-position: -220px -680px; +} +.emoji-page_facing_up { + background-position: -240px -680px; +} +.emoji-page_with_curl { + background-position: -260px -680px; +} +.emoji-pager { + background-position: -280px -680px; +} +.emoji-paintbrush { + background-position: -300px -680px; +} +.emoji-palm_tree { + background-position: -320px -680px; +} +.emoji-pancakes { + background-position: -340px -680px; +} +.emoji-panda_face { + background-position: -360px -680px; +} +.emoji-paperclip { + background-position: -380px -680px; +} +.emoji-paperclips { + background-position: -400px -680px; +} +.emoji-park { + background-position: -420px -680px; +} +.emoji-parking { + background-position: -440px -680px; +} +.emoji-part_alternation_mark { + background-position: -460px -680px; +} +.emoji-partly_sunny { + background-position: -480px -680px; +} +.emoji-passport_control { + background-position: -500px -680px; +} +.emoji-pause_button { + background-position: -520px -680px; +} +.emoji-peace { + background-position: -540px -680px; +} +.emoji-peach { + background-position: -560px -680px; +} +.emoji-peanuts { + background-position: -580px -680px; +} +.emoji-pear { + background-position: -600px -680px; +} +.emoji-pen_ballpoint { + background-position: -620px -680px; +} +.emoji-pen_fountain { + background-position: -640px -680px; +} +.emoji-pencil { + background-position: -660px -680px; +} +.emoji-pencil2 { + background-position: -680px -680px; +} +.emoji-penguin { + background-position: -700px 0; +} +.emoji-pensive { + background-position: -700px -20px; +} +.emoji-performing_arts { + background-position: -700px -40px; +} +.emoji-persevere { + background-position: -700px -60px; +} +.emoji-person_frowning { + background-position: -700px -80px; +} +.emoji-person_frowning_tone1 { + background-position: -700px -100px; +} +.emoji-person_frowning_tone2 { + background-position: -700px -120px; +} +.emoji-person_frowning_tone3 { + background-position: -700px -140px; +} +.emoji-person_frowning_tone4 { + background-position: -700px -160px; +} +.emoji-person_frowning_tone5 { + background-position: -700px -180px; +} +.emoji-person_with_blond_hair { + background-position: -700px -200px; +} +.emoji-person_with_blond_hair_tone1 { + background-position: -700px -220px; +} +.emoji-person_with_blond_hair_tone2 { + background-position: -700px -240px; +} +.emoji-person_with_blond_hair_tone3 { + background-position: -700px -260px; +} +.emoji-person_with_blond_hair_tone4 { + background-position: -700px -280px; +} +.emoji-person_with_blond_hair_tone5 { + background-position: -700px -300px; +} +.emoji-person_with_pouting_face { + background-position: -700px -320px; +} +.emoji-person_with_pouting_face_tone1 { + background-position: -700px -340px; +} +.emoji-person_with_pouting_face_tone2 { + background-position: -700px -360px; +} +.emoji-person_with_pouting_face_tone3 { + background-position: -700px -380px; +} +.emoji-person_with_pouting_face_tone4 { + background-position: -700px -400px; +} +.emoji-person_with_pouting_face_tone5 { + background-position: -700px -420px; +} +.emoji-pick { + background-position: -700px -440px; +} +.emoji-pig { + background-position: -700px -460px; +} +.emoji-pig2 { + background-position: -700px -480px; +} +.emoji-pig_nose { + background-position: -700px -500px; +} +.emoji-pill { + background-position: -700px -520px; +} +.emoji-pineapple { + background-position: -700px -540px; +} +.emoji-ping_pong { + background-position: -700px -560px; +} +.emoji-pisces { + background-position: -700px -580px; +} +.emoji-pizza { + background-position: -700px -600px; +} +.emoji-place_of_worship { + background-position: -700px -620px; +} +.emoji-play_pause { + background-position: -700px -640px; +} +.emoji-point_down { + background-position: -700px -660px; +} +.emoji-point_down_tone1 { + background-position: -700px -680px; +} +.emoji-point_down_tone2 { + background-position: 0 -700px; +} +.emoji-point_down_tone3 { + background-position: -20px -700px; +} +.emoji-point_down_tone4 { + background-position: -40px -700px; +} +.emoji-point_down_tone5 { + background-position: -60px -700px; +} +.emoji-point_left { + background-position: -80px -700px; +} +.emoji-point_left_tone1 { + background-position: -100px -700px; +} +.emoji-point_left_tone2 { + background-position: -120px -700px; +} +.emoji-point_left_tone3 { + background-position: -140px -700px; +} +.emoji-point_left_tone4 { + background-position: -160px -700px; +} +.emoji-point_left_tone5 { + background-position: -180px -700px; +} +.emoji-point_right { + background-position: -200px -700px; +} +.emoji-point_right_tone1 { + background-position: -220px -700px; +} +.emoji-point_right_tone2 { + background-position: -240px -700px; +} +.emoji-point_right_tone3 { + background-position: -260px -700px; +} +.emoji-point_right_tone4 { + background-position: -280px -700px; +} +.emoji-point_right_tone5 { + background-position: -300px -700px; +} +.emoji-point_up { + background-position: -320px -700px; +} +.emoji-point_up_2 { + background-position: -340px -700px; +} +.emoji-point_up_2_tone1 { + background-position: -360px -700px; +} +.emoji-point_up_2_tone2 { + background-position: -380px -700px; +} +.emoji-point_up_2_tone3 { + background-position: -400px -700px; +} +.emoji-point_up_2_tone4 { + background-position: -420px -700px; +} +.emoji-point_up_2_tone5 { + background-position: -440px -700px; +} +.emoji-point_up_tone1 { + background-position: -460px -700px; +} +.emoji-point_up_tone2 { + background-position: -480px -700px; +} +.emoji-point_up_tone3 { + background-position: -500px -700px; +} +.emoji-point_up_tone4 { + background-position: -520px -700px; +} +.emoji-point_up_tone5 { + background-position: -540px -700px; +} +.emoji-police_car { + background-position: -560px -700px; +} +.emoji-poodle { + background-position: -580px -700px; +} +.emoji-poop { + background-position: -600px -700px; +} +.emoji-popcorn { + background-position: -620px -700px; +} +.emoji-post_office { + background-position: -640px -700px; +} +.emoji-postal_horn { + background-position: -660px -700px; +} +.emoji-postbox { + background-position: -680px -700px; +} +.emoji-potable_water { + background-position: -700px -700px; +} +.emoji-potato { + background-position: -720px 0; +} +.emoji-pouch { + background-position: -720px -20px; +} +.emoji-poultry_leg { + background-position: -720px -40px; +} +.emoji-pound { + background-position: -720px -60px; +} +.emoji-pouting_cat { + background-position: -720px -80px; +} +.emoji-pray { + background-position: -720px -100px; +} +.emoji-pray_tone1 { + background-position: -720px -120px; +} +.emoji-pray_tone2 { + background-position: -720px -140px; +} +.emoji-pray_tone3 { + background-position: -720px -160px; +} +.emoji-pray_tone4 { + background-position: -720px -180px; +} +.emoji-pray_tone5 { + background-position: -720px -200px; +} +.emoji-prayer_beads { + background-position: -720px -220px; +} +.emoji-pregnant_woman { + background-position: -720px -240px; +} +.emoji-pregnant_woman_tone1 { + background-position: -720px -260px; +} +.emoji-pregnant_woman_tone2 { + background-position: -720px -280px; +} +.emoji-pregnant_woman_tone3 { + background-position: -720px -300px; +} +.emoji-pregnant_woman_tone4 { + background-position: -720px -320px; +} +.emoji-pregnant_woman_tone5 { + background-position: -720px -340px; +} +.emoji-prince { + background-position: -720px -360px; +} +.emoji-prince_tone1 { + background-position: -720px -380px; +} +.emoji-prince_tone2 { + background-position: -720px -400px; +} +.emoji-prince_tone3 { + background-position: -720px -420px; +} +.emoji-prince_tone4 { + background-position: -720px -440px; +} +.emoji-prince_tone5 { + background-position: -720px -460px; +} +.emoji-princess { + background-position: -720px -480px; +} +.emoji-princess_tone1 { + background-position: -720px -500px; +} +.emoji-princess_tone2 { + background-position: -720px -520px; +} +.emoji-princess_tone3 { + background-position: -720px -540px; +} +.emoji-princess_tone4 { + background-position: -720px -560px; +} +.emoji-princess_tone5 { + background-position: -720px -580px; +} +.emoji-printer { + background-position: -720px -600px; +} +.emoji-projector { + background-position: -720px -620px; +} +.emoji-punch { + background-position: -720px -640px; +} +.emoji-punch_tone1 { + background-position: -720px -660px; +} +.emoji-punch_tone2 { + background-position: -720px -680px; +} +.emoji-punch_tone3 { + background-position: -720px -700px; +} +.emoji-punch_tone4 { + background-position: 0 -720px; +} +.emoji-punch_tone5 { + background-position: -20px -720px; +} +.emoji-purple_heart { + background-position: -40px -720px; +} +.emoji-purse { + background-position: -60px -720px; +} +.emoji-pushpin { + background-position: -80px -720px; +} +.emoji-put_litter_in_its_place { + background-position: -100px -720px; +} +.emoji-question { + background-position: -120px -720px; +} +.emoji-rabbit { + background-position: -140px -720px; +} +.emoji-rabbit2 { + background-position: -160px -720px; +} +.emoji-race_car { + background-position: -180px -720px; +} +.emoji-racehorse { + background-position: -200px -720px; +} +.emoji-radio { + background-position: -220px -720px; +} +.emoji-radio_button { + background-position: -240px -720px; +} +.emoji-radioactive { + background-position: -260px -720px; +} +.emoji-rage { + background-position: -280px -720px; +} +.emoji-railway_car { + background-position: -300px -720px; +} +.emoji-railway_track { + background-position: -320px -720px; +} +.emoji-rainbow { + background-position: -340px -720px; +} +.emoji-raised_back_of_hand { + background-position: -360px -720px; +} +.emoji-raised_back_of_hand_tone1 { + background-position: -380px -720px; +} +.emoji-raised_back_of_hand_tone2 { + background-position: -400px -720px; +} +.emoji-raised_back_of_hand_tone3 { + background-position: -420px -720px; +} +.emoji-raised_back_of_hand_tone4 { + background-position: -440px -720px; +} +.emoji-raised_back_of_hand_tone5 { + background-position: -460px -720px; +} +.emoji-raised_hand { + background-position: -480px -720px; +} +.emoji-raised_hand_tone1 { + background-position: -500px -720px; +} +.emoji-raised_hand_tone2 { + background-position: -520px -720px; +} +.emoji-raised_hand_tone3 { + background-position: -540px -720px; +} +.emoji-raised_hand_tone4 { + background-position: -560px -720px; +} +.emoji-raised_hand_tone5 { + background-position: -580px -720px; +} +.emoji-raised_hands { + background-position: -600px -720px; +} +.emoji-raised_hands_tone1 { + background-position: -620px -720px; +} +.emoji-raised_hands_tone2 { + background-position: -640px -720px; +} +.emoji-raised_hands_tone3 { + background-position: -660px -720px; +} +.emoji-raised_hands_tone4 { + background-position: -680px -720px; +} +.emoji-raised_hands_tone5 { + background-position: -700px -720px; +} +.emoji-raising_hand { + background-position: -720px -720px; +} +.emoji-raising_hand_tone1 { + background-position: -740px 0; +} +.emoji-raising_hand_tone2 { + background-position: -740px -20px; +} +.emoji-raising_hand_tone3 { + background-position: -740px -40px; +} +.emoji-raising_hand_tone4 { + background-position: -740px -60px; +} +.emoji-raising_hand_tone5 { + background-position: -740px -80px; +} +.emoji-ram { + background-position: -740px -100px; +} +.emoji-ramen { + background-position: -740px -120px; +} +.emoji-rat { + background-position: -740px -140px; +} +.emoji-record_button { + background-position: -740px -160px; +} +.emoji-recycle { + background-position: -740px -180px; +} +.emoji-red_car { + background-position: -740px -200px; +} +.emoji-red_circle { + background-position: -740px -220px; +} +.emoji-registered { + background-position: -740px -240px; +} +.emoji-relaxed { + background-position: -740px -260px; +} +.emoji-relieved { + background-position: -740px -280px; +} +.emoji-reminder_ribbon { + background-position: -740px -300px; +} +.emoji-repeat { + background-position: -740px -320px; +} +.emoji-repeat_one { + background-position: -740px -340px; +} +.emoji-restroom { + background-position: -740px -360px; +} +.emoji-revolving_hearts { + background-position: -740px -380px; +} +.emoji-rewind { + background-position: -740px -400px; +} +.emoji-rhino { + background-position: -740px -420px; +} +.emoji-ribbon { + background-position: -740px -440px; +} +.emoji-rice { + background-position: -740px -460px; +} +.emoji-rice_ball { + background-position: -740px -480px; +} +.emoji-rice_cracker { + background-position: -740px -500px; +} +.emoji-rice_scene { + background-position: -740px -520px; +} +.emoji-right_facing_fist { + background-position: -740px -540px; +} +.emoji-right_facing_fist_tone1 { + background-position: -740px -560px; +} +.emoji-right_facing_fist_tone2 { + background-position: -740px -580px; +} +.emoji-right_facing_fist_tone3 { + background-position: -740px -600px; +} +.emoji-right_facing_fist_tone4 { + background-position: -740px -620px; +} +.emoji-right_facing_fist_tone5 { + background-position: -740px -640px; +} +.emoji-ring { + background-position: -740px -660px; +} +.emoji-robot { + background-position: -740px -680px; +} +.emoji-rocket { + background-position: -740px -700px; +} +.emoji-rofl { + background-position: -740px -720px; +} +.emoji-roller_coaster { + background-position: 0 -740px; +} +.emoji-rolling_eyes { + background-position: -20px -740px; +} +.emoji-rooster { + background-position: -40px -740px; +} +.emoji-rose { + background-position: -60px -740px; +} +.emoji-rosette { + background-position: -80px -740px; +} +.emoji-rotating_light { + background-position: -100px -740px; +} +.emoji-round_pushpin { + background-position: -120px -740px; +} +.emoji-rowboat { + background-position: -140px -740px; +} +.emoji-rowboat_tone1 { + background-position: -160px -740px; +} +.emoji-rowboat_tone2 { + background-position: -180px -740px; +} +.emoji-rowboat_tone3 { + background-position: -200px -740px; +} +.emoji-rowboat_tone4 { + background-position: -220px -740px; +} +.emoji-rowboat_tone5 { + background-position: -240px -740px; +} +.emoji-rugby_football { + background-position: -260px -740px; +} +.emoji-runner { + background-position: -280px -740px; +} +.emoji-runner_tone1 { + background-position: -300px -740px; +} +.emoji-runner_tone2 { + background-position: -320px -740px; +} +.emoji-runner_tone3 { + background-position: -340px -740px; +} +.emoji-runner_tone4 { + background-position: -360px -740px; +} +.emoji-runner_tone5 { + background-position: -380px -740px; +} +.emoji-running_shirt_with_sash { + background-position: -400px -740px; +} +.emoji-sa { + background-position: -420px -740px; +} +.emoji-sagittarius { + background-position: -440px -740px; +} +.emoji-sailboat { + background-position: -460px -740px; +} +.emoji-sake { + background-position: -480px -740px; +} +.emoji-salad { + background-position: -500px -740px; +} +.emoji-sandal { + background-position: -520px -740px; +} +.emoji-santa { + background-position: -540px -740px; +} +.emoji-santa_tone1 { + background-position: -560px -740px; +} +.emoji-santa_tone2 { + background-position: -580px -740px; +} +.emoji-santa_tone3 { + background-position: -600px -740px; +} +.emoji-santa_tone4 { + background-position: -620px -740px; +} +.emoji-santa_tone5 { + background-position: -640px -740px; +} +.emoji-satellite { + background-position: -660px -740px; +} +.emoji-satellite_orbital { + background-position: -680px -740px; +} +.emoji-saxophone { + background-position: -700px -740px; +} +.emoji-scales { + background-position: -720px -740px; +} +.emoji-school { + background-position: -740px -740px; +} +.emoji-school_satchel { + background-position: -760px 0; +} +.emoji-scissors { + background-position: -760px -20px; +} +.emoji-scooter { + background-position: -760px -40px; +} +.emoji-scorpion { + background-position: -760px -60px; +} +.emoji-scorpius { + background-position: -760px -80px; +} +.emoji-scream { + background-position: -760px -100px; +} +.emoji-scream_cat { + background-position: -760px -120px; +} +.emoji-scroll { + background-position: -760px -140px; +} +.emoji-seat { + background-position: -760px -160px; +} +.emoji-second_place { + background-position: -760px -180px; +} +.emoji-secret { + background-position: -760px -200px; +} +.emoji-see_no_evil { + background-position: -760px -220px; +} +.emoji-seedling { + background-position: -760px -240px; +} +.emoji-selfie { + background-position: -760px -260px; +} +.emoji-selfie_tone1 { + background-position: -760px -280px; +} +.emoji-selfie_tone2 { + background-position: -760px -300px; +} +.emoji-selfie_tone3 { + background-position: -760px -320px; +} +.emoji-selfie_tone4 { + background-position: -760px -340px; +} +.emoji-selfie_tone5 { + background-position: -760px -360px; +} +.emoji-seven { + background-position: -760px -380px; +} +.emoji-shallow_pan_of_food { + background-position: -760px -400px; +} +.emoji-shamrock { + background-position: -760px -420px; +} +.emoji-shark { + background-position: -760px -440px; +} +.emoji-shaved_ice { + background-position: -760px -460px; +} +.emoji-sheep { + background-position: -760px -480px; +} +.emoji-shell { + background-position: -760px -500px; +} +.emoji-shield { + background-position: -760px -520px; +} +.emoji-shinto_shrine { + background-position: -760px -540px; +} +.emoji-ship { + background-position: -760px -560px; +} +.emoji-shirt { + background-position: -760px -580px; +} +.emoji-shopping_bags { + background-position: -760px -600px; +} +.emoji-shopping_cart { + background-position: -760px -620px; +} +.emoji-shower { + background-position: -760px -640px; +} +.emoji-shrimp { + background-position: -760px -660px; +} +.emoji-shrug { + background-position: -760px -680px; +} +.emoji-shrug_tone1 { + background-position: -760px -700px; +} +.emoji-shrug_tone2 { + background-position: -760px -720px; +} +.emoji-shrug_tone3 { + background-position: -760px -740px; +} +.emoji-shrug_tone4 { + background-position: 0 -760px; +} +.emoji-shrug_tone5 { + background-position: -20px -760px; +} +.emoji-signal_strength { + background-position: -40px -760px; +} +.emoji-six { + background-position: -60px -760px; +} +.emoji-six_pointed_star { + background-position: -80px -760px; +} +.emoji-ski { + background-position: -100px -760px; +} +.emoji-skier { + background-position: -120px -760px; +} +.emoji-skull { + background-position: -140px -760px; +} +.emoji-skull_crossbones { + background-position: -160px -760px; +} +.emoji-sleeping { + background-position: -180px -760px; +} +.emoji-sleeping_accommodation { + background-position: -200px -760px; +} +.emoji-sleepy { + background-position: -220px -760px; +} +.emoji-slight_frown { + background-position: -240px -760px; +} +.emoji-slight_smile { + background-position: -260px -760px; +} +.emoji-slot_machine { + background-position: -280px -760px; +} +.emoji-small_blue_diamond { + background-position: -300px -760px; +} +.emoji-small_orange_diamond { + background-position: -320px -760px; +} +.emoji-small_red_triangle { + background-position: -340px -760px; +} +.emoji-small_red_triangle_down { + background-position: -360px -760px; +} +.emoji-smile { + background-position: -380px -760px; +} +.emoji-smile_cat { + background-position: -400px -760px; +} +.emoji-smiley { + background-position: -420px -760px; +} +.emoji-smiley_cat { + background-position: -440px -760px; +} +.emoji-smiling_imp { + background-position: -460px -760px; +} +.emoji-smirk { + background-position: -480px -760px; +} +.emoji-smirk_cat { + background-position: -500px -760px; +} +.emoji-smoking { + background-position: -520px -760px; +} +.emoji-snail { + background-position: -540px -760px; +} +.emoji-snake { + background-position: -560px -760px; +} +.emoji-sneezing_face { + background-position: -580px -760px; +} +.emoji-snowboarder { + background-position: -600px -760px; +} +.emoji-snowflake { + background-position: -620px -760px; +} +.emoji-snowman { + background-position: -640px -760px; +} +.emoji-snowman2 { + background-position: -660px -760px; +} +.emoji-sob { + background-position: -680px -760px; +} +.emoji-soccer { + background-position: -700px -760px; +} +.emoji-soon { + background-position: -720px -760px; +} +.emoji-sos { + background-position: -740px -760px; +} +.emoji-sound { + background-position: -760px -760px; +} +.emoji-space_invader { + background-position: -780px 0; +} +.emoji-spades { + background-position: -780px -20px; +} +.emoji-spaghetti { + background-position: -780px -40px; +} +.emoji-sparkle { + background-position: -780px -60px; +} +.emoji-sparkler { + background-position: -780px -80px; +} +.emoji-sparkles { + background-position: -780px -100px; +} +.emoji-sparkling_heart { + background-position: -780px -120px; +} +.emoji-speak_no_evil { + background-position: -780px -140px; +} +.emoji-speaker { + background-position: -780px -160px; +} +.emoji-speaking_head { + background-position: -780px -180px; +} +.emoji-speech_balloon { + background-position: -780px -200px; +} +.emoji-speech_left { + background-position: -780px -220px; +} +.emoji-speedboat { + background-position: -780px -240px; +} +.emoji-spider { + background-position: -780px -260px; +} +.emoji-spider_web { + background-position: -780px -280px; +} +.emoji-spoon { + background-position: -780px -300px; +} +.emoji-spy { + background-position: -780px -320px; +} +.emoji-spy_tone1 { + background-position: -780px -340px; +} +.emoji-spy_tone2 { + background-position: -780px -360px; +} +.emoji-spy_tone3 { + background-position: -780px -380px; +} +.emoji-spy_tone4 { + background-position: -780px -400px; +} +.emoji-spy_tone5 { + background-position: -780px -420px; +} +.emoji-squid { + background-position: -780px -440px; +} +.emoji-stadium { + background-position: -780px -460px; +} +.emoji-star { + background-position: -780px -480px; +} +.emoji-star2 { + background-position: -780px -500px; +} +.emoji-star_and_crescent { + background-position: -780px -520px; +} +.emoji-star_of_david { + background-position: -780px -540px; +} +.emoji-stars { + background-position: -780px -560px; +} +.emoji-station { + background-position: -780px -580px; +} +.emoji-statue_of_liberty { + background-position: -780px -600px; +} +.emoji-steam_locomotive { + background-position: -780px -620px; +} +.emoji-stew { + background-position: -780px -640px; +} +.emoji-stop_button { + background-position: -780px -660px; +} +.emoji-stopwatch { + background-position: -780px -680px; +} +.emoji-straight_ruler { + background-position: -780px -700px; +} +.emoji-strawberry { + background-position: -780px -720px; +} +.emoji-stuck_out_tongue { + background-position: -780px -740px; +} +.emoji-stuck_out_tongue_closed_eyes { + background-position: -780px -760px; +} +.emoji-stuck_out_tongue_winking_eye { + background-position: 0 -780px; +} +.emoji-stuffed_flatbread { + background-position: -20px -780px; +} +.emoji-sun_with_face { + background-position: -40px -780px; +} +.emoji-sunflower { + background-position: -60px -780px; +} +.emoji-sunglasses { + background-position: -80px -780px; +} +.emoji-sunny { + background-position: -100px -780px; +} +.emoji-sunrise { + background-position: -120px -780px; +} +.emoji-sunrise_over_mountains { + background-position: -140px -780px; +} +.emoji-surfer { + background-position: -160px -780px; +} +.emoji-surfer_tone1 { + background-position: -180px -780px; +} +.emoji-surfer_tone2 { + background-position: -200px -780px; +} +.emoji-surfer_tone3 { + background-position: -220px -780px; +} +.emoji-surfer_tone4 { + background-position: -240px -780px; +} +.emoji-surfer_tone5 { + background-position: -260px -780px; +} +.emoji-sushi { + background-position: -280px -780px; +} +.emoji-suspension_railway { + background-position: -300px -780px; +} +.emoji-sweat { + background-position: -320px -780px; +} +.emoji-sweat_drops { + background-position: -340px -780px; +} +.emoji-sweat_smile { + background-position: -360px -780px; +} +.emoji-sweet_potato { + background-position: -380px -780px; +} +.emoji-swimmer { + background-position: -400px -780px; +} +.emoji-swimmer_tone1 { + background-position: -420px -780px; +} +.emoji-swimmer_tone2 { + background-position: -440px -780px; +} +.emoji-swimmer_tone3 { + background-position: -460px -780px; +} +.emoji-swimmer_tone4 { + background-position: -480px -780px; +} +.emoji-swimmer_tone5 { + background-position: -500px -780px; +} +.emoji-symbols { + background-position: -520px -780px; +} +.emoji-synagogue { + background-position: -540px -780px; +} +.emoji-syringe { + background-position: -560px -780px; +} +.emoji-taco { + background-position: -580px -780px; +} +.emoji-tada { + background-position: -600px -780px; +} +.emoji-tanabata_tree { + background-position: -620px -780px; +} +.emoji-tangerine { + background-position: -640px -780px; +} +.emoji-taurus { + background-position: -660px -780px; +} +.emoji-taxi { + background-position: -680px -780px; +} +.emoji-tea { + background-position: -700px -780px; +} +.emoji-telephone { + background-position: -720px -780px; +} +.emoji-telephone_receiver { + background-position: -740px -780px; +} +.emoji-telescope { + background-position: -760px -780px; +} +.emoji-ten { + background-position: -780px -780px; +} +.emoji-tennis { + background-position: -800px 0; +} +.emoji-tent { + background-position: -800px -20px; +} +.emoji-thermometer { + background-position: -800px -40px; +} +.emoji-thermometer_face { + background-position: -800px -60px; +} +.emoji-thinking { + background-position: -800px -80px; +} +.emoji-third_place { + background-position: -800px -100px; +} +.emoji-thought_balloon { + background-position: -800px -120px; +} +.emoji-three { + background-position: -800px -140px; +} +.emoji-thumbsdown { + background-position: -800px -160px; +} +.emoji-thumbsdown_tone1 { + background-position: -800px -180px; +} +.emoji-thumbsdown_tone2 { + background-position: -800px -200px; +} +.emoji-thumbsdown_tone3 { + background-position: -800px -220px; +} +.emoji-thumbsdown_tone4 { + background-position: -800px -240px; +} +.emoji-thumbsdown_tone5 { + background-position: -800px -260px; +} +.emoji-thumbsup { + background-position: -800px -280px; +} +.emoji-thumbsup_tone1 { + background-position: -800px -300px; +} +.emoji-thumbsup_tone2 { + background-position: -800px -320px; +} +.emoji-thumbsup_tone3 { + background-position: -800px -340px; +} +.emoji-thumbsup_tone4 { + background-position: -800px -360px; +} +.emoji-thumbsup_tone5 { + background-position: -800px -380px; +} +.emoji-thunder_cloud_rain { + background-position: -800px -400px; +} +.emoji-ticket { + background-position: -800px -420px; +} +.emoji-tickets { + background-position: -800px -440px; +} +.emoji-tiger { + background-position: -800px -460px; +} +.emoji-tiger2 { + background-position: -800px -480px; +} +.emoji-timer { + background-position: -800px -500px; +} +.emoji-tired_face { + background-position: -800px -520px; +} +.emoji-tm { + background-position: -800px -540px; +} +.emoji-toilet { + background-position: -800px -560px; +} +.emoji-tokyo_tower { + background-position: -800px -580px; +} +.emoji-tomato { + background-position: -800px -600px; +} +.emoji-tone1 { + background-position: -800px -620px; +} +.emoji-tone2 { + background-position: -800px -640px; +} +.emoji-tone3 { + background-position: -800px -660px; +} +.emoji-tone4 { + background-position: -800px -680px; +} +.emoji-tone5 { + background-position: -800px -700px; +} +.emoji-tongue { + background-position: -800px -720px; +} +.emoji-tools { + background-position: -800px -740px; +} +.emoji-top { + background-position: -800px -760px; +} +.emoji-tophat { + background-position: -800px -780px; +} +.emoji-track_next { + background-position: 0 -800px; +} +.emoji-track_previous { + background-position: -20px -800px; +} +.emoji-trackball { + background-position: -40px -800px; +} +.emoji-tractor { + background-position: -60px -800px; +} +.emoji-traffic_light { + background-position: -80px -800px; +} +.emoji-train { + background-position: -100px -800px; +} +.emoji-train2 { + background-position: -120px -800px; +} +.emoji-tram { + background-position: -140px -800px; +} +.emoji-triangular_flag_on_post { + background-position: -160px -800px; +} +.emoji-triangular_ruler { + background-position: -180px -800px; +} +.emoji-trident { + background-position: -200px -800px; +} +.emoji-triumph { + background-position: -220px -800px; +} +.emoji-trolleybus { + background-position: -240px -800px; +} +.emoji-trophy { + background-position: -260px -800px; +} +.emoji-tropical_drink { + background-position: -280px -800px; +} +.emoji-tropical_fish { + background-position: -300px -800px; +} +.emoji-truck { + background-position: -320px -800px; +} +.emoji-trumpet { + background-position: -340px -800px; +} +.emoji-tulip { + background-position: -360px -800px; +} +.emoji-tumbler_glass { + background-position: -380px -800px; +} +.emoji-turkey { + background-position: -400px -800px; +} +.emoji-turtle { + background-position: -420px -800px; +} +.emoji-tv { + background-position: -440px -800px; +} +.emoji-twisted_rightwards_arrows { + background-position: -460px -800px; +} +.emoji-two { + background-position: -480px -800px; +} +.emoji-two_hearts { + background-position: -500px -800px; +} +.emoji-two_men_holding_hands { + background-position: -520px -800px; +} +.emoji-two_women_holding_hands { + background-position: -540px -800px; +} +.emoji-u5272 { + background-position: -560px -800px; +} +.emoji-u5408 { + background-position: -580px -800px; +} +.emoji-u55b6 { + background-position: -600px -800px; +} +.emoji-u6307 { + background-position: -620px -800px; +} +.emoji-u6708 { + background-position: -640px -800px; +} +.emoji-u6709 { + background-position: -660px -800px; +} +.emoji-u6e80 { + background-position: -680px -800px; +} +.emoji-u7121 { + background-position: -700px -800px; +} +.emoji-u7533 { + background-position: -720px -800px; +} +.emoji-u7981 { + background-position: -740px -800px; +} +.emoji-u7a7a { + background-position: -760px -800px; +} +.emoji-umbrella { + background-position: -780px -800px; +} +.emoji-umbrella2 { + background-position: -800px -800px; +} +.emoji-unamused { + background-position: -820px 0; +} +.emoji-underage { + background-position: -820px -20px; +} +.emoji-unicorn { + background-position: -820px -40px; +} +.emoji-unlock { + background-position: -820px -60px; +} +.emoji-up { + background-position: -820px -80px; +} +.emoji-upside_down { + background-position: -820px -100px; +} +.emoji-urn { + background-position: -820px -120px; +} +.emoji-v { + background-position: -820px -140px; +} +.emoji-v_tone1 { + background-position: -820px -160px; +} +.emoji-v_tone2 { + background-position: -820px -180px; +} +.emoji-v_tone3 { + background-position: -820px -200px; +} +.emoji-v_tone4 { + background-position: -820px -220px; +} +.emoji-v_tone5 { + background-position: -820px -240px; +} +.emoji-vertical_traffic_light { + background-position: -820px -260px; +} +.emoji-vhs { + background-position: -820px -280px; +} +.emoji-vibration_mode { + background-position: -820px -300px; +} +.emoji-video_camera { + background-position: -820px -320px; +} +.emoji-video_game { + background-position: -820px -340px; +} +.emoji-violin { + background-position: -820px -360px; +} +.emoji-virgo { + background-position: -820px -380px; +} +.emoji-volcano { + background-position: -820px -400px; +} +.emoji-volleyball { + background-position: -820px -420px; +} +.emoji-vs { + background-position: -820px -440px; +} +.emoji-vulcan { + background-position: -820px -460px; +} +.emoji-vulcan_tone1 { + background-position: -820px -480px; +} +.emoji-vulcan_tone2 { + background-position: -820px -500px; +} +.emoji-vulcan_tone3 { + background-position: -820px -520px; +} +.emoji-vulcan_tone4 { + background-position: -820px -540px; +} +.emoji-vulcan_tone5 { + background-position: -820px -560px; +} +.emoji-walking { + background-position: -820px -580px; +} +.emoji-walking_tone1 { + background-position: -820px -600px; +} +.emoji-walking_tone2 { + background-position: -820px -620px; +} +.emoji-walking_tone3 { + background-position: -820px -640px; +} +.emoji-walking_tone4 { + background-position: -820px -660px; +} +.emoji-walking_tone5 { + background-position: -820px -680px; +} +.emoji-waning_crescent_moon { + background-position: -820px -700px; +} +.emoji-waning_gibbous_moon { + background-position: -820px -720px; +} +.emoji-warning { + background-position: -820px -740px; +} +.emoji-wastebasket { + background-position: -820px -760px; +} +.emoji-watch { + background-position: -820px -780px; +} +.emoji-water_buffalo { + background-position: -820px -800px; +} +.emoji-water_polo { + background-position: 0 -820px; +} +.emoji-water_polo_tone1 { + background-position: -20px -820px; +} +.emoji-water_polo_tone2 { + background-position: -40px -820px; +} +.emoji-water_polo_tone3 { + background-position: -60px -820px; +} +.emoji-water_polo_tone4 { + background-position: -80px -820px; +} +.emoji-water_polo_tone5 { + background-position: -100px -820px; +} +.emoji-watermelon { + background-position: -120px -820px; +} +.emoji-wave { + background-position: -140px -820px; +} +.emoji-wave_tone1 { + background-position: -160px -820px; +} +.emoji-wave_tone2 { + background-position: -180px -820px; +} +.emoji-wave_tone3 { + background-position: -200px -820px; +} +.emoji-wave_tone4 { + background-position: -220px -820px; +} +.emoji-wave_tone5 { + background-position: -240px -820px; +} +.emoji-wavy_dash { + background-position: -260px -820px; +} +.emoji-waxing_crescent_moon { + background-position: -280px -820px; +} +.emoji-waxing_gibbous_moon { + background-position: -300px -820px; +} +.emoji-wc { + background-position: -320px -820px; +} +.emoji-weary { + background-position: -340px -820px; +} +.emoji-wedding { + background-position: -360px -820px; +} +.emoji-whale { + background-position: -380px -820px; +} +.emoji-whale2 { + background-position: -400px -820px; +} +.emoji-wheel_of_dharma { + background-position: -420px -820px; +} +.emoji-wheelchair { + background-position: -440px -820px; +} +.emoji-white_check_mark { + background-position: -460px -820px; +} +.emoji-white_circle { + background-position: -480px -820px; +} +.emoji-white_flower { + background-position: -500px -820px; +} +.emoji-white_large_square { + background-position: -520px -820px; +} +.emoji-white_medium_small_square { + background-position: -540px -820px; +} +.emoji-white_medium_square { + background-position: -560px -820px; +} +.emoji-white_small_square { + background-position: -580px -820px; +} +.emoji-white_square_button { + background-position: -600px -820px; +} +.emoji-white_sun_cloud { + background-position: -620px -820px; +} +.emoji-white_sun_rain_cloud { + background-position: -640px -820px; +} +.emoji-white_sun_small_cloud { + background-position: -660px -820px; +} +.emoji-wilted_rose { + background-position: -680px -820px; +} +.emoji-wind_blowing_face { + background-position: -700px -820px; +} +.emoji-wind_chime { + background-position: -720px -820px; +} +.emoji-wine_glass { + background-position: -740px -820px; +} +.emoji-wink { + background-position: -760px -820px; +} +.emoji-wolf { + background-position: -780px -820px; +} +.emoji-woman { + background-position: -800px -820px; +} +.emoji-woman_tone1 { + background-position: -820px -820px; +} +.emoji-woman_tone2 { + background-position: -840px 0; +} +.emoji-woman_tone3 { + background-position: -840px -20px; +} +.emoji-woman_tone4 { + background-position: -840px -40px; +} +.emoji-woman_tone5 { + background-position: -840px -60px; +} +.emoji-womans_clothes { + background-position: -840px -80px; +} +.emoji-womans_hat { + background-position: -840px -100px; +} +.emoji-womens { + background-position: -840px -120px; +} +.emoji-worried { + background-position: -840px -140px; +} +.emoji-wrench { + background-position: -840px -160px; +} +.emoji-wrestlers { + background-position: -840px -180px; +} +.emoji-wrestlers_tone1 { + background-position: -840px -200px; +} +.emoji-wrestlers_tone2 { + background-position: -840px -220px; +} +.emoji-wrestlers_tone3 { + background-position: -840px -240px; +} +.emoji-wrestlers_tone4 { + background-position: -840px -260px; +} +.emoji-wrestlers_tone5 { + background-position: -840px -280px; +} +.emoji-writing_hand { + background-position: -840px -300px; +} +.emoji-writing_hand_tone1 { + background-position: -840px -320px; +} +.emoji-writing_hand_tone2 { + background-position: -840px -340px; +} +.emoji-writing_hand_tone3 { + background-position: -840px -360px; +} +.emoji-writing_hand_tone4 { + background-position: -840px -380px; +} +.emoji-writing_hand_tone5 { + background-position: -840px -400px; +} +.emoji-x { + background-position: -840px -420px; +} +.emoji-yellow_heart { + background-position: -840px -440px; +} +.emoji-yen { + background-position: -840px -460px; +} +.emoji-yin_yang { + background-position: -840px -480px; +} +.emoji-yum { + background-position: -840px -500px; +} +.emoji-zap { + background-position: -840px -520px; +} +.emoji-zero { + background-position: -840px -540px; +} +.emoji-zipper_mouth { + background-position: -840px -560px; +} +.emoji-100 { + background-position: -840px -580px; +} + +.emoji-icon { + background-image: image-url('emoji.png'); + background-repeat: no-repeat; + color: transparent; + text-indent: -99em; + height: 20px; + width: 20px; + + @media only screen and (-webkit-min-device-pixel-ratio: 2), + only screen and (min--moz-device-pixel-ratio: 2), + only screen and (-o-min-device-pixel-ratio: 2/1), + only screen and (min-device-pixel-ratio: 2), + only screen and (min-resolution: 192dpi), + only screen and (min-resolution: 2dppx) { + background-image: image-url('emoji@2x.png'); + background-size: 860px 840px; + } +} diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 2fccfa4011c..9bd35183d8a 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -1,64 +1,64 @@ -@import "framework/variables"; -@import "framework/mixins"; +@import 'framework/variables'; +@import 'framework/mixins'; @import 'framework/tw_bootstrap_variables'; @import 'framework/tw_bootstrap'; -@import "framework/layout"; +@import 'framework/layout'; -@import "framework/animations"; -@import "framework/vue_transitions"; -@import "framework/avatar"; -@import "framework/asciidoctor"; -@import "framework/banner"; -@import "framework/blocks"; -@import "framework/buttons"; -@import "framework/badges"; -@import "framework/calendar"; -@import "framework/callout"; -@import "framework/common"; -@import "framework/dropdowns"; -@import "framework/files"; -@import "framework/filters"; -@import "framework/flash"; -@import "framework/forms"; -@import "framework/gfm"; -@import "framework/gitlab_theme"; -@import "framework/header"; -@import "framework/highlight"; -@import "framework/issue_box"; -@import "framework/jquery"; -@import "framework/lists"; -@import "framework/logo"; -@import "framework/markdown_area"; -@import "framework/media_object"; -@import "framework/mobile"; -@import "framework/modal"; -@import "framework/pagination"; -@import "framework/panels"; -@import "framework/popup"; -@import "framework/secondary_navigation_elements"; -@import "framework/selects"; -@import "framework/sidebar"; -@import "framework/contextual_sidebar"; -@import "framework/tables"; -@import "framework/notes"; -@import "framework/tabs"; -@import "framework/timeline"; -@import "framework/tooltips"; -@import "framework/toggle"; -@import "framework/typography"; -@import "framework/zen"; -@import "framework/blank"; -@import "framework/wells"; -@import "framework/page_header"; -@import "framework/awards"; -@import "framework/images"; -@import "framework/broadcast_messages"; -@import "framework/emojis"; -@import "framework/emoji_sprites"; -@import "framework/icons"; -@import "framework/snippets"; -@import "framework/memory_graph"; -@import "framework/responsive_tables"; -@import "framework/stacked_progress_bar"; -@import "framework/ci_variable_list"; -@import "framework/feature_highlight"; +@import 'framework/animations'; +@import 'framework/vue_transitions'; +@import 'framework/avatar'; +@import 'framework/asciidoctor'; +@import 'framework/banner'; +@import 'framework/blocks'; +@import 'framework/buttons'; +@import 'framework/badges'; +@import 'framework/calendar'; +@import 'framework/callout'; +@import 'framework/common'; +@import 'framework/dropdowns'; +@import 'framework/files'; +@import 'framework/filters'; +@import 'framework/flash'; +@import 'framework/forms'; +@import 'framework/gfm'; +@import 'framework/gitlab_theme'; +@import 'framework/header'; +@import 'framework/highlight'; +@import 'framework/issue_box'; +@import 'framework/jquery'; +@import 'framework/lists'; +@import 'framework/logo'; +@import 'framework/markdown_area'; +@import 'framework/media_object'; +@import 'framework/mobile'; +@import 'framework/modal'; +@import 'framework/pagination'; +@import 'framework/panels'; +@import 'framework/popup'; +@import 'framework/secondary_navigation_elements'; +@import 'framework/selects'; +@import 'framework/sidebar'; +@import 'framework/contextual_sidebar'; +@import 'framework/tables'; +@import 'framework/notes'; +@import 'framework/tabs'; +@import 'framework/timeline'; +@import 'framework/tooltips'; +@import 'framework/toggle'; +@import 'framework/typography'; +@import 'framework/zen'; +@import 'framework/blank'; +@import 'framework/wells'; +@import 'framework/page_header'; +@import 'framework/awards'; +@import 'framework/images'; +@import 'framework/broadcast_messages'; +@import 'framework/emojis'; +@import 'framework/icons'; +@import 'framework/snippets'; +@import 'framework/memory_graph'; +@import 'framework/responsive_tables'; +@import 'framework/stacked_progress_bar'; +@import 'framework/ci_variable_list'; +@import 'framework/feature_highlight'; +@import 'framework/terms'; diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index f4f5926e198..cd9d60b96d3 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -106,10 +106,6 @@ @include btn-color($red-500, $red-600, $red-600, $red-700, $red-700, $red-800, $white-light); } -@mixin btn-gray { - @include btn-color($gray-light, $border-gray-normal, $gray-normal, $border-gray-normal, $gray-dark, $border-gray-dark, $gl-text-color); -} - @mixin btn-white { @include btn-color($white-light, $border-color, $white-normal, $border-white-normal, $white-dark, $border-gray-dark, $gl-text-color); } @@ -183,10 +179,6 @@ } } - &.btn-gray { - @include btn-gray; - } - &.btn-info, &.btn-primary, &.btn-register { diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss index 05cb0196ced..0bbd6eb27c1 100644 --- a/app/assets/stylesheets/framework/gitlab_theme.scss +++ b/app/assets/stylesheets/framework/gitlab_theme.scss @@ -177,25 +177,6 @@ } } - // Web IDE - .ide-sidebar-link { - color: $color-200; - background-color: $color-700; - - &:hover, - &:focus { - background-color: $color-500; - } - - &:active { - background: $color-800; - } - } - - .branch-container { - border-left-color: $color-700; - } - .branch-header-title { color: $color-700; } @@ -203,6 +184,13 @@ .ide-file-list .file.file-active { color: $color-700; } + + .ide-sidebar-link { + &.active { + color: $color-700; + box-shadow: inset 3px 0 $color-700; + } + } } body { @@ -343,9 +331,5 @@ body { .sidebar-top-level-items > li.active .badge { color: $theme-gray-900; } - - .ide-sidebar-link { - color: $white-light; - } } } diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index e12b5aab381..0ea0b65b95f 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -17,6 +17,16 @@ */ @mixin markdown-table { width: auto; + display: inline-block; + overflow-x: auto; + border-left: 0; + border-right: 0; + border-bottom: 0; + + @supports(width: fit-content) { + display: block; + width: fit-content; + } } /* @@ -200,3 +210,15 @@ margin-left: -$size; } } + +/* + * Mixin that fixes wrapping issues with long strings (e.g. URLs) + * + * Note: the width needs to be set for it to work in Firefox + */ +@mixin overflow-break-word { + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + max-width: 100%; +} diff --git a/app/assets/stylesheets/framework/terms.scss b/app/assets/stylesheets/framework/terms.scss new file mode 100644 index 00000000000..744fd0ff796 --- /dev/null +++ b/app/assets/stylesheets/framework/terms.scss @@ -0,0 +1,64 @@ +.terms { + .with-performance-bar & { + margin-top: 0; + } + + .alert-wrapper { + min-height: $header-height + $gl-padding; + } + + .content { + padding-top: $gl-padding; + } + + .panel { + .panel-heading { + display: -webkit-flex; + display: flex; + align-items: center; + justify-content: space-between; + line-height: $line-height-base; + + .title { + display: flex; + align-items: center; + + .logo-text { + width: 55px; + height: 24px; + display: flex; + flex-direction: column; + justify-content: center; + } + } + + .navbar-collapse { + padding-right: 0; + + .navbar-nav { + margin: 0; + } + } + + .nav li { + float: none; + } + } + + .panel-content { + padding: $gl-padding; + + *:first-child { + margin-top: 0; + } + + *:last-child { + margin-bottom: 0; + } + } + + .footer-block { + margin: 0; + } + } +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 3d28df455bb..b5505538541 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -230,6 +230,7 @@ $row-hover: $blue-50; $row-hover-border: $blue-200; $progress-color: #c0392b; $header-height: 40px; +$ide-statusbar-height: 27px; $fixed-layout-width: 1280px; $limited-layout-width: 990px; $limited-layout-width-sm: 790px; @@ -264,6 +265,7 @@ $performance-bar-height: 35px; $flash-height: 52px; $context-header-height: 60px; $breadcrumb-min-height: 48px; +$gcp-signup-offer-icon-max-width: 125px; /* * Common component specific colors @@ -333,11 +335,10 @@ $diff-jagged-border-gradient-color: darken($white-normal, 8%); /* * Fonts */ -$monospace_font: 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', - 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace; -$regular_font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, - Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif, - 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; +$monospace_font: 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', 'Ubuntu Mono', + 'Courier New', 'andale mono', 'lucida console', monospace; +$regular_font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, + 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; /* * Dropdowns @@ -465,11 +466,9 @@ $issue-boards-card-shadow: rgba(186, 186, 186, 0.5); */ $issue-boards-filter-height: 68px; $issue-boards-breadcrumbs-height-xs: 63px; -$issue-board-list-difference-xs: $header-height + - $issue-boards-breadcrumbs-height-xs; +$issue-board-list-difference-xs: $header-height + $issue-boards-breadcrumbs-height-xs; $issue-board-list-difference-sm: $header-height + $breadcrumb-min-height; -$issue-board-list-difference-md: $issue-board-list-difference-sm + - $issue-boards-filter-height; +$issue-board-list-difference-md: $issue-board-list-difference-sm + $issue-boards-filter-height; /* * Avatar @@ -690,6 +689,8 @@ $stage-hover-bg: $gray-darker; $ci-action-icon-size: 22px; $pipeline-dropdown-line-height: 20px; $pipeline-dropdown-status-icon-size: 18px; +$ci-action-dropdown-button-size: 24px; +$ci-action-dropdown-svg-size: 12px; /* CI variable lists diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 318d3ddaece..011d38532b4 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -284,6 +284,9 @@ box-shadow: 0 1px 2px $issue-boards-card-shadow; list-style: none; + // as a fallback, hide overflow content so that dragging and dropping still works + overflow: hidden; + &:not(:last-child) { margin-bottom: 5px; } @@ -310,13 +313,13 @@ } .card-title { + @include overflow-break-word(); margin: 0 30px 0 0; font-size: 1em; line-height: inherit; a { color: $gl-text-color; - word-wrap: break-word; margin-right: 2px; } } @@ -461,6 +464,7 @@ } .issuable-header-text { + @include overflow-break-word(); padding-right: 35px; > strong { diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index 7b8ee026357..3fd13078131 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -26,3 +26,51 @@ margin-right: 0; } } + +.gcp-signup-offer { + background-color: $blue-50; + border: 1px solid $blue-300; + border-radius: $border-radius-default; + + // TODO: To be superceded by cssLab + &.alert { + padding: 24px 16px; + + &-dismissable { + padding-right: 32px; + + .close { + top: -8px; + right: -16px; + color: $blue-500; + opacity: 1; + } + } + } + + .gcp-logo { + margin-bottom: $gl-padding; + text-align: center; + } + + img { + max-width: $gcp-signup-offer-icon-max-width; + } + + a:not(.btn) { + color: $gl-link-color; + font-weight: normal; + text-decoration: none; + } + + @media (min-width: $screen-sm-min) { + > div { + display: flex; + align-items: center; + } + + .gcp-logo { + margin: 0; + } + } +} diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 1aca3c5cf1a..944996159d7 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -2,7 +2,6 @@ background: none; border: 0; padding: 0; - margin-top: 10px; word-break: normal; white-space: pre-wrap; } @@ -21,10 +20,6 @@ margin: 0; color: $gl-text-color; } - - .commit-description { - margin-top: 15px; - } } .commit-hash-full { @@ -178,7 +173,7 @@ .commit-detail { display: flex; justify-content: space-between; - align-items: center; + align-items: start; flex-grow: 1; } @@ -268,20 +263,16 @@ .commit-row-description { font-size: 14px; - padding: 10px 15px; - margin: 10px 0; - background: $gray-light; + padding: 0 0 0 $gl-padding-8; + border: 0; display: none; white-space: pre-wrap; word-break: normal; - - pre { - border: 0; - background: inherit; - padding: 0; - margin: 0; - white-space: pre-wrap; - } + color: $gl-text-color-secondary; + background: none; + font-family: inherit; + border-left: 2px solid $theme-gray-300; + border-radius: unset; a { color: $gl-text-color; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 484b480dc02..f64530695bd 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -44,6 +44,12 @@ } } + .note-text { + table { + font-family: $font-family-sans-serif; + } + } + table { width: 100%; font-family: $monospace_font; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 3a300086fa3..1f406cc1c2d 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -283,28 +283,59 @@ } &.popover { + padding: 0; + border: 1px solid $border-color; + &.left { left: auto; right: 0; margin-right: 10px; + + > .arrow { + right: -16px; + border-left-color: $border-color; + } + + > .arrow::after { + border-left-color: $theme-gray-50; + } } &.right { left: 0; right: auto; margin-left: 10px; + + > .arrow { + left: -16px; + border-right-color: $border-color; + } + + > .arrow::after { + border-right-color: $theme-gray-50; + } } > .arrow { - top: 40px; + top: 16px; + margin-top: -8px; + border-width: 8px; } > .popover-title, > .popover-content { - padding: 5px 8px; + padding: 8px; font-size: 12px; white-space: nowrap; } + + > .popover-title { + background-color: $theme-gray-50; + } + } + + strong { + font-weight: 600; } } @@ -317,7 +348,7 @@ vertical-align: middle; + td { - padding-left: 5px; + padding-left: 8px; vertical-align: top; } } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 66db4917178..3581dd36a10 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -156,10 +156,6 @@ .dropdown-menu { z-index: 300; } - - .ci-action-icon-wrapper { - line-height: 16px; - } } .mini-pipeline-graph-dropdown-toggle { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index d87df4ca3ae..37ab472f062 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -414,10 +414,6 @@ ul.notes { .note-header { display: flex; justify-content: space-between; - - @include notes-media('max', $screen-xs-max) { - flex-flow: row wrap; - } } .note-header-info { @@ -480,11 +476,6 @@ ul.notes { margin-left: 10px; color: $gray-darkest; - @include notes-media('max', $screen-md-max) { - float: none; - margin-left: 0; - } - .btn-group > .discussion-next-btn { margin-left: -1px; } diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 3a8ec779c14..02803e7b040 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -22,7 +22,6 @@ } .ci-table { - .label { margin-bottom: 3px; } @@ -123,7 +122,6 @@ } .branch-commit { - .ref-name { font-weight: $gl-font-weight-bold; max-width: 100px; @@ -481,43 +479,6 @@ @extend .build-content:hover; } - .ci-action-icon-container { - position: absolute; - right: 5px; - top: 5px; - - // Action Icons in big pipeline-graph nodes - &.ci-action-icon-wrapper { - height: 30px; - width: 30px; - background: $white-light; - border: 1px solid $border-color; - border-radius: 100%; - display: block; - - &:hover { - background-color: $stage-hover-bg; - border: 1px solid $dropdown-toggle-active-border-color; - - svg { - fill: $gl-text-color; - } - } - - svg { - fill: $gl-text-color-secondary; - position: relative; - top: -1px; - } - - &.play { - svg { - left: 2px; - } - } - } - } - .ci-status-icon svg { height: 20px; width: 20px; @@ -548,7 +509,6 @@ border: 1px solid $dropdown-toggle-active-border-color; } - // Connect first build in each stage with right horizontal line &:first-child { &::after { @@ -602,6 +562,43 @@ } } } + + .ci-action-icon-container { + position: absolute; + right: 5px; + top: 5px; + + // Action Icons in big pipeline-graph nodes + &.ci-action-icon-wrapper { + height: 30px; + width: 30px; + background: $white-light; + border: 1px solid $border-color; + border-radius: 100%; + display: block; + + &:hover { + background-color: $stage-hover-bg; + border: 1px solid $dropdown-toggle-active-border-color; + + svg { + fill: $gl-text-color; + } + } + + svg { + fill: $gl-text-color-secondary; + position: relative; + top: -1px; + } + + &.play { + svg { + left: 2px; + } + } + } + } } // Triggers the dropdown in the big pipeline graph @@ -710,93 +707,77 @@ button.mini-pipeline-graph-dropdown-toggle { } } -// dropdown content for big and mini pipeline +/** + Action icons inside dropdowns: + - mini graph in pipelines table + - dropdown in big graph + - mini graph in MR widget pipeline + - mini graph in Commit widget pipeline +*/ .big-pipeline-graph-dropdown-menu, .mini-pipeline-graph-dropdown-menu { width: 240px; max-width: 240px; - .scrollable-menu { + // override dropdown.scss + &.dropdown-menu li button, + &.dropdown-menu li a.ci-action-icon-container { padding: 0; - max-height: 245px; - overflow: auto; + text-align: center; } - li { - position: relative; + .ci-action-icon-container { + position: absolute; + right: 8px; + top: 8px; - // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered - &:hover > .mini-pipeline-graph-dropdown-item, - &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item { - @extend .mini-pipeline-graph-dropdown-item:hover; - } + &.ci-action-icon-wrapper { + height: $ci-action-dropdown-button-size; + width: $ci-action-dropdown-button-size; - // Action icon on the right - a.ci-action-icon-wrapper { - border-radius: 50%; + background: $white-light; border: 1px solid $border-color; - width: $ci-action-icon-size; - height: $ci-action-icon-size; - padding: 2px 0 0 5px; - font-size: 12px; - background-color: $white-light; - position: absolute; - top: 50%; - right: $gl-padding; - margin-top: -#{$ci-action-icon-size / 2}; + border-radius: 50%; + display: block; - &:hover, - &:focus { + &:hover { background-color: $stage-hover-bg; border: 1px solid $dropdown-toggle-active-border-color; + + svg { + fill: $gl-text-color; + } } svg { + width: $ci-action-dropdown-svg-size; + height: $ci-action-dropdown-svg-size; fill: $gl-text-color-secondary; - width: #{$ci-action-icon-size - 6}; - height: #{$ci-action-icon-size - 6}; - left: -3px; position: relative; - top: -1px; - - &.icon-action-stop, - &.icon-action-cancel { - width: 12px; - height: 12px; - top: 1px; - left: -1px; - } - - &.icon-action-play { - width: 11px; - height: 11px; - top: 1px; - left: 1px; - } - - &.icon-action-retry { - width: 16px; - height: 16px; - top: 0; - left: -3px; - } + top: 0; + vertical-align: initial; } + } + } - &:hover svg, - &:focus svg { - fill: $gl-text-color; - } + // SVGs in the commit widget and mr widget + a.ci-action-icon-container.ci-action-icon-wrapper svg { + top: 2px; + } - &.icon-action-retry, - &.icon-action-play { - svg { - width: #{$ci-action-icon-size - 6}; - height: #{$ci-action-icon-size - 6}; - left: 8px; - } - } + .scrollable-menu { + padding: 0; + max-height: 245px; + overflow: auto; + } + li { + position: relative; + // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered + &:hover > .mini-pipeline-graph-dropdown-item, + &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item { + @extend .mini-pipeline-graph-dropdown-item:hover; } // link to the build @@ -808,6 +789,11 @@ button.mini-pipeline-graph-dropdown-toggle { line-height: $line-height-base; white-space: nowrap; + // Match dropdown.scss for all `a` tags + &.non-details-job-component { + padding: 8px 16px; + } + .ci-job-name-component { align-items: center; display: flex; @@ -939,7 +925,7 @@ button.mini-pipeline-graph-dropdown-toggle { &.dropdown-menu { transform: translate(-80%, 0); - @media(min-width: $screen-md-min) { + @media (min-width: $screen-md-min) { transform: translate(-50%, 0); right: auto; left: 50%; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index d7d343b088a..dd0cb2c2613 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -205,7 +205,6 @@ .project-repo-buttons, .group-buttons { .btn { - @include btn-gray; padding: 3px 10px; &:last-child { @@ -294,7 +293,7 @@ } .count { - @include btn-gray; + @include btn-white; display: inline-block; background: $white-light; border-radius: 2px; @@ -354,30 +353,48 @@ min-width: 200px; } -.deploy-key-content { - @media (min-width: $screen-sm-min) { - float: left; +.deploy-keys { + .scrolling-tabs-container { + position: relative; + } +} - &:last-child { - float: right; +.deploy-key { + // Ensure that the fingerprint does not overflow on small screens + .fingerprint { + word-break: break-all; + white-space: normal; + } + + .deploy-project-label, + .key-created-at { + svg { + vertical-align: text-top; } } -} -.deploy-key-projects { - @media (min-width: $screen-sm-min) { - line-height: 42px; + .btn svg { + vertical-align: top; + } + + .key-created-at { + line-height: unset; } } -a.deploy-project-label { - padding: 5px; - margin-right: 5px; - color: $gl-text-color; - background-color: $row-hover; +.deploy-project-list { + margin-bottom: -$gl-padding-4; - &:hover { - color: $gl-link-color; + a.deploy-project-label { + margin-right: $gl-padding-4; + margin-bottom: $gl-padding-4; + color: $gl-text-color-secondary; + background-color: $theme-gray-100; + line-height: $gl-btn-line-height; + + &:hover { + color: $gl-link-color; + } } } diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 6342042374f..00457717f00 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -23,6 +23,7 @@ margin-top: 0; border-top: 1px solid $white-dark; border-bottom: 1px solid $white-dark; + padding-bottom: $ide-statusbar-height; &.is-collapsed { .ide-file-list { @@ -121,14 +122,6 @@ .multi-file-loading-container { margin-top: 10px; padding: 10px; - - .animation-container { - background: $gray-light; - - div { - background: $gray-light; - } - } } .multi-file-table-col-commit-message { @@ -155,69 +148,56 @@ } li { - position: relative; - } - - .dropdown { display: flex; - margin-left: auto; - margin-bottom: 1px; - padding: 0 $grid-size; - border-left: 1px solid $white-dark; - background-color: $white-light; - - &.shadow { - box-shadow: 0 0 10px $dropdown-shadow-color; - } + align-items: center; + padding: $grid-size $gl-padding; + background-color: $gray-normal; + border-right: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; - .btn { - margin-top: auto; - margin-bottom: auto; + &.active { + background-color: $white-light; + border-bottom-color: $white-light; } } } .multi-file-tab { - @include str-truncated(150px); - padding: ($gl-padding / 2) ($gl-padding + 12) ($gl-padding / 2) $gl-padding; - background-color: $gray-normal; - border-right: 1px solid $white-dark; - border-bottom: 1px solid $white-dark; + @include str-truncated(141px); cursor: pointer; svg { vertical-align: middle; } - - &.active { - background-color: $white-light; - border-bottom-color: $white-light; - } } .multi-file-tab-close { - position: absolute; - right: 8px; - top: 50%; width: 16px; height: 16px; padding: 0; + margin-left: $grid-size; background: none; border: 0; border-radius: $border-radius-default; color: $theme-gray-900; - transform: translateY(-50%); svg { position: relative; top: -1px; } - &:hover { + .ide-file-changed-icon { + display: block; + position: relative; + top: 1px; + right: -2px; + } + + &:not([disabled]):hover { background-color: $theme-gray-200; } - &:focus { + &:not([disabled]):focus { background-color: $blue-500; color: $white-light; outline: 0; @@ -248,6 +228,17 @@ display: none; } + .is-readonly, + .editor.original { + .view-lines { + cursor: default; + } + + .cursors-layer { + display: none; + } + } + .monaco-diff-editor.vs { .editor.modified { box-shadow: none; @@ -306,15 +297,12 @@ .margin-view-overlays .delete-sign { opacity: 0.4; } - - .cursors-layer { - display: none; - } } } .multi-file-editor-holder { height: 100%; + min-height: 0; } .preview-container { @@ -380,6 +368,7 @@ .ide-btn-group { padding: $gl-padding-4 $gl-vert-padding; + line-height: 24px; } .ide-status-bar { @@ -387,7 +376,13 @@ padding: $gl-bar-padding $gl-padding; background: $white-light; display: flex; - justify-content: flex-end; + justify-content: space-between; + height: $ide-statusbar-height; + + position: absolute; + bottom: 0; + left: 0; + width: 100%; > div + div { padding-left: $gl-padding; @@ -398,6 +393,14 @@ } } +.ide-status-file { + text-align: right; + + .ide-status-branch + &, + &:first-child { + margin-left: auto; + } +} // Not great, but this is to deal with our current output .multi-file-preview-holder { height: 100%; @@ -433,27 +436,35 @@ .multi-file-commit-panel { display: flex; position: relative; - flex-direction: column; width: 340px; padding: 0; background-color: $gray-light; - padding-right: 3px; + padding-right: 1px; + + .context-header { + width: auto; + margin-right: 0; + + a:hover, + a:focus { + text-decoration: none; + } + } .projects-sidebar { + min-height: 0; display: flex; flex-direction: column; flex: 1; - - .context-header { - width: auto; - margin-right: 0; - } } .multi-file-commit-panel-inner { + position: relative; display: flex; flex-direction: column; height: 100%; + min-width: 0; + width: 100%; } .multi-file-commit-panel-inner-scroll { @@ -461,68 +472,10 @@ flex: 1; flex-direction: column; overflow: auto; - } - - &.is-collapsed { - width: 60px; - - .multi-file-commit-list { - padding-top: $gl-padding; - overflow: hidden; - } - - .multi-file-context-bar-icon { - align-items: center; - - svg { - float: none; - margin: 0; - } - } - } - - .branch-container { - border-left: 4px solid; - margin-bottom: $gl-bar-padding; - } - - .branch-header { - background: $white-dark; - display: flex; - } - - .branch-header-title { - flex: 1; - padding: $grid-size $gl-padding; - font-weight: $gl-font-weight-bold; - - svg { - vertical-align: middle; - } - } - - .branch-header-btns { - padding: $gl-vert-padding $gl-padding; - } - - .left-collapse-btn { - display: none; - background: $gray-light; - text-align: left; + background-color: $white-light; + border-left: 1px solid $white-dark; border-top: 1px solid $white-dark; - - svg { - vertical-align: middle; - } - } -} - -.multi-file-context-bar-icon { - padding: 10px; - - svg { - margin-right: 10px; - float: left; + border-top-left-radius: $border-radius-small; } } @@ -548,13 +501,13 @@ align-items: center; margin-bottom: 0; border-bottom: 1px solid $white-dark; - padding: $gl-btn-padding 0; + padding: $gl-btn-padding $gl-padding; } .multi-file-commit-panel-header-title { display: flex; flex: 1; - padding-left: $grid-size; + align-items: center; svg { margin-right: $gl-btn-padding; @@ -570,7 +523,7 @@ .multi-file-commit-list { flex: 1; overflow: auto; - padding: $gl-padding 0; + padding: $gl-padding; min-height: 60px; } @@ -602,14 +555,14 @@ } } -.multi-file-additions, -.multi-file-additions-solid { - fill: $green-500; +.multi-file-addition, +.multi-file-addition-solid { + color: $green-500; } .multi-file-modified, .multi-file-modified-solid { - fill: $orange-500; + color: $orange-500; } .multi-file-commit-list-collapsed { @@ -665,12 +618,24 @@ } .multi-file-commit-form { + position: relative; padding: $gl-padding; + background-color: $white-light; border-top: 1px solid $white-dark; + border-left: 1px solid $white-dark; + transition: all 0.3s ease; .btn { font-size: $gl-font-size; } + + .multi-file-commit-panel-success-message { + top: 0; + } +} + +.multi-file-commit-panel-bottom { + position: relative; } .dirty-diff { @@ -806,7 +771,7 @@ position: absolute; top: 0; bottom: 0; - width: 3px; + width: 1px; background-color: $white-dark; &.dragright { @@ -820,42 +785,40 @@ .ide-commit-list-container { display: flex; + flex: 1; flex-direction: column; width: 100%; - padding: 0 16px; - - &:not(.is-collapsed) { - flex: 1; - min-height: 140px; - } - - &.is-collapsed { - .multi-file-commit-panel-header { - margin-left: -$gl-padding; - margin-right: -$gl-padding; - - svg { - margin-left: auto; - margin-right: auto; - } + min-height: 140px; - .multi-file-commit-panel-collapse-btn { - margin-right: auto; - margin-left: auto; - border-left: 0; - } - } + &.is-first { + border-bottom: 1px solid $white-dark; } } .ide-staged-action-btn { margin-left: auto; - color: $gl-link-color; + line-height: 22px; +} + +.ide-commit-file-count { + min-width: 22px; + margin-left: auto; + background-color: $gray-light; + border-radius: $border-radius-default; + border: 1px solid $white-dark; + line-height: 20px; + text-align: center; } .ide-commit-radios { label { font-weight: normal; + + &.is-disabled { + .ide-radio-label { + text-decoration: line-through; + } + } } .help-block { @@ -868,17 +831,58 @@ margin-left: 25px; } -.ide-external-links { - p { - margin: 0; - } -} - .ide-sidebar-link { - padding: $gl-padding-8 $gl-padding; display: flex; align-items: center; - font-weight: $gl-font-weight-bold; + position: relative; + height: 60px; + width: 100%; + padding: 0 $gl-padding; + color: $gl-text-color-secondary; + background-color: transparent; + border: 0; + border-top: 1px solid transparent; + border-bottom: 1px solid transparent; + outline: 0; + + svg { + margin: 0 auto; + } + + &:hover { + color: $gl-text-color; + background-color: $theme-gray-100; + } + + &:focus { + color: $gl-text-color; + background-color: $theme-gray-200; + } + + &.active { + // extend width over border of sidebar section + width: calc(100% + 1px); + padding-right: $gl-padding + 1px; + background-color: $white-light; + border-top-color: $white-dark; + border-bottom-color: $white-dark; + + &::after { + content: ''; + position: absolute; + right: -1px; + top: 0; + bottom: 0; + width: 1px; + background: $white-light; + } + } +} + +.ide-activity-bar { + position: relative; + flex: 0 0 60px; + z-index: 1; } .ide-file-finder-overlay { @@ -972,6 +976,120 @@ resize: none; } +.ide-tree-header { + display: flex; + align-items: center; + padding: 10px 0; + margin-left: 10px; + margin-right: 10px; + border-bottom: 1px solid $white-dark; + + .ide-new-btn { + margin-left: auto; + } +} + +.ide-sidebar-branch-title { + font-weight: $gl-font-weight-normal; + + svg { + position: relative; + top: 3px; + margin-top: -1px; + } +} + +.commit-form-compact { + .btn { + margin-bottom: 8px; + } + + p { + margin-bottom: 0; + } +} + +.commit-form-slide-up-enter-active, +.commit-form-slide-up-leave-active { + position: absolute; + top: 16px; + left: 16px; + right: 16px; + transition: all 0.3s ease; +} + +.is-full .commit-form-slide-up-enter, +.is-compact .commit-form-slide-up-leave-to { + transform: translateY(100%); +} + +.is-full .commit-form-slide-up-enter-to, +.is-compact .commit-form-slide-up-leave { + transform: translateY(0); +} + +.commit-form-slide-up-enter, +.commit-form-slide-up-leave-to { + opacity: 0; +} + +.ide-review-header { + flex-direction: column; + align-items: flex-start; + + .dropdown { + margin-left: auto; + } + + a { + color: $gl-link-color; + } +} + +.ide-review-sub-header { + color: $gl-text-color-secondary; +} + +.ide-tree-changes { + display: flex; + align-items: center; + font-size: 12px; +} + .ide-new-modal-label { line-height: 34px; } + +.multi-file-commit-panel-success-message { + position: absolute; + top: 61px; + left: 1px; + bottom: 0; + right: 0; + z-index: 10; + background: $white-light; + overflow: auto; + display: flex; + flex-direction: column; + justify-content: center; +} + +.ide-review-button-holder { + display: flex; + width: 100%; + align-items: center; +} + +.ide-context-header { + .avatar { + flex: 0 0 40px; + } +} + +.ide-sidebar-project-title { + min-width: 0; + + .sidebar-context-title { + white-space: nowrap; + } +} diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss index 9a0ec936979..e70a57c2a67 100644 --- a/app/assets/stylesheets/pages/wiki.scss +++ b/app/assets/stylesheets/pages/wiki.scss @@ -180,11 +180,6 @@ ul.wiki-pages-list.content-list { } } -.wiki-holder { - overflow-x: auto; - overflow-y: hidden; -} - .wiki { table { @include markdown-table; diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index b07a5ae22cd..90ccd4abd90 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -36,7 +36,9 @@ ul.notes-form, .gutter-toggle, .issuable-details .content-block-small, .edit-link, -.note-action-button { +.note-action-button, +.right-sidebar, +.flash-container { display: none !important; } @@ -53,3 +55,7 @@ pre { .right-sidebar { top: 0; } + +a[href]::after { + content: none !important; +} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 8ad13a82f89..2843d70c645 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -13,12 +13,13 @@ class ApplicationController < ActionController::Base before_action :authenticate_sessionless_user! before_action :authenticate_user! + before_action :enforce_terms!, if: :should_enforce_terms? before_action :validate_user_service_ticket! before_action :check_password_expiration before_action :ldap_security_check before_action :sentry_context before_action :default_headers - before_action :add_gon_variables, unless: -> { request.path.start_with?('/-/peek') } + before_action :add_gon_variables, unless: :peek_request? before_action :configure_permitted_parameters, if: :devise_controller? before_action :require_email, unless: :devise_controller? @@ -269,6 +270,27 @@ class ApplicationController < ActionController::Base end end + def enforce_terms! + return unless current_user + return if current_user.terms_accepted? + + if sessionless_user? + render_403 + else + # Redirect to the destination if the request is a get. + # Redirect to the source if it was a post, so the user can re-submit after + # accepting the terms. + redirect_path = if request.get? + request.fullpath + else + URI(request.referer).path if request.referer + end + + flash[:notice] = _("Please accept the Terms of Service before continuing.") + redirect_to terms_path(redirect: redirect_path), status: :found + end + end + def import_sources_enabled? !Gitlab::CurrentSettings.import_sources.empty? end @@ -342,4 +364,18 @@ class ApplicationController < ActionController::Base # Per https://tools.ietf.org/html/rfc5987, headers need to be ISO-8859-1, not UTF-8 response.headers['Page-Title'] = URI.escape(page_title('GitLab')) end + + def sessionless_user? + current_user && !session.keys.include?('warden.user.user.key') + end + + def peek_request? + request.path.start_with?('/-/peek') + end + + def should_enforce_terms? + return false unless Gitlab::CurrentSettings.current_application_settings.enforce_terms + + !(peek_request? || devise_controller?) + end end diff --git a/app/controllers/concerns/continue_params.rb b/app/controllers/concerns/continue_params.rb index eb3a623acdd..8b7355974df 100644 --- a/app/controllers/concerns/continue_params.rb +++ b/app/controllers/concerns/continue_params.rb @@ -1,4 +1,5 @@ module ContinueParams + include InternalRedirect extend ActiveSupport::Concern def continue_params @@ -6,8 +7,7 @@ module ContinueParams return nil unless continue_params continue_params = continue_params.permit(:to, :notice, :notice_now) - return unless continue_params[:to] && continue_params[:to].start_with?('/') - return if continue_params[:to].start_with?('//') + continue_params[:to] = safe_redirect_path(continue_params[:to]) continue_params end diff --git a/app/controllers/concerns/internal_redirect.rb b/app/controllers/concerns/internal_redirect.rb new file mode 100644 index 00000000000..7409b2e89a5 --- /dev/null +++ b/app/controllers/concerns/internal_redirect.rb @@ -0,0 +1,35 @@ +module InternalRedirect + extend ActiveSupport::Concern + + def safe_redirect_path(path) + return unless path + # Verify that the string starts with a `/` but not a double `/`. + return unless path =~ %r{^/\w.*$} + + uri = URI(path) + # Ignore anything path of the redirect except for the path, querystring and, + # fragment, forcing the redirect within the same host. + full_path_for_uri(uri) + rescue URI::InvalidURIError + nil + end + + def safe_redirect_path_for_url(url) + return unless url + + uri = URI(url) + safe_redirect_path(full_path_for_uri(uri)) if host_allowed?(uri) + rescue URI::InvalidURIError + nil + end + + def host_allowed?(uri) + uri.host == request.host && + uri.port == request.port + end + + def full_path_for_uri(uri) + path_with_query = [uri.path, uri.query].compact.join('?') + [path_with_query, uri.fragment].compact.join("#") + end +end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 0379f76fc3d..c925b4aada5 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -18,7 +18,6 @@ module IssuableActions def update @issuable = update_service.execute(issuable) # rubocop:disable Gitlab/ModuleWithInstanceVariables - respond_to do |format| format.html do recaptcha_check_if_spammable { render :edit } diff --git a/app/controllers/concerns/send_file_upload.rb b/app/controllers/concerns/send_file_upload.rb index 55011c89886..237c93daee8 100644 --- a/app/controllers/concerns/send_file_upload.rb +++ b/app/controllers/concerns/send_file_upload.rb @@ -2,6 +2,10 @@ module SendFileUpload def send_upload(file_upload, send_params: {}, redirect_params: {}, attachment: nil, disposition: 'attachment') if attachment redirect_params[:query] = { "response-content-disposition" => "#{disposition};filename=#{attachment.inspect}" } + # By default, Rails will send uploads with an extension of .js with a + # content-type of text/javascript, which will trigger Rails' + # cross-origin JavaScript protection. + send_params[:content_type] = 'text/plain' if File.extname(attachment) == '.js' send_params.merge!(filename: attachment, disposition: disposition) end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 134b0dfc0db..ef3eba80154 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -11,13 +11,20 @@ class Groups::GroupMembersController < Groups::ApplicationController :override def index + can_manage_members = can?(current_user, :admin_group_member, @group) + @sort = params[:sort].presence || sort_value_name @project = @group.projects.find(params[:project_id]) if params[:project_id] @members = GroupMembersFinder.new(@group).execute - @members = @members.non_invite unless can?(current_user, :admin_group, @group) + @members = @members.non_invite unless can_manage_members @members = @members.search(params[:search]) if params[:search].present? @members = @members.sort_by_attribute(@sort) + + if can_manage_members && params[:two_factor].present? + @members = @members.filter_by_2fa(params[:two_factor]) + end + @members = @members.page(params[:page]).per(50) @members = present_members(@members.includes(:user)) diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb new file mode 100644 index 00000000000..78992ec7f46 --- /dev/null +++ b/app/controllers/groups/runners_controller.rb @@ -0,0 +1,58 @@ +class Groups::RunnersController < Groups::ApplicationController + # Proper policies should be implemented per + # https://gitlab.com/gitlab-org/gitlab-ce/issues/45894 + before_action :authorize_admin_pipeline! + + before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show] + + def show + render 'shared/runners/show' + end + + def edit + end + + def update + if Ci::UpdateRunnerService.new(@runner).update(runner_params) + redirect_to group_runner_path(@group, @runner), notice: 'Runner was successfully updated.' + else + render 'edit' + end + end + + def destroy + @runner.destroy + + redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: 302 + end + + def resume + if Ci::UpdateRunnerService.new(@runner).update(active: true) + redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: 'Runner was successfully updated.' + else + redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: 'Runner was not updated.' + end + end + + def pause + if Ci::UpdateRunnerService.new(@runner).update(active: false) + redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: 'Runner was successfully updated.' + else + redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: 'Runner was not updated.' + end + end + + private + + def runner + @runner ||= @group.runners.find(params[:id]) + end + + def authorize_admin_pipeline! + return render_404 unless can?(current_user, :admin_pipeline, group) + end + + def runner_params + params.require(:runner).permit(Ci::Runner::FORM_EDITABLE) + end +end diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb index c84fc2d305d..663269a0f92 100644 --- a/app/controllers/import/base_controller.rb +++ b/app/controllers/import/base_controller.rb @@ -1,6 +1,17 @@ class Import::BaseController < ApplicationController private + def find_already_added_projects(import_type) + current_user.created_projects.where(import_type: import_type).includes(:import_state) + end + + def find_jobs(import_type) + current_user.created_projects + .includes(:import_state) + .where(import_type: import_type) + .to_json(only: [:id], methods: [:import_status]) + end + def find_or_create_namespace(names, owner) names = params[:target_namespace].presence || names diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index 61d81ad8a71..77af5fb9c4f 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -22,16 +22,14 @@ class Import::BitbucketController < Import::BaseController @repos, @incompatible_repos = repos.partition { |repo| repo.valid? } - @already_added_projects = current_user.created_projects.where(import_type: 'bitbucket') + @already_added_projects = find_already_added_projects('bitbucket') already_added_projects_names = @already_added_projects.pluck(:import_source) @repos.to_a.reject! { |repo| already_added_projects_names.include?(repo.full_name) } end def jobs - render json: current_user.created_projects - .where(import_type: 'bitbucket') - .to_json(only: [:id, :import_status]) + render json: find_jobs('bitbucket') end def create diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb index 669eb31a995..25ec13b8075 100644 --- a/app/controllers/import/fogbugz_controller.rb +++ b/app/controllers/import/fogbugz_controller.rb @@ -46,15 +46,14 @@ class Import::FogbugzController < Import::BaseController @repos = client.repos - @already_added_projects = current_user.created_projects.where(import_type: 'fogbugz') + @already_added_projects = find_already_added_projects('fogbugz') already_added_projects_names = @already_added_projects.pluck(:import_source) @repos.reject! { |repo| already_added_projects_names.include? repo.name } end def jobs - jobs = current_user.created_projects.where(import_type: 'fogbugz').to_json(only: [:id, :import_status]) - render json: jobs + render json: find_jobs('fogbugz') end def create diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index eb7d5fca367..f67ec4c248b 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -24,15 +24,14 @@ class Import::GithubController < Import::BaseController def status @repos = client.repos - @already_added_projects = current_user.created_projects.where(import_type: provider) + @already_added_projects = find_already_added_projects(provider) already_added_projects_names = @already_added_projects.pluck(:import_source) @repos.reject! { |repo| already_added_projects_names.include? repo.full_name } end def jobs - jobs = current_user.created_projects.where(import_type: provider).to_json(only: [:id, :import_status]) - render json: jobs + render json: find_jobs(provider) end def create diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb index 18f1d20f5a9..39e2e9e094b 100644 --- a/app/controllers/import/gitlab_controller.rb +++ b/app/controllers/import/gitlab_controller.rb @@ -12,15 +12,14 @@ class Import::GitlabController < Import::BaseController def status @repos = client.projects - @already_added_projects = current_user.created_projects.where(import_type: "gitlab") + @already_added_projects = find_already_added_projects('gitlab') already_added_projects_names = @already_added_projects.pluck(:import_source) @repos = @repos.to_a.reject { |repo| already_added_projects_names.include? repo["path_with_namespace"] } end def jobs - jobs = current_user.created_projects.where(import_type: "gitlab").to_json(only: [:id, :import_status]) - render json: jobs + render json: find_jobs('gitlab') end def create diff --git a/app/controllers/import/google_code_controller.rb b/app/controllers/import/google_code_controller.rb index baa19fb383d..9b26a00f7c7 100644 --- a/app/controllers/import/google_code_controller.rb +++ b/app/controllers/import/google_code_controller.rb @@ -73,15 +73,14 @@ class Import::GoogleCodeController < Import::BaseController @repos = client.repos @incompatible_repos = client.incompatible_repos - @already_added_projects = current_user.created_projects.where(import_type: "google_code") + @already_added_projects = find_already_added_projects('google_code') already_added_projects_names = @already_added_projects.pluck(:import_source) @repos.reject! { |repo| already_added_projects_names.include? repo.name } end def jobs - jobs = current_user.created_projects.where(import_type: "google_code").to_json(only: [:id, :import_status]) - render json: jobs + render json: find_jobs('google_code') end def create diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 40d9fa18a10..ed89bed029b 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -82,7 +82,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController if identity_linker.changed? redirect_identity_linked - elsif identity_linker.error_message.present? + elsif identity_linker.failed? redirect_identity_link_failed(identity_linker.error_message) else redirect_identity_exists diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index 2b0c2ca97c0..f93e500a07a 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -8,8 +8,11 @@ class Projects::CompareController < Projects::ApplicationController # Authorize before_action :require_non_empty_project before_action :authorize_download_code! - before_action :define_ref_vars, only: [:index, :show, :diff_for_path] - before_action :define_diff_vars, only: [:show, :diff_for_path] + # Defining ivars + before_action :define_diffs, only: [:show, :diff_for_path] + before_action :define_environment, only: [:show] + before_action :define_diff_notes_disabled, only: [:show, :diff_for_path] + before_action :define_commits, only: [:show, :diff_for_path, :signatures] before_action :merge_request, only: [:index, :show] def index @@ -22,9 +25,9 @@ class Projects::CompareController < Projects::ApplicationController end def diff_for_path - return render_404 unless @compare + return render_404 unless compare - render_diff_for_path(@compare.diffs(diff_options)) + render_diff_for_path(compare.diffs(diff_options)) end def create @@ -41,30 +44,60 @@ class Projects::CompareController < Projects::ApplicationController end end + def signatures + respond_to do |format| + format.json do + render json: { + signatures: @commits.select(&:has_signature?).map do |commit| + { + commit_sha: commit.sha, + html: view_to_html_string('projects/commit/_signature', signature: commit.signature) + } + end + } + end + end + end + private - def define_ref_vars - @start_ref = Addressable::URI.unescape(params[:from]) + def compare + return @compare if defined?(@compare) + + @compare = CompareService.new(@project, head_ref).execute(@project, start_ref) + end + + def start_ref + @start_ref ||= Addressable::URI.unescape(params[:from]) + end + + def head_ref + return @ref if defined?(@ref) + @ref = @head_ref = Addressable::URI.unescape(params[:to]) end - def define_diff_vars - @compare = CompareService.new(@project, @head_ref) - .execute(@project, @start_ref) + def define_commits + @commits = compare.present? ? prepare_commits_for_rendering(compare.commits) : [] + end - if @compare - @commits = prepare_commits_for_rendering(@compare.commits) - @diffs = @compare.diffs(diff_options) + def define_diffs + @diffs = compare.present? ? compare.diffs(diff_options) : [] + end - environment_params = @repository.branch_exists?(@head_ref) ? { ref: @head_ref } : { commit: @compare.commit } + def define_environment + if compare + environment_params = @repository.branch_exists?(head_ref) ? { ref: head_ref } : { commit: compare.commit } @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last - - @diff_notes_disabled = true end end + def define_diff_notes_disabled + @diff_notes_disabled = compare.present? + end + def merge_request @merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened - .find_by(source_project: @project, source_branch: @head_ref, target_branch: @start_ref) + .find_by(source_project: @project, source_branch: head_ref, target_branch: start_ref) end end diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 4a377fefc62..81129456ad8 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -83,13 +83,6 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap render layout: false end - def update_branches - @target_project = selected_target_project - @target_branches = @target_project ? @target_project.repository.branch_names : [] - - render layout: false - end - private def build_merge_request diff --git a/app/controllers/projects/mirrors_controller.rb b/app/controllers/projects/mirrors_controller.rb new file mode 100644 index 00000000000..5698ff4e706 --- /dev/null +++ b/app/controllers/projects/mirrors_controller.rb @@ -0,0 +1,67 @@ +class Projects::MirrorsController < Projects::ApplicationController + include RepositorySettingsRedirect + + # Authorize + before_action :remote_mirror, only: [:update] + before_action :check_mirror_available! + before_action :authorize_admin_project! + + layout "project_settings" + + def show + redirect_to_repository_settings(project) + end + + def update + if project.update_attributes(mirror_params) + flash[:notice] = 'Mirroring settings were successfully updated.' + else + flash[:alert] = project.errors.full_messages.join(', ').html_safe + end + + respond_to do |format| + format.html { redirect_to_repository_settings(project) } + format.json do + if project.errors.present? + render json: project.errors, status: :unprocessable_entity + else + render json: ProjectMirrorSerializer.new.represent(project) + end + end + end + end + + def update_now + if params[:sync_remote] + project.update_remote_mirrors + flash[:notice] = "The remote repository is being updated..." + end + + redirect_to_repository_settings(project) + end + + private + + def remote_mirror + @remote_mirror = project.remote_mirrors.first_or_initialize + end + + def check_mirror_available! + Gitlab::CurrentSettings.current_application_settings.mirror_available || current_user&.admin? + end + + def mirror_params_attributes + [ + remote_mirrors_attributes: %i[ + url + id + enabled + only_protected_branches + ] + ] + end + + def mirror_params + params.require(:project).permit(mirror_params_attributes) + end +end diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index bc13b8ad7ba..4d4c2af2415 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -8,19 +8,6 @@ class Projects::NotesController < Projects::ApplicationController before_action :authorize_create_note!, only: [:create] before_action :authorize_resolve_note!, only: [:resolve, :unresolve] - # - # This is a fix to make spinach feature tests passing: - # Controller actions are returned from AbstractController::Base and methods of parent classes are - # excluded in order to return only specific controller related methods. - # That is ok for the app (no :create method in ancestors) - # but fails for tests because there is a :create method on FactoryBot (one of the ancestors) - # - # see https://github.com/rails/rails/blob/v4.2.7/actionpack/lib/abstract_controller/base.rb#L78 - # - def create - super - end - def delete_attachment note.remove_attachment! note.update_attribute(:attachment, nil) diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 78d109cf33e..f7417a6a5aa 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -87,7 +87,7 @@ class Projects::PipelinesController < Projects::ApplicationController end def failures - if @pipeline.statuses.latest.failed.present? + if @pipeline.failed_builds.present? render_show else redirect_to pipeline_path(@pipeline) @@ -104,9 +104,18 @@ class Projects::PipelinesController < Projects::ApplicationController @stage = pipeline.legacy_stage(params[:stage]) return not_found unless @stage - respond_to do |format| - format.json { render json: { html: view_to_html_string('projects/pipelines/_stage') } } - end + render json: StageSerializer + .new(project: @project, current_user: @current_user) + .represent(@stage, details: true) + end + + # TODO: This endpoint is used by mini-pipeline-graph + # TODO: This endpoint should be migrated to `stage.json` + def stage_ajax + @stage = pipeline.legacy_stage(params[:stage]) + return not_found unless @stage + + render json: { html: view_to_html_string('projects/pipelines/_stage') } end def retry @@ -157,7 +166,7 @@ class Projects::PipelinesController < Projects::ApplicationController end def create_params - params.require(:pipeline).permit(:ref) + params.require(:pipeline).permit(:ref, variables_attributes: %i[key secret_value]) end def pipeline @@ -172,4 +181,8 @@ class Projects::PipelinesController < Projects::ApplicationController # Also see https://gitlab.com/gitlab-org/gitlab-ce/issues/42343 Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42339') end + + def authorize_update_pipeline! + return access_denied! unless can?(current_user, :update_pipeline, @pipeline) + end end diff --git a/app/controllers/projects/runner_projects_controller.rb b/app/controllers/projects/runner_projects_controller.rb index 3cb01405b05..0ec2490655f 100644 --- a/app/controllers/projects/runner_projects_controller.rb +++ b/app/controllers/projects/runner_projects_controller.rb @@ -8,7 +8,7 @@ class Projects::RunnerProjectsController < Projects::ApplicationController return head(403) unless can?(current_user, :assign_runner, @runner) - path = runners_path(project) + path = project_runners_path(project) runner_project = @runner.assign_to(project, current_user) if runner_project.persisted? @@ -22,6 +22,6 @@ class Projects::RunnerProjectsController < Projects::ApplicationController runner_project = project.runner_projects.find(params[:id]) runner_project.destroy - redirect_to runners_path(project), status: 302 + redirect_to project_runners_path(project), status: 302 end end diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb index c950d0f7001..bef94cea989 100644 --- a/app/controllers/projects/runners_controller.rb +++ b/app/controllers/projects/runners_controller.rb @@ -1,6 +1,6 @@ class Projects::RunnersController < Projects::ApplicationController before_action :authorize_admin_build! - before_action :set_runner, only: [:edit, :update, :destroy, :pause, :resume, :show] + before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show] layout 'project_settings' @@ -13,7 +13,7 @@ class Projects::RunnersController < Projects::ApplicationController def update if Ci::UpdateRunnerService.new(@runner).update(runner_params) - redirect_to runner_path(@runner), notice: 'Runner was successfully updated.' + redirect_to project_runner_path(@project, @runner), notice: 'Runner was successfully updated.' else render 'edit' end @@ -24,26 +24,27 @@ class Projects::RunnersController < Projects::ApplicationController @runner.destroy end - redirect_to runners_path(@project), status: 302 + redirect_to project_runners_path(@project), status: 302 end def resume if Ci::UpdateRunnerService.new(@runner).update(active: true) - redirect_to runners_path(@project), notice: 'Runner was successfully updated.' + redirect_to project_runners_path(@project), notice: 'Runner was successfully updated.' else - redirect_to runners_path(@project), alert: 'Runner was not updated.' + redirect_to project_runners_path(@project), alert: 'Runner was not updated.' end end def pause if Ci::UpdateRunnerService.new(@runner).update(active: false) - redirect_to runners_path(@project), notice: 'Runner was successfully updated.' + redirect_to project_runners_path(@project), notice: 'Runner was successfully updated.' else - redirect_to runners_path(@project), alert: 'Runner was not updated.' + redirect_to project_runners_path(@project), alert: 'Runner was not updated.' end end def show + render 'shared/runners/show' end def toggle_shared_runners @@ -52,9 +53,15 @@ class Projects::RunnersController < Projects::ApplicationController redirect_to project_settings_ci_cd_path(@project) end + def toggle_group_runners + project.toggle_ci_cd_settings!(:group_runners_enabled) + + redirect_to project_settings_ci_cd_path(@project) + end + protected - def set_runner + def runner @runner ||= project.runners.find(params[:id]) end diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index d80ef8113aa..177c8a54099 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -67,10 +67,18 @@ module Projects def define_runners_variables @project_runners = @project.runners.ordered - @assignable_runners = current_user.ci_authorized_runners - .assignable_for(project).ordered.page(params[:page]).per(20) + + @assignable_runners = current_user + .ci_authorized_runners + .assignable_for(project) + .ordered + .page(params[:page]).per(20) + @shared_runners = ::Ci::Runner.shared.active + @shared_runners_count = @shared_runners.count(:all) + + @group_runners = ::Ci::Runner.belonging_to_parent_group_of_project(@project.id) end def define_secret_variables diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index f17056f13e0..4697af4f26a 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -2,6 +2,7 @@ module Projects module Settings class RepositoryController < Projects::ApplicationController before_action :authorize_admin_project! + before_action :remote_mirror, only: [:show] def show render_show @@ -25,6 +26,7 @@ module Projects define_deploy_token define_protected_refs + remote_mirror render 'show' end @@ -41,6 +43,10 @@ module Projects load_gon_index end + def remote_mirror + @remote_mirror = project.remote_mirrors.first_or_initialize + end + def access_levels_options { create_access_levels: levels_for_dropdown, diff --git a/app/controllers/sent_notifications_controller.rb b/app/controllers/sent_notifications_controller.rb index 04c36b3ebfe..93a71103a09 100644 --- a/app/controllers/sent_notifications_controller.rb +++ b/app/controllers/sent_notifications_controller.rb @@ -17,16 +17,20 @@ class SentNotificationsController < ApplicationController flash[:notice] = "You have been unsubscribed from this thread." if current_user - case noteable - when Issue - redirect_to issue_path(noteable) - when MergeRequest - redirect_to merge_request_path(noteable) - else - redirect_to root_path - end + redirect_to noteable_path(noteable) else redirect_to new_user_session_path end end + + def noteable_path(noteable) + case noteable + when Issue + issue_path(noteable) + when MergeRequest + merge_request_path(noteable) + else + root_path + end + end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index f3a4aa849c7..1a339f76d26 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,4 +1,5 @@ class SessionsController < Devise::SessionsController + include InternalRedirect include AuthenticatesWithTwoFactor include Devise::Controllers::Rememberable include Recaptcha::ClientHelper @@ -102,18 +103,12 @@ class SessionsController < Devise::SessionsController # we should never redirect to '/users/sign_in' after signing in successfully. return true if redirect_uri.path == new_user_session_path - redirect_to = redirect_uri.to_s if redirect_allowed_to?(redirect_uri) + redirect_to = redirect_uri.to_s if host_allowed?(redirect_uri) @redirect_to = redirect_to store_location_for(:redirect, redirect_to) end - # Overridden in EE - def redirect_allowed_to?(uri) - uri.host == Gitlab.config.gitlab.host && - uri.port == Gitlab.config.gitlab.port - end - def two_factor_enabled? find_user&.two_factor_enabled? end diff --git a/app/controllers/users/terms_controller.rb b/app/controllers/users/terms_controller.rb new file mode 100644 index 00000000000..ab685b9106e --- /dev/null +++ b/app/controllers/users/terms_controller.rb @@ -0,0 +1,70 @@ +module Users + class TermsController < ApplicationController + include InternalRedirect + + skip_before_action :enforce_terms! + skip_before_action :check_password_expiration + skip_before_action :check_two_factor_requirement + skip_before_action :require_email + + before_action :terms + + layout 'terms' + + def index + @redirect = redirect_path + end + + def accept + agreement = Users::RespondToTermsService.new(current_user, viewed_term) + .execute(accepted: true) + + if agreement.persisted? + redirect_to redirect_path + else + flash[:alert] = agreement.errors.full_messages.join(', ') + redirect_to terms_path, redirect: redirect_path + end + end + + def decline + agreement = Users::RespondToTermsService.new(current_user, viewed_term) + .execute(accepted: false) + + if agreement.persisted? + sign_out(current_user) + redirect_to root_path + else + flash[:alert] = agreement.errors.full_messages.join(', ') + redirect_to terms_path, redirect: redirect_path + end + end + + private + + def viewed_term + @viewed_term ||= ApplicationSetting::Term.find(params[:id]) + end + + def terms + unless @term = Gitlab::CurrentSettings.current_application_settings.latest_terms + redirect_to redirect_path + end + end + + def redirect_path + redirect_to_path = safe_redirect_path(params[:redirect]) || safe_redirect_path_for_url(request.referer) + + if redirect_to_path && + excluded_redirect_paths.none? { |excluded| redirect_to_path.include?(excluded) } + redirect_to_path + else + root_path + end + end + + def excluded_redirect_paths + [terms_path, new_user_session_path] + end + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 6aa307b4db4..aa4569500b8 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -258,4 +258,17 @@ module ApplicationHelper _('You are on a read-only GitLab instance.') end + + def autocomplete_data_sources(object, noteable_type) + return {} unless object && noteable_type + + { + members: members_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), + issues: issues_project_autocomplete_sources_path(object), + merge_requests: merge_requests_project_autocomplete_sources_path(object), + labels: labels_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), + milestones: milestones_project_autocomplete_sources_path(object), + commands: commands_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]) + } + end end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 3fbb32c5229..b948e431882 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -248,7 +248,10 @@ module ApplicationSettingsHelper :user_default_external, :user_oauth_applications, :version_check_enabled, - :allow_local_requests_from_hooks_and_services + :allow_local_requests_from_hooks_and_services, + :enforce_terms, + :terms, + :mirror_available ] end end diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb index 16451993e93..7b076728685 100644 --- a/app/helpers/auto_devops_helper.rb +++ b/app/helpers/auto_devops_helper.rb @@ -24,6 +24,15 @@ module AutoDevopsHelper end end + def cluster_ingress_ip(project) + project + .cluster_ingresses + .where("external_ip is not null") + .limit(1) + .pluck(:external_ip) + .first + end + private def missing_auto_devops_domain?(project) diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 7e4eb06b99d..c24d340d184 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -2,4 +2,12 @@ module ClustersHelper def has_multiple_clusters?(project) false end + + def render_gcp_signup_offer + return unless show_gcp_signup_offer? + + content_tag :section, class: 'no-animate expanded' do + render 'projects/clusters/gcp_signup_offer_banner' + end + end end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 079b3cd3aa0..cb6f709c604 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -41,7 +41,7 @@ module EventsHelper key = key.to_s active = 'active' if @event_filter.active?(key) link_opts = { - class: "event-filter-link has-tooltip", + class: "event-filter-link", id: "#{key}_event_filter", title: tooltip } diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 40073f714ee..61e12b0f31e 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -19,14 +19,6 @@ module GitlabRoutingHelper project_commits_path(project, ref_name, *args) end - def runners_path(project, *args) - project_runners_path(project, *args) - end - - def runner_path(runner, *args) - project_runner_path(@project, runner, *args) - end - def environment_path(environment, *args) project_environment_path(environment.project, environment, *args) end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index eb81dc2de43..fa54eafd3a3 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -257,6 +257,7 @@ module ProjectsHelper if project.builds_enabled? && can?(current_user, :read_pipeline, project) nav_tabs << :pipelines + nav_tabs << :operations end if project.external_issue_tracker diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index 36abfaf19a5..da5fe25c07d 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -1,11 +1,16 @@ module UserCalloutsHelper GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration'.freeze + GCP_SIGNUP_OFFER = 'gcp_signup_offer'.freeze def show_gke_cluster_integration_callout?(project) can?(current_user, :create_cluster, project) && !user_dismissed?(GKE_CLUSTER_INTEGRATION) end + def show_gcp_signup_offer? + !user_dismissed?(GCP_SIGNUP_OFFER) + end + private def user_dismissed?(feature_name) diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 01af68088df..ce9373f5883 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -23,9 +23,31 @@ module UsersHelper profile_tabs.include?(tab) end + def current_user_menu_items + @current_user_menu_items ||= get_current_user_menu_items + end + + def current_user_menu?(item) + current_user_menu_items.include?(item) + end + private def get_profile_tabs [:activity, :groups, :contributed, :projects, :snippets] end + + def get_current_user_menu_items + items = [] + + items << :sign_out if current_user + + return items if current_user&.required_terms_not_accepted? + + items << :help + items << :profile if can?(current_user, :read_user, current_user) + items << :settings if can?(current_user, :update_user, current_user) + + items + end end diff --git a/app/helpers/webpack_helper.rb b/app/helpers/webpack_helper.rb index 8bcced70d63..e12e4ba70e9 100644 --- a/app/helpers/webpack_helper.rb +++ b/app/helpers/webpack_helper.rb @@ -1,12 +1,12 @@ -require 'webpack/rails/manifest' +require 'gitlab/webpack/manifest' module WebpackHelper - def webpack_bundle_tag(bundle, force_same_domain: false) - javascript_include_tag(*gitlab_webpack_asset_paths(bundle, force_same_domain: force_same_domain)) + def webpack_bundle_tag(bundle) + javascript_include_tag(*webpack_entrypoint_paths(bundle)) end def webpack_controller_bundle_tags - bundles = [] + chunks = [] action = case controller.action_name when 'create' then 'new' @@ -16,37 +16,44 @@ module WebpackHelper route = [*controller.controller_path.split('/'), action].compact - until route.empty? + until chunks.any? || route.empty? + entrypoint = "pages.#{route.join('.')}" begin - asset_paths = gitlab_webpack_asset_paths("pages.#{route.join('.')}", extension: 'js') - bundles.unshift(*asset_paths) - rescue Webpack::Rails::Manifest::EntryPointMissingError + chunks = webpack_entrypoint_paths(entrypoint, extension: 'js') + rescue Gitlab::Webpack::Manifest::AssetMissingError # no bundle exists for this path end - route.pop end - javascript_include_tag(*bundles) + if chunks.empty? + chunks = webpack_entrypoint_paths("default", extension: 'js') + end + + javascript_include_tag(*chunks) end - # override webpack-rails gem helper until changes can make it upstream - def gitlab_webpack_asset_paths(source, extension: nil, force_same_domain: false) + def webpack_entrypoint_paths(source, extension: nil, exclude_duplicates: true) return "" unless source.present? - paths = Webpack::Rails::Manifest.asset_paths(source) + paths = Gitlab::Webpack::Manifest.entrypoint_paths(source) if extension paths.select! { |p| p.ends_with? ".#{extension}" } end - unless force_same_domain - force_host = webpack_public_host - if force_host - paths.map! { |p| "#{force_host}#{p}" } - end + force_host = webpack_public_host + if force_host + paths.map! { |p| "#{force_host}#{p}" } end - paths + if exclude_duplicates + @used_paths ||= [] + new_paths = paths - @used_paths + @used_paths += new_paths + new_paths + else + paths + end end def webpack_public_host diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb index 50e17fe7717..d9a6fe2a41e 100644 --- a/app/mailers/emails/notes.rb +++ b/app/mailers/emails/notes.rb @@ -43,7 +43,7 @@ module Emails private def note_target_url_options - [@project, @note.noteable, anchor: "note_#{@note.id}"] + [@project || @group, @note.noteable, anchor: "note_#{@note.id}"] end def note_thread_options(recipient_id) @@ -58,8 +58,9 @@ module Emails # `note_id` is a `Note` when originating in `NotifyPreview` @note = note_id.is_a?(Note) ? note_id : Note.find(note_id) @project = @note.project + @group = @note.noteable.try(:group) - if @project && @note.persisted? + if (@project || @group) && @note.persisted? @sent_notification = SentNotification.record_note(@note, recipient_id, reply_key) end end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 3646e08a15f..1db1482d6b7 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -94,6 +94,7 @@ class Notify < BaseMailer def subject(*extra) subject = "" subject << "#{@project.name} | " if @project + subject << "#{@group.name} | " if @group subject << extra.join(' | ') if extra.present? subject << " | #{Gitlab.config.gitlab.email_subject_suffix}" if Gitlab.config.gitlab.email_subject_suffix.present? subject @@ -117,10 +118,9 @@ class Notify < BaseMailer @reason = headers['X-GitLab-NotificationReason'] if Gitlab::IncomingEmail.enabled? && @sent_notification - address = Mail::Address.new(Gitlab::IncomingEmail.reply_address(reply_key)) - address.display_name = @project.full_name - - headers['Reply-To'] = address + headers['Reply-To'] = Mail::Address.new(Gitlab::IncomingEmail.reply_address(reply_key)).tap do |address| + address.display_name = reply_display_name(model) + end fallback_reply_message_id = "<reply-#{reply_key}@#{Gitlab.config.gitlab.host}>".freeze headers['References'] ||= [] @@ -132,6 +132,11 @@ class Notify < BaseMailer mail(headers) end + # `model` is used on EE code + def reply_display_name(_model) + @project.full_name + end + # Send an email that starts a new conversation thread, # with headers suitable for grouping by thread in email clients. # diff --git a/app/models/ability.rb b/app/models/ability.rb index 618d4af4272..bb600eaccba 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -10,6 +10,14 @@ class Ability end end + # Given a list of users and a group this method returns the users that can + # read the given group. + def users_that_can_read_group(users, group) + DeclarativePolicy.subject_scope do + users.select { |u| allowed?(u, :read_group, group) } + end + end + # Given a list of users and a snippet this method returns the users that can # read the given snippet. def users_that_can_read_personal_snippet(users, snippet) diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 862933bf127..451e512aef7 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -220,12 +220,15 @@ class ApplicationSetting < ActiveRecord::Base end end + validate :terms_exist, if: :enforce_terms? + before_validation :ensure_uuid! before_save :ensure_runners_registration_token before_save :ensure_health_check_access_token after_commit do + reset_memoized_terms Rails.cache.write(CACHE_KEY, self) end @@ -331,7 +334,8 @@ class ApplicationSetting < ActiveRecord::Base gitaly_timeout_fast: 10, gitaly_timeout_medium: 30, gitaly_timeout_default: 55, - allow_local_requests_from_hooks_and_services: false + allow_local_requests_from_hooks_and_services: false, + mirror_available: true } end @@ -507,6 +511,16 @@ class ApplicationSetting < ActiveRecord::Base password_authentication_enabled_for_web? || password_authentication_enabled_for_git? end + delegate :terms, to: :latest_terms, allow_nil: true + def latest_terms + @latest_terms ||= Term.latest + end + + def reset_memoized_terms + @latest_terms = nil + latest_terms + end + private def ensure_uuid! @@ -520,4 +534,10 @@ class ApplicationSetting < ActiveRecord::Base errors.add(:repository_storages, "can't include: #{invalid.join(", ")}") unless invalid.empty? end + + def terms_exist + return unless enforce_terms? + + errors.add(:terms, "You need to set terms to be enforced") unless terms.present? + end end diff --git a/app/models/application_setting/term.rb b/app/models/application_setting/term.rb new file mode 100644 index 00000000000..e8ce0ccbb71 --- /dev/null +++ b/app/models/application_setting/term.rb @@ -0,0 +1,13 @@ +class ApplicationSetting + class Term < ActiveRecord::Base + include CacheMarkdownField + + validates :terms, presence: true + + cache_markdown_field :terms + + def self.latest + order(:id).last + end + end +end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 9000ad860e9..61c10c427dd 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -19,6 +19,7 @@ module Ci has_one :last_deployment, -> { order('deployments.id DESC') }, as: :deployable, class_name: 'Deployment' has_many :trace_sections, class_name: 'Ci::BuildTraceSection' + has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent has_one :job_artifacts_archive, -> { where(file_type: Ci::JobArtifact.file_types[:archive]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb new file mode 100644 index 00000000000..4856f10846c --- /dev/null +++ b/app/models/ci/build_trace_chunk.rb @@ -0,0 +1,180 @@ +module Ci + class BuildTraceChunk < ActiveRecord::Base + include FastDestroyAll + extend Gitlab::Ci::Model + + belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id + + default_value_for :data_store, :redis + + WriteError = Class.new(StandardError) + + CHUNK_SIZE = 128.kilobytes + CHUNK_REDIS_TTL = 1.week + WRITE_LOCK_RETRY = 10 + WRITE_LOCK_SLEEP = 0.01.seconds + WRITE_LOCK_TTL = 1.minute + + enum data_store: { + redis: 1, + db: 2 + } + + class << self + def redis_data_key(build_id, chunk_index) + "gitlab:ci:trace:#{build_id}:chunks:#{chunk_index}" + end + + def redis_data_keys + redis.pluck(:build_id, :chunk_index).map do |data| + redis_data_key(data.first, data.second) + end + end + + def redis_delete_data(keys) + return if keys.empty? + + Gitlab::Redis::SharedState.with do |redis| + redis.del(keys) + end + end + + ## + # FastDestroyAll concerns + def begin_fast_destroy + redis_data_keys + end + + ## + # FastDestroyAll concerns + def finalize_fast_destroy(keys) + redis_delete_data(keys) + end + end + + ## + # Data is memoized for optimizing #size and #end_offset + def data + @data ||= get_data.to_s + end + + def truncate(offset = 0) + raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0 + return if offset == size # Skip the following process as it doesn't affect anything + + self.append("", offset) + end + + def append(new_data, offset) + raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0 + raise ArgumentError, 'Chunk size overflow' if CHUNK_SIZE < (offset + new_data.bytesize) + + set_data(data.byteslice(0, offset) + new_data) + end + + def size + data&.bytesize.to_i + end + + def start_offset + chunk_index * CHUNK_SIZE + end + + def end_offset + start_offset + size + end + + def range + (start_offset...end_offset) + end + + def use_database! + in_lock do + break if db? + break unless size > 0 + + self.update!(raw_data: data, data_store: :db) + self.class.redis_delete_data([redis_data_key]) + end + end + + private + + def get_data + if redis? + redis_data + elsif db? + raw_data + else + raise 'Unsupported data store' + end&.force_encoding(Encoding::BINARY) # Redis/Database return UTF-8 string as default + end + + def set_data(value) + raise ArgumentError, 'too much data' if value.bytesize > CHUNK_SIZE + + in_lock do + if redis? + redis_set_data(value) + elsif db? + self.raw_data = value + else + raise 'Unsupported data store' + end + + @data = value + + save! if changed? + end + + schedule_to_db if full? + end + + def schedule_to_db + return if db? + + Ci::BuildTraceChunkFlushWorker.perform_async(id) + end + + def full? + size == CHUNK_SIZE + end + + def redis_data + Gitlab::Redis::SharedState.with do |redis| + redis.get(redis_data_key) + end + end + + def redis_set_data(data) + Gitlab::Redis::SharedState.with do |redis| + redis.set(redis_data_key, data, ex: CHUNK_REDIS_TTL) + end + end + + def redis_data_key + self.class.redis_data_key(build_id, chunk_index) + end + + def in_lock + write_lock_key = "trace_write:#{build_id}:chunks:#{chunk_index}" + + lease = Gitlab::ExclusiveLease.new(write_lock_key, timeout: WRITE_LOCK_TTL) + retry_count = 0 + + until uuid = lease.try_obtain + # Keep trying until we obtain the lease. To prevent hammering Redis too + # much we'll wait for a bit between retries. + sleep(WRITE_LOCK_SLEEP) + break if WRITE_LOCK_RETRY < (retry_count += 1) + end + + raise WriteError, 'Failed to obtain write lock' unless uuid + + self.reload if self.persisted? + return yield + ensure + Gitlab::ExclusiveLease.cancel(write_lock_key, uuid) + end + end +end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index e1b9bc76475..1f49764e7cc 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -32,15 +32,21 @@ module Ci has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id' has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id' + accepts_nested_attributes_for :variables, reject_if: :persisted? + delegate :id, to: :project, prefix: true delegate :full_path, to: :project, prefix: true - validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create validates :sha, presence: { unless: :importing? } validates :ref, presence: { unless: :importing? } validates :status, presence: { unless: :importing? } validate :valid_commit_sha, unless: :importing? + # Replace validator below with + # `validates :source, presence: { unless: :importing? }, on: :create` + # when removing Gitlab.rails5? code. + validate :valid_source, unless: :importing?, on: :create + after_create :keep_around_commits, unless: :importing? enum source: { @@ -269,19 +275,39 @@ module Ci end def git_author_name - commit.try(:author_name) + strong_memoize(:git_author_name) do + commit.try(:author_name) + end end def git_author_email - commit.try(:author_email) + strong_memoize(:git_author_email) do + commit.try(:author_email) + end end def git_commit_message - commit.try(:message) + strong_memoize(:git_commit_message) do + commit.try(:message) + end end def git_commit_title - commit.try(:title) + strong_memoize(:git_commit_title) do + commit.try(:title) + end + end + + def git_commit_full_title + strong_memoize(:git_commit_full_title) do + commit.try(:full_title) + end + end + + def git_commit_description + strong_memoize(:git_commit_description) do + commit.try(:description) + end end def short_sha @@ -491,6 +517,9 @@ module Ci .append(key: 'CI_PIPELINE_ID', value: id.to_s) .append(key: 'CI_CONFIG_PATH', value: ci_yaml_file_path) .append(key: 'CI_PIPELINE_SOURCE', value: source.to_s) + .append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message) + .append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title) + .append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description) end def queued_duration @@ -576,5 +605,11 @@ module Ci project.repository.keep_around(self.sha) project.repository.keep_around(self.before_sha) end + + def valid_source + if source.nil? || source == "unknown" + errors.add(:source, "invalid source") + end + end end end diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb index de5aae17a15..38e14ffbc0c 100644 --- a/app/models/ci/pipeline_variable.rb +++ b/app/models/ci/pipeline_variable.rb @@ -5,6 +5,8 @@ module Ci belongs_to :pipeline + alias_attribute :secret_value, :value + validates :key, uniqueness: { scope: :pipeline_id } end end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 5a4c56ec0dc..bda69f85a78 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -14,32 +14,51 @@ module Ci has_many :builds has_many :runner_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :projects, through: :runner_projects + has_many :runner_namespaces + has_many :groups, through: :runner_namespaces has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build' before_validation :set_default_values - scope :specific, ->() { where(is_shared: false) } - scope :shared, ->() { where(is_shared: true) } - scope :active, ->() { where(active: true) } - scope :paused, ->() { where(active: false) } - scope :online, ->() { where('contacted_at > ?', contact_time_deadline) } - scope :ordered, ->() { order(id: :desc) } + scope :specific, -> { where(is_shared: false) } + scope :shared, -> { where(is_shared: true) } + scope :active, -> { where(active: true) } + scope :paused, -> { where(active: false) } + scope :online, -> { where('contacted_at > ?', contact_time_deadline) } + scope :ordered, -> { order(id: :desc) } - scope :owned_or_shared, ->(project_id) do - joins('LEFT JOIN ci_runner_projects ON ci_runner_projects.runner_id = ci_runners.id') - .where("ci_runner_projects.project_id = :project_id OR ci_runners.is_shared = true", project_id: project_id) + scope :belonging_to_project, -> (project_id) { + joins(:runner_projects).where(ci_runner_projects: { project_id: project_id }) + } + + scope :belonging_to_parent_group_of_project, -> (project_id) { + project_groups = ::Group.joins(:projects).where(projects: { id: project_id }) + hierarchy_groups = Gitlab::GroupHierarchy.new(project_groups).base_and_ancestors + + joins(:groups).where(namespaces: { id: hierarchy_groups }) + } + + scope :owned_or_shared, -> (project_id) do + union = Gitlab::SQL::Union.new( + [belonging_to_project(project_id), belonging_to_parent_group_of_project(project_id), shared], + remove_duplicates: false + ) + from("(#{union.to_sql}) ci_runners") end scope :assignable_for, ->(project) do # FIXME: That `to_sql` is needed to workaround a weird Rails bug. # Without that, placeholders would miss one and couldn't match. where(locked: false) - .where.not("id IN (#{project.runners.select(:id).to_sql})").specific + .where.not("ci_runners.id IN (#{project.runners.select(:id).to_sql})") + .specific end validate :tag_constraints + validate :either_projects_or_group validates :access_level, presence: true + validates :runner_type, presence: true acts_as_taggable @@ -50,6 +69,12 @@ module Ci ref_protected: 1 } + enum runner_type: { + instance_type: 1, + group_type: 2, + project_type: 3 + } + cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at, :ip_address chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout @@ -83,7 +108,13 @@ module Ci end def assign_to(project, current_user = nil) - self.is_shared = false if shared? + if shared? + self.is_shared = false if shared? + self.runner_type = :project_type + elsif group_type? + raise ArgumentError, 'Transitioning a group runner to a project runner is not supported' + end + self.save project.runner_projects.create(runner_id: self.id) end @@ -120,6 +151,14 @@ module Ci !shared? end + def assigned_to_group? + runner_namespaces.any? + end + + def assigned_to_project? + runner_projects.any? + end + def can_pick?(build) return false if self.ref_protected? && !build.protected? @@ -174,6 +213,12 @@ module Ci end end + def pick_build!(build) + if can_pick?(build) + tick_runner_queue + end + end + private def cleanup_runner_queue @@ -205,7 +250,17 @@ module Ci end def assignable_for?(project_id) - is_shared? || projects.exists?(id: project_id) + self.class.owned_or_shared(project_id).where(id: self.id).any? + end + + def either_projects_or_group + if groups.many? + errors.add(:runner, 'can only be assigned to one group') + end + + if assigned_to_group? && assigned_to_project? + errors.add(:runner, 'can only be assigned either to projects or to a group') + end end def accepting_tags?(build) diff --git a/app/models/ci/runner_namespace.rb b/app/models/ci/runner_namespace.rb new file mode 100644 index 00000000000..3269f86e8ca --- /dev/null +++ b/app/models/ci/runner_namespace.rb @@ -0,0 +1,9 @@ +module Ci + class RunnerNamespace < ActiveRecord::Base + extend Gitlab::Ci::Model + + belongs_to :runner + belongs_to :namespace, class_name: '::Namespace' + belongs_to :group, class_name: '::Group', foreign_key: :namespace_id + end +end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 16efe90fa27..b881b4eaf36 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -43,12 +43,20 @@ module Clusters def create_and_assign_runner transaction do - project.runners.create!(name: 'kubernetes-cluster', tag_list: %w(kubernetes cluster)).tap do |runner| + project.runners.create!(runner_create_params).tap do |runner| update!(runner_id: runner.id) end end end + def runner_create_params + { + name: 'kubernetes-cluster', + runner_type: :project_type, + tag_list: %w(kubernetes cluster) + } + end + def gitlab_url Gitlab::Routing.url_helpers.root_url(only_path: false) end diff --git a/app/models/concerns/fast_destroy_all.rb b/app/models/concerns/fast_destroy_all.rb new file mode 100644 index 00000000000..7ea042c6742 --- /dev/null +++ b/app/models/concerns/fast_destroy_all.rb @@ -0,0 +1,91 @@ +## +# This module is for replacing `dependent: :destroy` and `before_destroy` hooks. +# +# In general, `destroy_all` is inefficient because it calls each callback with `DELETE` queries i.e. O(n), whereas, +# `delete_all` is efficient as it deletes all rows with a single `DELETE` query. +# +# It's better to use `delete_all` as our best practice, however, +# if external data (e.g. ObjectStorage, FileStorage or Redis) are assosiated with database records, +# it is difficult to accomplish it. +# +# This module defines a format to use `delete_all` and delete associated external data. +# Here is an exmaple +# +# Situation +# - `Project` has many `Ci::BuildTraceChunk` through `Ci::Build` +# - `Ci::BuildTraceChunk` stores associated data in Redis, so it relies on `dependent: :destroy` and `before_destroy` for the deletion +# +# How to use +# - Define `use_fast_destroy :build_trace_chunks` in `Project` model. +# - Define `begin_fast_destroy` and `finalize_fast_destroy(params)` in `Ci::BuildTraceChunk` model. +# - Use `fast_destroy_all` instead of `destroy` and `destroy_all` +# - Remove `dependent: :destroy` and `before_destroy` as it's no longer need +# +# Expectation +# - When a project is `destroy`ed, the associated trace_chunks will be deleted by `delete_all`, +# and the associated data will be removed, too. +# - When `fast_destroy_all` is called, it also performns as same. +module FastDestroyAll + extend ActiveSupport::Concern + + ForbiddenActionError = Class.new(StandardError) + + included do + before_destroy do + raise ForbiddenActionError, '`destroy` and `destroy_all` are forbbiden. Please use `fast_destroy_all`' + end + end + + class_methods do + ## + # This method delete rows and associated external data efficiently + # + # This method can replace `destroy` and `destroy_all` without having `after_destroy` hook + def fast_destroy_all + params = begin_fast_destroy + + delete_all + + finalize_fast_destroy(params) + end + + ## + # This method returns identifiers to delete associated external data (e.g. file paths, redis keys) + # + # This method must be defined in fast destroyable model + def begin_fast_destroy + raise NotImplementedError + end + + ## + # This method deletes associated external data with the identifiers returned by `begin_fast_destroy` + # + # This method must be defined in fast destroyable model + def finalize_fast_destroy(params) + raise NotImplementedError + end + end + + module Helpers + extend ActiveSupport::Concern + + class_methods do + ## + # This method is to be defined on models which have fast destroyable models as children, + # and let us avoid to use `dependent: :destroy` hook + def use_fast_destroy(relation) + before_destroy(prepend: true) do + perform_fast_destroy(public_send(relation)) # rubocop:disable GitlabSecurity/PublicSend + end + end + end + + def perform_fast_destroy(subject) + params = subject.begin_fast_destroy + + run_after_commit do + subject.finalize_fast_destroy(params) + end + end + end +end diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb index e48bc0be410..01b1ef9f82c 100644 --- a/app/models/concerns/participable.rb +++ b/app/models/concerns/participable.rb @@ -98,6 +98,10 @@ module Participable participants.merge(ext.users) + filter_by_ability(participants) + end + + def filter_by_ability(participants) case self when PersonalSnippet Ability.users_that_can_read_personal_snippet(participants.to_a, self) diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index 2589215ad19..eef9caf1c8e 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -60,13 +60,16 @@ module ReactiveCaching end def with_reactive_cache(*args, &blk) - within_reactive_cache_lifetime(*args) do + bootstrap = !within_reactive_cache_lifetime?(*args) + Rails.cache.write(alive_reactive_cache_key(*args), true, expires_in: self.class.reactive_cache_lifetime) + + if bootstrap + ReactiveCachingWorker.perform_async(self.class, id, *args) + nil + else data = Rails.cache.read(full_reactive_cache_key(*args)) yield data if data.present? end - ensure - Rails.cache.write(alive_reactive_cache_key(*args), true, expires_in: self.class.reactive_cache_lifetime) - ReactiveCachingWorker.perform_async(self.class, id, *args) end def clear_reactive_cache!(*args) @@ -75,7 +78,7 @@ module ReactiveCaching def exclusively_update_reactive_cache!(*args) locking_reactive_cache(*args) do - within_reactive_cache_lifetime(*args) do + if within_reactive_cache_lifetime?(*args) enqueuing_update(*args) do value = calculate_reactive_cache(*args) Rails.cache.write(full_reactive_cache_key(*args), value) @@ -105,8 +108,8 @@ module ReactiveCaching Gitlab::ExclusiveLease.cancel(full_reactive_cache_key(*args), uuid) end - def within_reactive_cache_lifetime(*args) - yield if Rails.cache.read(alive_reactive_cache_key(*args)) + def within_reactive_cache_lifetime?(*args) + !!Rails.cache.read(alive_reactive_cache_key(*args)) end def enqueuing_update(*args) diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 915ad6959be..0176a12a131 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -4,7 +4,9 @@ module Routable extend ActiveSupport::Concern included do - has_one :route, as: :source, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + # Remove `inverse_of: source` when upgraded to rails 5.2 + # See https://github.com/rails/rails/pull/28808 + has_one :route, as: :source, autosave: true, dependent: :destroy, inverse_of: :source # rubocop:disable Cop/ActiveRecordDependent has_many :redirect_routes, as: :source, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent validates :route, presence: true diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb index 703a72c355c..3796737427a 100644 --- a/app/models/concerns/sha_attribute.rb +++ b/app/models/concerns/sha_attribute.rb @@ -4,18 +4,34 @@ module ShaAttribute module ClassMethods def sha_attribute(name) return if ENV['STATIC_VERIFICATION'] - return unless table_exists? + + validate_binary_column_exists!(name) unless Rails.env.production? + + attribute(name, Gitlab::Database::ShaAttribute.new) + end + + # This only gets executed in non-production environments as an additional check to ensure + # the column is the correct type. In production it should behave like any other attribute. + # See https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/5502 for more discussion + def validate_binary_column_exists!(name) + unless table_exists? + warn "WARNING: sha_attribute #{name.inspect} is invalid since the table doesn't exist - you may need to run database migrations" + return + end column = columns.find { |c| c.name == name.to_s } - # In case the table doesn't exist we won't be able to find the column, - # thus we will only check the type if the column is present. - if column && column.type != :binary - raise ArgumentError, - "sha_attribute #{name.inspect} is invalid since the column type is not :binary" + unless column + warn "WARNING: sha_attribute #{name.inspect} is invalid since the column doesn't exist - you may need to run database migrations" + return end - attribute(name, Gitlab::Database::ShaAttribute.new) + unless column.type == :binary + raise ArgumentError.new("sha_attribute #{name.inspect} is invalid since the column type is not :binary") + end + rescue => error + Gitlab::AppLogger.error "ShaAttribute initialization: #{error.message}" + raise end end end diff --git a/app/models/concerns/time_trackable.rb b/app/models/concerns/time_trackable.rb index 5911b56c34c..73fc5048dcf 100644 --- a/app/models/concerns/time_trackable.rb +++ b/app/models/concerns/time_trackable.rb @@ -30,6 +30,8 @@ module TimeTrackable return if @time_spent == 0 + touch if touchable? + if @time_spent == :reset reset_spent_time else @@ -53,6 +55,10 @@ module TimeTrackable private + def touchable? + valid? && persisted? + end + def reset_spent_time timelogs.new(time_spent: total_time_spent * -1, user: @time_spent_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables end diff --git a/app/models/group.rb b/app/models/group.rb index 9b42bbf99be..cefca316399 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -9,6 +9,7 @@ class Group < Namespace include SelectForProjectAuthorization include LoadedInGroupList include GroupDescendant + include TokenAuthenticatable has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent alias_method :members, :group_members @@ -43,6 +44,8 @@ class Group < Namespace validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 } + add_authentication_token_field :runners_token + after_create :post_create_hook after_destroy :post_destroy_hook after_save :update_two_factor_requirement @@ -238,6 +241,13 @@ class Group < Namespace .where(source_id: self_and_descendants.reorder(nil).select(:id)) end + # Returns all members that are part of the group, it's subgroups, and ancestor groups + def direct_and_indirect_members + GroupMember + .active_without_invites_and_requests + .where(source_id: self_and_hierarchy.reorder(nil).select(:id)) + end + def users_with_parents User .where(id: members_with_parents.select(:user_id)) @@ -250,6 +260,30 @@ class Group < Namespace .reorder(nil) end + # Returns all users that are members of the group because: + # 1. They belong to the group + # 2. They belong to a project that belongs to the group + # 3. They belong to a sub-group or project in such sub-group + # 4. They belong to an ancestor group + def direct_and_indirect_users + union = Gitlab::SQL::Union.new([ + User + .where(id: direct_and_indirect_members.select(:user_id)) + .reorder(nil), + project_users_with_descendants + ]) + + User.from("(#{union.to_sql}) #{User.table_name}") + end + + # Returns all users that are members of projects + # belonging to the current group or sub-groups + def project_users_with_descendants + User + .joins(projects: :group) + .where(namespaces: { id: self_and_descendants.select(:id) }) + end + def max_member_access_for_user(user) return GroupMember::OWNER if user.admin? @@ -294,6 +328,13 @@ class Group < Namespace refresh_members_authorized_projects(blocking: false) end + # each existing group needs to have a `runners_token`. + # we do this on read since migrating all existing groups is not a feasible + # solution. + def runners_token + ensure_runners_token! + end + private def update_two_factor_requirement diff --git a/app/models/identity.rb b/app/models/identity.rb index 1011b9f1109..3fd0c5e751d 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -1,12 +1,16 @@ class Identity < ActiveRecord::Base + def self.uniqueness_scope + :provider + end + include Sortable include CaseSensitivity belongs_to :user validates :provider, presence: true - validates :extern_uid, allow_blank: true, uniqueness: { scope: :provider, case_sensitive: false } - validates :user_id, uniqueness: { scope: :provider } + validates :extern_uid, allow_blank: true, uniqueness: { scope: uniqueness_scope, case_sensitive: false } + validates :user_id, uniqueness: { scope: uniqueness_scope } before_save :ensure_normalized_extern_uid, if: :extern_uid_changed? after_destroy :clear_user_synced_attributes, if: :user_synced_attributes_metadata_from_provider? diff --git a/app/models/member.rb b/app/models/member.rb index eac4a22a03f..68572f2e33a 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -96,6 +96,17 @@ class Member < ActiveRecord::Base joins(:user).merge(User.search(query)) end + def filter_by_2fa(value) + case value + when 'enabled' + left_join_users.merge(User.with_two_factor_indistinct) + when 'disabled' + left_join_users.merge(User.without_two_factor) + else + all + end + end + def sort_by_attribute(method) case method.to_s when 'access_level_asc' then reorder(access_level: :asc) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 3f924992db7..1d2f0856dbb 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -323,7 +323,7 @@ class MergeRequest < ActiveRecord::Base # updates `merge_jid` with the MergeWorker#jid. # This helps tracking enqueued and ongoing merge jobs. def merge_async(user_id, params) - jid = MergeWorker.perform_async(id, user_id, params) + jid = MergeWorker.perform_async(id, user_id, params.to_h) update_column(:merge_jid, jid) end @@ -1007,6 +1007,10 @@ class MergeRequest < ActiveRecord::Base @merge_commit ||= project.commit(merge_commit_sha) if merge_commit_sha end + def short_merge_commit_sha + Commit.truncate_sha(merge_commit_sha) if merge_commit_sha + end + def can_be_reverted?(current_user) return false unless merge_commit diff --git a/app/models/namespace.rb b/app/models/namespace.rb index c29a53e5ce7..3dad4277713 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -21,6 +21,9 @@ class Namespace < ActiveRecord::Base has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :project_statistics + has_many :runner_namespaces, class_name: 'Ci::RunnerNamespace' + has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner' + # This should _not_ be `inverse_of: :namespace`, because that would also set # `user.namespace` when this user creates a group with themselves as `owner`. belongs_to :owner, class_name: "User" @@ -163,6 +166,13 @@ class Namespace < ActiveRecord::Base projects.with_shared_runners.any? end + # Returns all ancestors, self, and descendants of the current namespace. + def self_and_hierarchy + Gitlab::GroupHierarchy + .new(self.class.where(id: id)) + .all_groups + end + # Returns all the ancestors of the current namespaces. def ancestors return self.class.none unless parent_id diff --git a/app/models/note.rb b/app/models/note.rb index e426f84832b..109405d3f17 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -317,10 +317,6 @@ class Note < ActiveRecord::Base !system? && !for_snippet? end - def can_create_notification? - true - end - def discussion_class(noteable = nil) # When commit notes are rendered on an MR's Discussion page, they are # displayed in one discussion instead of individually. diff --git a/app/models/project.rb b/app/models/project.rb index d4e9e51c7be..534a0e630af 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -22,6 +22,7 @@ class Project < ActiveRecord::Base include DeploymentPlatform include ::Gitlab::Utils::StrongMemoize include ChronicDurationAttribute + include FastDestroyAll::Helpers extend Gitlab::ConfigHelper @@ -64,9 +65,15 @@ class Project < ActiveRecord::Base default_value_for :only_allow_merge_if_all_discussions_are_resolved, false add_authentication_token_field :runners_token + + before_validation :mark_remote_mirrors_for_removal, if: -> { ActiveRecord::Base.connection.table_exists?(:remote_mirrors) } + before_save :ensure_runners_token after_save :update_project_statistics, if: :namespace_id_changed? + + after_save :create_import_state, if: ->(project) { project.import? && project.import_state.nil? } + after_create :create_project_feature, unless: :project_feature after_create :create_ci_cd_settings, @@ -78,6 +85,9 @@ class Project < ActiveRecord::Base after_update :update_forks_visibility_level before_destroy :remove_private_deploy_keys + + use_fast_destroy :build_trace_chunks + after_destroy -> { run_after_commit { remove_pages } } after_destroy :remove_exports @@ -157,6 +167,8 @@ class Project < ActiveRecord::Base has_one :fork_network_member has_one :fork_network, through: :fork_network_member + has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project + # Merge Requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id' has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest' @@ -205,6 +217,7 @@ class Project < ActiveRecord::Base has_one :cluster_project, class_name: 'Clusters::Project' has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster' + has_many :cluster_ingresses, through: :clusters, source: :application_ingress, class_name: 'Clusters::Applications::Ingress' # Container repositories need to remove data from the container registry, # which is not managed by the DB. Hence we're still using dependent: :destroy @@ -220,6 +233,7 @@ class Project < ActiveRecord::Base # still using `dependent: :destroy` here. has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName' + has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks has_many :runner_projects, class_name: 'Ci::RunnerProject' has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' has_many :variables, class_name: 'Ci::Variable' @@ -230,23 +244,28 @@ class Project < ActiveRecord::Base has_many :project_deploy_tokens has_many :deploy_tokens, through: :project_deploy_tokens - has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' - has_one :auto_devops, class_name: 'ProjectAutoDevops' has_many :custom_attributes, class_name: 'ProjectCustomAttribute' has_many :project_badges, class_name: 'ProjectBadge' - has_one :ci_cd_settings, class_name: 'ProjectCiCdSetting' + has_one :ci_cd_settings, class_name: 'ProjectCiCdSetting', inverse_of: :project, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + + has_many :remote_mirrors, inverse_of: :project accepts_nested_attributes_for :variables, allow_destroy: true accepts_nested_attributes_for :project_feature, update_only: true accepts_nested_attributes_for :import_data accepts_nested_attributes_for :auto_devops, update_only: true + accepts_nested_attributes_for :remote_mirrors, + allow_destroy: true, + reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? } + delegate :name, to: :owner, allow_nil: true, prefix: true delegate :members, to: :team, prefix: true delegate :add_user, :add_users, to: :team delegate :add_guest, :add_reporter, :add_developer, :add_master, :add_role, to: :team + delegate :group_runners_enabled, :group_runners_enabled=, :group_runners_enabled?, to: :ci_cd_settings # Validations validates :creator, presence: true, on: :create @@ -331,6 +350,12 @@ class Project < ActiveRecord::Base scope :with_issues_enabled, -> { with_feature_enabled(:issues) } scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) } scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) } + scope :with_remote_mirrors, -> { joins(:remote_mirrors).where(remote_mirrors: { enabled: true }).distinct } + + scope :with_group_runners_enabled, -> do + joins(:ci_cd_settings) + .where(project_ci_cd_settings: { group_runners_enabled: true }) + end enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } @@ -381,55 +406,9 @@ 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 - transition [:none, :finished, :failed] => :scheduled - end - - event :force_import_start do - transition [:none, :finished, :failed] => :started - end - - event :import_start do - transition scheduled: :started - end - - event :import_finish do - transition started: :finished - end - - event :import_fail do - transition [:scheduled, :started] => :failed - end - - event :import_retry do - transition failed: :started - end - - state :scheduled - state :started - state :finished - state :failed - - after_transition [:none, :finished, :failed] => :scheduled do |project, _| - project.run_after_commit do - job_id = add_import_job - update(import_jid: job_id) if job_id - end - end - after_transition started: :finished do |project, _| - project.reset_cache_and_import_attrs - - if Gitlab::ImportSources.importer_names.include?(project.import_type) && project.repo_exists? - project.run_after_commit do - Projects::AfterImportService.new(project).execute - end - end - end - end + scope :joins_import_state, -> { joins("LEFT JOIN project_mirror_data import_state ON import_state.project_id = projects.id") } + scope :import_started, -> { joins_import_state.where("import_state.status = 'started' OR projects.import_status = 'started'") } class << self # Searches for a list of projects based on the query given in `query`. @@ -659,10 +638,6 @@ class Project < ActiveRecord::Base external_import? || forked? || gitlab_project_import? || bare_repository_import? end - def no_import? - import_status == 'none' - end - def external_import? import_url.present? end @@ -675,6 +650,99 @@ class Project < ActiveRecord::Base import_started? || import_scheduled? end + def import_state_args + { + status: self[:import_status], + jid: self[:import_jid], + last_error: self[:import_error] + } + end + + def ensure_import_state(force: false) + return if !force && (self[:import_status] == 'none' || self[:import_status].nil?) + return unless import_state.nil? + + if persisted? + create_import_state(import_state_args) + + update_column(:import_status, 'none') + else + build_import_state(import_state_args) + + self[:import_status] = 'none' + end + end + + def import_schedule + ensure_import_state(force: true) + + import_state.schedule + end + + def force_import_start + ensure_import_state(force: true) + + import_state.force_start + end + + def import_start + ensure_import_state(force: true) + + import_state.start + end + + def import_fail + ensure_import_state(force: true) + + import_state.fail_op + end + + def import_finish + ensure_import_state(force: true) + + import_state.finish + end + + def import_jid=(new_jid) + ensure_import_state(force: true) + + import_state.jid = new_jid + end + + def import_jid + ensure_import_state + + import_state&.jid + end + + def import_error=(new_error) + ensure_import_state(force: true) + + import_state.last_error = new_error + end + + def import_error + ensure_import_state + + import_state&.last_error + end + + def import_status=(new_status) + ensure_import_state(force: true) + + import_state.status = new_status + end + + def import_status + ensure_import_state + + import_state&.status || 'none' + end + + def no_import? + import_status == 'none' + end + def import_started? # import? does SQL work so only run it if it looks like there's an import running import_status == 'started' && import? @@ -708,6 +776,37 @@ class Project < ActiveRecord::Base import_type == 'gitea' end + def has_remote_mirror? + remote_mirror_available? && remote_mirrors.enabled.exists? + end + + def updating_remote_mirror? + remote_mirrors.enabled.started.exists? + end + + def update_remote_mirrors + return unless remote_mirror_available? + + remote_mirrors.enabled.each(&:sync) + end + + def mark_stuck_remote_mirrors_as_failed! + remote_mirrors.stuck.update_all( + update_status: :failed, + last_error: 'The remote mirror took to long to complete.', + last_update_at: Time.now + ) + end + + def mark_remote_mirrors_for_removal + remote_mirrors.each(&:mark_for_delete_if_blank_url) + end + + def remote_mirror_available? + remote_mirror_available_overridden || + ::Gitlab::CurrentSettings.mirror_available + end + def check_limit unless creator.can_create_project? || namespace.kind == 'group' projects_limit = creator.projects_limit @@ -1301,12 +1400,17 @@ class Project < ActiveRecord::Base @shared_runners ||= shared_runners_available? ? Ci::Runner.shared : Ci::Runner.none end - def active_shared_runners - @active_shared_runners ||= shared_runners.active + def group_runners + @group_runners ||= group_runners_enabled? ? Ci::Runner.belonging_to_parent_group_of_project(self.id) : Ci::Runner.none + end + + def all_runners + union = Gitlab::SQL::Union.new([runners, group_runners, shared_runners]) + Ci::Runner.from("(#{union.to_sql}) ci_runners") end def any_runners?(&block) - active_runners.any?(&block) || active_shared_runners.any?(&block) + all_runners.active.any?(&block) end def valid_runners_token?(token) @@ -1471,7 +1575,7 @@ class Project < ActiveRecord::Base def rename_repo_notify! # When we import a project overwriting the original project, there # is a move operation. In that case we don't want to send the instructions. - send_move_instructions(full_path_was) unless started? + send_move_instructions(full_path_was) unless import_started? expires_full_path_cache self.old_path_with_namespace = full_path_was @@ -1525,7 +1629,8 @@ class Project < ActiveRecord::Base return unless import_jid Gitlab::SidekiqStatus.unset(import_jid) - update_column(:import_jid, nil) + + import_state.update_column(:jid, nil) end def running_or_pending_build_count(force: false) @@ -1544,7 +1649,8 @@ class Project < ActiveRecord::Base sanitized_message = Gitlab::UrlSanitizer.sanitize(error_message) import_fail - update_column(:import_error, sanitized_message) + + import_state.update_column(:last_error, sanitized_message) rescue ActiveRecord::ActiveRecordError => e Rails.logger.error("Error setting import status to failed: #{e.message}. Original error: #{sanitized_message}") ensure @@ -1874,6 +1980,10 @@ class Project < ActiveRecord::Base [] end + def toggle_ci_cd_settings!(settings_attribute) + ci_cd_settings.toggle!(settings_attribute) + end + def gitlab_deploy_token @gitlab_deploy_token ||= deploy_tokens.gitlab_deploy_token end diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index 9f10a93148c..588cced5781 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -1,5 +1,5 @@ class ProjectCiCdSetting < ActiveRecord::Base - belongs_to :project + belongs_to :project, inverse_of: :ci_cd_settings # The version of the schema that first introduced this model/table. MINIMUM_SCHEMA_VERSION = 20180403035759 diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb new file mode 100644 index 00000000000..1605317ae14 --- /dev/null +++ b/app/models/project_import_state.rb @@ -0,0 +1,55 @@ +class ProjectImportState < ActiveRecord::Base + include AfterCommitQueue + + self.table_name = "project_mirror_data" + + belongs_to :project, inverse_of: :import_state + + validates :project, presence: true + + state_machine :status, initial: :none do + event :schedule do + transition [:none, :finished, :failed] => :scheduled + end + + event :force_start do + transition [:none, :finished, :failed] => :started + end + + event :start do + transition scheduled: :started + end + + event :finish do + transition started: :finished + end + + event :fail_op do + transition [:scheduled, :started] => :failed + end + + state :scheduled + state :started + state :finished + state :failed + + after_transition [:none, :finished, :failed] => :scheduled do |state, _| + state.run_after_commit do + job_id = project.add_import_job + update(jid: job_id) if job_id + end + end + + after_transition started: :finished do |state, _| + project = state.project + + project.reset_cache_and_import_attrs + + if Gitlab::ImportSources.importer_names.include?(project.import_type) && project.repo_exists? + state.run_after_commit do + Projects::AfterImportService.new(project).execute + end + end + end + end +end diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb new file mode 100644 index 00000000000..bbf8fd9c6a7 --- /dev/null +++ b/app/models/remote_mirror.rb @@ -0,0 +1,219 @@ +class RemoteMirror < ActiveRecord::Base + include AfterCommitQueue + + PROTECTED_BACKOFF_DELAY = 1.minute + UNPROTECTED_BACKOFF_DELAY = 5.minutes + + attr_encrypted :credentials, + key: Gitlab::Application.secrets.db_key_base, + marshal: true, + encode: true, + mode: :per_attribute_iv_and_salt, + insecure_mode: true, + algorithm: 'aes-256-cbc' + + default_value_for :only_protected_branches, true + + belongs_to :project, inverse_of: :remote_mirrors + + validates :url, presence: true, url: { protocols: %w(ssh git http https), allow_blank: true } + validates :url, addressable_url: true, if: :url_changed? + + before_save :set_new_remote_name, if: :mirror_url_changed? + + after_save :set_override_remote_mirror_available, unless: -> { Gitlab::CurrentSettings.current_application_settings.mirror_available } + after_save :refresh_remote, if: :mirror_url_changed? + after_update :reset_fields, if: :mirror_url_changed? + + after_commit :remove_remote, on: :destroy + + scope :enabled, -> { where(enabled: true) } + scope :started, -> { with_update_status(:started) } + scope :stuck, -> { started.where('last_update_at < ? OR (last_update_at IS NULL AND updated_at < ?)', 1.day.ago, 1.day.ago) } + + state_machine :update_status, initial: :none do + event :update_start do + transition [:none, :finished, :failed] => :started + end + + event :update_finish do + transition started: :finished + end + + event :update_fail do + transition started: :failed + end + + state :started + state :finished + state :failed + + after_transition any => :started do |remote_mirror, _| + Gitlab::Metrics.add_event(:remote_mirrors_running, path: remote_mirror.project.full_path) + + remote_mirror.update(last_update_started_at: Time.now) + end + + after_transition started: :finished do |remote_mirror, _| + Gitlab::Metrics.add_event(:remote_mirrors_finished, path: remote_mirror.project.full_path) + + timestamp = Time.now + remote_mirror.update_attributes!( + last_update_at: timestamp, last_successful_update_at: timestamp, last_error: nil + ) + end + + after_transition started: :failed do |remote_mirror, _| + Gitlab::Metrics.add_event(:remote_mirrors_failed, path: remote_mirror.project.full_path) + + remote_mirror.update(last_update_at: Time.now) + end + end + + def remote_name + super || fallback_remote_name + end + + def update_failed? + update_status == 'failed' + end + + def update_in_progress? + update_status == 'started' + end + + def update_repository(options) + raw.update(options) + end + + def sync? + enabled? + end + + def sync + return unless sync? + + if recently_scheduled? + RepositoryUpdateRemoteMirrorWorker.perform_in(backoff_delay, self.id, Time.now) + else + RepositoryUpdateRemoteMirrorWorker.perform_async(self.id, Time.now) + end + end + + def enabled + return false unless project && super + return false unless project.remote_mirror_available? + return false unless project.repository_exists? + return false if project.pending_delete? + + true + end + alias_method :enabled?, :enabled + + def updated_since?(timestamp) + last_update_started_at && last_update_started_at > timestamp && !update_failed? + end + + def mark_for_delete_if_blank_url + mark_for_destruction if url.blank? + end + + def mark_as_failed(error_message) + update_fail + update_column(:last_error, Gitlab::UrlSanitizer.sanitize(error_message)) + end + + def url=(value) + super(value) && return unless Gitlab::UrlSanitizer.valid?(value) + + mirror_url = Gitlab::UrlSanitizer.new(value) + self.credentials = mirror_url.credentials + + super(mirror_url.sanitized_url) + end + + def url + if super + Gitlab::UrlSanitizer.new(super, credentials: credentials).full_url + end + rescue + super + end + + def safe_url + return if url.nil? + + result = URI.parse(url) + result.password = '*****' if result.password + result.user = '*****' if result.user && result.user != "git" # tokens or other data may be saved as user + result.to_s + end + + private + + def raw + @raw ||= Gitlab::Git::RemoteMirror.new(project.repository.raw, remote_name) + end + + def fallback_remote_name + return unless id + + "remote_mirror_#{id}" + end + + def recently_scheduled? + return false unless self.last_update_started_at + + self.last_update_started_at >= Time.now - backoff_delay + end + + def backoff_delay + if self.only_protected_branches + PROTECTED_BACKOFF_DELAY + else + UNPROTECTED_BACKOFF_DELAY + end + end + + def reset_fields + update_columns( + last_error: nil, + last_update_at: nil, + last_successful_update_at: nil, + update_status: 'finished' + ) + end + + def set_override_remote_mirror_available + enabled = read_attribute(:enabled) + + project.update(remote_mirror_available_overridden: enabled) + end + + def set_new_remote_name + self.remote_name = "remote_mirror_#{SecureRandom.hex}" + end + + def refresh_remote + return unless project + + # Before adding a new remote we have to delete the data from + # the previous remote name + prev_remote_name = remote_name_was || fallback_remote_name + run_after_commit do + project.repository.async_remove_remote(prev_remote_name) + end + + project.repository.add_remote(remote_name, url) + end + + def remove_remote + return unless project # could be pending to delete so don't need to touch the git repository + + project.repository.async_remove_remote(remote_name) + end + + def mirror_url_changed? + url_changed? || encrypted_credentials_changed? + end +end diff --git a/app/models/repository.rb b/app/models/repository.rb index 6831305fb93..44c6bff6b66 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -37,7 +37,7 @@ class Repository changelog license_blob license_key gitignore koding_yml gitlab_ci_yml branch_names tag_names branch_count tag_count avatar exists? root_ref has_visible_content? - issue_template_names merge_request_template_names).freeze + issue_template_names merge_request_template_names xcode_project?).freeze # Methods that use cache_method but only memoize the value MEMOIZED_CACHED_METHODS = %i(license).freeze @@ -55,7 +55,8 @@ class Repository gitlab_ci: :gitlab_ci_yml, avatar: :avatar, issue_template: :issue_template_names, - merge_request_template: :merge_request_template_names + merge_request_template: :merge_request_template_names, + xcode_config: :xcode_project? }.freeze def initialize(full_path, project, disk_path: nil, is_wiki: false) @@ -594,6 +595,11 @@ class Repository end cache_method :gitlab_ci_yml + def xcode_project? + file_on_head(:xcode_config).present? + end + cache_method :xcode_project? + def head_commit @head_commit ||= commit(self.root_ref) end @@ -854,13 +860,27 @@ class Repository add_remote(remote_name, url, mirror_refmap: refmap) fetch_remote(remote_name, forced: forced, prune: prune) ensure - remove_remote(remote_name) if tmp_remote_name + async_remove_remote(remote_name) if tmp_remote_name end def fetch_remote(remote, forced: false, ssh_auth: nil, no_tags: false, prune: true) gitlab_shell.fetch_remote(raw_repository, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags, prune: prune) end + def async_remove_remote(remote_name) + return unless remote_name + + job_id = RepositoryRemoveRemoteWorker.perform_async(project.id, remote_name) + + if job_id + Rails.logger.info("Remove remote job scheduled for #{project.id} with remote name: #{remote_name} job ID #{job_id}.") + else + Rails.logger.info("Remove remote job failed to create for #{project.id} with remote name #{remote_name}.") + end + + job_id + end + def fetch_source_branch!(source_repository, source_branch, local_ref) raw_repository.fetch_source_branch!(source_repository.raw_repository, source_branch, local_ref) end diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index 6e311806be1..3da7c301d28 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -5,14 +5,14 @@ class SentNotification < ActiveRecord::Base belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :recipient, class_name: "User" - validates :project, :recipient, presence: true + validates :recipient, presence: true validates :reply_key, presence: true, uniqueness: true validates :noteable_id, presence: true, unless: :for_commit? validates :commit_id, presence: true, if: :for_commit? validates :in_reply_to_discussion_id, format: { with: /\A\h{40}\z/, allow_nil: true } validate :note_valid - after_save :keep_around_commit + after_save :keep_around_commit, if: :for_commit? class << self def reply_key diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 29035480371..1c2161accc4 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -17,7 +17,11 @@ class SystemNoteMetadata < ActiveRecord::Base ].freeze validates :note, presence: true - validates :action, inclusion: ICON_TYPES, allow_nil: true + validates :action, inclusion: { in: :icon_types }, allow_nil: true belongs_to :note + + def icon_types + ICON_TYPES + end end diff --git a/app/models/term_agreement.rb b/app/models/term_agreement.rb new file mode 100644 index 00000000000..8458a231bbd --- /dev/null +++ b/app/models/term_agreement.rb @@ -0,0 +1,6 @@ +class TermAgreement < ActiveRecord::Base + belongs_to :term, class_name: 'ApplicationSetting::Term' + belongs_to :user + + validates :user, :term, presence: true +end diff --git a/app/models/user.rb b/app/models/user.rb index 4a602ffbb05..173ab38e20c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -138,6 +138,8 @@ class User < ActiveRecord::Base has_many :custom_attributes, class_name: 'UserCustomAttribute' has_many :callouts, class_name: 'UserCallout' has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :term_agreements + belongs_to :accepted_term, class_name: 'ApplicationSetting::Term' # # Validations @@ -235,14 +237,18 @@ class User < ActiveRecord::Base scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) } scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) } - def self.with_two_factor + def self.with_two_factor_indistinct joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id") - .where("u2f.id IS NOT NULL OR otp_required_for_login = ?", true).distinct(arel_table[:id]) + .where("u2f.id IS NOT NULL OR users.otp_required_for_login = ?", true) + end + + def self.with_two_factor + with_two_factor_indistinct.distinct(arel_table[:id]) end def self.without_two_factor joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id") - .where("u2f.id IS NULL AND otp_required_for_login = ?", false) + .where("u2f.id IS NULL AND users.otp_required_for_login = ?", false) end # @@ -1091,8 +1097,11 @@ class User < ActiveRecord::Base # <https://github.com/plataformatec/devise/blob/v4.0.0/lib/devise/models/lockable.rb#L92> # def increment_failed_attempts! + return if ::Gitlab::Database.read_only? + self.failed_attempts ||= 0 self.failed_attempts += 1 + if attempts_exceeded? lock_access! unless access_locked? else @@ -1187,6 +1196,15 @@ class User < ActiveRecord::Base max_member_access_for_group_ids([group_id])[group_id] end + def terms_accepted? + accepted_term_id.present? + end + + def required_terms_not_accepted? + Gitlab::CurrentSettings.current_application_settings.enforce_terms? && + !terms_accepted? + end + protected # override, from Devise::Validatable diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb index e4b69382626..9d461c6750a 100644 --- a/app/models/user_callout.rb +++ b/app/models/user_callout.rb @@ -2,7 +2,8 @@ class UserCallout < ActiveRecord::Base belongs_to :user enum feature_name: { - gke_cluster_integration: 1 + gke_cluster_integration: 1, + gcp_signup_offer: 2 } validates :user, presence: true diff --git a/app/policies/application_setting/term_policy.rb b/app/policies/application_setting/term_policy.rb new file mode 100644 index 00000000000..f03bf748c76 --- /dev/null +++ b/app/policies/application_setting/term_policy.rb @@ -0,0 +1,28 @@ +class ApplicationSetting + class TermPolicy < BasePolicy + include Gitlab::Utils::StrongMemoize + + condition(:current_terms, scope: :subject) do + Gitlab::CurrentSettings.current_application_settings.latest_terms == @subject + end + + condition(:terms_accepted, score: 1) do + agreement&.accepted + end + + rule { ~anonymous & current_terms }.policy do + enable :accept_terms + enable :decline_terms + end + + rule { terms_accepted }.prevent :accept_terms + + def agreement + strong_memoize(:agreement) do + next nil if @user.nil? || @subject.nil? + + @user.term_agreements.find_by(term: @subject) + end + end + end +end diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index 808a81cbbf9..8b65758f3e8 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -14,11 +14,20 @@ module Ci @subject.triggered_by?(@user) end + condition(:branch_allows_maintainer_push) do + @subject.project.branch_allows_maintainer_push?(@user, @subject.ref) + end + rule { protected_ref }.policy do prevent :update_build prevent :erase_build end rule { can?(:admin_build) | (can?(:update_build) & owner_of_job) }.enable :erase_build + + rule { can?(:public_access) & branch_allows_maintainer_push }.policy do + enable :update_build + enable :update_commit_status + end end end diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb index 6363c382ff8..540e4235299 100644 --- a/app/policies/ci/pipeline_policy.rb +++ b/app/policies/ci/pipeline_policy.rb @@ -4,8 +4,16 @@ module Ci condition(:protected_ref) { ref_protected?(@user, @subject.project, @subject.tag?, @subject.ref) } + condition(:branch_allows_maintainer_push) do + @subject.project.branch_allows_maintainer_push?(@user, @subject.ref) + end + rule { protected_ref }.prevent :update_pipeline + rule { can?(:public_access) & branch_allows_maintainer_push }.policy do + enable :update_pipeline + end + def ref_protected?(user, project, tag, ref) access = ::Gitlab::UserAccess.new(user, project: project) diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 64e550d19d0..1cf5515d9d7 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -1,22 +1,24 @@ class GlobalPolicy < BasePolicy desc "User is blocked" with_options scope: :user, score: 0 - condition(:blocked) { @user.blocked? } + condition(:blocked) { @user&.blocked? } desc "User is an internal user" with_options scope: :user, score: 0 - condition(:internal) { @user.internal? } + condition(:internal) { @user&.internal? } desc "User's access has been locked" with_options scope: :user, score: 0 - condition(:access_locked) { @user.access_locked? } + condition(:access_locked) { @user&.access_locked? } - condition(:can_create_fork, scope: :user) { @user.manageable_namespaces.any? { |namespace| @user.can?(:create_projects, namespace) } } + condition(:can_create_fork, scope: :user) { @user && @user.manageable_namespaces.any? { |namespace| @user.can?(:create_projects, namespace) } } + + condition(:required_terms_not_accepted, scope: :user, score: 0) do + @user&.required_terms_not_accepted? + end rule { anonymous }.policy do prevent :log_in - prevent :access_api - prevent :access_git prevent :receive_notifications prevent :use_quick_actions prevent :create_group @@ -38,6 +40,11 @@ class GlobalPolicy < BasePolicy prevent :use_quick_actions end + rule { required_terms_not_accepted }.policy do + prevent :access_api + prevent :access_git + end + rule { can_create_group }.policy do enable :create_group end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 3529d0aa60c..99a0d7118f2 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -76,10 +76,15 @@ class ProjectPolicy < BasePolicy condition(:request_access_enabled, scope: :subject, score: 0) { project.request_access_enabled } desc "Has merge requests allowing pushes to user" - condition(:has_merge_requests_allowing_pushes, scope: :subject) do + condition(:has_merge_requests_allowing_pushes) do project.merge_requests_allowing_push_to_user(user).any? end + with_scope :global + condition(:mirror_available, score: 0) do + ::Gitlab::CurrentSettings.current_application_settings.mirror_available + end + # We aren't checking `:read_issue` or `:read_merge_request` in this case # because it could be possible for a user to see an issuable-iid # (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be @@ -246,6 +251,8 @@ class ProjectPolicy < BasePolicy enable :create_cluster end + rule { (mirror_available & can?(:admin_project)) | admin }.enable :admin_remote_mirror + rule { archived }.policy do prevent :push_code prevent :push_to_delete_protected_branch @@ -347,9 +354,7 @@ class ProjectPolicy < BasePolicy # to run pipelines for the branches they have access to. rule { can?(:public_access) & has_merge_requests_allowing_pushes }.policy do enable :create_build - enable :update_build enable :create_pipeline - enable :update_pipeline end rule do diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 0905ddd9b38..ee219f0a0d0 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -8,6 +8,8 @@ class UserPolicy < BasePolicy rule { ~restricted_public_level }.enable :read_user rule { ~anonymous }.enable :read_user - rule { user_is_self | admin }.enable :destroy_user - rule { subject_ghost }.prevent :destroy_user + rule { ~subject_ghost & (user_is_self | admin) }.policy do + enable :destroy_user + enable :update_user + end end diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index 099b4720fb6..cc2bce9862d 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -1,11 +1,21 @@ module Ci class PipelinePresenter < Gitlab::View::Presenter::Delegated + include Gitlab::Utils::StrongMemoize + FAILURE_REASONS = { config_error: 'CI/CD YAML configuration error!' }.freeze presents :pipeline + def failed_builds + return [] unless can?(current_user, :read_build, pipeline) + + strong_memoize(:failed_builds) do + pipeline.builds.latest.failed + end + end + def failure_reason return unless pipeline.failure_reason? diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb index f947c79d919..ba8c7aa08fd 100644 --- a/app/serializers/diff_file_entity.rb +++ b/app/serializers/diff_file_entity.rb @@ -131,11 +131,9 @@ class DiffFileEntity < Grape::Entity def memoized_submodule_links(diff_file) strong_memoize(:submodule_links) do - if diff_file.submodule? - submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository) - else - [] - end + return [] unless diff_file.submodule? + + submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository) end end end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 1dd2c6f247f..63f481a35eb 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -2,6 +2,7 @@ class MergeRequestWidgetEntity < IssuableEntity expose :state expose :in_progress_merge_commit_sha expose :merge_commit_sha + expose :short_merge_commit_sha expose :merge_error expose :merge_params expose :merge_status @@ -119,7 +120,7 @@ class MergeRequestWidgetEntity < IssuableEntity end expose :can_create_note do |issue| - # TODO correct issue to merge_request where applicable + #TODO correct issue to merge_request where applicable can?(request.current_user, :create_note, issue) end diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index 1c45395358e..fe7fcee1a58 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -27,8 +27,6 @@ class NoteEntity < API::Entities::Note expose :resolved?, as: :resolved expose :resolvable?, as: :resolvable expose :resolved_by, using: NoteUserEntity - expose :resolved_at - expose :resolved_by_push?, as: :resolved_by_push expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note| SystemNoteHelper.system_note_icon_name(note) diff --git a/app/serializers/project_mirror_entity.rb b/app/serializers/project_mirror_entity.rb new file mode 100644 index 00000000000..a9c08ac021a --- /dev/null +++ b/app/serializers/project_mirror_entity.rb @@ -0,0 +1,11 @@ +class ProjectMirrorEntity < Grape::Entity + expose :id + + expose :remote_mirrors_attributes do |project| + next [] unless project.remote_mirrors.present? + + project.remote_mirrors.map do |remote| + remote.as_json(only: %i[id url enabled]) + end + end +end diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb index 4523b15152e..2516df70ad9 100644 --- a/app/serializers/stage_entity.rb +++ b/app/serializers/stage_entity.rb @@ -11,6 +11,12 @@ class StageEntity < Grape::Entity if: -> (_, opts) { opts[:grouped] }, with: JobGroupEntity + expose :latest_statuses, + if: -> (_, opts) { opts[:details] }, + with: JobEntity do |stage| + latest_statuses + end + expose :detailed_status, as: :status, with: StatusEntity expose :path do |stage| @@ -35,4 +41,14 @@ class StageEntity < Grape::Entity def detailed_status stage.detailed_status(request.current_user) end + + def grouped_statuses + @grouped_statuses ||= stage.statuses.latest_ordered.group_by(&:status) + end + + def latest_statuses + HasStatus::ORDERED_STATUSES.map do |ordered_status| + grouped_statuses.fetch(ordered_status, []) + end.flatten + end end diff --git a/app/serializers/stage_serializer.rb b/app/serializers/stage_serializer.rb new file mode 100644 index 00000000000..091d8e91e43 --- /dev/null +++ b/app/serializers/stage_serializer.rb @@ -0,0 +1,7 @@ +class StageSerializer < BaseSerializer + include WithPagination + + InvalidResourceError = Class.new(StandardError) + + entity StageEntity +end diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb index 61589a07250..d6d3a661dab 100644 --- a/app/services/application_settings/update_service.rb +++ b/app/services/application_settings/update_service.rb @@ -1,7 +1,22 @@ module ApplicationSettings class UpdateService < ApplicationSettings::BaseService def execute + update_terms(@params.delete(:terms)) + @application_setting.update(@params) end + + private + + def update_terms(terms) + return unless terms.present? + + # Avoid creating a new terms record if the text is exactly the same. + terms = terms.strip + return if terms == @application_setting.terms + + ApplicationSetting::Term.create(terms: terms) + @application_setting.reset_memoized_terms + end end end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 6ce86983287..17a53b6a8fd 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -24,6 +24,7 @@ module Ci ignore_skip_ci: ignore_skip_ci, save_incompleted: save_on_errors, seeds_block: block, + variables_attributes: params[:variables_attributes], project: project, current_user: current_user) diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 0b087ad73da..4291631913a 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -17,8 +17,10 @@ module Ci builds = if runner.shared? builds_for_shared_runner + elsif runner.group_type? + builds_for_group_runner else - builds_for_specific_runner + builds_for_project_runner end valid = true @@ -75,15 +77,24 @@ module Ci .joins('LEFT JOIN project_features ON ci_builds.project_id = project_features.project_id') .where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0'). - # Implement fair scheduling - # this returns builds that are ordered by number of running builds - # we prefer projects that don't use shared runners at all - joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id=project_builds.project_id") + # Implement fair scheduling + # this returns builds that are ordered by number of running builds + # we prefer projects that don't use shared runners at all + joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id=project_builds.project_id") .order('COALESCE(project_builds.running_builds, 0) ASC', 'ci_builds.id ASC') end - def builds_for_specific_runner - new_builds.where(project: runner.projects.without_deleted.with_builds_enabled).order('created_at ASC') + def builds_for_project_runner + new_builds.where(project: runner.projects.without_deleted.with_builds_enabled).order('id ASC') + end + + def builds_for_group_runner + hierarchy_groups = Gitlab::GroupHierarchy.new(runner.groups).base_and_descendants + projects = Project.where(namespace_id: hierarchy_groups) + .with_group_runners_enabled + .with_builds_enabled + .without_deleted + new_builds.where(project: projects).order('id ASC') end def running_builds_for_shared_runners @@ -97,10 +108,6 @@ module Ci builds end - def shared_runner_build_limits_feature_enabled? - ENV['DISABLE_SHARED_RUNNER_BUILD_MINUTES_LIMIT'].to_s != 'true' - end - def register_failure failed_attempt_counter.increment attempt_counter.increment diff --git a/app/services/ci/update_build_queue_service.rb b/app/services/ci/update_build_queue_service.rb index 152c8ae5006..41b1c144c3e 100644 --- a/app/services/ci/update_build_queue_service.rb +++ b/app/services/ci/update_build_queue_service.rb @@ -1,18 +1,14 @@ module Ci class UpdateBuildQueueService def execute(build) - build.project.runners.each do |runner| - if runner.can_pick?(build) - runner.tick_runner_queue - end - end + tick_for(build, build.project.all_runners) + end - return unless build.project.shared_runners_enabled? + private - Ci::Runner.shared.each do |runner| - if runner.can_pick?(build) - runner.tick_runner_queue - end + def tick_for(build, runners) + runners.each do |runner| + runner.pick_build!(build) end end end diff --git a/app/services/concerns/exclusive_lease_guard.rb b/app/services/concerns/exclusive_lease_guard.rb new file mode 100644 index 00000000000..30be6accc32 --- /dev/null +++ b/app/services/concerns/exclusive_lease_guard.rb @@ -0,0 +1,52 @@ +# +# Concern that helps with getting an exclusive lease for running a block +# of code. +# +# `#try_obtain_lease` takes a block which will be run if it was able to +# obtain the lease. Implement `#lease_timeout` to configure the timeout +# for the exclusive lease. Optionally override `#lease_key` to set the +# lease key, it defaults to the class name with underscores. +# +module ExclusiveLeaseGuard + extend ActiveSupport::Concern + + def try_obtain_lease + lease = exclusive_lease.try_obtain + + unless lease + log_error('Cannot obtain an exclusive lease. There must be another instance already in execution.') + return + end + + begin + yield lease + ensure + release_lease(lease) + end + end + + def exclusive_lease + @lease ||= Gitlab::ExclusiveLease.new(lease_key, timeout: lease_timeout) + end + + def lease_key + @lease_key ||= self.class.name.underscore + end + + def lease_timeout + raise NotImplementedError, + "#{self.class.name} does not implement #{__method__}" + end + + def release_lease(uuid) + Gitlab::ExclusiveLease.cancel(lease_key, uuid) + end + + def renew_lease! + exclusive_lease.renew + end + + def log_error(message, extra_args = {}) + logger.error(message) + end +end diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb new file mode 100644 index 00000000000..bf60b96938d --- /dev/null +++ b/app/services/concerns/users/participable_service.rb @@ -0,0 +1,41 @@ +module Users + module ParticipableService + extend ActiveSupport::Concern + + included do + attr_reader :noteable + end + + def noteable_owner + return [] unless noteable && noteable.author.present? + + [as_hash(noteable.author)] + end + + def participants_in_noteable + return [] unless noteable + + users = noteable.participants(current_user) + sorted(users) + end + + def sorted(users) + users.uniq.to_a.compact.sort_by(&:username).map do |user| + as_hash(user) + end + end + + def groups + current_user.authorized_groups.sort_by(&:path).map do |group| + count = group.users.count + { username: group.full_path, name: group.full_name, count: count, avatar_url: group.avatar_url } + end + end + + private + + def as_hash(user) + { username: user.username, name: user.name, avatar_url: user.avatar_url } + end + end +end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index c037141fcde..f3bfc53dcd3 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -55,6 +55,7 @@ class GitPushService < BaseService execute_related_hooks perform_housekeeping + update_remote_mirrors update_caches update_signatures @@ -119,6 +120,13 @@ class GitPushService < BaseService protected + def update_remote_mirrors + return unless @project.has_remote_mirror? + + @project.mark_stuck_remote_mirrors_as_failed! + @project.update_remote_mirrors + end + def execute_related_hooks # Update merge requests that may be affected by this push. A new branch # could cause the last commit of a merge request to change. diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index 83e59a649b6..5658699664d 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -45,6 +45,10 @@ module NotificationRecipientService target.project end + def group + project&.group || target.try(:group) + end + def recipients @recipients ||= [] end @@ -67,6 +71,7 @@ module NotificationRecipientService user, type, reason: reason, project: project, + group: group, custom_action: custom_action, target: target, acting_user: acting_user @@ -107,11 +112,11 @@ module NotificationRecipientService # Users with a notification setting on group or project user_ids += user_ids_notifiable_on(project, :custom) - user_ids += user_ids_notifiable_on(project.group, :custom) + user_ids += user_ids_notifiable_on(group, :custom) # Users with global level custom user_ids_with_project_level_global = user_ids_notifiable_on(project, :global) - user_ids_with_group_level_global = user_ids_notifiable_on(project.group, :global) + user_ids_with_group_level_global = user_ids_notifiable_on(group, :global) global_users_ids = user_ids_with_project_level_global.concat(user_ids_with_group_level_global) user_ids += user_ids_with_global_level_custom(global_users_ids, custom_action) @@ -123,6 +128,10 @@ module NotificationRecipientService add_recipients(project_watchers, :watch, nil) end + def add_group_watchers + add_recipients(group_watchers, :watch, nil) + end + # Get project users with WATCH notification level def project_watchers project_members_ids = user_ids_notifiable_on(project) @@ -138,6 +147,14 @@ module NotificationRecipientService user_scope.where(id: user_ids_with_project_setting.concat(user_ids_with_group_setting).uniq) end + def group_watchers + user_ids_with_group_global = user_ids_notifiable_on(group, :global) + user_ids = user_ids_with_global_level_watch(user_ids_with_group_global) + user_ids_with_group_setting = select_group_members_ids(group, [], user_ids_with_group_global, user_ids) + + user_scope.where(id: user_ids_with_group_setting) + end + def add_subscribed_users return unless target.respond_to? :subscribers @@ -281,6 +298,14 @@ module NotificationRecipientService note.project end + def group + if note.for_project_noteable? + project.group + else + target.try(:group) + end + end + def build! # Add all users participating in the thread (author, assignee, comment authors) add_participants(note.author) @@ -289,11 +314,11 @@ module NotificationRecipientService if note.for_project_noteable? # Merge project watchers add_project_watchers - - # Merge project with custom notification - add_custom_notifications + else + add_group_watchers end + add_custom_notifications add_subscribed_users end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index d361d070993..d16ecdb7b9b 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -142,7 +142,7 @@ module Projects if @project @project.errors.add(:base, message) - @project.mark_import_as_failed(message) if @project.import? + @project.mark_import_as_failed(message) if @project.persisted? && @project.import? end @project diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 71c93660b4b..adbc498d0bf 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -87,7 +87,7 @@ module Projects new_path = removal_path(path) if mv_repository(path, new_path) - log_info("Repository \"#{path}\" moved to \"#{new_path}\"") + log_info(%Q{Repository "#{path}" moved to "#{new_path}" for project "#{project.full_path}"}) project.run_after_commit do # self is now project diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index e6193fcacee..eb0472c6024 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -1,6 +1,6 @@ module Projects class ParticipantsService < BaseService - attr_reader :noteable + include Users::ParticipableService def execute(noteable) @noteable = noteable @@ -10,36 +10,6 @@ module Projects participants.uniq end - def noteable_owner - return [] unless noteable && noteable.author.present? - - [{ - name: noteable.author.name, - username: noteable.author.username, - avatar_url: noteable.author.avatar_url - }] - end - - def participants_in_noteable - return [] unless noteable - - users = noteable.participants(current_user) - sorted(users) - end - - def sorted(users) - users.uniq.to_a.compact.sort_by(&:username).map do |user| - { username: user.username, name: user.name, avatar_url: user.avatar_url } - end - end - - def groups - current_user.authorized_groups.sort_by(&:path).map do |group| - count = group.users.count - { username: group.full_path, name: group.full_name, count: count, avatar_url: group.avatar_url } - end - end - def all_members count = project.team.members.flatten.count [{ username: "all", name: "All Project and Group Members", count: count }] diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb new file mode 100644 index 00000000000..8183a2f26d7 --- /dev/null +++ b/app/services/projects/update_remote_mirror_service.rb @@ -0,0 +1,30 @@ +module Projects + class UpdateRemoteMirrorService < BaseService + attr_reader :errors + + def execute(remote_mirror) + @errors = [] + + return success unless remote_mirror.enabled? + + begin + repository.fetch_remote(remote_mirror.remote_name, no_tags: true) + + opts = {} + if remote_mirror.only_protected_branches? + opts[:only_branches_matching] = project.protected_branches.select(:name).map(&:name) + end + + remote_mirror.update_repository(opts) + rescue => e + errors << e.message.strip + end + + if errors.present? + error(errors.join("\n\n")) + else + success + end + end + end +end diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb index 976017dfa82..a2833b1e051 100644 --- a/app/services/users/migrate_to_ghost_user_service.rb +++ b/app/services/users/migrate_to_ghost_user_service.rb @@ -49,7 +49,7 @@ module Users migrate_merge_requests migrate_notes migrate_abuse_reports - migrate_award_emojis + migrate_award_emoji end def migrate_issues @@ -70,7 +70,7 @@ module Users user.reported_abuse_reports.update_all(reporter_id: ghost_user.id) end - def migrate_award_emojis + def migrate_award_emoji user.award_emoji.update_all(user_id: ghost_user.id) end end diff --git a/app/services/users/respond_to_terms_service.rb b/app/services/users/respond_to_terms_service.rb new file mode 100644 index 00000000000..06d660186cf --- /dev/null +++ b/app/services/users/respond_to_terms_service.rb @@ -0,0 +1,24 @@ +module Users + class RespondToTermsService + def initialize(user, term) + @user, @term = user, term + end + + def execute(accepted:) + agreement = @user.term_agreements.find_or_initialize_by(term: @term) + agreement.accepted = accepted + + if agreement.save + store_accepted_term(accepted) + end + + agreement + end + + private + + def store_accepted_term(accepted) + @user.update_column(:accepted_term_id, accepted ? @term.id : nil) + end + end +end diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 809ce1303d8..7ec52b6ce2b 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -41,7 +41,7 @@ class WebHookService http_status: response.code, message: response.to_s } - rescue SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, Net::ReadTimeout => e + rescue SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, Net::ReadTimeout, Gitlab::HTTP::BlockedUrlError => e log_execution( trigger: hook_name, url: hook.url, diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml index f33769b23c2..fe335f30a62 100644 --- a/app/views/admin/application_settings/_repository_check.html.haml +++ b/app/views/admin/application_settings/_repository_check.html.haml @@ -12,7 +12,7 @@ Enable Repository Checks .help-block GitLab will periodically run - %a{ href: 'https://www.kernel.org/pub/software/scm/git/docs/git-fsck.html', target: 'blank' } 'git fsck' + %a{ href: 'https://git-scm.com/docs/git-fsck', target: 'blank' } 'git fsck' in all project and wiki repositories to look for silent disk corruption issues. .form-group .col-sm-offset-2.col-sm-10 diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml new file mode 100644 index 00000000000..09183ec6260 --- /dev/null +++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml @@ -0,0 +1,16 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :mirror_available, 'Enable mirror configuration', class: 'control-label col-sm-2' + .col-sm-10 + .checkbox + = f.label :mirror_available do + = f.check_box :mirror_available + Allow mirrors to be setup for projects + %span.help-block + If disabled, only admins will be able to setup mirrors in projects. + = link_to icon('question-circle'), help_page_path('workflow/repository_mirroring') + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_terms.html.haml b/app/views/admin/application_settings/_terms.html.haml new file mode 100644 index 00000000000..724246ab7e7 --- /dev/null +++ b/app/views/admin/application_settings/_terms.html.haml @@ -0,0 +1,22 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-12 + .checkbox + = f.label :enforce_terms do + = f.check_box :enforce_terms + = _("Require all users to accept Terms of Service when they access GitLab.") + .help-block + = _("When enabled, users cannot use GitLab until the terms have been accepted.") + .form-group + .col-sm-12 + = f.label :terms do + = _("Terms of Service Agreement") + .col-sm-12 + = f.text_area :terms, class: 'form-control', rows: 8 + .help-block + = _("Markdown enabled") + + = f.submit _("Save changes"), class: "btn btn-success" diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml index caaa93aa1e2..3f440c76ee0 100644 --- a/app/views/admin/application_settings/show.html.haml +++ b/app/views/admin/application_settings/show.html.haml @@ -8,7 +8,7 @@ %h4 = _('Visibility and access controls') %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + = expanded ? _('Collapse') : _('Expand') %p = _('Set default and restrict visibility levels. Configure import sources and git access protocol.') .settings-content @@ -19,7 +19,7 @@ %h4 = _('Account and limit settings') %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + = expanded ? _('Collapse') : _('Expand') %p = _('Session expiration, projects limit and attachment size.') .settings-content @@ -30,7 +30,7 @@ %h4 = _('Sign-up restrictions') %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + = expanded ? _('Collapse') : _('Expand') %p = _('Configure the way a user creates a new account.') .settings-content @@ -41,18 +41,29 @@ %h4 = _('Sign-in restrictions') %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + = expanded ? _('Collapse') : _('Expand') %p = _('Set requirements for a user to sign-in. Enable mandatory two-factor authentication.') .settings-content = render 'signin' +%section.settings.as-terms.no-animate#js-terms-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Terms of Service') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = _('Include a Terms of Service agreement that all users must accept.') + .settings-content + = render 'terms' + %section.settings.as-help-page.no-animate#js-help-settings{ class: ('expanded' if expanded) } .settings-header %h4 = _('Help page') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Help page text and support page url.') .settings-content @@ -62,8 +73,8 @@ .settings-header %h4 = _('Pages') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Size and domain settings for static websites') .settings-content @@ -73,8 +84,8 @@ .settings-header %h4 = _('Continuous Integration and Deployment') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Auto DevOps, runners and job artifacts') .settings-content @@ -84,8 +95,8 @@ .settings-header %h4 = _('Metrics - Influx') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Enable and configure InfluxDB metrics.') .settings-content @@ -95,8 +106,8 @@ .settings-header %h4 = _('Metrics - Prometheus') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Enable and configure Prometheus metrics.') .settings-content @@ -106,8 +117,8 @@ .settings-header %h4 = _('Profiling - Performance bar') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Enable the Performance Bar for a given group.') = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/performance_bar') @@ -118,8 +129,8 @@ .settings-header %h4 = _('Background jobs') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Configure Sidekiq job throttling.') .settings-content @@ -129,8 +140,8 @@ .settings-header %h4 = _('Spam and Anti-bot Protection') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Enable reCAPTCHA or Akismet and set IP limits.') .settings-content @@ -140,8 +151,8 @@ .settings-header %h4 = _('Abuse reports') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Set notification email for abuse reports.') .settings-content @@ -151,8 +162,8 @@ .settings-header %h4 = _('Error Reporting and Logging') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Enable Sentry for error reporting and logging.') .settings-content @@ -162,8 +173,8 @@ .settings-header %h4 = _('Repository storage') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Configure storage path and circuit breaker settings.') .settings-content @@ -173,8 +184,8 @@ .settings-header %h4 = _('Repository maintenance') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Configure automatic git checks and housekeeping on repositories.') .settings-content @@ -185,8 +196,8 @@ .settings-header %h4 = _('Container Registry') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Various container registry settings.') .settings-content @@ -197,8 +208,8 @@ .settings-header %h4 = _('Koding') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Online IDE integration settings.') .settings-content @@ -208,8 +219,8 @@ .settings-header %h4 = _('PlantUML') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Allow rendering of PlantUML diagrams in Asciidoc documents.') .settings-content @@ -219,8 +230,8 @@ .settings-header#usage-statistics %h4 = _('Usage statistics') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Enable or disable version check and usage ping.') .settings-content @@ -230,8 +241,8 @@ .settings-header %h4 = _('Email') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Various email settings.') .settings-content @@ -241,8 +252,8 @@ .settings-header %h4 = _('Gitaly') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Configure Gitaly timeouts.') .settings-content @@ -252,8 +263,8 @@ .settings-header %h4 = _('Web terminal') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Set max session time for web terminal.') .settings-content @@ -263,8 +274,8 @@ .settings-header %h4 = _('Real-time features') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Change this value to influence how frequently the GitLab UI polls for updates.') .settings-content @@ -274,8 +285,8 @@ .settings-header %h4 = _('Performance optimization') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Various settings that affect GitLab performance.') .settings-content @@ -285,8 +296,8 @@ .settings-header %h4 = _('User and IP Rate Limits') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Configure limits for web and API requests.') .settings-content @@ -296,9 +307,20 @@ .settings-header %h4 = _('Outbound requests') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') %p = _('Allow requests to the local network from hooks and services.') .settings-content = render 'outbound' + +%section.settings.as-mirror.no-animate#js-mirror-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Repository mirror settings') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Configure push mirrors.') + .settings-content + = render partial: 'repository_mirrors_form' diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml index f90b8b8c0a4..6e76e7c2768 100644 --- a/app/views/admin/runners/_runner.html.haml +++ b/app/views/admin/runners/_runner.html.haml @@ -2,6 +2,8 @@ %td - if runner.shared? %span.label.label-success shared + - elsif runner.group_type? + %span.label.label-success group - else %span.label.label-info specific - if runner.locked? @@ -19,7 +21,7 @@ %td = runner.ip_address %td - - if runner.shared? + - if runner.shared? || runner.group_type? n/a - else = runner.projects.count(:all) @@ -31,7 +33,7 @@ = tag %td - if runner.contacted_at - = time_ago_with_tooltip runner.contacted_at + #{time_ago_in_words(runner.contacted_at)} ago - else Never %td.admin-runner-btn-group-cell diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index 9f13dbbbd82..1a3b5e58ed5 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -17,6 +17,9 @@ %span.label.label-success shared \- Runner runs jobs from all unassigned projects %li + %span.label.label-success group + \- Runner runs jobs from all unassigned projects in its group + %li %span.label.label-info specific \- Runner runs jobs from assigned projects %li diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index d04cf48b05c..73fadc042b1 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -19,6 +19,9 @@ %p If you want Runners to build only specific projects, enable them in the table below. Keep in mind that this is a one way transition. +- elsif @runner.group_type? + .bs-callout.bs-callout-success + %h4 This runner will process jobs from all projects in its group and subgroups - else .bs-callout.bs-callout-info %h4 This Runner will process jobs only from ASSIGNED projects @@ -26,7 +29,7 @@ %hr .append-bottom-20 - = render '/projects/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner) + = render 'shared/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner) .row .col-md-6 diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index 0ef4b71f4fe..10b8bf5d565 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -42,31 +42,31 @@ = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do = link_to admin_users_path do Active - %small.badge= number_with_delimiter(User.active.count) + %small.badge= limited_counter_with_delimiter(User.active) = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do = link_to admin_users_path(filter: "admins") do Admins - %small.badge= number_with_delimiter(User.admins.count) + %small.badge= limited_counter_with_delimiter(User.admins) = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do = link_to admin_users_path(filter: 'two_factor_enabled') do 2FA Enabled - %small.badge= number_with_delimiter(User.with_two_factor.count) + %small.badge= limited_counter_with_delimiter(User.with_two_factor) = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do = link_to admin_users_path(filter: 'two_factor_disabled') do 2FA Disabled - %small.badge= number_with_delimiter(User.without_two_factor.count) + %small.badge= limited_counter_with_delimiter(User.without_two_factor) = nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do = link_to admin_users_path(filter: 'external') do External - %small.badge= number_with_delimiter(User.external.count) + %small.badge= limited_counter_with_delimiter(User.external) = nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do = link_to admin_users_path(filter: "blocked") do Blocked - %small.badge= number_with_delimiter(User.blocked.count) + %small.badge= limited_counter_with_delimiter(User.blocked) = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do = link_to admin_users_path(filter: "wop") do Without projects - %small.badge= number_with_delimiter(User.without_projects.count) + %small.badge= limited_counter_with_delimiter(User.without_projects) %ul.flex-list.content-list - if @users.empty? diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml index db2040110fa..d828f6f971d 100644 --- a/app/views/ci/status/_dropdown_graph_badge.html.haml +++ b/app/views/ci/status/_dropdown_graph_badge.html.haml @@ -16,5 +16,5 @@ %span.ci-build-text= subject.name - if status.has_action? - = link_to status.action_path, class: "ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do + = link_to status.action_path, class: "ci-action-icon-container ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do = sprite_icon(status.action_icon, css_class: "icon-action-#{status.action_icon}") diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index e9589213f80..ebe8c327079 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -13,7 +13,7 @@ = icon("chevron-up") - else = icon("chevron-down") - Toggle discussion + = _('Toggle discussion') = link_to_member(@project, discussion.author, avatar: false) .inline.discussion-headline-light diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index ad9d5562ded..c8addc49117 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -1,10 +1,11 @@ - page_title "Members" +- can_manage_members = can?(current_user, :admin_group_member, @group) .project-members-page.prepend-top-default %h4 Members %hr - - if can?(current_user, :admin_group_member, @group) + - if can_manage_members .project-members-new.append-bottom-default %p.clearfix Add new member to @@ -13,20 +14,23 @@ = render 'shared/members/requests', membership_source: @group, requesters: @requesters - .append-bottom-default.clearfix + .clearfix %h5.member.existing-title Existing members - = form_tag group_group_members_path(@group), method: :get, class: 'form-inline member-search-form' do - .form-group - = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } - %button.member-search-btn{ type: "submit", "aria-label" => "Submit search" } - = icon("search") - = render 'shared/members/sort_dropdown' .panel.panel-default - .panel-heading - Members with access to - %strong= @group.name + .panel-heading.flex-project-members-panel + %span.flex-project-title + Members with access to + %strong= @group.name %span.badge= @members.total_count + = form_tag group_group_members_path(@group), method: :get, class: 'form-inline member-search-form flex-project-members-form' do + .form-group + = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } + %button.member-search-btn{ type: "submit", "aria-label" => "Submit search" } + = icon("search") + - if can_manage_members + = render 'shared/members/filter_2fa_dropdown' + = render 'shared/members/sort_dropdown' %ul.content-list.members-list = render partial: 'shared/members/member', collection: @members, as: :member = paginate @members, theme: 'gitlab' diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index bbfbea4ac7a..662db18cf86 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -8,7 +8,7 @@ .top-area = render 'shared/issuable/nav', type: :issues .nav-controls - = link_to params.merge(rss_url_options), class: 'btn' do + = link_to safe_params.merge(rss_url_options), class: 'btn' do = icon('rss') %span.icon-label Subscribe diff --git a/app/views/groups/runners/_group_runners.html.haml b/app/views/groups/runners/_group_runners.html.haml new file mode 100644 index 00000000000..e6c089c3494 --- /dev/null +++ b/app/views/groups/runners/_group_runners.html.haml @@ -0,0 +1,24 @@ +- link = link_to _('Runners API'), help_page_path('api/runners.md') + +%h3 + = _('Group Runners') + +.bs-callout.bs-callout-warning + = _('GitLab Group Runners can execute code for all the projects in this group.') + = _('They can be managed using the %{link}.').html_safe % { link: link } + +-# Proper policies should be implemented per +-# https://gitlab.com/gitlab-org/gitlab-ce/issues/45894 +- if can?(current_user, :admin_pipeline, @group) + = render partial: 'ci/runner/how_to_setup_runner', + locals: { registration_token: @group.runners_token, type: 'group' } + +- if @group.runners.empty? + %h4.underlined-title + = _('This group does not provide any group Runners yet.') + +- else + %h4.underlined-title + = _('Available group Runners : %{runners}.').html_safe % { runners: @group.runners.count } + %ul.bordered-list + = render partial: 'groups/runners/runner', collection: @group.runners, as: :runner diff --git a/app/views/groups/runners/_index.html.haml b/app/views/groups/runners/_index.html.haml new file mode 100644 index 00000000000..0cf9011b471 --- /dev/null +++ b/app/views/groups/runners/_index.html.haml @@ -0,0 +1,9 @@ += render 'shared/runners/runner_description' + +%hr + +%p.lead + = _('To start serving your jobs you can add Runners to your group') +.row + .col-sm-6 + = render 'groups/runners/group_runners' diff --git a/app/views/groups/runners/_runner.html.haml b/app/views/groups/runners/_runner.html.haml new file mode 100644 index 00000000000..76650a961d6 --- /dev/null +++ b/app/views/groups/runners/_runner.html.haml @@ -0,0 +1,27 @@ +%li.runner{ id: dom_id(runner) } + %h4 + = runner_status_icon(runner) + + = link_to runner.short_sha, group_runner_path(@group, runner), class: 'commit-sha' + + %small.edit-runner + = link_to edit_group_runner_path(@group, runner) do + = icon('edit') + + .pull-right + - if runner.active? + = link_to _('Pause'), pause_group_runner_path(@group, runner), method: :post, class: 'btn btn-sm btn-danger', data: { confirm: _("Are you sure?") } + - else + = link_to _('Resume'), resume_group_runner_path(@group, runner), method: :post, class: 'btn btn-success btn-sm' + = link_to _('Remove Runner'), group_runner_path(@group, runner), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn btn-danger btn-sm' + .pull-right + %small.light + \##{runner.id} + - if runner.description.present? + %p.runner-description + = runner.description + - if runner.tag_list.present? + %p + - runner.tag_list.sort.each do |tag| + %span.label.label-primary + = tag diff --git a/app/views/groups/runners/edit.html.haml b/app/views/groups/runners/edit.html.haml new file mode 100644 index 00000000000..fcd096eeaa0 --- /dev/null +++ b/app/views/groups/runners/edit.html.haml @@ -0,0 +1,6 @@ +- page_title _('Edit'), "#{@runner.description} ##{@runner.id}", 'Runners' + +%h4 Runner ##{@runner.id} + +%hr + = render 'shared/runners/form', runner: @runner, runner_form_url: group_runner_path(@group, @runner) diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index dd82922ec55..082e1b7befa 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -1,11 +1,27 @@ - breadcrumb_title "CI / CD Settings" - page_title "CI / CD" -%h4 - = _('Secret variables') - = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank', rel: 'noopener noreferrer' +- expanded = Rails.env.test? -%p - = render "ci/variables/content" +%section.settings#secret-variables.no-animate{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Secret variables') + = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank', rel: 'noopener noreferrer' + %button.btn.btn-default.js-settings-toggle{ type: "button" } + = expanded ? _('Collapse') : _('Expand') + %p.append-bottom-0 + = render "ci/variables/content" + .settings-content + = render 'ci/variables/index', save_endpoint: group_variables_path -= render 'ci/variables/index', save_endpoint: group_variables_path +%section.settings#runners-settings.no-animate{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Runners settings') + %button.btn.btn-default.js-settings-toggle{ type: "button" } + = expanded ? _('Collapse') : _('Expand') + %p + = _('Register and see your runners for this group.') + .settings-content + = render 'groups/runners/index' diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index 29b23ae2e52..1c5b4aecabb 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -1,5 +1,5 @@ #modal-shortcuts.modal{ tabindex: -1 } - .modal-dialog + .modal-dialog.modal-lg .modal-content .modal-header %a.close{ href: "#", "data-dismiss" => "modal" } × diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index ce09b44fbb2..7908a04c2eb 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -74,10 +74,10 @@ = lorem .cover-controls - = link_to '#', class: 'btn btn-gray' do + = link_to '#', class: 'btn btn-default' do = icon('pencil') - = link_to '#', class: 'btn btn-gray' do + = link_to '#', class: 'btn btn-default' do = icon('rss') %h2#lists Lists @@ -206,7 +206,6 @@ .example %button.btn.btn-default{ :type => "button" } Default - %button.btn.btn-gray{ :type => "button" } Gray %button.btn.btn-primary{ :type => "button" } Primary %button.btn.btn-success{ :type => "button" } Success %button.btn.btn-info{ :type => "button" } Info diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml index e0e8fe548d0..da9331b45dd 100644 --- a/app/views/ide/index.html.haml +++ b/app/views/ide/index.html.haml @@ -1,9 +1,6 @@ - @body_class = 'ide' - page_title 'IDE' -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'ide', force_same_domain: true - #ide.ide-loading{ data: {"empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'), "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'), "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg') } } diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml index 05ddd0ec733..8bd5708d490 100644 --- a/app/views/layouts/_flash.html.haml +++ b/app/views/layouts/_flash.html.haml @@ -1,8 +1,10 @@ +- extra_flash_class = local_assigns.fetch(:extra_flash_class, nil) + .flash-container.flash-container-page -# We currently only support `alert`, `notice`, `success` - flash.each do |key, value| -# Don't show a flash message if the message is nil - if value %div{ class: "flash-#{key}" } - %div{ class: (container_class) } + %div{ class: "#{container_class} #{extra_flash_class}" } %span= value diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index b981b5fdafa..02bdfe9aa3c 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -38,9 +38,6 @@ = yield :library_javascripts = javascript_include_tag locale_path unless I18n.locale == :en - = webpack_bundle_tag "webpack_runtime" - = webpack_bundle_tag "common" - = webpack_bundle_tag "main" = webpack_bundle_tag "raven" if Gitlab::CurrentSettings.clientside_sentry_enabled - if content_for?(:page_specific_javascripts) diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml index 4276e6ee4bb..240e03a5d53 100644 --- a/app/views/layouts/_init_auto_complete.html.haml +++ b/app/views/layouts/_init_auto_complete.html.haml @@ -1,16 +1,11 @@ -- project = @target_project || @project +- object = @target_project || @project || @group - noteable_type = @noteable.class if @noteable.present? -- if project +- datasources = autocomplete_data_sources(object, noteable_type) + +- if object -# haml-lint:disable InlineJavaScript :javascript gl = window.gl || {}; gl.GfmAutoComplete = gl.GfmAutoComplete || {}; - gl.GfmAutoComplete.dataSources = { - members: "#{members_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}", - issues: "#{issues_project_autocomplete_sources_path(project)}", - mergeRequests: "#{merge_requests_project_autocomplete_sources_path(project)}", - labels: "#{labels_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}", - milestones: "#{milestones_project_autocomplete_sources_path(project)}", - commands: "#{commands_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}" - }; + gl.GfmAutoComplete.dataSources = #{datasources.to_json}; diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml new file mode 100644 index 00000000000..24b6c490a5a --- /dev/null +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -0,0 +1,22 @@ +- return unless current_user + +%ul + %li.current-user + .user-name.bold + = current_user.name + = current_user.to_reference + %li.divider + - if current_user_menu?(:profile) + %li + = link_to s_("CurrentUser|Profile"), current_user, class: 'profile-link', data: { user: current_user.username } + - if current_user_menu?(:settings) + %li + = link_to s_("CurrentUser|Settings"), profile_path + - if current_user_menu?(:help) + %li + = link_to _("Help"), help_path + - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile) + %li.divider + - if current_user_menu?(:sign_out) + %li + = link_to _("Sign out"), destroy_user_session_path, class: "sign-out-link" diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index e6238c0dddb..dc121812406 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -53,22 +53,7 @@ = image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar" = sprite_icon('angle-down', css_class: 'caret-down') .dropdown-menu-nav.dropdown-menu-align-right - %ul - %li.current-user - .user-name.bold - = current_user.name - @#{current_user.username} - %li.divider - %li - = link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username } - %li - = link_to "Settings", profile_path - - if current_user - %li - = link_to "Help", help_path - %li.divider - %li - = link_to "Sign out", destroy_user_session_path, class: "sign-out-link" + = render 'layouts/header/current_user_dropdown' - if header_link?(:admin_impersonation) %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/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 196db08cebd..c3ea592a6b5 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -13,13 +13,13 @@ .nav-icon-container = sprite_icon('project') %span.nav-item-name - Project + = _('Project') %ul.sidebar-sub-level-items = nav_link(path: 'projects#show', html_options: { class: "fly-out-top-item" } ) do = link_to project_path(@project) do %strong.fly-out-top-item-name - #{ _('Overview') } + = _('Overview') %li.divider.fly-out-top-item = nav_link(path: 'projects#show') do = link_to project_path(@project), title: _('Project details'), class: 'shortcuts-project' do @@ -40,45 +40,45 @@ .nav-icon-container = sprite_icon('doc_text') %span.nav-item-name - Repository + = _('Repository') %ul.sidebar-sub-level-items = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file commit commits compare projects/repositories tags branches releases graphs network), html_options: { class: "fly-out-top-item" } ) do = link_to project_tree_path(@project) do %strong.fly-out-top-item-name - #{ _('Repository') } + = _('Repository') %li.divider.fly-out-top-item = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file)) do = link_to project_tree_path(@project) do - #{ _('Files') } + = _('Files') = nav_link(controller: [:commit, :commits]) do = link_to project_commits_path(@project, current_ref) do - #{ _('Commits') } + = _('Commits') = nav_link(html_options: {class: branches_tab_class}) do = link_to project_branches_path(@project) do - #{ _('Branches') } + = _('Branches') = nav_link(controller: [:tags, :releases]) do = link_to project_tags_path(@project) do - #{ _('Tags') } + = _('Tags') = nav_link(path: 'graphs#show') do = link_to project_graph_path(@project, current_ref) do - #{ _('Contributors') } + = _('Contributors') = nav_link(controller: %w(network)) do = link_to project_network_path(@project, current_ref) do - #{ s_('ProjectNetworkGraph|Graph') } + = _('Graph') = nav_link(controller: :compare) do = link_to project_compare_index_path(@project, from: @repository.root_ref, to: current_ref) do - #{ _('Compare') } + = _('Compare') = nav_link(path: 'graphs#charts') do = link_to charts_project_graph_path(@project, current_ref) do - #{ _('Charts') } + = _('Charts') - if project_nav_tab? :issues = nav_link(controller: @project.issues_enabled? ? [:issues, :labels, :milestones, :boards] : :issues) do @@ -86,7 +86,7 @@ .nav-icon-container = sprite_icon('issues') %span.nav-item-name - Issues + = _('Issues') - if @project.issues_enabled? %span.badge.count.issue_counter = number_with_delimiter(@project.open_issues_count) @@ -95,7 +95,7 @@ = nav_link(controller: :issues, html_options: { class: "fly-out-top-item" } ) do = link_to project_issues_path(@project) do %strong.fly-out-top-item-name - #{ _('Issues') } + = _('Issues') - if @project.issues_enabled? %span.badge.count.issue_counter.fly-out-badge = number_with_delimiter(@project.open_issues_count) @@ -103,7 +103,7 @@ = nav_link(controller: :issues, action: :index) do = link_to project_issues_path(@project), title: 'Issues' do %span - List + = _('List') = nav_link(controller: :boards) do = link_to project_boards_path(@project), title: boards_link_text do @@ -113,12 +113,12 @@ = nav_link(controller: :labels) do = link_to project_labels_path(@project), title: 'Labels' do %span - Labels + = _('Labels') = nav_link(controller: :milestones) do = link_to project_milestones_path(@project), title: 'Milestones' do %span - Milestones + = _('Milestones') - if project_nav_tab? :external_issue_tracker = nav_link do - issue_tracker = @project.external_issue_tracker @@ -139,54 +139,75 @@ .nav-icon-container = sprite_icon('git-merge') %span.nav-item-name - Merge Requests + = _('Merge Requests') %span.badge.count.merge_counter.js-merge-counter = number_with_delimiter(@project.open_merge_requests_count) %ul.sidebar-sub-level-items.is-fly-out-only = nav_link(controller: :merge_requests, html_options: { class: "fly-out-top-item" } ) do = link_to project_merge_requests_path(@project) do %strong.fly-out-top-item-name - #{ _('Merge Requests') } + = _('Merge Requests') %span.badge.count.merge_counter.js-merge-counter.fly-out-badge = number_with_delimiter(@project.open_merge_requests_count) - if project_nav_tab? :pipelines - = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :environments, :artifacts, :clusters, :user, :gcp]) do + = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts]) do = link_to project_pipelines_path(@project), class: 'shortcuts-pipelines' do .nav-icon-container = sprite_icon('pipeline') %span.nav-item-name - CI / CD + = _('CI / CD') %ul.sidebar-sub-level-items - = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :environments, :artifacts, :clusters, :user, :gcp], html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts], html_options: { class: "fly-out-top-item" } ) do = link_to project_pipelines_path(@project) do %strong.fly-out-top-item-name - #{ _('CI / CD') } + = _('CI / CD') %li.divider.fly-out-top-item - if project_nav_tab? :pipelines = nav_link(path: ['pipelines#index', 'pipelines#show']) do = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do %span - Pipelines + = _('Pipelines') - if project_nav_tab? :builds = nav_link(controller: [:jobs, :artifacts]) do = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do %span - Jobs + = _('Jobs') - if project_nav_tab? :pipelines = nav_link(controller: :pipeline_schedules) do = link_to pipeline_schedules_path(@project), title: 'Schedules', class: 'shortcuts-builds' do %span - Schedules + = _('Schedules') + + - if @project.feature_available?(:builds, current_user) && !@project.empty_repo? + = nav_link(path: 'pipelines#charts') do + = link_to charts_project_pipelines_path(@project), title: 'Charts', class: 'shortcuts-pipelines-charts' do + %span + = _('Charts') + + - if project_nav_tab? :operations + = nav_link(controller: [:environments, :clusters, :user, :gcp]) do + = link_to project_environments_path(@project), class: 'shortcuts-operations' do + .nav-icon-container + = sprite_icon('cloud-gear') + %span.nav-item-name + = _('Operations') + + %ul.sidebar-sub-level-items + = nav_link(controller: [:environments, :clusters, :user, :gcp], html_options: { class: "fly-out-top-item" } ) do + = link_to project_environments_path(@project) do + %strong.fly-out-top-item-name + = _('Operations') + %li.divider.fly-out-top-item - if project_nav_tab? :environments = nav_link(controller: :environments) do = link_to project_environments_path(@project), title: 'Environments', class: 'shortcuts-environments' do %span - Environments + = _('Environments') - if project_nav_tab? :clusters - show_cluster_hint = show_gke_cluster_integration_callout?(@project) @@ -217,19 +238,18 @@ %span= _("Got it!") = sprite_icon('thumb-up') - - if @project.feature_available?(:builds, current_user) && !@project.empty_repo? - = nav_link(path: 'pipelines#charts') do - = link_to charts_project_pipelines_path(@project), title: 'Charts', class: 'shortcuts-pipelines-charts' do - %span - Charts - - if project_nav_tab? :container_registry = nav_link(controller: %w[projects/registry/repositories]) do = link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry' do .nav-icon-container = sprite_icon('disk') %span.nav-item-name - Registry + = _('Registry') + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(controller: %w[projects/registry/repositories], html_options: { class: "fly-out-top-item" } ) do + = link_to project_container_registry_index_path(@project) do + %strong.fly-out-top-item-name + = _('Registry') - if project_nav_tab? :wiki = nav_link(controller: :wikis) do @@ -237,12 +257,12 @@ .nav-icon-container = sprite_icon('book') %span.nav-item-name - Wiki + = _('Wiki') %ul.sidebar-sub-level-items.is-fly-out-only = nav_link(controller: :wikis, html_options: { class: "fly-out-top-item" } ) do = link_to get_project_wiki_path(@project) do %strong.fly-out-top-item-name - #{ _('Wiki') } + = _('Wiki') - if project_nav_tab? :snippets = nav_link(controller: :snippets) do @@ -250,12 +270,12 @@ .nav-icon-container = sprite_icon('snippet') %span.nav-item-name - Snippets + = _('Snippets') %ul.sidebar-sub-level-items.is-fly-out-only = nav_link(controller: :snippets, html_options: { class: "fly-out-top-item" } ) do = link_to project_snippets_path(@project) do %strong.fly-out-top-item-name - #{ _('Snippets') } + = _('Snippets') - if project_nav_tab? :settings = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show badges#index pages#show]) do @@ -263,7 +283,7 @@ .nav-icon-container = sprite_icon('settings') %span.nav-item-name.qa-settings-item - Settings + = _('Settings') %ul.sidebar-sub-level-items - can_edit = can?(current_user, :admin_project, @project) @@ -271,16 +291,16 @@ = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show badges#index pages#show], html_options: { class: "fly-out-top-item" } ) do = link_to edit_project_path(@project) do %strong.fly-out-top-item-name - #{ _('Settings') } + = _('Settings') %li.divider.fly-out-top-item = nav_link(path: %w[projects#edit]) do = link_to edit_project_path(@project), title: 'General' do %span - General + = _('General') = nav_link(controller: :project_members) do = link_to project_project_members_path(@project), title: 'Members' do %span - Members + = _('Members') - if can_edit = nav_link(controller: :badges) do = link_to project_settings_badges_path(@project), title: _('Badges') do @@ -290,21 +310,21 @@ = nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do = link_to project_settings_integrations_path(@project), title: 'Integrations' do %span - Integrations + = _('Integrations') = nav_link(controller: :repository) do = link_to project_settings_repository_path(@project), title: 'Repository' do %span - Repository + = _('Repository') - if @project.feature_available?(:builds, current_user) = nav_link(controller: :ci_cd) do = link_to project_settings_ci_cd_path(@project), title: 'CI / CD' do %span - CI / CD + = _('CI / CD') - if @project.pages_available? = nav_link(controller: :pages) do = link_to project_pages_path(@project), title: 'Pages' do %span - Pages + = _('Pages') - else = nav_link(controller: :project_members) do @@ -312,12 +332,12 @@ .nav-icon-container = sprite_icon('users') %span.nav-item-name - Members + = _('Members') %ul.sidebar-sub-level-items.is-fly-out-only = nav_link(path: %w[members#show], html_options: { class: "fly-out-top-item" } ) do = link_to project_project_members_path(@project) do %strong.fly-out-top-item-name - #{ _('Members') } + = _('Members') = render 'shared/sidebar_toggle_button' diff --git a/app/views/layouts/terms.html.haml b/app/views/layouts/terms.html.haml new file mode 100644 index 00000000000..87f4151f241 --- /dev/null +++ b/app/views/layouts/terms.html.haml @@ -0,0 +1,34 @@ +!!! 5 +- @hide_breadcrumbs = true +%html{ lang: I18n.locale, class: page_class } + = render "layouts/head" + + %body{ data: { page: body_data_page } } + .layout-page.terms{ class: page_class } + .content-wrapper.prepend-top-0 + .mobile-overlay + .alert-wrapper + = render "layouts/broadcast" + = render 'layouts/header/read_only_banner' + = render "layouts/flash", extra_flash_class: 'limit-container-width' + + %div{ class: "#{container_class} limit-container-width" } + .content{ id: "content-body" } + .panel.panel-default + .panel-heading + .title + = brand_header_logo + - logo_text = brand_header_logo_type + - if logo_text.present? + %span.logo-text.prepend-left-8 + = logo_text + - if header_link?(:user_dropdown) + .navbar-collapse + %ul.nav.navbar-nav + %li.header-user.dropdown + = link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do + = image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar" + = sprite_icon('angle-down', css_class: 'caret-down') + .dropdown-menu-nav.dropdown-menu-align-right + = render 'layouts/header/current_user_dropdown' + = yield diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml new file mode 100644 index 00000000000..4bee6cb97eb --- /dev/null +++ b/app/views/projects/_import_project_pane.html.haml @@ -0,0 +1,51 @@ +- active_tab = local_assigns.fetch(:active_tab, 'blank') +- f = local_assigns.fetch(:f) + +.project-import.row + .col-lg-12 + .form-group.import-btn-container.clearfix + = f.label :visibility_level, class: 'label-light' do #the label here seems wrong + Import project from + .import-buttons + - if gitlab_project_import_enabled? + .import_gitlab_project.has-tooltip{ data: { container: 'body' } } + = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do + = icon('gitlab', text: 'GitLab export') + %div + - if github_import_enabled? + = link_to new_import_github_path, class: 'btn js-import-github' do + = icon('github', text: 'GitHub') + %div + - if bitbucket_import_enabled? + = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}" do + = icon('bitbucket', text: 'Bitbucket') + - unless bitbucket_import_configured? + = render 'bitbucket_import_modal' + %div + - if gitlab_import_enabled? + = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do + = icon('gitlab', text: 'GitLab.com') + - unless gitlab_import_configured? + = render 'gitlab_import_modal' + %div + - if google_code_import_enabled? + = link_to new_import_google_code_path, class: 'btn import_google_code' do + = icon('google', text: 'Google Code') + %div + - if fogbugz_import_enabled? + = link_to new_import_fogbugz_path, class: 'btn import_fogbugz' do + = icon('bug', text: 'Fogbugz') + %div + - if gitea_import_enabled? + = link_to new_import_gitea_path, class: 'btn import_gitea' do + = custom_icon('go_logo') + Gitea + %div + - if git_import_enabled? + %button.btn.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' } } + = icon('git', text: 'Repo by URL') + .col-lg-12 + .js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') } + %hr + = render "shared/import_form", f: f + = render 'new_project_fields', f: f, project_name_id: "import-url-name" diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml index a56c3503c77..5646dc464f8 100644 --- a/app/views/projects/_wiki.html.haml +++ b/app/views/projects/_wiki.html.haml @@ -1,6 +1,6 @@ - if @wiki_home.present? %div{ class: container_class } - .wiki-holder.prepend-top-default.append-bottom-default + .prepend-top-default.append-bottom-default .wiki = render_wiki_content(@wiki_home) - else diff --git a/app/views/projects/clusters/_gcp_signup_offer_banner.html.haml b/app/views/projects/clusters/_gcp_signup_offer_banner.html.haml new file mode 100644 index 00000000000..d0402197821 --- /dev/null +++ b/app/views/projects/clusters/_gcp_signup_offer_banner.html.haml @@ -0,0 +1,12 @@ +- link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer') +.gcp-signup-offer.alert.alert-block.alert-dismissable.prepend-top-default.append-bottom-default{ role: 'alert' } + %button.close{ type: "button", data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } × + %div + .col-sm-2.gcp-logo + = image_tag 'illustrations/logos/google-cloud-platform_logo.svg' + .col-sm-10 + %h4= s_('ClusterIntegration|Redeem up to $500 in free credit for Google Cloud Platform') + %p= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for new GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link } + %a.btn.btn-info{ href: 'https://goo.gl/AaJzRW', target: '_blank', rel: 'noopener noreferrer' } + Apply for credit + diff --git a/app/views/projects/clusters/gcp/login.html.haml b/app/views/projects/clusters/gcp/login.html.haml index dada51f39da..ff046c59a7a 100644 --- a/app/views/projects/clusters/gcp/login.html.haml +++ b/app/views/projects/clusters/gcp/login.html.haml @@ -1,6 +1,8 @@ - breadcrumb_title 'Kubernetes' - page_title _("Login") += render_gcp_signup_offer + .row.prepend-top-default .col-sm-4 = render 'projects/clusters/sidebar' diff --git a/app/views/projects/clusters/index.html.haml b/app/views/projects/clusters/index.html.haml index 17b244f4bf7..a55de84b5cd 100644 --- a/app/views/projects/clusters/index.html.haml +++ b/app/views/projects/clusters/index.html.haml @@ -1,6 +1,8 @@ - breadcrumb_title 'Kubernetes' - page_title "Kubernetes Clusters" += render_gcp_signup_offer + .clusters-container - if @clusters.empty? = render "empty_state" diff --git a/app/views/projects/clusters/new.html.haml b/app/views/projects/clusters/new.html.haml index e004966bdcc..828e2a84753 100644 --- a/app/views/projects/clusters/new.html.haml +++ b/app/views/projects/clusters/new.html.haml @@ -1,6 +1,8 @@ - breadcrumb_title 'Kubernetes' - page_title _("Kubernetes Cluster") += render_gcp_signup_offer + .row.prepend-top-default .col-sm-4 = render 'sidebar' diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 213c4c90a0e..1bffb3e8bf0 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -54,7 +54,7 @@ %h3.commit-title = markdown_field(@commit, :title) - if @commit.description.present? - %pre.commit-description + .commit-description< = preserve(markdown_field(@commit, :description)) .info-well diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 3fd0fa348b3..c390c9c4469 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -36,16 +36,16 @@ - if commit.description? %button.text-expander.hidden-xs.js-toggle-button{ type: "button" } ... - - if commit.description? - %pre.commit-row-description.js-toggle-content - = preserve(markdown_field(commit, :description)) - .commiter - commit_author_link = commit_author_link(commit, avatar: false, size: 24) - commit_timeago = time_ago_with_tooltip(commit.authored_date, placement: 'bottom') - commit_text = _('%{commit_author_link} authored %{commit_timeago}') % { commit_author_link: commit_author_link, commit_timeago: commit_timeago } #{ commit_text.html_safe } + - if commit.description? + %pre.commit-row-description.js-toggle-content.prepend-top-8.append-bottom-8 + = preserve(markdown_field(commit, :description)) + .commit-actions.flex-row.hidden-xs - if request.xhr? = render partial: 'projects/commit/signature', object: commit.signature diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index ab371521840..483cca11df9 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -24,7 +24,7 @@ = link_to _("Create merge request"), create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success' .control - = form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form', data: { 'signatures-path' => namespace_project_signatures_path }) do + = form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form js-signature-container', data: { 'signatures-path' => namespace_project_signatures_path }) do = search_field_tag :search, params[:search], { placeholder: _('Filter by commit message'), id: 'commits-search', class: 'form-control search-text-input input-short', spellcheck: false } .control = link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn' do diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index d0c8a699608..40cdf96e76d 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -1,4 +1,4 @@ -= form_tag project_compare_index_path(@project), method: :post, class: 'form-inline js-requires-input' do += form_tag project_compare_index_path(@project), method: :post, class: 'form-inline js-requires-input js-signature-container', data: { 'signatures-path' => signatures_namespace_project_compare_index_path } do .clearfix - if params[:to] && params[:from] .compare-switch-container diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index 7dd8dc28e5b..6af57d3ab26 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -12,4 +12,4 @@ Create a new deploy key for this project = render @deploy_keys.form_partial_path %hr - #js-deploy-keys{ data: { endpoint: project_deploy_keys_path(@project) } } + #js-deploy-keys{ data: { endpoint: project_deploy_keys_path(@project), project_id: @project.id } } diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml index f81db9b4e28..773b12b4536 100644 --- a/app/views/projects/merge_requests/creations/_new_compare.html.haml +++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml @@ -3,15 +3,15 @@ = form_for [@project.namespace.becomes(Namespace), @project, @merge_request], url: project_new_merge_request_path(@project), method: :get, html: { class: "merge-request-form form-inline js-requires-input" } do |f| .hide.alert.alert-danger.mr-compare-errors - .js-merge-request-new-compare.row{ 'data-target-project-url': project_new_merge_request_update_branches_path(@source_project), 'data-source-branch-url': project_new_merge_request_branch_from_path(@source_project), 'data-target-branch-url': project_new_merge_request_branch_to_path(@source_project) } - .col-md-6 + .js-merge-request-new-compare.row{ 'data-source-branch-url': project_new_merge_request_branch_from_path(@source_project), 'data-target-branch-url': project_new_merge_request_branch_to_path(@source_project) } + .col-lg-6 .panel.panel-default.panel-new-merge-request .panel-heading Source branch .panel-body.clearfix .merge-request-select.dropdown = f.hidden_field :source_project_id - = dropdown_toggle @merge_request.source_project_path, { toggle: "dropdown", field_name: "#{f.object_name}[source_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-source-project" } + = dropdown_toggle @merge_request.source_project_path, { toggle: "dropdown", 'field-name': "#{f.object_name}[source_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-source-project" } .dropdown-menu.dropdown-menu-selectable.dropdown-source-project = dropdown_title("Select source project") = dropdown_filter("Search projects") @@ -21,19 +21,17 @@ selected: f.object.source_project_id .merge-request-select.dropdown = f.hidden_field :source_branch - = dropdown_toggle f.object.source_branch || "Select source branch", { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch git-revision-dropdown-toggle" } - .dropdown-menu.dropdown-menu-selectable.dropdown-source-branch.git-revision-dropdown - = dropdown_title("Select source branch") - = dropdown_filter("Search branches") - = dropdown_content do - = render 'projects/merge_requests/dropdowns/branch', - branches: @merge_request.source_branches, - selected: f.object.source_branch + = dropdown_toggle f.object.source_branch || _("Select source branch"), { toggle: "dropdown", 'field-name': "#{f.object_name}[source_branch]", 'refs-url': refs_project_path(@source_project), selected: f.object.source_branch }, { toggle_class: "js-compare-dropdown js-source-branch git-revision-dropdown-toggle" } + .dropdown-menu.dropdown-menu-selectable.js-source-branch-dropdown.git-revision-dropdown + = dropdown_title(_("Select source branch")) + = dropdown_filter(_("Search branches")) + = dropdown_content + = dropdown_loading .panel-footer .text-center= icon('spinner spin', class: 'js-source-loading') %ul.list-unstyled.mr_source_commit - .col-md-6 + .col-lg-6 .panel.panel-default.panel-new-merge-request .panel-heading Target branch @@ -41,7 +39,7 @@ - projects = target_projects(@project) .merge-request-select.dropdown = f.hidden_field :target_project_id - = dropdown_toggle f.object.target_project.full_path, { toggle: "dropdown", field_name: "#{f.object_name}[target_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-target-project" } + = dropdown_toggle f.object.target_project.full_path, { toggle: "dropdown", 'field-name': "#{f.object_name}[target_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-target-project" } .dropdown-menu.dropdown-menu-selectable.dropdown-target-project = dropdown_title("Select target project") = dropdown_filter("Search projects") @@ -51,14 +49,12 @@ selected: f.object.target_project_id .merge-request-select.dropdown = f.hidden_field :target_branch - = dropdown_toggle f.object.target_branch, { toggle: "dropdown", field_name: "#{f.object_name}[target_branch]" }, { toggle_class: "js-compare-dropdown js-target-branch git-revision-dropdown-toggle" } - .dropdown-menu.dropdown-menu-selectable.dropdown-target-branch.js-target-branch-dropdown.git-revision-dropdown - = dropdown_title("Select target branch") - = dropdown_filter("Search branches") - = dropdown_content do - = render 'projects/merge_requests/dropdowns/branch', - branches: @merge_request.target_branches, - selected: f.object.target_branch + = dropdown_toggle f.object.target_branch, { toggle: "dropdown", 'field-name': "#{f.object_name}[target_branch]", 'refs-url': refs_project_path(f.object.target_project), selected: f.object.target_branch }, { toggle_class: "js-compare-dropdown js-target-branch git-revision-dropdown-toggle" } + .dropdown-menu.dropdown-menu-selectable.js-target-branch-dropdown.git-revision-dropdown + = dropdown_title(_("Select target branch")) + = dropdown_filter(_("Search branches")) + = dropdown_content + = dropdown_loading .panel-footer .text-center= icon('spinner spin', class: "js-target-loading") %ul.list-unstyled.mr_target_commit diff --git a/app/views/projects/merge_requests/dropdowns/_project.html.haml b/app/views/projects/merge_requests/dropdowns/_project.html.haml index aaf1ab00eeb..b3cf3c1d369 100644 --- a/app/views/projects/merge_requests/dropdowns/_project.html.haml +++ b/app/views/projects/merge_requests/dropdowns/_project.html.haml @@ -1,5 +1,5 @@ %ul - projects.each do |project| %li - %a{ href: "#", class: "#{('is-active' if selected == project.id)}", data: { id: project.id } } + %a{ href: "#", class: "#{('is-active' if selected == project.id)}", data: { id: project.id, 'refs-url': refs_project_path(project) } } = project.full_path diff --git a/app/views/projects/mirrors/_instructions.html.haml b/app/views/projects/mirrors/_instructions.html.haml new file mode 100644 index 00000000000..64f0fde30cf --- /dev/null +++ b/app/views/projects/mirrors/_instructions.html.haml @@ -0,0 +1,10 @@ +.account-well.prepend-top-default.append-bottom-default + %ul + %li + The repository must be accessible over <code>http://</code>, <code>https://</code>, <code>ssh://</code> or <code>git://</code>. + %li + Include the username in the URL if required: <code>https://username@gitlab.company.com/group/project.git</code>. + %li + The update action will time out after 10 minutes. For big repositories, use a clone/push combination. + %li + The Git LFS objects will <strong>not</strong> be synced. diff --git a/app/views/projects/mirrors/_push.html.haml b/app/views/projects/mirrors/_push.html.haml new file mode 100644 index 00000000000..4a6aefce351 --- /dev/null +++ b/app/views/projects/mirrors/_push.html.haml @@ -0,0 +1,50 @@ +- expanded = Rails.env.test? +%section.settings.no-animate{ class: ('expanded' if expanded) } + .settings-header + %h4 + Push to a remote repository + %button.btn.js-settings-toggle + = expanded ? 'Collapse' : 'Expand' + %p + Set up the remote repository that you want to update with the content of the current repository + every time someone pushes to it. + = link_to 'Read more', help_page_path('workflow/repository_mirroring', anchor: 'pushing-to-a-remote-repository'), target: '_blank' + .settings-content + = form_for @project, url: project_mirror_path(@project) do |f| + %div + = form_errors(@project) + = render "shared/remote_mirror_update_button", remote_mirror: @remote_mirror + - if @remote_mirror.last_error.present? + .panel.panel-danger + .panel-heading + - if @remote_mirror.last_update_at + The remote repository failed to update #{time_ago_with_tooltip(@remote_mirror.last_update_at)}. + - else + The remote repository failed to update. + + - if @remote_mirror.last_successful_update_at + Last successful update #{time_ago_with_tooltip(@remote_mirror.last_successful_update_at)}. + .panel-body + %pre + :preserve + #{h(@remote_mirror.last_error.strip)} + = f.fields_for :remote_mirrors, @remote_mirror do |rm_form| + .form-group + = rm_form.check_box :enabled, class: "pull-left" + .prepend-left-20 + = rm_form.label :enabled, "Remote mirror repository", class: "label-light append-bottom-0" + %p.light.append-bottom-0 + Automatically update the remote mirror's branches, tags, and commits from this repository every time someone pushes to it. + .form-group.has-feedback + = rm_form.label :url, "Git repository URL", class: "label-light" + = rm_form.text_field :url, class: "form-control", placeholder: 'https://username:password@gitlab.company.com/group/project.git' + + = render "projects/mirrors/instructions" + + .form-group + = rm_form.check_box :only_protected_branches, class: 'pull-left' + .prepend-left-20 + = rm_form.label :only_protected_branches, class: 'label-light' + = link_to icon('question-circle'), help_page_path('user/project/protected_branches') + + = f.submit 'Save changes', class: 'btn btn-create', name: 'update_remote_mirror' diff --git a/app/views/projects/mirrors/_show.html.haml b/app/views/projects/mirrors/_show.html.haml new file mode 100644 index 00000000000..de77701a373 --- /dev/null +++ b/app/views/projects/mirrors/_show.html.haml @@ -0,0 +1,3 @@ +- if can?(current_user, :admin_remote_mirror, @project) + = render 'projects/mirrors/push' + diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index b66e0559603..5beaa3c6d23 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -57,54 +57,11 @@ .tab-pane.import-project-pane.js-toggle-container{ id: 'import-project-pane', class: active_when(active_tab == 'import'), role: 'tabpanel' } = form_for @project, html: { class: 'new_project' } do |f| - if import_sources_enabled? - .project-import.row - .col-lg-12 - .form-group.import-btn-container.clearfix - = f.label :visibility_level, class: 'label-light' do #the label here seems wrong - Import project from - .import-buttons - - if gitlab_project_import_enabled? - .import_gitlab_project.has-tooltip{ data: { container: 'body' } } - = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do - = icon('gitlab', text: 'GitLab export') - %div - - if github_import_enabled? - = link_to new_import_github_path, class: 'btn js-import-github' do - = icon('github', text: 'GitHub') - %div - - if bitbucket_import_enabled? - = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}" do - = icon('bitbucket', text: 'Bitbucket') - - unless bitbucket_import_configured? - = render 'bitbucket_import_modal' - %div - - if gitlab_import_enabled? - = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do - = icon('gitlab', text: 'GitLab.com') - - unless gitlab_import_configured? - = render 'gitlab_import_modal' - %div - - if google_code_import_enabled? - = link_to new_import_google_code_path, class: 'btn import_google_code' do - = icon('google', text: 'Google Code') - %div - - if fogbugz_import_enabled? - = link_to new_import_fogbugz_path, class: 'btn import_fogbugz' do - = icon('bug', text: 'Fogbugz') - %div - - if gitea_import_enabled? - = link_to new_import_gitea_path, class: 'btn import_gitea' do - = custom_icon('go_logo') - Gitea - %div - - if git_import_enabled? - %button.btn.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' } } - = icon('git', text: 'Repo by URL') - .col-lg-12 - .js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') } - %hr - = render "shared/import_form", f: f - = render 'new_project_fields', f: f, project_name_id: "import-url-name" + = render 'import_project_pane', f: f, active_tab: active_tab + - else + .nothing-here-block + %h4 No import options available + %p Contact an administrator to enable options for importing your project. .save-project-loader.hide .center diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 85946aec1f2..9db30042bf4 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -5,7 +5,7 @@ %h3.commit-title = markdown(@commit.title, pipeline: :single_line) - if @commit.description.present? - %pre.commit-description + .commit-description< = preserve(markdown(@commit.description, pipeline: :single_line)) .info-well diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 218e7338c83..4dbf95be357 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -1,5 +1,3 @@ -- failed_builds = @pipeline.statuses.latest.failed - .tabs-holder %ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator %li.js-pipeline-tab-link @@ -9,11 +7,11 @@ = link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do = _("Jobs") %span.badge.js-builds-counter= pipeline.total_size - - if failed_builds.present? + - if @pipeline.failed_builds.present? %li.js-failures-tab-link = link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do = _("Failed Jobs") - %span.badge.js-failures-counter= failed_builds.count + %span.badge.js-failures-counter= @pipeline.failed_builds.count .tab-content #js-tab-pipeline.tab-pane @@ -43,9 +41,10 @@ %th Coverage %th = render partial: "projects/stage/stage", collection: pipeline.legacy_stages, as: :stage - - if failed_builds.present? + + - if @pipeline.failed_builds.present? #js-tab-failures.build-failures.tab-pane - - failed_builds.each_with_index do |build, index| + - @pipeline.failed_builds.each_with_index do |build, index| .build-state %span.ci-status-icon-failed= custom_icon('icon_status_failed') %span.stage diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index 8f2142af2ce..81984ee94b0 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -1,5 +1,6 @@ - breadcrumb_title "Pipelines" - page_title = s_("Pipeline|Run Pipeline") +- settings_link = link_to _('CI/CD settings'), project_settings_ci_cd_path(@project) %h3.page-title = s_("Pipeline|Run Pipeline") @@ -8,17 +9,26 @@ = form_for @pipeline, as: :pipeline, url: project_pipelines_path(@project), html: { id: "new-pipeline-form", class: "form-horizontal js-new-pipeline-form js-requires-input" } do |f| = form_errors(@pipeline) .form-group - = f.label :ref, s_('Pipeline|Run on'), class: 'control-label' - .col-sm-10 + .col-sm-12 + = f.label :ref, s_('Pipeline|Create for') = hidden_field_tag 'pipeline[ref]', params[:ref] || @project.default_branch = dropdown_tag(params[:ref] || @project.default_branch, options: { toggle_class: 'js-branch-select wide git-revision-dropdown-toggle', filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: s_("Pipeline|Search branches"), data: { selected: params[:ref] || @project.default_branch, field_name: 'pipeline[ref]' } }) .help-block - = s_("Pipeline|Existing branch name, tag") + = s_("Pipeline|Existing branch name or tag") + + .col-sm-12.prepend-top-10.js-ci-variable-list-section + %label + = s_('Pipeline|Variables') + %ul.ci-variable-list + = render 'ci/variables/variable_row', form_field: 'pipeline', only_key_value: true + .help-block + = (s_("Pipeline|Specify variable values to be used in this run. The values specified in %{settings_link} will be used by default.") % {settings_link: settings_link}).html_safe + .form-actions - = f.submit s_('Pipeline|Run pipeline'), class: 'btn btn-success', tabindex: 3 + = f.submit s_('Pipeline|Create pipeline'), class: 'btn btn-success js-variables-save-button', tabindex: 3 = link_to 'Cancel', project_pipelines_path(@project), class: 'btn btn-default pull-right' -# haml-lint:disable InlineJavaScript diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml new file mode 100644 index 00000000000..dfed0553f84 --- /dev/null +++ b/app/views/projects/runners/_group_runners.html.haml @@ -0,0 +1,37 @@ +- link = link_to _('Runners API'), help_page_path('api/runners.md') + +%h3 + = _('Group Runners') + +.bs-callout.bs-callout-warning + = _('GitLab Group Runners can execute code for all the projects in this group.') + = _('They can be managed using the %{link}.').html_safe % { link: link } + + - if @project.group + %hr + - if @project.group_runners_enabled? + = link_to toggle_group_runners_project_runners_path(@project), class: 'btn btn-close', method: :post do + = _('Disable group Runners') + - else + = link_to toggle_group_runners_project_runners_path(@project), class: 'btn btn-success btn-inverted', method: :post do + = _('Enable group Runners') + + = _('for this project') + +- if !@project.group + = _('This project does not belong to a group and can therefore not make use of group Runners.') + +- elsif @group_runners.empty? + = _('This group does not provide any group Runners yet.') + + - if can?(current_user, :admin_pipeline, @project.group) + - group_link = link_to _('Group CI/CD settings'), group_settings_ci_cd_path(@project.group) + = _('Group masters can register group runners in the %{link}').html_safe % { link: group_link } + - else + = _('Ask your group master to setup a group Runner.') + +- else + %h4.underlined-title + = _('Available group Runners : %{runners}').html_safe % { runners: @group_runners.count } + %ul.bordered-list + = render partial: 'projects/runners/runner', collection: @group_runners, as: :runner diff --git a/app/views/projects/runners/_index.html.haml b/app/views/projects/runners/_index.html.haml index f9808f7c990..022687b831f 100644 --- a/app/views/projects/runners/_index.html.haml +++ b/app/views/projects/runners/_index.html.haml @@ -1,19 +1,4 @@ -.light.prepend-top-default - %p - A 'Runner' is a process which runs a job. - You can setup as many Runners as you need. - %br - Runners can be placed on separate users, servers, and even on your local machine. - - %p Each Runner can be in one of the following states: - %div - %ul - %li - %span.label.label-success active - \- Runner is active and can process any new jobs - %li - %span.label.label-danger paused - \- Runner is paused and will not receive any new jobs += render 'shared/runners/runner_description' %hr @@ -23,3 +8,4 @@ = render 'projects/runners/specific_runners' .col-sm-6 = render 'projects/runners/shared_runners' + = render 'projects/runners/group_runners' diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml index 6376496ee1a..69218f344f7 100644 --- a/app/views/projects/runners/_runner.html.haml +++ b/app/views/projects/runners/_runner.html.haml @@ -3,10 +3,10 @@ = runner_status_icon(runner) - if @project_runners.include?(runner) - = link_to runner.short_sha, runner_path(runner), class: 'commit-sha' + = link_to runner.short_sha, project_runner_path(@project, runner), class: 'commit-sha' - if runner.locked? - = icon('lock', class: 'has-tooltip', title: 'Locked to current projects') + = icon('lock', class: 'has-tooltip', title: _('Locked to current projects')) %small.edit-runner = link_to edit_project_runner_path(@project, runner) do @@ -18,18 +18,18 @@ .pull-right - if @project_runners.include?(runner) - if runner.active? - = link_to 'Pause', pause_project_runner_path(@project, runner), method: :post, class: 'btn btn-sm btn-danger', data: { confirm: "Are you sure?" } + = link_to _('Pause'), pause_project_runner_path(@project, runner), method: :post, class: 'btn btn-sm btn-danger', data: { confirm: _("Are you sure?") } - else - = link_to 'Resume', resume_project_runner_path(@project, runner), method: :post, class: 'btn btn-success btn-sm' + = link_to _('Resume'), resume_project_runner_path(@project, runner), method: :post, class: 'btn btn-success btn-sm' - if runner.belongs_to_one_project? - = link_to 'Remove Runner', runner_path(runner), data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-danger btn-sm' + = link_to _('Remove Runner'), project_runner_path(@project, runner), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn btn-danger btn-sm' - else - runner_project = @project.runner_projects.find_by(runner_id: runner) - = link_to 'Disable for this project', project_runner_project_path(@project, runner_project), data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-danger btn-sm' - - elsif runner.specific? + = link_to _('Disable for this project'), project_runner_project_path(@project, runner_project), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn btn-danger btn-sm' + - elsif !(runner.is_shared? || runner.group_type?) # We can simplify this to `runner.project_type?` when migrating #runner_type is complete = form_for [@project.namespace.becomes(Namespace), @project, @project.runner_projects.new] do |f| = f.hidden_field :runner_id, value: runner.id - = f.submit 'Enable for this project', class: 'btn btn-sm' + = f.submit _('Enable for this project'), class: 'btn btn-sm' .pull-right %small.light \##{runner.id} diff --git a/app/views/projects/runners/_shared_runners.html.haml b/app/views/projects/runners/_shared_runners.html.haml index 4fd4ca355a8..20a5ef039f8 100644 --- a/app/views/projects/runners/_shared_runners.html.haml +++ b/app/views/projects/runners/_shared_runners.html.haml @@ -1,4 +1,5 @@ -%h3 Shared Runners +%h3 + = _('Shared Runners') .bs-callout.shared-runners-description - if Gitlab::CurrentSettings.shared_runners_text.present? @@ -17,8 +18,7 @@ for this project - if @shared_runners_count.zero? - This GitLab server does not provide any shared Runners yet. - Please use the specific Runners or ask your administrator to create one. + = _('This GitLab instance does not provide any shared Runners yet. Instance administrators can register shared Runners in the admin area.') - else %h4.underlined-title Available shared Runners : #{@shared_runners_count} %ul.bordered-list.available-shared-runners diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml index f0813e56b71..6c11ce3b394 100644 --- a/app/views/projects/runners/_specific_runners.html.haml +++ b/app/views/projects/runners/_specific_runners.html.haml @@ -1,4 +1,5 @@ -%h3 Specific Runners +%h3 + = _('Specific Runners') = render partial: 'ci/runner/how_to_setup_specific_runner', locals: { registration_token: @project.runners_token } diff --git a/app/views/projects/runners/edit.html.haml b/app/views/projects/runners/edit.html.haml index 78dc4817ed7..d59f9c19862 100644 --- a/app/views/projects/runners/edit.html.haml +++ b/app/views/projects/runners/edit.html.haml @@ -1,6 +1,6 @@ -- page_title "Edit", "#{@runner.description} ##{@runner.id}", "Runners" +- page_title _('Edit'), "#{@runner.description} ##{@runner.id}", 'Runners' %h4 Runner ##{@runner.id} %hr - = render 'form', runner: @runner, runner_form_url: runner_path(@runner) + = render 'shared/runners/form', runner: @runner, runner_form_url: project_runner_path(@project, @runner) diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml index 71e77dae69e..8cb6c446e18 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -35,7 +35,9 @@ = _('Domain') = form.text_field :domain, class: 'form-control', placeholder: 'domain.com' .help-block - = s_('CICD|You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages.') + = s_('CICD|A domain is required to use Auto Review Apps and Auto Deploy Stages.') + - if cluster_ingress_ip = cluster_ingress_ip(@project) + = s_('%{nip_domain} can be used as an alternative to a custom domain.').html_safe % { nip_domain: "<code>#{cluster_ingress_ip}.nip.io</code>".html_safe } = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-devops-base-domain'), target: '_blank' = f.submit 'Save changes', class: "btn btn-success prepend-top-15" diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index f57590a908f..5dda2ec28b4 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -2,6 +2,8 @@ - page_title "Repository" - @content_class = "limit-container-width" unless fluid_layout += render "projects/mirrors/show" + -# Protected branches & tags use a lot of nested partials. -# The shared parts of the views can be found in the `shared` directory. -# Those are used throughout the actual views. These `shared` views are then diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/projects/wikis/edit.html.haml index 9d3d4072027..35c7dc2984a 100644 --- a/app/views/projects/wikis/edit.html.haml +++ b/app/views/projects/wikis/edit.html.haml @@ -28,9 +28,16 @@ = link_to project_wiki_history_path(@project, @page), class: "btn" do = s_("Wiki|Page history") - if can?(current_user, :admin_wiki, @project) - = link_to project_wiki_path(@project, @page), data: { confirm: s_("WikiPageConfirmDelete|Are you sure you want to delete this page?")}, method: :delete, class: "btn btn-danger" do - = _("Delete") + %button.btn.btn-danger{ data: { toggle: 'modal', + target: '#delete-wiki-modal', + delete_wiki_url: project_wiki_path(@project, @page), + page_title: @page.title.capitalize }, + id: 'delete-wiki-button', + type: 'button' } + = _('Delete') = render 'form' = render 'sidebar' + +#delete-wiki-modal.modal.fade diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml index b3b83cee81a..ff72c8bb75d 100644 --- a/app/views/projects/wikis/show.html.haml +++ b/app/views/projects/wikis/show.html.haml @@ -24,7 +24,7 @@ - history_link = link_to s_("WikiHistoricalPage|history"), project_wiki_history_path(@project, @page) = (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe -.wiki-holder.prepend-top-default.append-bottom-default +.prepend-top-default.append-bottom-default .wiki = render_wiki_content(@page) diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml index 901a177323b..ac2164a4a71 100644 --- a/app/views/shared/_mini_pipeline_graph.html.haml +++ b/app/views/shared/_mini_pipeline_graph.html.haml @@ -6,12 +6,13 @@ - status_klass = "ci-status-icon ci-status-icon-#{detailed_status.group}" .stage-container.dropdown{ class: klass } - %button.mini-pipeline-graph-dropdown-toggle.has-tooltip.js-builds-dropdown-button{ class: "ci-status-icon-#{detailed_status.group}", type: 'button', data: { toggle: 'dropdown', title: "#{stage.name}: #{detailed_status.label}", placement: 'top', "stage-endpoint" => stage_project_pipeline_path(pipeline.project, pipeline, stage: stage.name) } } + %button.mini-pipeline-graph-dropdown-toggle.has-tooltip.js-builds-dropdown-button{ class: "ci-status-icon-#{detailed_status.group}", type: 'button', data: { toggle: 'dropdown', title: "#{stage.name}: #{detailed_status.label}", placement: 'top', "stage-endpoint" => stage_ajax_project_pipeline_path(pipeline.project, pipeline, stage: stage.name) } } = sprite_icon(icon_status) = icon('caret-down') %ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container %li.js-builds-dropdown-list.scrollable-menu + %ul %li.js-builds-dropdown-loading.hidden .text-center diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml new file mode 100644 index 00000000000..34de1c0695f --- /dev/null +++ b/app/views/shared/_remote_mirror_update_button.html.haml @@ -0,0 +1,13 @@ +- if @project.has_remote_mirror? + .append-bottom-default + - if remote_mirror.update_in_progress? + %span.btn.disabled + = icon("refresh spin") + Updating… + - else + = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn" do + = icon("refresh") + Update Now + - if @remote_mirror.last_successful_update_at + %p.inline.prepend-left-10 + Successfully updated #{time_ago_with_tooltip(@remote_mirror.last_successful_update_at)}. diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index 149bf8da4b9..4bff6468bb0 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -15,8 +15,9 @@ ":title" => '(list.label ? list.label.description : "")', data: { container: "body", placement: "bottom" }, class: "label color-label title board-title-text", - ":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.color ? list.label.text_color : \"#2e2e2e\") }" } + ":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.text_color ? list.label.text_color : \"#2e2e2e\") }" } {{ list.title }} + - if can?(current_user, :admin_list, current_board_parent) %board-delete{ "inline-template" => true, ":list" => "list", diff --git a/app/views/shared/members/_filter_2fa_dropdown.html.haml b/app/views/shared/members/_filter_2fa_dropdown.html.haml new file mode 100644 index 00000000000..95c35c56b3c --- /dev/null +++ b/app/views/shared/members/_filter_2fa_dropdown.html.haml @@ -0,0 +1,11 @@ +- filter = params[:two_factor] || 'everyone' +- filter_options = { 'everyone' => 'Everyone', 'enabled' => 'Enabled', 'disabled' => 'Disabled' } +.dropdown.inline.member-filter-2fa-dropdown + = dropdown_toggle('2FA: ' + filter_options[filter], { toggle: 'dropdown' }) + %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable + %li.dropdown-header + Filter by two-factor authentication + - filter_options.each do |value, title| + %li + = link_to filter_group_project_member_path(two_factor: value), class: ("is-active" if filter == value) do + = title diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 1c139827acf..1961ad6d616 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -20,6 +20,10 @@ %label.label.label-danger %strong Blocked + - if user.two_factor_enabled? + %label.label.label-info + 2FA + - if source.instance_of?(Group) && source != @group · = link_to source.full_name, source, class: "member-group-link" diff --git a/app/views/shared/runners/_form.html.haml b/app/views/shared/runners/_form.html.haml new file mode 100644 index 00000000000..302a543cf12 --- /dev/null +++ b/app/views/shared/runners/_form.html.haml @@ -0,0 +1,56 @@ += form_for runner, url: runner_form_url, html: { class: 'form-horizontal' } do |f| + = form_errors(runner) + .form-group + = label :active, "Active", class: 'control-label' + .col-sm-10 + .checkbox + = f.check_box :active + %span.light Paused Runners don't accept new jobs + .form-group + = label :protected, "Protected", class: 'control-label' + .col-sm-10 + .checkbox + = f.check_box :access_level, {}, 'ref_protected', 'not_protected' + %span.light This runner will only run on pipelines triggered on protected branches + .form-group + = label :run_untagged, 'Run untagged jobs', class: 'control-label' + .col-sm-10 + .checkbox + = f.check_box :run_untagged + %span.light Indicates whether this runner can pick jobs without tags + - unless runner.group_type? + .form-group + = label :locked, _('Lock to current projects'), class: 'control-label' + .col-sm-10 + .checkbox + = f.check_box :locked + %span.light= _('When a runner is locked, it cannot be assigned to other projects') + .form-group + = label_tag :token, class: 'control-label' do + Token + .col-sm-10 + = f.text_field :token, class: 'form-control', readonly: true + .form-group + = label_tag :ip_address, class: 'control-label' do + IP Address + .col-sm-10 + = f.text_field :ip_address, class: 'form-control', readonly: true + .form-group + = label_tag :description, class: 'control-label' do + Description + .col-sm-10 + = f.text_field :description, class: 'form-control' + .form-group + = label_tag :maximum_timeout_human_readable, class: 'control-label' do + Maximum job timeout + .col-sm-10 + = f.text_field :maximum_timeout_human_readable, class: 'form-control' + .help-block This timeout will take precedence when lower than Project-defined timeout + .form-group + = label_tag :tag_list, class: 'control-label' do + Tags + .col-sm-10 + = f.text_field :tag_list, value: runner.tag_list.sort.join(', '), class: 'form-control' + .help-block You can setup jobs to only use Runners with specific tags. Separate tags with commas. + .form-actions + = f.submit 'Save changes', class: 'btn btn-save' diff --git a/app/views/shared/runners/_runner_description.html.haml b/app/views/shared/runners/_runner_description.html.haml new file mode 100644 index 00000000000..1d59c2f7078 --- /dev/null +++ b/app/views/shared/runners/_runner_description.html.haml @@ -0,0 +1,16 @@ +.light.prepend-top-default + %p + = _("A 'Runner' is a process which runs a job. You can setup as many Runners as you need.") + %br + = _('Runners can be placed on separate users, servers, and even on your local machine.') + + %p + = _('Each Runner can be in one of the following states:') + %div + %ul + %li + %span.label.label-success active + = _('- Runner is active and can process any new jobs') + %li + %span.label.label-danger paused + = _('- Runner is paused and will not receive any new jobs') diff --git a/app/views/shared/runners/show.html.haml b/app/views/shared/runners/show.html.haml new file mode 100644 index 00000000000..480a224b6d5 --- /dev/null +++ b/app/views/shared/runners/show.html.haml @@ -0,0 +1,71 @@ +- page_title "#{@runner.description} ##{@runner.id}", "Runners" + +%h3.page-title + Runner ##{@runner.id} + .pull-right + - if @runner.shared? + %span.runner-state.runner-state-shared + Shared + - elsif @runner.group_type? + %span.runner-state.runner-state-shared + Group + - else + %span.runner-state.runner-state-specific + Specific + +.table-holder + %table.table + %thead + %tr + %th Property Name + %th Value + %tr + %td Active + %td= @runner.active? ? _('Yes') : _('No') + %tr + %td Protected + %td= @runner.ref_protected? ? _('Yes') : _('No') + %tr + %td= _('Can run untagged jobs') + %td= @runner.run_untagged? ? _('Yes') : _('No') + - unless @runner.group_type? + %tr + %td= _('Locked to this project') + %td= @runner.locked? ? _('Yes') : _('No') + %tr + %td Tags + %td + - @runner.tag_list.sort.each do |tag| + %span.label.label-primary + = tag + %tr + %td Name + %td= @runner.name + %tr + %td Version + %td= @runner.version + %tr + %td IP Address + %td= @runner.ip_address + %tr + %td Revision + %td= @runner.revision + %tr + %td Platform + %td= @runner.platform + %tr + %td Architecture + %td= @runner.architecture + %tr + %td Description + %td= @runner.description + %tr + %td= _('Maximum job timeout') + %td= @runner.maximum_timeout_human_readable + %tr + %td Last contact + %td + - if @runner.contacted_at + #{time_ago_in_words(@runner.contacted_at)} ago + - else + Never diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 4bf01ecb48c..fb909237b9a 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -12,7 +12,7 @@ .cover-block.user-cover-block.top-area .cover-controls - if @user == current_user - = link_to profile_path, class: 'btn btn-gray has-tooltip', title: 'Edit profile', 'aria-label': 'Edit profile' do + = link_to profile_path, class: 'btn btn-default has-tooltip', title: 'Edit profile', 'aria-label': 'Edit profile' do = icon('pencil') - elsif current_user - if @user.abuse_report @@ -20,13 +20,13 @@ data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } } = icon('exclamation-circle') - else - = link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: 'btn btn-gray', + = link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: 'btn', title: 'Report abuse', data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do = icon('exclamation-circle') - = link_to user_path(@user, rss_url_options), class: 'btn btn-gray has-tooltip', title: 'Subscribe', 'aria-label': 'Subscribe' do + = link_to user_path(@user, rss_url_options), class: 'btn btn-default has-tooltip', title: 'Subscribe', 'aria-label': 'Subscribe' do = icon('rss') - if current_user && current_user.admin? - = link_to [:admin, @user], class: 'btn btn-gray', title: 'View user in admin area', + = link_to [:admin, @user], class: 'btn btn-default', title: 'View user in admin area', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('users') @@ -35,7 +35,7 @@ = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do = image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: '' - .user-info + .user-info.prepend-left-default.append-right-default .cover-title = @user.name diff --git a/app/views/users/terms/index.html.haml b/app/views/users/terms/index.html.haml new file mode 100644 index 00000000000..c5406696bdd --- /dev/null +++ b/app/views/users/terms/index.html.haml @@ -0,0 +1,13 @@ +- redirect_params = { redirect: @redirect } if @redirect + +.panel-content.rendered-terms + = markdown_field(@term, :terms) +.row-content-block.footer-block.clearfix + - if can?(current_user, :accept_terms, @term) + .pull-right + = button_to accept_term_path(@term, redirect_params), class: 'btn btn-success prepend-left-8' do + = _('Accept terms') + - if can?(current_user, :decline_terms, @term) + .pull-right + = button_to decline_term_path(@term, redirect_params), class: 'btn btn-default prepend-left-8' do + = _('Decline and sign out') diff --git a/app/workers/admin_email_worker.rb b/app/workers/admin_email_worker.rb index bec0a003a1c..044e470141e 100644 --- a/app/workers/admin_email_worker.rb +++ b/app/workers/admin_email_worker.rb @@ -3,6 +3,12 @@ class AdminEmailWorker include CronjobQueue def perform + send_repository_check_mail if Gitlab::CurrentSettings.repository_checks_enabled + end + + private + + def send_repository_check_mail repository_check_failed_count = Project.where(last_repository_check_failed: true).count return if repository_check_failed_count.zero? diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index c469aea7052..b6433eb3eff 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -52,6 +52,7 @@ - pipeline_creation:create_pipeline - pipeline_creation:run_pipeline_schedule - pipeline_background:archive_trace +- pipeline_background:ci_build_trace_chunk_flush - pipeline_default:build_coverage - pipeline_default:build_trace_sections - pipeline_default:pipeline_metrics @@ -106,9 +107,11 @@ - rebase - repository_fork - repository_import +- repository_remove_remote - storage_migrator - system_hook_push - update_merge_requests - update_user_activity - upload_checksum - web_hook +- repository_update_remote_mirror diff --git a/app/workers/ci/build_trace_chunk_flush_worker.rb b/app/workers/ci/build_trace_chunk_flush_worker.rb new file mode 100644 index 00000000000..218d6688bd9 --- /dev/null +++ b/app/workers/ci/build_trace_chunk_flush_worker.rb @@ -0,0 +1,12 @@ +module Ci + class BuildTraceChunkFlushWorker + include ApplicationWorker + include PipelineBackgroundQueue + + def perform(build_trace_chunk_id) + ::Ci::BuildTraceChunk.find_by(id: build_trace_chunk_id).try do |build_trace_chunk| + build_trace_chunk.use_database! + 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 index f7f498af840..8d708e15a66 100644 --- a/app/workers/gitlab/github_import/advance_stage_worker.rb +++ b/app/workers/gitlab/github_import/advance_stage_worker.rb @@ -63,11 +63,10 @@ module Gitlab 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) + # TODO: Only select the JID + # This is due to the fact that the JID could be present in either the project record or + # its associated import_state record + Project.import_started.find_by(id: id) 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 index 7108b531bc2..68d2c5c4331 100644 --- a/app/workers/gitlab/github_import/refresh_import_jid_worker.rb +++ b/app/workers/gitlab/github_import/refresh_import_jid_worker.rb @@ -31,7 +31,10 @@ module Gitlab end def find_project(id) - Project.select(:import_jid).import_started.find_by(id: id) + # TODO: Only select the JID + # This is due to the fact that the JID could be present in either the project record or + # its associated import_state record + Project.import_started.find_by(id: id) end end end diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb index b925741934a..67c54fbf10e 100644 --- a/app/workers/new_note_worker.rb +++ b/app/workers/new_note_worker.rb @@ -5,7 +5,7 @@ class NewNoteWorker # old `NewNoteWorker` jobs (can remove later) def perform(note_id, _params = {}) if note = Note.find_by(id: note_id) - NotificationService.new.new_note(note) if note.can_create_notification? + NotificationService.new.new_note(note) Notes::PostProcessService.new(note).execute else Rails.logger.error("NewNoteWorker: couldn't find note with ID=#{note_id}, skipping job") diff --git a/app/workers/object_storage/migrate_uploads_worker.rb b/app/workers/object_storage/migrate_uploads_worker.rb index a6b2c251254..a3ecfa8e711 100644 --- a/app/workers/object_storage/migrate_uploads_worker.rb +++ b/app/workers/object_storage/migrate_uploads_worker.rb @@ -9,85 +9,6 @@ module ObjectStorage SanityCheckError = Class.new(StandardError) - class Upload < ActiveRecord::Base - # Upper limit for foreground checksum processing - CHECKSUM_THRESHOLD = 100.megabytes - - belongs_to :model, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations - - validates :size, presence: true - validates :path, presence: true - validates :model, presence: true - validates :uploader, presence: true - - before_save :calculate_checksum!, if: :foreground_checksummable? - after_commit :schedule_checksum, if: :checksummable? - - scope :stored_locally, -> { where(store: [nil, ObjectStorage::Store::LOCAL]) } - scope :stored_remotely, -> { where(store: ObjectStorage::Store::REMOTE) } - - def self.hexdigest(path) - Digest::SHA256.file(path).hexdigest - end - - def absolute_path - raise ObjectStorage::RemoteStoreError, "Remote object has no absolute path." unless local? - return path unless relative_path? - - uploader_class.absolute_path(self) - end - - def calculate_checksum! - self.checksum = nil - return unless checksummable? - - self.checksum = self.class.hexdigest(absolute_path) - end - - def build_uploader(mounted_as = nil) - uploader_class.new(model, mounted_as).tap do |uploader| - uploader.upload = self - uploader.retrieve_from_store!(identifier) - end - end - - def exist? - File.exist?(absolute_path) - end - - def local? - return true if store.nil? - - store == ObjectStorage::Store::LOCAL - end - - private - - def checksummable? - checksum.nil? && local? && exist? - end - - def foreground_checksummable? - checksummable? && size <= CHECKSUM_THRESHOLD - end - - def schedule_checksum - UploadChecksumWorker.perform_async(id) - end - - def relative_path? - !path.start_with?('/') - end - - def identifier - File.basename(path) - end - - def uploader_class - Object.const_get(uploader) - end - end - class MigrationResult attr_reader :upload attr_accessor :error diff --git a/app/workers/repository_check/batch_worker.rb b/app/workers/repository_check/batch_worker.rb index 76688cf51c1..72f0a9b0619 100644 --- a/app/workers/repository_check/batch_worker.rb +++ b/app/workers/repository_check/batch_worker.rb @@ -4,8 +4,11 @@ module RepositoryCheck include CronjobQueue RUN_TIME = 3600 + BATCH_SIZE = 10_000 def perform + return unless Gitlab::CurrentSettings.repository_checks_enabled + start = Time.now # This loop will break after a little more than one hour ('a little @@ -15,7 +18,6 @@ module RepositoryCheck # check, only one (or two) will be checked at a time. project_ids.each do |project_id| break if Time.now - start >= RUN_TIME - break unless current_settings.repository_checks_enabled next unless try_obtain_lease(project_id) @@ -31,12 +33,20 @@ module RepositoryCheck # getting ID's from Postgres is not terribly slow, and because no user # has to sit and wait for this query to finish. def project_ids - limit = 10_000 - never_checked_projects = Project.where('last_repository_check_at IS NULL AND created_at < ?', 24.hours.ago) - .limit(limit).pluck(:id) - old_check_projects = Project.where('last_repository_check_at < ?', 1.month.ago) - .reorder('last_repository_check_at ASC').limit(limit).pluck(:id) - never_checked_projects + old_check_projects + never_checked_project_ids(BATCH_SIZE) + old_checked_project_ids(BATCH_SIZE) + end + + def never_checked_project_ids(batch_size) + Project.where(last_repository_check_at: nil) + .where('created_at < ?', 24.hours.ago) + .limit(batch_size).pluck(:id) + end + + def old_checked_project_ids(batch_size) + Project.where.not(last_repository_check_at: nil) + .where('last_repository_check_at < ?', 1.month.ago) + .reorder(last_repository_check_at: :asc) + .limit(batch_size).pluck(:id) end def try_obtain_lease(id) @@ -47,16 +57,5 @@ module RepositoryCheck timeout: 24.hours ).try_obtain end - - def current_settings - # No caching of the settings! If we cache them and an admin disables - # this feature, an active RepositoryCheckWorker would keep going for up - # to 1 hour after the feature was disabled. - if Rails.env.test? - Gitlab::CurrentSettings.fake_application_settings - else - ApplicationSetting.current - end - end end end diff --git a/app/workers/repository_check/single_repository_worker.rb b/app/workers/repository_check/single_repository_worker.rb index 116bc185b38..3cffb8b14e4 100644 --- a/app/workers/repository_check/single_repository_worker.rb +++ b/app/workers/repository_check/single_repository_worker.rb @@ -5,27 +5,34 @@ module RepositoryCheck def perform(project_id) project = Project.find(project_id) + healthy = project_healthy?(project) + + update_repository_check_status(project, healthy) + end + + private + + def update_repository_check_status(project, healthy) project.update_columns( - last_repository_check_failed: !check(project), + last_repository_check_failed: !healthy, last_repository_check_at: Time.now ) end - private + def project_healthy?(project) + repo_healthy?(project) && wiki_repo_healthy?(project) + end - def check(project) - if has_pushes?(project) && !git_fsck(project.repository) - false - elsif project.wiki_enabled? - # Historically some projects never had their wiki repos initialized; - # this happens on project creation now. Let's initialize an empty repo - # if it is not already there. - project.create_wiki + def repo_healthy?(project) + return true unless has_changes?(project) - git_fsck(project.wiki.repository) - else - true - end + git_fsck(project.repository) + end + + def wiki_repo_healthy?(project) + return true unless has_wiki_changes?(project) + + git_fsck(project.wiki.repository) end def git_fsck(repository) @@ -39,8 +46,19 @@ module RepositoryCheck false end - def has_pushes?(project) + def has_changes?(project) Project.with_push.exists?(project.id) end + + def has_wiki_changes?(project) + return false unless project.wiki_enabled? + + # Historically some projects never had their wiki repos initialized; + # this happens on project creation now. Let's initialize an empty repo + # if it is not already there. + return false unless project.create_wiki + + has_changes?(project) + end end end diff --git a/app/workers/repository_remove_remote_worker.rb b/app/workers/repository_remove_remote_worker.rb new file mode 100644 index 00000000000..1c19b604b77 --- /dev/null +++ b/app/workers/repository_remove_remote_worker.rb @@ -0,0 +1,35 @@ +class RepositoryRemoveRemoteWorker + include ApplicationWorker + include ExclusiveLeaseGuard + + LEASE_TIMEOUT = 1.hour + + attr_reader :project, :remote_name + + def perform(project_id, remote_name) + @remote_name = remote_name + @project = Project.find_by_id(project_id) + + return unless @project + + logger.info("Removing remote #{remote_name} from project #{project.id}") + + try_obtain_lease do + remove_remote = @project.repository.remove_remote(remote_name) + + if remove_remote + logger.info("Remote #{remote_name} was successfully removed from project #{project.id}") + else + logger.error("Could not remove remote #{remote_name} from project #{project.id}") + end + end + end + + def lease_timeout + LEASE_TIMEOUT + end + + def lease_key + "remove_remote_#{project.id}_#{remote_name}" + end +end diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb new file mode 100644 index 00000000000..bb963979e88 --- /dev/null +++ b/app/workers/repository_update_remote_mirror_worker.rb @@ -0,0 +1,49 @@ +class RepositoryUpdateRemoteMirrorWorker + UpdateAlreadyInProgressError = Class.new(StandardError) + UpdateError = Class.new(StandardError) + + include ApplicationWorker + include Gitlab::ShellAdapter + + sidekiq_options retry: 3, dead: false + + sidekiq_retry_in { |count| 30 * count } + + sidekiq_retries_exhausted do |msg, _| + Sidekiq.logger.warn "Failed #{msg['class']} with #{msg['args']}: #{msg['error_message']}" + end + + def perform(remote_mirror_id, scheduled_time) + remote_mirror = RemoteMirror.find(remote_mirror_id) + return if remote_mirror.updated_since?(scheduled_time) + + raise UpdateAlreadyInProgressError if remote_mirror.update_in_progress? + + remote_mirror.update_start + + project = remote_mirror.project + current_user = project.creator + result = Projects::UpdateRemoteMirrorService.new(project, current_user).execute(remote_mirror) + raise UpdateError, result[:message] if result[:status] == :error + + remote_mirror.update_finish + rescue UpdateAlreadyInProgressError + raise + rescue UpdateError => ex + fail_remote_mirror(remote_mirror, ex.message) + raise + rescue => ex + return unless remote_mirror + + fail_remote_mirror(remote_mirror, ex.message) + raise UpdateError, "#{ex.class}: #{ex.message}" + end + + private + + def fail_remote_mirror(remote_mirror, message) + remote_mirror.mark_as_failed(message) + + Rails.logger.error(message) + end +end diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb index fbb14efc525..6fdd7592e74 100644 --- a/app/workers/stuck_import_jobs_worker.rb +++ b/app/workers/stuck_import_jobs_worker.rb @@ -22,7 +22,8 @@ class StuckImportJobsWorker end def mark_projects_with_jid_as_failed! - jids_and_ids = enqueued_projects_with_jid.pluck(:import_jid, :id).to_h + # TODO: Rollback this change to use SQL through #pluck + jids_and_ids = enqueued_projects_with_jid.map { |project| [project.import_jid, project.id] }.to_h # Find the jobs that aren't currently running or that exceeded the threshold. completed_jids = Gitlab::SidekiqStatus.completed_jids(jids_and_ids.keys) @@ -42,15 +43,15 @@ class StuckImportJobsWorker end def enqueued_projects - Project.with_import_status(:scheduled, :started) + Project.joins_import_state.where("(import_state.status = 'scheduled' OR import_state.status = 'started') OR (projects.import_status = 'scheduled' OR projects.import_status = 'started')") end def enqueued_projects_with_jid - enqueued_projects.where.not(import_jid: nil) + enqueued_projects.where.not("import_state.jid IS NULL AND projects.import_jid IS NULL") end def enqueued_projects_without_jid - enqueued_projects.where(import_jid: nil) + enqueued_projects.where("import_state.jid IS NULL AND projects.import_jid IS NULL") end def error_message |