diff options
Diffstat (limited to 'app')
1005 files changed, 12844 insertions, 4473 deletions
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico b/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico Binary files differnew file mode 100644 index 00000000000..6cdf3ae2e36 --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico diff --git a/app/assets/images/ci_favicons/favicon_status_preparing.png b/app/assets/images/ci_favicons/favicon_status_preparing.png Binary files differnew file mode 100644 index 00000000000..f81baa0ece3 --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_preparing.png diff --git a/app/assets/javascripts/analytics/cycle_analytics/mixins/add_stage_mixin.js b/app/assets/javascripts/analytics/cycle_analytics/mixins/add_stage_mixin.js deleted file mode 100644 index 6a40f1cbc5e..00000000000 --- a/app/assets/javascripts/analytics/cycle_analytics/mixins/add_stage_mixin.js +++ /dev/null @@ -1,11 +0,0 @@ -export default { - data() { - return { - isCustomStageForm: false, - }; - }, - methods: { - showAddStageForm: () => {}, - hideAddStageForm: () => {}, - }, -}; diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 992c5e5e330..908dc730aa4 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -36,6 +36,7 @@ const Api = { branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', createBranchPath: '/api/:version/projects/:id/repository/branches', releasesPath: '/api/:version/projects/:id/releases', + releasePath: '/api/:version/projects/:id/releases/:tag_name', mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines', adminStatisticsPath: 'api/:version/application/statistics', @@ -74,6 +75,11 @@ const Api = { }); }, + groupLabels(namespace) { + const url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespace); + return axios.get(url).then(({ data }) => data); + }, + // Return namespaces list. Filtered by query namespaces(query, callback) { const url = Api.buildUrl(Api.namespacesPath); @@ -386,6 +392,22 @@ const Api = { return axios.get(url); }, + release(projectPath, tagName) { + const url = Api.buildUrl(this.releasePath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':tag_name', encodeURIComponent(tagName)); + + return axios.get(url); + }, + + updateRelease(projectPath, tagName, release) { + const url = Api.buildUrl(this.releasePath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':tag_name', encodeURIComponent(tagName)); + + return axios.put(url, release); + }, + adminStatistics() { const url = Api.buildUrl(this.adminStatisticsPath); return axios.get(url); diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index e8c59fab609..7652b67ae1e 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -1,4 +1,4 @@ -/* eslint-disable no-param-reassign, prefer-template, no-void, consistent-return */ +/* eslint-disable no-param-reassign, no-void, consistent-return */ import AccessorUtilities from './lib/utils/accessor'; @@ -10,7 +10,7 @@ export default class Autosave { if (key.join != null) { key = key.join('/'); } - this.key = 'autosave/' + key; + this.key = `autosave/${key}`; this.field.data('autosave', this); this.restore(); this.field.on('input', () => this.save()); diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue index 75a522efe7e..531f84ad272 100644 --- a/app/assets/javascripts/badges/components/badge_settings.vue +++ b/app/assets/javascripts/badges/components/badge_settings.vue @@ -2,7 +2,7 @@ import { mapState, mapActions } from 'vuex'; import createFlash from '~/flash'; import { s__ } from '~/locale'; -import GlModal from '~/vue_shared/components/gl_modal.vue'; +import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import Badge from './badge.vue'; import BadgeForm from './badge_form.vue'; import BadgeList from './badge_list.vue'; @@ -13,7 +13,7 @@ export default { Badge, BadgeForm, BadgeList, - GlModal, + GlModal: DeprecatedModal2, }, computed: { ...mapState(['badgeInModal', 'isEditing']), diff --git a/app/assets/javascripts/behaviors/markdown/editor_extensions.js b/app/assets/javascripts/behaviors/markdown/editor_extensions.js index 47e5fc65c48..8bd2145db1c 100644 --- a/app/assets/javascripts/behaviors/markdown/editor_extensions.js +++ b/app/assets/javascripts/behaviors/markdown/editor_extensions.js @@ -21,6 +21,7 @@ import Reference from './nodes/reference'; import TableOfContents from './nodes/table_of_contents'; import Video from './nodes/video'; +import Audio from './nodes/audio'; import BulletList from './nodes/bullet_list'; import OrderedList from './nodes/ordered_list'; @@ -78,6 +79,7 @@ export default [ new TableOfContents(), new Video(), + new Audio(), new BulletList(), new OrderedList(), diff --git a/app/assets/javascripts/behaviors/markdown/nodes/audio.js b/app/assets/javascripts/behaviors/markdown/nodes/audio.js new file mode 100644 index 00000000000..48ac408cf24 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/audio.js @@ -0,0 +1,53 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::AudioLinkFilter +export default class Audio extends Node { + get name() { + return 'audio'; + } + + get schema() { + return { + attrs: { + src: {}, + alt: { + default: null, + }, + }, + group: 'block', + draggable: true, + parseDOM: [ + { + tag: '.audio-container', + skip: true, + }, + { + tag: '.audio-container p', + priority: 51, + ignore: true, + }, + { + tag: 'audio[src]', + getAttrs: el => ({ src: el.getAttribute('src'), alt: el.dataset.title }), + }, + ], + toDOM: node => [ + 'audio', + { + src: node.attrs.src, + controls: true, + 'data-setup': '{}', + 'data-title': node.attrs.alt, + }, + ], + }; + } + + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.image(state, node); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index 1909830e9ed..a07942d87cb 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, prefer-arrow-callback */ +/* eslint-disable func-names, no-var */ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; @@ -45,26 +45,22 @@ MarkdownPreview.prototype.showPreview = function($form) { this.hideReferencedUsers($form); } else { preview.addClass('md-preview-loading').text(__('Loading...')); - this.fetchMarkdownPreview( - mdText, - url, - function(response) { - var body; - if (response.body.length > 0) { - ({ body } = response); - } else { - body = this.emptyMessage; - } - - preview.removeClass('md-preview-loading').html(body); - preview.renderGFM(); - this.renderReferencedUsers(response.references.users, $form); - - if (response.references.commands) { - this.renderReferencedCommands(response.references.commands, $form); - } - }.bind(this), - ); + this.fetchMarkdownPreview(mdText, url, response => { + var body; + if (response.body.length > 0) { + ({ body } = response); + } else { + body = this.emptyMessage; + } + + preview.removeClass('md-preview-loading').html(body); + preview.renderGFM(); + this.renderReferencedUsers(response.references.users, $form); + + if (response.references.commands) { + this.renderReferencedCommands(response.references.commands, $form); + } + }); } }; @@ -132,12 +128,12 @@ const markdownToolbar = $('.md-header-toolbar'); $.fn.setupMarkdownPreview = function() { var $form = $(this); - $form.find('textarea.markdown-area').on('input', function() { + $form.find('textarea.markdown-area').on('input', () => { markdownPreview.hideReferencedUsers($form); }); }; -$(document).on('markdown-preview:show', function(e, $form) { +$(document).on('markdown-preview:show', (e, $form) => { if (!$form) { return; } @@ -162,7 +158,7 @@ $(document).on('markdown-preview:show', function(e, $form) { markdownPreview.showPreview($form); }); -$(document).on('markdown-preview:hide', function(e, $form) { +$(document).on('markdown-preview:hide', (e, $form) => { if (!$form) { return; } @@ -191,7 +187,7 @@ $(document).on('markdown-preview:hide', function(e, $form) { markdownPreview.hideReferencedCommands($form); }); -$(document).on('markdown-preview:toggle', function(e, keyboardEvent) { +$(document).on('markdown-preview:toggle', (e, keyboardEvent) => { var $target; $target = $(keyboardEvent.target); if ($target.is('textarea.markdown-area')) { diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js index d8056e48d4e..7cf18d1fd83 100644 --- a/app/assets/javascripts/behaviors/requires_input.js +++ b/app/assets/javascripts/behaviors/requires_input.js @@ -26,7 +26,7 @@ $.fn.requiresInput = function requiresInput() { const values = _.map($(fieldSelector, $form), field => field.value); // Disable the button if any required fields are empty - if (values.length && _.any(values, _.isEmpty)) { + if (values.length && _.some(values, _.isEmpty)) { $button.disable(); } else { $button.enable(); diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js index 75777b910ca..87c8568802e 100644 --- a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js @@ -1,5 +1,7 @@ import sqljs from 'sql.js'; import { template as _template } from 'underscore'; +import axios from '~/lib/utils/axios_utils'; +import { successCodes } from '~/lib/utils/http_status'; const PREVIEW_TEMPLATE = _template(` <div class="card"> @@ -16,30 +18,25 @@ class BalsamiqViewer { } loadFile(endpoint) { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - - xhr.open('GET', endpoint, true); - xhr.responseType = 'arraybuffer'; - xhr.onload = loadEvent => this.fileLoaded(loadEvent, resolve, reject); - xhr.onerror = reject; - - xhr.send(); - }); - } - - fileLoaded(loadEvent, resolve, reject) { - if (loadEvent.target.status !== 200) return reject(); - - this.renderFile(loadEvent); - - return resolve(); + return axios + .get(endpoint, { + responseType: 'arraybuffer', + validateStatus(status) { + return status !== successCodes.OK; + }, + }) + .then(({ data }) => { + this.renderFile(data); + }) + .catch(e => { + throw new Error(e); + }); } - renderFile(loadEvent) { + renderFile(fileBuffer) { const container = document.createElement('ul'); - this.initDatabase(loadEvent.target.response); + this.initDatabase(fileBuffer); const previews = this.getPreviews(); previews.forEach(preview => { diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js index 9f0680cc6a7..8acf0827c44 100644 --- a/app/assets/javascripts/blob/blob_file_dropzone.js +++ b/app/assets/javascripts/blob/blob_file_dropzone.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, prefer-arrow-callback */ +/* eslint-disable func-names */ import $ from 'jquery'; import Dropzone from 'dropzone'; @@ -43,18 +43,18 @@ export default class BlobFileDropzone { previewsContainer: '.dropzone-previews', headers: csrf.headers, init() { - this.on('addedfile', function() { + this.on('addedfile', () => { toggleLoading(submitButton, submitButtonLoadingIcon, false); dropzoneMessage.addClass(HIDDEN_CLASS); $('.dropzone-alerts') .html('') .hide(); }); - this.on('removedfile', function() { + this.on('removedfile', () => { toggleLoading(submitButton, submitButtonLoadingIcon, false); dropzoneMessage.removeClass(HIDDEN_CLASS); }); - this.on('success', function(header, response) { + this.on('success', (header, response) => { $('#modal-upload-blob').modal('hide'); visitUrl(response.filePath); }); @@ -62,7 +62,7 @@ export default class BlobFileDropzone { dropzoneMessage.addClass(HIDDEN_CLASS); this.removeFile(file); }); - this.on('sending', function(file, xhr, formData) { + this.on('sending', (file, xhr, formData) => { formData.append('branch_name', form.find('.js-branch-name').val()); formData.append('create_merge_request', form.find('.js-create-merge-request').val()); formData.append('commit_message', form.find('.js-commit-message').val()); diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index 106fe2e0cef..b371f6be268 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -7,6 +7,8 @@ import BlobCiYamlSelector from './template_selectors/ci_yaml_selector'; import DockerfileSelector from './template_selectors/dockerfile_selector'; import GitignoreSelector from './template_selectors/gitignore_selector'; import LicenseSelector from './template_selectors/license_selector'; +import toast from '~/vue_shared/plugins/global_toast'; +import { __ } from '~/locale'; export default class FileTemplateMediator { constructor({ editor, currentAction, projectId }) { @@ -19,6 +21,7 @@ export default class FileTemplateMediator { this.initDomElements(); this.initDropdowns(); this.initPageEvents(); + this.cacheFileContents(); } initTemplateSelectors() { @@ -40,6 +43,7 @@ export default class FileTemplateMediator { return { name: cfg.name, key: cfg.key, + id: cfg.key, }; }), }); @@ -58,6 +62,7 @@ export default class FileTemplateMediator { this.$fileContent = $fileEditor.find('#file-content'); this.$commitForm = $fileEditor.find('form'); this.$navLinks = $fileEditor.find('.nav-links'); + this.$templateTypes = this.$templateSelectors.find('.template-type-selector'); } initDropdowns() { @@ -113,7 +118,11 @@ export default class FileTemplateMediator { } }); - this.typeSelector.setToggleText(item.name); + this.setFilename(item.name); + + if (this.editor.getValue() !== '') { + this.setTypeSelectorToggleText(item.name); + } this.cacheToggleText(); } @@ -123,15 +132,24 @@ export default class FileTemplateMediator { } selectTemplateFile(selector, query, data) { + const self = this; + selector.renderLoading(); - // in case undo menu is already there - this.destroyUndoMenu(); + this.fetchFileTemplate(selector.config.type, query, data) .then(file => { - this.showUndoMenu(); this.setEditorContent(file); - this.setFilename(selector.config.name); selector.renderLoaded(); + this.typeSelector.setToggleText(selector.config.name); + toast(__(`${query} template applied`), { + action: { + text: __('Undo'), + onClick: (e, toastObj) => { + self.restoreFromCache(); + toastObj.goAway(0); + }, + }, + }); }) .catch(err => new Flash(`An error occurred while fetching the template: ${err}`)); } @@ -173,22 +191,6 @@ export default class FileTemplateMediator { return this.templateSelectors.find(selector => selector.config.key === key); } - showUndoMenu() { - this.$undoMenu.removeClass('hidden'); - - this.$undoBtn.on('click', () => { - this.restoreFromCache(); - this.destroyUndoMenu(); - }); - } - - destroyUndoMenu() { - this.cacheFileContents(); - this.cacheToggleText(); - this.$undoMenu.addClass('hidden'); - this.$undoBtn.off('click'); - } - hideTemplateSelectorMenu() { this.$templatesMenu.hide(); } @@ -210,6 +212,7 @@ export default class FileTemplateMediator { this.setEditorContent(this.cachedContent); this.setFilename(this.cachedFilename); this.setTemplateSelectorToggleText(); + this.setTypeSelectorToggleText(__('Select a template type')); } getTemplateSelectorToggleText() { @@ -228,6 +231,10 @@ export default class FileTemplateMediator { return this.typeSelector.getToggleText(); } + setTypeSelectorToggleText(text) { + this.typeSelector.setToggleText(text); + } + getFilename() { return this.$filenameInput.val(); } diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js index 9e69c7d7164..b0de4dc8628 100644 --- a/app/assets/javascripts/blob/template_selector.js +++ b/app/assets/javascripts/blob/template_selector.js @@ -1,6 +1,7 @@ /* eslint-disable class-methods-use-this */ import $ from 'jquery'; +import '~/gl_dropdown'; export default class TemplateSelector { constructor({ dropdown, data, pattern, wrapper, editor, $input } = {}) { @@ -26,11 +27,16 @@ export default class TemplateSelector { search: { fields: ['name'], }, - clicked: options => this.fetchFileTemplate(options), + clicked: options => this.onDropdownClicked(options), text: item => item.name, }); } + // Subclasses can override this method to conditionally prevent fetching file templates + onDropdownClicked(options) { + this.fetchFileTemplate(options); + } + initAutosizeUpdateEvent() { this.autosizeUpdateEvent = document.createEvent('Event'); this.autosizeUpdateEvent.initEvent('autosize:update', true, false); @@ -77,9 +83,14 @@ export default class TemplateSelector { if (this.editor instanceof $) { this.editor.get(0).dispatchEvent(this.autosizeUpdateEvent); + this.editor.trigger('input'); } } + getEditorContent() { + return this.editor.getValue(); + } + startLoadingSpinner() { this.$dropdownIcon.addClass('fa-spinner fa-spin').removeClass('fa-chevron-down'); } diff --git a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js index 43f7aead8b9..d819452df68 100644 --- a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js +++ b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js @@ -19,7 +19,6 @@ export default class BlobCiYamlSelector extends FileTemplateSelector { data: this.$dropdown.data('data'), filterable: true, selectable: true, - toggleLabel: item => item.name, search: { fields: ['name'], }, diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js index 659d57e6a6f..7d5e98889d3 100644 --- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js +++ b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js @@ -20,7 +20,6 @@ export default class DockerfileSelector extends FileTemplateSelector { data: this.$dropdown.data('data'), filterable: true, selectable: true, - toggleLabel: item => item.name, search: { fields: ['name'], }, diff --git a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js index a8067ec5c84..39a8937641d 100644 --- a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js +++ b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js @@ -18,7 +18,6 @@ export default class BlobGitignoreSelector extends FileTemplateSelector { data: this.$dropdown.data('data'), filterable: true, selectable: true, - toggleLabel: item => item.name, search: { fields: ['name'], }, diff --git a/app/assets/javascripts/blob/template_selectors/license_selector.js b/app/assets/javascripts/blob/template_selectors/license_selector.js index d01ab9257d6..f4041835a7d 100644 --- a/app/assets/javascripts/blob/template_selectors/license_selector.js +++ b/app/assets/javascripts/blob/template_selectors/license_selector.js @@ -18,7 +18,6 @@ export default class BlobLicenseSelector extends FileTemplateSelector { data: this.$dropdown.data('data'), filterable: true, selectable: true, - toggleLabel: item => item.name, search: { fields: ['name'], }, diff --git a/app/assets/javascripts/blob/template_selectors/type_selector.js b/app/assets/javascripts/blob/template_selectors/type_selector.js index db3c144cbe3..cb4e1aaa9ac 100644 --- a/app/assets/javascripts/blob/template_selectors/type_selector.js +++ b/app/assets/javascripts/blob/template_selectors/type_selector.js @@ -16,7 +16,6 @@ export default class FileTemplateTypeSelector extends FileTemplateSelector { data: this.config.dropdownData, filterable: false, selectable: true, - toggleLabel: item => item.name, clicked: options => this.mediator.selectTemplateTypeOptions(options), text: item => item.name, }); diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 9ea455069f3..07e4dde41d9 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -107,18 +107,18 @@ export default class BlobViewer { toggleCopyButtonState() { if (!this.copySourceBtn) return; if (this.simpleViewer.getAttribute('data-loaded')) { - this.copySourceBtn.setAttribute('title', __('Copy source to clipboard')); + this.copySourceBtn.setAttribute('title', __('Copy file contents')); this.copySourceBtn.classList.remove('disabled'); } else if (this.activeViewer === this.simpleViewer) { this.copySourceBtn.setAttribute( 'title', - __('Wait for the source to load to copy it to the clipboard'), + __('Wait for the file to load to copy its contents'), ); this.copySourceBtn.classList.add('disabled'); } else { this.copySourceBtn.setAttribute( 'title', - __('Switch to the source to copy it to the clipboard'), + __('Switch to the source to copy the file contents'), ); this.copySourceBtn.classList.add('disabled'); } diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue index 9f26337d153..9a1da810ad0 100644 --- a/app/assets/javascripts/boards/components/board_blank_state.vue +++ b/app/assets/javascripts/boards/components/board_blank_state.vue @@ -29,25 +29,25 @@ export default { }); }); + const loadListIssues = listObj => { + const list = boardsStore.findList('title', listObj.title); + + if (!list) { + return null; + } + + list.id = listObj.id; + list.label.id = listObj.label.id; + return list.getIssues().catch(() => { + // TODO: handle request error + }); + }; + // Save the labels boardsStore .generateDefaultLists() .then(res => res.data) - .then(data => { - data.forEach(listObj => { - const list = boardsStore.findList('title', listObj.title); - - if (!list) { - return; - } - - list.id = listObj.id; - list.label.id = listObj.label.id; - list.getIssues().catch(() => { - // TODO: handle request error - }); - }); - }) + .then(data => Promise.all(data.map(loadListIssues))) .catch(() => { boardsStore.removeList(undefined, 'label'); Cookies.remove('issue_board_welcome_hidden', { diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index faf722f61af..12d68256598 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -42,12 +42,19 @@ export default { return { showDetail: false, detailIssue: boardsStore.detail, + multiSelect: boardsStore.multiSelect, }; }, computed: { issueDetailVisible() { return this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id; }, + multiSelectVisible() { + return this.multiSelect.list.findIndex(issue => issue.id === this.issue.id) > -1; + }, + canMultiSelect() { + return gon.features && gon.features.multiSelectBoard; + }, }, methods: { mouseDown() { @@ -58,14 +65,20 @@ export default { }, showIssue(e) { if (e.target.classList.contains('js-no-trigger')) return; - if (this.showDetail) { this.showDetail = false; + // If CMD or CTRL is clicked + const isMultiSelect = this.canMultiSelect && (e.ctrlKey || e.metaKey); + if (boardsStore.detail.issue && boardsStore.detail.issue.id === this.issue.id) { - eventHub.$emit('clearDetailIssue'); + eventHub.$emit('clearDetailIssue', isMultiSelect); + + if (isMultiSelect) { + eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); + } } else { - eventHub.$emit('newDetailIssue', this.issue); + eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); boardsStore.setListDetail(this.list); } } @@ -77,6 +90,7 @@ export default { <template> <li :class="{ + 'multi-select': multiSelectVisible, 'user-can-drag': !disabled && issue.id, 'is-disabled': disabled || !issue.id, 'is-active': issueDetailVisible, diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index ebf48cee2ae..34560560756 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -194,6 +194,7 @@ export default { ref="name" v-model="board.name" class="form-control" + data-qa-selector="board_name_field" type="text" :placeholder="__('Enter board name')" @keyup.enter="submit" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index de41698ca04..1273fcc6a91 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -1,12 +1,22 @@ <script> -/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ -import Sortable from 'sortablejs'; +import { Sortable, MultiDrag } from 'sortablejs'; import { GlLoadingIcon } from '@gitlab/ui'; +import _ from 'underscore'; import boardNewIssue from './board_new_issue.vue'; import boardCard from './board_card.vue'; import eventHub from '../eventhub'; import boardsStore from '../stores/boards_store'; -import { getBoardSortableDefaultOptions, sortableStart } from '../mixins/sortable_default_options'; +import { sprintf, __ } from '~/locale'; +import createFlash from '~/flash'; +import { + getBoardSortableDefaultOptions, + sortableStart, + sortableEnd, +} from '../mixins/sortable_default_options'; + +if (gon.features && gon.features.multiSelectBoard) { + Sortable.mount(new MultiDrag()); +} export default { name: 'BoardList', @@ -54,6 +64,14 @@ export default { showIssueForm: false, }; }, + computed: { + paginatedIssueText() { + return sprintf(__('Showing %{pageSize} of %{total} issues'), { + pageSize: this.list.issues.length, + total: this.list.issuesSize, + }); + }, + }, watch: { filters: { handler() { @@ -87,11 +105,20 @@ export default { eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); }, mounted() { + const multiSelectOpts = {}; + if (gon.features && gon.features.multiSelectBoard) { + multiSelectOpts.multiDrag = true; + multiSelectOpts.selectedClass = 'js-multi-select'; + multiSelectOpts.animation = 500; + } + const options = getBoardSortableDefaultOptions({ scroll: true, disabled: this.disabled, filter: '.board-list-count, .is-disabled', dataIdAttr: 'data-issue-id', + removeCloneOnHide: false, + ...multiSelectOpts, group: { name: 'issues', /** @@ -145,25 +172,66 @@ export default { card.showDetail = false; const { list } = card; + const issue = list.findIssue(Number(e.item.dataset.issueId)); + boardsStore.startMoving(list, issue); sortableStart(); }, onAdd: e => { - boardsStore.moveIssueToList( - boardsStore.moving.list, - this.list, - boardsStore.moving.issue, - e.newIndex, - ); + const { items = [], newIndicies = [] } = e; + if (items.length) { + // Not using e.newIndex here instead taking a min of all + // the newIndicies. Basically we have to find that during + // a drop what is the index we're going to start putting + // all the dropped elements from. + const newIndex = Math.min(...newIndicies.map(obj => obj.index).filter(i => i !== -1)); + const issues = items.map(item => + boardsStore.moving.list.findIssue(Number(item.dataset.issueId)), + ); - this.$nextTick(() => { - e.item.remove(); - }); + boardsStore.moveMultipleIssuesToList({ + listFrom: boardsStore.moving.list, + listTo: this.list, + issues, + newIndex, + }); + } else { + boardsStore.moveIssueToList( + boardsStore.moving.list, + this.list, + boardsStore.moving.issue, + e.newIndex, + ); + this.$nextTick(() => { + e.item.remove(); + }); + } }, onUpdate: e => { const sortedArray = this.sortable.toArray().filter(id => id !== '-1'); + + const { items = [], newIndicies = [], oldIndicies = [] } = e; + if (items.length) { + const newIndex = Math.min(...newIndicies.map(obj => obj.index)); + const issues = items.map(item => + boardsStore.moving.list.findIssue(Number(item.dataset.issueId)), + ); + boardsStore.moveMultipleIssuesInList({ + list: this.list, + issues, + oldIndicies: oldIndicies.map(obj => obj.index), + newIndex, + idArray: sortedArray, + }); + e.items.forEach(el => { + Sortable.utils.deselect(el); + }); + boardsStore.clearMultiSelect(); + return; + } + boardsStore.moveIssueInList( this.list, boardsStore.moving.issue, @@ -172,9 +240,133 @@ export default { sortedArray, ); }, + onEnd: e => { + const { items = [], clones = [], to } = e; + + // This is not a multi select operation + if (!items.length && !clones.length) { + sortableEnd(); + return; + } + + let toList; + if (to) { + const containerEl = to.closest('.js-board-list'); + toList = boardsStore.findList('id', Number(containerEl.dataset.board)); + } + + /** + * onEnd is called irrespective if the cards were moved in the + * same list or the other list. Don't remove items if it's same list. + */ + const isSameList = toList && toList.id === this.list.id; + + if (toList && !isSameList && boardsStore.shouldRemoveIssue(this.list, toList)) { + const issues = items.map(item => this.list.findIssue(Number(item.dataset.issueId))); + + if (_.compact(issues).length && !boardsStore.issuesAreContiguous(this.list, issues)) { + const indexes = []; + const ids = this.list.issues.map(i => i.id); + issues.forEach(issue => { + const index = ids.indexOf(issue.id); + if (index > -1) { + indexes.push(index); + } + }); + + // Descending sort because splice would cause index discrepancy otherwise + const sortedIndexes = indexes.sort((a, b) => (a < b ? 1 : -1)); + + sortedIndexes.forEach(i => { + /** + * **setTimeout and splice each element one-by-one in a loop + * is intended.** + * + * The problem here is all the indexes are in the list but are + * non-contiguous. Due to that, when we splice all the indexes, + * at once, Vue -- during a re-render -- is unable to find reference + * nodes and the entire app crashes. + * + * If the indexes are contiguous, this piece of code is not + * executed. If it is, this is a possible regression. Only when + * issue indexes are far apart, this logic should ever kick in. + */ + setTimeout(() => { + this.list.issues.splice(i, 1); + }, 0); + }); + } + } + + if (!toList) { + createFlash(__('Something went wrong while performing the action.')); + } + + if (!isSameList) { + boardsStore.clearMultiSelect(); + + // Since Vue's list does not re-render the same keyed item, we'll + // remove `multi-select` class to express it's unselected + if (clones && clones.length) { + clones.forEach(el => el.classList.remove('multi-select')); + } + + // Due to some bug which I am unable to figure out + // Sortable does not deselect some pending items from the + // source list. + // We'll just do it forcefully here. + Array.from(document.querySelectorAll('.js-multi-select') || []).forEach(item => { + Sortable.utils.deselect(item); + }); + + /** + * SortableJS leaves all the moving items "as is" on the DOM. + * Vue picks up and rehydrates the DOM, but we need to explicity + * remove the "trash" items from the DOM. + * + * This is in parity to the logic on single item move from a list/in + * a list. For reference, look at the implementation of onAdd method. + */ + this.$nextTick(() => { + if (items && items.length) { + items.forEach(item => { + item.remove(); + }); + } + }); + } + sortableEnd(); + }, onMove(e) { return !e.related.classList.contains('board-list-count'); }, + onSelect(e) { + const { + item: { classList }, + } = e; + + if ( + classList && + classList.contains('js-multi-select') && + !classList.contains('multi-select') + ) { + Sortable.utils.deselect(e.item); + } + }, + onDeselect: e => { + const { + item: { dataset, classList }, + } = e; + + if ( + classList && + classList.contains('multi-select') && + !classList.contains('js-multi-select') + ) { + const issue = this.list.findIssue(Number(dataset.issueId)); + boardsStore.toggleMultiSelect(issue); + } + }, }); this.sortable = Sortable.create(this.$refs.list, options); @@ -260,7 +452,7 @@ export default { <li v-if="showCount" class="board-list-count text-center" data-issue-id="-1"> <gl-loading-icon v-show="list.loadingMore" label="Loading more issues" /> <span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span> - <span v-else> Showing {{ list.issues.length }} of {{ list.issuesSize }} issues </span> + <span v-else>{{ paginatedIssueText }}</span> </li> </ul> </div> diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index ebb2f5b23e4..334c162954e 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -305,13 +305,18 @@ export default { <div v-if="canAdminBoard"> <gl-dropdown-divider /> - <gl-dropdown-item v-if="multipleIssueBoardsAvailable" @click.prevent="showPage('new')"> + <gl-dropdown-item + v-if="multipleIssueBoardsAvailable" + data-qa-selector="create_new_board_button" + @click.prevent="showPage('new')" + > {{ s__('IssueBoards|Create new board') }} </gl-dropdown-item> <gl-dropdown-item v-if="showDelete" class="text-danger" + data-qa-selector="delete_board_button" @click.prevent="showPage('delete')" > {{ s__('IssueBoards|Delete board') }} diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 7f554c99669..40d75d53f75 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -99,7 +99,10 @@ export default { return !groupId ? referencePath.split('#')[0] : null; }, orderedLabels() { - return _.sortBy(this.issue.labels, 'title'); + return _.chain(this.issue.labels) + .filter(this.isNonListLabel) + .sortBy('title') + .value(); }, helpLink() { return boardsStore.scopedLabels.helpLink; @@ -130,6 +133,9 @@ export default { if (!label.id) return false; return true; }, + isNonListLabel(label) { + return label.id && !(this.list.type === 'label' && this.list.title === label.title); + }, filterByLabel(label) { if (!this.updateFilters) return; const labelTitle = encodeURIComponent(label.title); @@ -167,7 +173,7 @@ export default { </h4> </div> <div v-if="showLabelFooter" class="board-card-labels prepend-top-4 d-flex flex-wrap"> - <template v-for="label in orderedLabels" v-if="showLabel(label)"> + <template v-for="label in orderedLabels"> <issue-card-inner-scoped-label v-if="showScopedLabel(label)" :key="label.id" @@ -212,7 +218,7 @@ export default { <issue-due-date v-if="issue.dueDate" :date="issue.dueDate" /> <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" /> <issue-card-weight - v-if="issue.weight" + v-if="validIssueWeight" :weight="issue.weight" @click="filterByWeight(issue.weight)" /> diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue index 3385aad5b11..5c33ba9461c 100644 --- a/app/assets/javascripts/boards/components/issue_time_estimate.vue +++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue @@ -34,7 +34,7 @@ export default { <template> <span> <span ref="issueTimeEstimate" class="board-card-info card-number"> - <icon name="hourglass" css-classes="board-card-info-icon align-top" /><time + <icon name="hourglass" class="board-card-info-icon align-top" /><time class="board-card-info-text" >{{ timeEstimate }}</time > diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js new file mode 100644 index 00000000000..3c66c7a0660 --- /dev/null +++ b/app/assets/javascripts/boards/constants.js @@ -0,0 +1,11 @@ +export const ListType = { + assignee: 'assignee', + milestone: 'milestone', + backlog: 'backlog', + closed: 'closed', + label: 'label', +}; + +export default { + ListType, +}; diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 3bded4a3258..befca70eeae 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -22,7 +22,6 @@ import Board from 'ee_else_ce/boards/components/board'; import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar'; import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown'; import BoardAddIssuesModal from '~/boards/components/modal/index.vue'; -import '~/vue_shared/vue_resource_interceptor'; import { NavigationType, convertObjectPropsToCamelCase, @@ -147,7 +146,7 @@ export default () => { updateTokens() { this.filterManager.updateTokens(); }, - updateDetailIssue(newIssue) { + updateDetailIssue(newIssue, multiSelect = false) { const { sidebarInfoEndpoint } = newIssue; if (sidebarInfoEndpoint && newIssue.subscribed === undefined) { newIssue.setFetchingState('subscriptions', true); @@ -186,9 +185,23 @@ export default () => { }); } + if (multiSelect) { + boardsStore.toggleMultiSelect(newIssue); + + if (boardsStore.detail.issue) { + boardsStore.clearDetailIssue(); + return; + } + + return; + } + boardsStore.setIssueDetail(newIssue); }, - clearDetailIssue() { + clearDetailIssue(multiSelect = false) { + if (multiSelect) { + boardsStore.clearMultiSelect(); + } boardsStore.clearDetailIssue(); }, toggleSubscription(id) { diff --git a/app/assets/javascripts/boards/mixins/issue_card_inner.js b/app/assets/javascripts/boards/mixins/issue_card_inner.js index 8000237da6d..04e971b756d 100644 --- a/app/assets/javascripts/boards/mixins/issue_card_inner.js +++ b/app/assets/javascripts/boards/mixins/issue_card_inner.js @@ -1,4 +1,9 @@ export default { + computed: { + validIssueWeight() { + return false; + }, + }, methods: { filterByWeight() {}, }, diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index b3e56a34c28..1e213c324eb 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -5,6 +5,7 @@ import ListLabel from './label'; import ListAssignee from './assignee'; import ListIssue from 'ee_else_ce/boards/models/issue'; import { urlParamsToObject } from '~/lib/utils/common_utils'; +import flash from '~/flash'; import boardsStore from '../stores/boards_store'; import ListMilestone from './milestone'; @@ -176,6 +177,53 @@ class List { }); } + addMultipleIssues(issues, listFrom, newIndex) { + let moveBeforeId = null; + let moveAfterId = null; + + const listHasIssues = issues.every(issue => this.findIssue(issue.id)); + + if (!listHasIssues) { + if (newIndex !== undefined) { + if (this.issues[newIndex - 1]) { + moveBeforeId = this.issues[newIndex - 1].id; + } + + if (this.issues[newIndex]) { + moveAfterId = this.issues[newIndex].id; + } + + this.issues.splice(newIndex, 0, ...issues); + } else { + this.issues.push(...issues); + } + + if (this.label) { + issues.forEach(issue => issue.addLabel(this.label)); + } + + if (this.assignee) { + if (listFrom && listFrom.type === 'assignee') { + issues.forEach(issue => issue.removeAssignee(listFrom.assignee)); + } + issues.forEach(issue => issue.addAssignee(this.assignee)); + } + + if (IS_EE && this.milestone) { + if (listFrom && listFrom.type === 'milestone') { + issues.forEach(issue => issue.removeMilestone(listFrom.milestone)); + } + issues.forEach(issue => issue.addMilestone(this.milestone)); + } + + if (listFrom) { + this.issuesSize += issues.length; + + this.updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId); + } + } + } + addIssue(issue, listFrom, newIndex) { let moveBeforeId = null; let moveAfterId = null; @@ -230,6 +278,23 @@ class List { }); } + moveMultipleIssues({ issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) { + oldIndicies.reverse().forEach(index => { + this.issues.splice(index, 1); + }); + this.issues.splice(newIndex, 0, ...issues); + + gl.boardService + .moveMultipleIssues({ + ids: issues.map(issue => issue.id), + fromListId: null, + toListId: null, + moveBeforeId, + moveAfterId, + }) + .catch(() => flash(__('Something went wrong while moving issues.'))); + } + updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) { gl.boardService .moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId) @@ -238,10 +303,37 @@ class List { }); } + updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId) { + gl.boardService + .moveMultipleIssues({ + ids: issues.map(issue => issue.id), + fromListId: listFrom.id, + toListId: this.id, + moveBeforeId, + moveAfterId, + }) + .catch(() => flash(__('Something went wrong while moving issues.'))); + } + findIssue(id) { return this.issues.find(issue => issue.id === id); } + removeMultipleIssues(removeIssues) { + const ids = removeIssues.map(issue => issue.id); + + this.issues = this.issues.filter(issue => { + const matchesRemove = ids.includes(issue.id); + + if (matchesRemove) { + this.issuesSize -= 1; + issue.removeLabel(this.label); + } + + return !matchesRemove; + }); + } + removeIssue(removeIssue) { this.issues = this.issues.filter(issue => { const matchesRemove = removeIssue.id === issue.id; diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js index 0d11db89511..03369febb4a 100644 --- a/app/assets/javascripts/boards/services/board_service.js +++ b/app/assets/javascripts/boards/services/board_service.js @@ -48,6 +48,16 @@ export default class BoardService { return boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId); } + moveMultipleIssues({ + ids, + fromListId = null, + toListId = null, + moveBeforeId = null, + moveAfterId = null, + }) { + return boardsStore.moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId }); + } + newIssue(id, issue) { return boardsStore.newIssue(id, issue); } diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 6da1cca9628..8b737d1dab0 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -11,6 +11,7 @@ import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import eventHub from '../eventhub'; +import { ListType } from '../constants'; const boardsStore = { disabled: false, @@ -39,6 +40,7 @@ const boardsStore = { issue: {}, list: {}, }, + multiSelect: { list: [] }, setEndpoints({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) { const listsEndpointGenerate = `${listsEndpoint}/generate.json`; @@ -51,7 +53,6 @@ const boardsStore = { recentBoardsEndpoint: `${recentBoardsEndpoint}.json`, }; }, - create() { this.state.lists = []; this.filter.path = getUrlParamsArray().join('&'); @@ -134,6 +135,107 @@ const boardsStore = { Object.assign(this.moving, { list, issue }); }, + moveMultipleIssuesToList({ listFrom, listTo, issues, newIndex }) { + const issueTo = issues.map(issue => listTo.findIssue(issue.id)); + const issueLists = _.flatten(issues.map(issue => issue.getLists())); + const listLabels = issueLists.map(list => list.label); + + const hasMoveableIssues = _.compact(issueTo).length > 0; + + if (!hasMoveableIssues) { + // Check if target list assignee is already present in this issue + if ( + listTo.type === ListType.assignee && + listFrom.type === ListType.assignee && + issues.some(issue => issue.findAssignee(listTo.assignee)) + ) { + const targetIssues = issues.map(issue => listTo.findIssue(issue.id)); + targetIssues.forEach(targetIssue => targetIssue.removeAssignee(listFrom.assignee)); + } else if (listTo.type === 'milestone') { + const currentMilestones = issues.map(issue => issue.milestone); + const currentLists = this.state.lists + .filter(list => list.type === 'milestone' && list.id !== listTo.id) + .filter(list => + list.issues.some(listIssue => issues.some(issue => listIssue.id === issue.id)), + ); + + issues.forEach(issue => { + currentMilestones.forEach(milestone => { + issue.removeMilestone(milestone); + }); + }); + + issues.forEach(issue => { + issue.addMilestone(listTo.milestone); + }); + + currentLists.forEach(currentList => { + issues.forEach(issue => { + currentList.removeIssue(issue); + }); + }); + + listTo.addMultipleIssues(issues, listFrom, newIndex); + } else { + // Add to new lists issues if it doesn't already exist + listTo.addMultipleIssues(issues, listFrom, newIndex); + } + } else { + listTo.updateMultipleIssues(issues, listFrom); + issues.forEach(issue => { + issue.removeLabel(listFrom.label); + }); + } + + if (listTo.type === ListType.closed && listFrom.type !== ListType.backlog) { + issueLists.forEach(list => { + issues.forEach(issue => { + list.removeIssue(issue); + }); + }); + + issues.forEach(issue => { + issue.removeLabels(listLabels); + }); + } else if (listTo.type === ListType.backlog && listFrom.type === ListType.assignee) { + issues.forEach(issue => { + issue.removeAssignee(listFrom.assignee); + }); + issueLists.forEach(list => { + issues.forEach(issue => { + list.removeIssue(issue); + }); + }); + } else if (listTo.type === ListType.backlog && listFrom.type === ListType.milestone) { + issues.forEach(issue => { + issue.removeMilestone(listFrom.milestone); + }); + issueLists.forEach(list => { + issues.forEach(issue => { + list.removeIssue(issue); + }); + }); + } else if ( + this.shouldRemoveIssue(listFrom, listTo) && + this.issuesAreContiguous(listFrom, issues) + ) { + listFrom.removeMultipleIssues(issues); + } + }, + + issuesAreContiguous(list, issues) { + // When there's only 1 issue selected, we can return early. + if (issues.length === 1) return true; + + // Create list of ids for issues involved. + const listIssueIds = list.issues.map(issue => issue.id); + const movedIssueIds = issues.map(issue => issue.id); + + // Check if moved issue IDs is sub-array + // of source list issue IDs (i.e. contiguous selection). + return listIssueIds.join('|').includes(movedIssueIds.join('|')); + }, + moveIssueToList(listFrom, listTo, issue, newIndex) { const issueTo = listTo.findIssue(issue.id); const issueLists = issue.getLists(); @@ -195,6 +297,17 @@ const boardsStore = { list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId); }, + moveMultipleIssuesInList({ list, issues, oldIndicies, newIndex, idArray }) { + const beforeId = parseInt(idArray[newIndex - 1], 10) || null; + const afterId = parseInt(idArray[newIndex + issues.length], 10) || null; + list.moveMultipleIssues({ + issues, + oldIndicies, + newIndex, + moveBeforeId: beforeId, + moveAfterId: afterId, + }); + }, findList(key, val, type = 'label') { const filteredList = this.state.lists.filter(list => { const byType = type @@ -260,6 +373,10 @@ const boardsStore = { }`; }, + generateMultiDragPath(boardId) { + return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues/bulk_move`; + }, + all() { return axios.get(this.state.endpoints.listsEndpoint); }, @@ -309,6 +426,16 @@ const boardsStore = { }); }, + moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId }) { + return axios.put(this.generateMultiDragPath(this.state.endpoints.boardId), { + from_list_id: fromListId, + to_list_id: toListId, + move_before_id: moveBeforeId, + move_after_id: moveAfterId, + ids, + }); + }, + newIssue(id, issue) { return axios.post(this.generateIssuesPath(id), { issue, @@ -379,6 +506,25 @@ const boardsStore = { setCurrentBoard(board) { this.state.currentBoard = board; }, + + toggleMultiSelect(issue) { + const selectedIssueIds = this.multiSelect.list.map(issue => issue.id); + const index = selectedIssueIds.indexOf(issue.id); + + if (index === -1) { + this.multiSelect.list.push(issue); + return; + } + + this.multiSelect.list = [ + ...this.multiSelect.list.slice(0, index), + ...this.multiSelect.list.slice(index + 1), + ]; + }, + + clearMultiSelect() { + this.multiSelect.list = []; + }, }; BoardsStoreEE.initEESpecific(boardsStore); diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js index b2c88e8c14e..2955f0f014b 100644 --- a/app/assets/javascripts/build_artifacts.js +++ b/app/assets/javascripts/build_artifacts.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, prefer-arrow-callback */ +/* eslint-disable func-names */ import $ from 'jquery'; import { visitUrl } from './lib/utils/url_utility'; @@ -12,11 +12,11 @@ export default class BuildArtifacts { } // eslint-disable-next-line class-methods-use-this disablePropagation() { - $('.top-block').on('click', '.download', function(e) { - return e.stopPropagation(); + $('.top-block').on('click', '.download', e => { + e.stopPropagation(); }); - return $('.tree-holder').on('click', 'tr[data-link] a', function(e) { - return e.stopImmediatePropagation(); + return $('.tree-holder').on('click', 'tr[data-link] a', e => { + e.stopImmediatePropagation(); }); } // eslint-disable-next-line class-methods-use-this diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index d386960f3b6..7ea8901ecbb 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -41,6 +41,8 @@ export default class Clusters { managePrometheusPath, clusterEnvironmentsPath, hasRbac, + providerType, + preInstalledKnative, clusterType, clusterStatus, clusterStatusReason, @@ -50,6 +52,7 @@ export default class Clusters { environmentsHelpPath, clustersHelpPath, deployBoardsHelpPath, + cloudRunHelpPath, clusterId, } = document.querySelector('.js-edit-cluster-form').dataset; @@ -65,10 +68,13 @@ export default class Clusters { environmentsHelpPath, clustersHelpPath, deployBoardsHelpPath, + cloudRunHelpPath, ); this.store.setManagePrometheusPath(managePrometheusPath); this.store.updateStatus(clusterStatus); this.store.updateStatusReason(clusterStatusReason); + this.store.updateProviderType(providerType); + this.store.updatePreInstalledKnative(preInstalledKnative); this.store.updateRbac(hasRbac); this.service = new ClustersService({ endpoint: statusPath, @@ -153,6 +159,9 @@ export default class Clusters { ingressHelpPath: this.state.ingressHelpPath, managePrometheusPath: this.state.managePrometheusPath, ingressDnsHelpPath: this.state.ingressDnsHelpPath, + cloudRunHelpPath: this.state.cloudRunHelpPath, + providerType: this.state.providerType, + preInstalledKnative: this.state.preInstalledKnative, rbac: this.state.rbac, }, }); diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 64364092016..c6c8dc6352c 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -78,6 +78,10 @@ export default { required: false, default: false, }, + installedVia: { + type: String, + required: false, + }, version: { type: String, required: false, @@ -311,6 +315,11 @@ export default { > <span v-else class="js-cluster-application-title">{{ title }}</span> </strong> + <span + v-if="installedVia" + class="js-cluster-application-installed-via" + v-html="installedVia" + ></span> <slot name="description"></slot> <div v-if="hasError" class="cluster-application-error text-danger prepend-top-10"> <p class="js-cluster-application-general-error-message append-bottom-0"> diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 27959898fb7..b95f97077f6 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -16,7 +16,7 @@ import { s__, sprintf } from '../../locale'; import applicationRow from './application_row.vue'; import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; import KnativeDomainEditor from './knative_domain_editor.vue'; -import { CLUSTER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants'; +import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; import eventHub from '~/clusters/event_hub'; @@ -54,11 +54,26 @@ export default { required: false, default: '', }, + cloudRunHelpPath: { + type: String, + required: false, + default: '', + }, managePrometheusPath: { type: String, required: false, default: '', }, + providerType: { + type: String, + required: false, + default: '', + }, + preInstalledKnative: { + type: Boolean, + required: false, + default: false, + }, rbac: { type: Boolean, required: false, @@ -156,6 +171,25 @@ export default { knative() { return this.applications.knative; }, + cloudRun() { + return this.providerType === PROVIDER_TYPE.GCP && this.preInstalledKnative; + }, + installedVia() { + if (this.cloudRun) { + return sprintf( + _.escape(s__(`ClusterIntegration|installed via %{installed_via}`)), + { + installed_via: `<a href="${ + this.cloudRunHelpPath + }" target="_blank" rel="noopener noreferrer">${_.escape( + s__('ClusterIntegration|Cloud Run'), + )}</a>`, + }, + false, + ); + } + return null; + }, }, created() { this.helmInstallIllustration = helmInstallIllustration; @@ -260,7 +294,7 @@ export default { <span class="input-group-append"> <clipboard-button :text="ingressExternalEndpoint" - :title="s__('ClusterIntegration|Copy Ingress Endpoint to clipboard')" + :title="s__('ClusterIntegration|Copy Ingress Endpoint')" class="input-group-text js-clipboard-btn" /> </span> @@ -438,7 +472,7 @@ export default { <span class="input-group-btn"> <clipboard-button :text="jupyterHostname" - :title="s__('ClusterIntegration|Copy Jupyter Hostname to clipboard')" + :title="s__('ClusterIntegration|Copy Jupyter Hostname')" class="js-clipboard-btn" /> </span> @@ -468,6 +502,7 @@ export default { :installed="applications.knative.installed" :install-failed="applications.knative.installFailed" :install-application-request-params="{ hostname: applications.knative.hostname }" + :installed-via="installedVia" :uninstallable="applications.knative.uninstallable" :uninstall-successful="applications.knative.uninstallSuccessful" :uninstall-failed="applications.knative.uninstallFailed" @@ -499,7 +534,7 @@ export default { </p> <knative-domain-editor - v-if="knative.installed || (helmInstalled && rbac)" + v-if="(knative.installed || (helmInstalled && rbac)) && !preInstalledKnative" :knative="knative" :ingress-dns-help-path="ingressDnsHelpPath" @save="saveKnativeDomain" diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue index e26ef135bc5..25347b11b6c 100644 --- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue +++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue @@ -103,7 +103,7 @@ export default { <span class="input-group-append"> <clipboard-button :text="knativeExternalEndpoint" - :title="s__('ClusterIntegration|Copy Knative Endpoint to clipboard')" + :title="s__('ClusterIntegration|Copy Knative Endpoint')" class="input-group-text js-knative-endpoint-clipboard-btn" /> </span> diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue index 4f60e543666..f1925c243f2 100644 --- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue +++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue @@ -5,8 +5,14 @@ import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uni import { HELM, INGRESS, CERT_MANAGER, PROMETHEUS, RUNNER, KNATIVE, JUPYTER } from '../constants'; const CUSTOM_APP_WARNING_TEXT = { - [HELM]: s__( - 'ClusterIntegration|The associated Tiller pod will be deleted and cannot be restored.', + [HELM]: sprintf( + s__( + 'ClusterIntegration|The associated Tiller pod, the %{gitlabManagedAppsNamespace} namespace, and all of its resources will be deleted and cannot be restored.', + ), + { + gitlabManagedAppsNamespace: '<code>gitlab-managed-apps</code>', + }, + false, ), [INGRESS]: s__( 'ClusterIntegration|The associated load balancer and IP will be deleted and cannot be restored.', @@ -76,6 +82,7 @@ export default { :modal-id="modalId" :title="title" @ok="confirmUninstall()" - >{{ warningText }} {{ customAppWarningText }}</gl-modal > + {{ warningText }} <span v-html="customAppWarningText"></span> + </gl-modal> </template> diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index 8fd752092c9..c6e4b7951cf 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -5,6 +5,11 @@ export const CLUSTER_TYPE = { PROJECT: 'project_type', }; +// These need to match the available providers in app/models/clusters/providers/ +export const PROVIDER_TYPE = { + GCP: 'gcp', +}; + // These need to match what is returned from the server export const APPLICATION_STATUS = { NO_STATUS: null, @@ -19,6 +24,7 @@ export const APPLICATION_STATUS = { UNINSTALLING: 'uninstalling', UNINSTALL_ERRORED: 'uninstall_errored', ERROR: 'errored', + PRE_INSTALLED: 'pre_installed', }; /* @@ -29,6 +35,7 @@ export const APPLICATION_INSTALLED_STATUSES = [ APPLICATION_STATUS.INSTALLED, APPLICATION_STATUS.UPDATING, APPLICATION_STATUS.UNINSTALLING, + APPLICATION_STATUS.PRE_INSTALLED, ]; // These are only used client-side diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js index 6e632519d8a..6bc4be7b93a 100644 --- a/app/assets/javascripts/clusters/services/application_state_machine.js +++ b/app/assets/javascripts/clusters/services/application_state_machine.js @@ -13,6 +13,7 @@ const { UPDATE_ERRORED, UNINSTALLING, UNINSTALL_ERRORED, + PRE_INSTALLED, } = APPLICATION_STATUS; const applicationStateMachine = { @@ -63,6 +64,9 @@ const applicationStateMachine = { uninstallFailed: true, }, }, + [PRE_INSTALLED]: { + target: PRE_INSTALLED, + }, }, }, [NOT_INSTALLABLE]: { @@ -123,6 +127,27 @@ const applicationStateMachine = { }, }, }, + [PRE_INSTALLED]: { + on: { + [UPDATE_EVENT]: { + target: UPDATING, + effects: { + updateFailed: false, + updateSuccessful: false, + }, + }, + [NOT_INSTALLABLE]: { + target: NOT_INSTALLABLE, + }, + [UNINSTALL_EVENT]: { + target: UNINSTALLING, + effects: { + uninstallFailed: false, + uninstallSuccessful: false, + }, + }, + }, + }, [UPDATING]: { on: { [UPDATED]: { diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 5cddb4cc098..6464461ea0c 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -35,7 +35,10 @@ export default class ClusterStore { environmentsHelpPath: null, clustersHelpPath: null, deployBoardsHelpPath: null, + cloudRunHelpPath: null, status: null, + providerType: null, + preInstalledKnative: false, rbac: false, statusReason: null, applications: { @@ -95,6 +98,7 @@ export default class ClusterStore { environmentsHelpPath, clustersHelpPath, deployBoardsHelpPath, + cloudRunHelpPath, ) { this.state.helpPath = helpPath; this.state.ingressHelpPath = ingressHelpPath; @@ -102,6 +106,7 @@ export default class ClusterStore { this.state.environmentsHelpPath = environmentsHelpPath; this.state.clustersHelpPath = clustersHelpPath; this.state.deployBoardsHelpPath = deployBoardsHelpPath; + this.state.cloudRunHelpPath = cloudRunHelpPath; } setManagePrometheusPath(managePrometheusPath) { @@ -112,6 +117,14 @@ export default class ClusterStore { this.state.status = status; } + updateProviderType(providerType) { + this.state.providerType = providerType; + } + + updatePreInstalledKnative(preInstalledKnative) { + this.state.preInstalledKnative = parseBoolean(preInstalledKnative); + } + updateRbac(rbac) { this.state.rbac = parseBoolean(rbac); } diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index 9454f760df8..6c04e0beb4d 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, prefer-arrow-callback, no-else-return, consistent-return, prefer-template, one-var, no-return-assign, no-unused-expressions, no-sequences */ +/* eslint-disable func-names, no-var, no-else-return, consistent-return, one-var, no-return-assign, no-unused-expressions, no-sequences */ import $ from 'jquery'; @@ -13,14 +13,14 @@ export default class ImageFile { $('.two-up.view .frame.deleted img', this.file), (function(_this) { return function() { - return _this.requestImageInfo($('.two-up.view .frame.added img', _this.file), function() { + return _this.requestImageInfo($('.two-up.view .frame.added img', _this.file), () => { _this.initViewModes(); // Load two-up view after images are loaded // so that we can display the correct width and height information const $images = $('.two-up.view img', _this.file); - $images.waitForImages(function() { + $images.waitForImages(() => { _this.initView('two-up'); }); }); @@ -49,13 +49,13 @@ export default class ImageFile { activateViewMode(viewMode) { $('.view-modes-menu li', this.file) .removeClass('active') - .filter('.' + viewMode) + .filter(`.${viewMode}`) .addClass('active'); - return $('.view:visible:not(.' + viewMode + ')', this.file).fadeOut( + return $(`.view:visible:not(.${viewMode})`, this.file).fadeOut( 200, (function(_this) { return function() { - $('.view.' + viewMode, _this.file).fadeIn(200); + $(`.view.${viewMode}`, _this.file).fadeIn(200); return _this.initView(viewMode); }; })(this), @@ -138,9 +138,9 @@ export default class ImageFile { return $(this).width(availWidth / 2); } }); - return _this.requestImageInfo($('img', wrap), function(width, height) { - $('.image-info .meta-width', wrap).text(width + 'px'); - $('.image-info .meta-height', wrap).text(height + 'px'); + return _this.requestImageInfo($('img', wrap), (width, height) => { + $('.image-info .meta-width', wrap).text(`${width}px`); + $('.image-info .meta-height', wrap).text(`${height}px`); return $('.image-info', wrap).removeClass('hide'); }); }; @@ -175,7 +175,7 @@ export default class ImageFile { wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10); - _this.initDraggable($swipeBar, wrapPadding, function(e, left) { + _this.initDraggable($swipeBar, wrapPadding, (e, left) => { if (left > 0 && left < $swipeFrame.width() - wrapPadding * 2) { $swipeWrap.width(maxWidth + 1 - left); $swipeBar.css('left', left); @@ -215,7 +215,7 @@ export default class ImageFile { $frameAdded.css('opacity', 1); framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10); - _this.initDraggable($dragger, framePadding, function(e, left) { + _this.initDraggable($dragger, framePadding, (e, left) => { var opacity = left / dragTrackWidth; if (opacity >= 0 && opacity <= 1) { diff --git a/app/assets/javascripts/commons/vue.js b/app/assets/javascripts/commons/vue.js index 798623b94fb..5b5a1507d38 100644 --- a/app/assets/javascripts/commons/vue.js +++ b/app/assets/javascripts/commons/vue.js @@ -1,6 +1,8 @@ import Vue from 'vue'; -import '../vue_shared/vue_resource_interceptor'; +import GlFeatureFlagsPlugin from '~/vue_shared/gl_feature_flags_plugin'; if (process.env.NODE_ENV !== 'production') { Vue.config.productionTip = false; } + +Vue.use(GlFeatureFlagsPlugin); diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue index f9465da6fda..3c6da43c4c4 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue @@ -3,6 +3,8 @@ import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_searc import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; +const findItem = (items, valueProp, value) => items.find(item => item[valueProp] === value); + export default { components: { DropdownButton, @@ -26,7 +28,7 @@ export default { default: '', }, value: { - type: Object, + type: [Object, String], required: false, default: () => null, }, @@ -93,8 +95,8 @@ export default { }, data() { return { + selectedItem: findItem(this.items, this.value), searchQuery: '', - selectedItem: null, }; }, computed: { @@ -127,10 +129,15 @@ export default { return (this.selectedItem && this.selectedItem[this.valueProperty]) || ''; }, }, + watch: { + value(value) { + this.selectedItem = findItem(this.items, this.valueProperty, value); + }, + }, methods: { select(item) { this.selectedItem = item; - this.$emit('input', item); + this.$emit('input', item[this.valueProperty]); }, }, }; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue index ce2e4b883e4..22ee368b8e0 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue @@ -7,8 +7,23 @@ export default { ServiceCredentialsForm, EksClusterConfigurationForm, }, + props: { + gitlabManagedClusterHelpPath: { + type: String, + required: true, + }, + kubernetesIntegrationHelpPath: { + type: String, + required: true, + }, + }, }; </script> <template> - <eks-cluster-configuration-form /> + <div class="js-create-eks-cluster"> + <eks-cluster-configuration-form + :gitlab-managed-cluster-help-path="gitlabManagedClusterHelpPath" + :kubernetes-integration-help-path="kubernetesIntegrationHelpPath" + /> + </div> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue index 6e74963dcb0..1188cf08850 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue @@ -1,25 +1,394 @@ <script> -import RoleNameDropdown from './role_name_dropdown.vue'; -import SecurityGroupDropdown from './security_group_dropdown.vue'; -import SubnetDropdown from './subnet_dropdown.vue'; -import VPCDropdown from './vpc_dropdown.vue'; +import { createNamespacedHelpers, mapState, mapActions } from 'vuex'; +import { sprintf, s__ } from '~/locale'; +import _ from 'underscore'; +import { GlFormInput, GlFormCheckbox } from '@gitlab/ui'; +import ClusterFormDropdown from './cluster_form_dropdown.vue'; +import RegionDropdown from './region_dropdown.vue'; +import { KUBERNETES_VERSIONS } from '../constants'; + +const { mapState: mapRolesState, mapActions: mapRolesActions } = createNamespacedHelpers('roles'); +const { mapState: mapRegionsState, mapActions: mapRegionsActions } = createNamespacedHelpers( + 'regions', +); +const { mapState: mapKeyPairsState, mapActions: mapKeyPairsActions } = createNamespacedHelpers( + 'keyPairs', +); +const { mapState: mapVpcsState, mapActions: mapVpcActions } = createNamespacedHelpers('vpcs'); +const { mapState: mapSubnetsState, mapActions: mapSubnetActions } = createNamespacedHelpers( + 'subnets', +); +const { + mapState: mapSecurityGroupsState, + mapActions: mapSecurityGroupsActions, +} = createNamespacedHelpers('securityGroups'); export default { components: { - RoleNameDropdown, - SecurityGroupDropdown, - SubnetDropdown, - VPCDropdown, + ClusterFormDropdown, + RegionDropdown, + GlFormInput, + GlFormCheckbox, + }, + props: { + gitlabManagedClusterHelpPath: { + type: String, + required: true, + }, + kubernetesIntegrationHelpPath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState([ + 'clusterName', + 'environmentScope', + 'kubernetesVersion', + 'selectedRegion', + 'selectedKeyPair', + 'selectedVpc', + 'selectedSubnet', + 'selectedRole', + 'selectedSecurityGroup', + 'gitlabManagedCluster', + ]), + ...mapRolesState({ + roles: 'items', + isLoadingRoles: 'isLoadingItems', + loadingRolesError: 'loadingItemsError', + }), + ...mapRegionsState({ + regions: 'items', + isLoadingRegions: 'isLoadingItems', + loadingRegionsError: 'loadingItemsError', + }), + ...mapKeyPairsState({ + keyPairs: 'items', + isLoadingKeyPairs: 'isLoadingItems', + loadingKeyPairsError: 'loadingItemsError', + }), + ...mapVpcsState({ + vpcs: 'items', + isLoadingVpcs: 'isLoadingItems', + loadingVpcsError: 'loadingItemsError', + }), + ...mapSubnetsState({ + subnets: 'items', + isLoadingSubnets: 'isLoadingItems', + loadingSubnetsError: 'loadingItemsError', + }), + ...mapSecurityGroupsState({ + securityGroups: 'items', + isLoadingSecurityGroups: 'isLoadingItems', + loadingSecurityGroupsError: 'loadingItemsError', + }), + kubernetesVersions() { + return KUBERNETES_VERSIONS; + }, + vpcDropdownDisabled() { + return !this.selectedRegion; + }, + keyPairDropdownDisabled() { + return !this.selectedRegion; + }, + subnetDropdownDisabled() { + return !this.selectedVpc; + }, + securityGroupDropdownDisabled() { + return !this.selectedVpc; + }, + kubernetesIntegrationHelpText() { + const escapedUrl = _.escape(this.kubernetesIntegrationHelpPath); + + return sprintf( + s__( + 'ClusterIntegration|Read our %{link_start}help page%{link_end} on Kubernetes cluster integration.', + ), + { + link_start: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`, + link_end: '</a>', + }, + false, + ); + }, + roleDropdownHelpText() { + return sprintf( + s__( + 'ClusterIntegration|Select the IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role name, first create one on %{startLink}Amazon Web Services%{endLink}.', + ), + { + startLink: + '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">', + endLink: '</a>', + }, + false, + ); + }, + keyPairDropdownHelpText() { + return sprintf( + s__( + 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{startLink}Amazon Web Services%{endLink}.', + ), + { + startLink: + '<a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair" target="_blank" rel="noopener noreferrer">', + endLink: '</a>', + }, + false, + ); + }, + vpcDropdownHelpText() { + return sprintf( + s__( + 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services%{endLink}.', + ), + { + startLink: + '<a href="https://console.aws.amazon.com/vpc/home?#vpc" target="_blank" rel="noopener noreferrer">', + endLink: '</a>', + }, + false, + ); + }, + subnetDropdownHelpText() { + return sprintf( + s__( + 'ClusterIntegration|Choose the %{startLink}subnets%{endLink} in your VPC where your worker nodes will run.', + ), + { + startLink: + '<a href="https://console.aws.amazon.com/vpc/home?#subnets" target="_blank" rel="noopener noreferrer">', + endLink: '</a>', + }, + false, + ); + }, + securityGroupDropdownHelpText() { + return sprintf( + s__( + 'ClusterIntegration|Choose the %{startLink}security groups%{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.', + ), + { + startLink: + '<a href="https://console.aws.amazon.com/vpc/home?#securityGroups" target="_blank" rel="noopener noreferrer">', + endLink: '</a>', + }, + false, + ); + }, + gitlabManagedHelpText() { + const escapedUrl = _.escape(this.gitlabManagedClusterHelpPath); + + return sprintf( + s__( + 'ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster. %{startLink}More information%{endLink}', + ), + { + startLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`, + endLink: '</a>', + }, + false, + ); + }, + }, + mounted() { + this.fetchRegions(); + this.fetchRoles(); + }, + methods: { + ...mapActions([ + 'setClusterName', + 'setEnvironmentScope', + 'setKubernetesVersion', + 'setRegion', + 'setVpc', + 'setSubnet', + 'setRole', + 'setKeyPair', + 'setSecurityGroup', + 'setGitlabManagedCluster', + ]), + ...mapRegionsActions({ fetchRegions: 'fetchItems' }), + ...mapVpcActions({ fetchVpcs: 'fetchItems' }), + ...mapSubnetActions({ fetchSubnets: 'fetchItems' }), + ...mapRolesActions({ fetchRoles: 'fetchItems' }), + ...mapKeyPairsActions({ fetchKeyPairs: 'fetchItems' }), + ...mapSecurityGroupsActions({ fetchSecurityGroups: 'fetchItems' }), + setRegionAndFetchVpcsAndKeyPairs(region) { + this.setRegion({ region }); + this.fetchVpcs({ region }); + this.fetchKeyPairs({ region }); + }, + setVpcAndFetchSubnets(vpc) { + this.setVpc({ vpc }); + this.fetchSubnets({ vpc }); + this.fetchSecurityGroups({ vpc }); + }, }, }; </script> <template> <form name="eks-cluster-configuration-form"> + <h2> + {{ s__('ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster') }} + </h2> + <p v-html="kubernetesIntegrationHelpText"></p> + <div class="form-group"> + <label class="label-bold" for="eks-cluster-name">{{ + s__('ClusterIntegration|Kubernetes cluster name') + }}</label> + <gl-form-input + id="eks-cluster-name" + :value="clusterName" + @input="setClusterName({ clusterName: $event })" + /> + </div> + <div class="form-group"> + <label class="label-bold" for="eks-environment-scope">{{ + s__('ClusterIntegration|Environment scope') + }}</label> + <gl-form-input + id="eks-environment-scope" + :value="environmentScope" + @input="setEnvironmentScope({ environmentScope: $event })" + /> + </div> + <div class="form-group"> + <label class="label-bold" for="eks-kubernetes-version">{{ + s__('ClusterIntegration|Kubernetes version') + }}</label> + <cluster-form-dropdown + field-id="eks-kubernetes-version" + field-name="eks-kubernetes-version" + :value="kubernetesVersion" + :items="kubernetesVersions" + :empty-text="s__('ClusterIntegration|Kubernetes version not found')" + @input="setKubernetesVersion({ kubernetesVersion: $event })" + /> + <p class="form-text text-muted" v-html="roleDropdownHelpText"></p> + </div> + <div class="form-group"> + <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Role name') }}</label> + <cluster-form-dropdown + field-id="eks-role" + field-name="eks-role" + :input="selectedRole" + :items="roles" + :loading="isLoadingRoles" + :loading-text="s__('ClusterIntegration|Loading IAM Roles')" + :placeholder="s__('ClusterIntergation|Select role name')" + :search-field-placeholder="s__('ClusterIntegration|Search IAM Roles')" + :empty-text="s__('ClusterIntegration|No IAM Roles found')" + :has-errors="Boolean(loadingRolesError)" + :error-message="s__('ClusterIntegration|Could not load IAM roles')" + @input="setRole({ role: $event })" + /> + <p class="form-text text-muted" v-html="roleDropdownHelpText"></p> + </div> + <div class="form-group"> + <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Region') }}</label> + <region-dropdown + :value="selectedRegion" + :regions="regions" + :error="loadingRegionsError" + :loading="isLoadingRegions" + @input="setRegionAndFetchVpcsAndKeyPairs($event)" + /> + </div> + <div class="form-group"> + <label class="label-bold" for="eks-key-pair">{{ + s__('ClusterIntegration|Key pair name') + }}</label> + <cluster-form-dropdown + field-id="eks-key-pair" + field-name="eks-key-pair" + :input="selectedKeyPair" + :items="keyPairs" + :disabled="keyPairDropdownDisabled" + :disabled-text="s__('ClusterIntegration|Select a region to choose a Key Pair')" + :loading="isLoadingKeyPairs" + :loading-text="s__('ClusterIntegration|Loading Key Pairs')" + :placeholder="s__('ClusterIntergation|Select key pair')" + :search-field-placeholder="s__('ClusterIntegration|Search Key Pairs')" + :empty-text="s__('ClusterIntegration|No Key Pairs found')" + :has-errors="Boolean(loadingKeyPairsError)" + :error-message="s__('ClusterIntegration|Could not load Key Pairs')" + @input="setKeyPair({ keyPair: $event })" + /> + <p class="form-text text-muted" v-html="keyPairDropdownHelpText"></p> + </div> + <div class="form-group"> + <label class="label-bold" for="eks-vpc">{{ s__('ClusterIntegration|VPC') }}</label> + <cluster-form-dropdown + field-id="eks-vpc" + field-name="eks-vpc" + :input="selectedVpc" + :items="vpcs" + :loading="isLoadingVpcs" + :disabled="vpcDropdownDisabled" + :disabled-text="s__('ClusterIntegration|Select a region to choose a VPC')" + :loading-text="s__('ClusterIntegration|Loading VPCs')" + :placeholder="s__('ClusterIntergation|Select a VPC')" + :search-field-placeholder="s__('ClusterIntegration|Search VPCs')" + :empty-text="s__('ClusterIntegration|No VPCs found')" + :has-errors="Boolean(loadingVpcsError)" + :error-message="s__('ClusterIntegration|Could not load VPCs for the selected region')" + @input="setVpcAndFetchSubnets($event)" + /> + <p class="form-text text-muted" v-html="vpcDropdownHelpText"></p> + </div> + <div class="form-group"> + <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnet') }}</label> + <cluster-form-dropdown + field-id="eks-subnet" + field-name="eks-subnet" + :input="selectedSubnet" + :items="subnets" + :loading="isLoadingSubnets" + :disabled="subnetDropdownDisabled" + :disabled-text="s__('ClusterIntegration|Select a VPC to choose a subnet')" + :loading-text="s__('ClusterIntegration|Loading subnets')" + :placeholder="s__('ClusterIntergation|Select a subnet')" + :search-field-placeholder="s__('ClusterIntegration|Search subnets')" + :empty-text="s__('ClusterIntegration|No subnet found')" + :has-errors="Boolean(loadingSubnetsError)" + :error-message="s__('ClusterIntegration|Could not load subnets for the selected VPC')" + @input="setSubnet({ subnet: $event })" + /> + <p class="form-text text-muted" v-html="subnetDropdownHelpText"></p> + </div> + <div class="form-group"> + <label class="label-bold" for="eks-security-group">{{ + s__('ClusterIntegration|Security groups') + }}</label> + <cluster-form-dropdown + field-id="eks-security-group" + field-name="eks-security-group" + :input="selectedSecurityGroup" + :items="securityGroups" + :loading="isLoadingSecurityGroups" + :disabled="securityGroupDropdownDisabled" + :disabled-text="s__('ClusterIntegration|Select a VPC to choose a security group')" + :loading-text="s__('ClusterIntegration|Loading security groups')" + :placeholder="s__('ClusterIntergation|Select a security group')" + :search-field-placeholder="s__('ClusterIntegration|Search security groups')" + :empty-text="s__('ClusterIntegration|No security group found')" + :has-errors="Boolean(loadingSecurityGroupsError)" + :error-message=" + s__('ClusterIntegration|Could not load security groups for the selected VPC') + " + @input="setSecurityGroup({ securityGroup: $event })" + /> + <p class="form-text text-muted" v-html="securityGroupDropdownHelpText"></p> + </div> <div class="form-group"> - <label class="label-bold" name="role" for="eks-role"> - {{ s__('ClusterIntegration|Role name') }} - </label> - <role-name-dropdown /> + <gl-form-checkbox + :checked="gitlabManagedCluster" + @input="setGitlabManagedCluster({ gitlabManagedCluster: $event })" + >{{ s__('ClusterIntegration|GitLab-managed cluster') }}</gl-form-checkbox + > + <p class="form-text text-muted" v-html="gitlabManagedHelpText"></p> </div> </form> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue new file mode 100644 index 00000000000..765955305c8 --- /dev/null +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue @@ -0,0 +1,63 @@ +<script> +import { sprintf, s__ } from '~/locale'; + +import ClusterFormDropdown from './cluster_form_dropdown.vue'; + +export default { + components: { + ClusterFormDropdown, + }, + props: { + regions: { + type: Array, + required: false, + default: () => [], + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + error: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + hasErrors() { + return Boolean(this.error); + }, + helpText() { + return sprintf( + s__('ClusterIntegration|Learn more about %{startLink}Regions%{endLink}.'), + { + startLink: + '<a href="https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/" target="_blank" rel="noopener noreferrer">', + endLink: '</a>', + }, + false, + ); + }, + }, +}; +</script> +<template> + <div> + <cluster-form-dropdown + field-id="eks-region" + field-name="eks-region" + :items="regions" + :loading="loading" + :loading-text="s__('ClusterIntegration|Loading Regions')" + :placeholder="s__('ClusterIntergation|Select a region')" + :search-field-placeholder="s__('ClusterIntegration|Search regions')" + :empty-text="s__('ClusterIntegration|No region found')" + :has-errors="hasErrors" + :error-message="s__('ClusterIntegration|Could not load regions from your AWS account')" + v-bind="$attrs" + v-on="$listeners" + /> + <p class="form-text text-muted" v-html="helpText"></p> + </div> +</template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/role_name_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/role_name_dropdown.vue deleted file mode 100644 index 70230b294ac..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/role_name_dropdown.vue +++ /dev/null @@ -1,53 +0,0 @@ -<script> -import { sprintf, s__ } from '~/locale'; - -import ClusterFormDropdown from './cluster_form_dropdown.vue'; - -export default { - components: { - ClusterFormDropdown, - }, - props: { - roles: { - type: Array, - required: false, - default: () => [], - }, - loading: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - helpText() { - return sprintf( - s__( - 'ClusterIntegration|Select the IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role name, first create one on %{startLink}Amazon Web Services%{endLink}.', - ), - { - startLink: - '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">', - endLink: '</a>', - }, - false, - ); - }, - }, -}; -</script> -<template> - <div> - <cluster-form-dropdown - field-id="eks-role-name" - field-name="eks-role-name" - :items="roles" - :loading="loading" - :loading-text="s__('ClusterIntegration|Loading IAM Roles')" - :placeholder="s__('ClusterIntergation|Select role name')" - :search-field-placeholder="s__('ClusterIntegration|Search IAM Roles')" - :empty-text="s__('ClusterIntegration|No IAM Roles found')" - /> - <p class="form-text text-muted" v-html="helpText"></p> - </div> -</template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/subnet_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/subnet_dropdown.vue deleted file mode 100644 index e69de29bb2d..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/subnet_dropdown.vue +++ /dev/null diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/vpc_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/vpc_dropdown.vue deleted file mode 100644 index e69de29bb2d..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/vpc_dropdown.vue +++ /dev/null diff --git a/app/assets/javascripts/create_cluster/eks_cluster/constants.js b/app/assets/javascripts/create_cluster/eks_cluster/constants.js new file mode 100644 index 00000000000..339642f991e --- /dev/null +++ b/app/assets/javascripts/create_cluster/eks_cluster/constants.js @@ -0,0 +1,7 @@ +// eslint-disable-next-line import/prefer-default-export +export const KUBERNETES_VERSIONS = [ + { name: '1.14', value: '1.14' }, + { name: '1.13', value: '1.13' }, + { name: '1.12', value: '1.12' }, + { name: '1.11', value: '1.11' }, +]; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/index.js b/app/assets/javascripts/create_cluster/eks_cluster/index.js index c62e5ec101d..1f595e9b2df 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/index.js @@ -5,15 +5,22 @@ import createStore from './store'; Vue.use(Vuex); -export default () => - new Vue({ - el: '.js-create-eks-cluster-form-container', +export default el => { + const { gitlabManagedClusterHelpPath, kubernetesIntegrationHelpPath } = el.dataset; + + return new Vue({ + el, store: createStore(), components: { CreateEksCluster, }, - data() {}, render(createElement) { - return createElement('create-eks-cluster'); + return createElement('create-eks-cluster', { + props: { + gitlabManagedClusterHelpPath, + kubernetesIntegrationHelpPath, + }, + }); }, }); +}; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js index e69de29bb2d..d982e4db4c1 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js @@ -0,0 +1,84 @@ +import EC2 from 'aws-sdk/clients/ec2'; +import IAM from 'aws-sdk/clients/iam'; + +export const fetchRoles = () => { + const iam = new IAM(); + + return iam + .listRoles() + .promise() + .then(({ Roles: roles }) => roles.map(({ RoleName: name }) => ({ name }))); +}; + +export const fetchKeyPairs = () => { + const ec2 = new EC2(); + + return ec2 + .describeKeyPairs() + .promise() + .then(({ KeyPairs: keyPairs }) => keyPairs.map(({ RegionName: name }) => ({ name }))); +}; + +export const fetchRegions = () => { + const ec2 = new EC2(); + + return ec2 + .describeRegions() + .promise() + .then(({ Regions: regions }) => + regions.map(({ RegionName: name }) => ({ + name, + value: name, + })), + ); +}; + +export const fetchVpcs = () => { + const ec2 = new EC2(); + + return ec2 + .describeVpcs() + .promise() + .then(({ Vpcs: vpcs }) => + vpcs.map(({ VpcId: id }) => ({ + value: id, + name: id, + })), + ); +}; + +export const fetchSubnets = ({ vpc }) => { + const ec2 = new EC2(); + + return ec2 + .describeSubnets({ + Filters: [ + { + Name: 'vpc-id', + Values: [vpc], + }, + ], + }) + .promise() + .then(({ Subnets: subnets }) => subnets.map(({ SubnetId: id }) => ({ id, name: id }))); +}; + +export const fetchSecurityGroups = ({ vpc }) => { + const ec2 = new EC2(); + + return ec2 + .describeSecurityGroups({ + Filters: [ + { + Name: 'vpc-id', + Values: [vpc], + }, + ], + }) + .promise() + .then(({ SecurityGroups: securityGroups }) => + securityGroups.map(({ GroupName: name, GroupId: value }) => ({ name, value })), + ); +}; + +export default () => {}; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js index 861bcddfcc7..917c8da6c3e 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js @@ -1,3 +1,43 @@ -// import awsServices from '../services/aws_services_facade'; +import * as types from './mutation_types'; + +export const setClusterName = ({ commit }, payload) => { + commit(types.SET_CLUSTER_NAME, payload); +}; + +export const setEnvironmentScope = ({ commit }, payload) => { + commit(types.SET_ENVIRONMENT_SCOPE, payload); +}; + +export const setKubernetesVersion = ({ commit }, payload) => { + commit(types.SET_KUBERNETES_VERSION, payload); +}; + +export const setRegion = ({ commit }, payload) => { + commit(types.SET_REGION, payload); +}; + +export const setKeyPair = ({ commit }, payload) => { + commit(types.SET_KEY_PAIR, payload); +}; + +export const setVpc = ({ commit }, payload) => { + commit(types.SET_VPC, payload); +}; + +export const setSubnet = ({ commit }, payload) => { + commit(types.SET_SUBNET, payload); +}; + +export const setRole = ({ commit }, payload) => { + commit(types.SET_ROLE, payload); +}; + +export const setSecurityGroup = ({ commit }, payload) => { + commit(types.SET_SECURITY_GROUP, payload); +}; + +export const setGitlabManagedCluster = ({ commit }, payload) => { + commit(types.SET_GITLAB_MANAGED_CLUSTER, payload); +}; export default () => {}; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js new file mode 100644 index 00000000000..5d250b2e29e --- /dev/null +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js @@ -0,0 +1,14 @@ +import * as types from './mutation_types'; + +export default fetchItems => ({ + requestItems: ({ commit }) => commit(types.REQUEST_ITEMS), + receiveItemsSuccess: ({ commit }, payload) => commit(types.RECEIVE_ITEMS_SUCCESS, payload), + receiveItemsError: ({ commit }, payload) => commit(types.RECEIVE_ITEMS_ERROR, payload), + fetchItems: ({ dispatch }, payload) => { + dispatch('requestItems'); + + return fetchItems(payload) + .then(items => dispatch('receiveItemsSuccess', { items })) + .catch(error => dispatch('receiveItemsError', { error })); + }, +}); diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/security_group_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/getters.js index e69de29bb2d..e69de29bb2d 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/security_group_dropdown.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/getters.js diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js new file mode 100644 index 00000000000..07a5821c47d --- /dev/null +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js @@ -0,0 +1,13 @@ +import * as getters from './getters'; +import actions from './actions'; +import mutations from './mutations'; +import state from './state'; + +const createStore = fetchFn => ({ + actions: actions(fetchFn), + getters, + mutations, + state: state(), +}); + +export default createStore; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js new file mode 100644 index 00000000000..48959a73924 --- /dev/null +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js @@ -0,0 +1,3 @@ +export const REQUEST_ITEMS = 'REQUEST_ITEMS'; +export const RECEIVE_ITEMS_SUCCESS = 'REQUEST_ITEMS_SUCCESS'; +export const RECEIVE_ITEMS_ERROR = 'RECEIVE_ITEMS_ERROR'; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js new file mode 100644 index 00000000000..d09689f1f6c --- /dev/null +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js @@ -0,0 +1,16 @@ +import * as types from './mutation_types'; + +export default { + [types.REQUEST_ITEMS](state) { + state.isLoadingItems = true; + state.loadingItemsError = null; + }, + [types.RECEIVE_ITEMS_SUCCESS](state, { items }) { + state.isLoadingItems = false; + state.items = items; + }, + [types.RECEIVE_ITEMS_ERROR](state, { error }) { + state.isLoadingItems = false; + state.loadingItemsError = error; + }, +}; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js new file mode 100644 index 00000000000..b949a24216e --- /dev/null +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js @@ -0,0 +1,5 @@ +export default () => ({ + isLoadingItems: false, + items: [], + loadingItemsError: null, +}); diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js index 99e9e35fd1a..d575deafd19 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js @@ -4,12 +4,42 @@ import * as getters from './getters'; import mutations from './mutations'; import state from './state'; +import clusterDropdownStore from './cluster_dropdown'; + +import * as awsServices from '../services/aws_services_facade'; + const createStore = () => new Vuex.Store({ actions, getters, mutations, - state, + state: state(), + modules: { + roles: { + namespaced: true, + ...clusterDropdownStore(awsServices.fetchRoles), + }, + regions: { + namespaced: true, + ...clusterDropdownStore(awsServices.fetchRegions), + }, + keyPairs: { + namespaced: true, + ...clusterDropdownStore(awsServices.fetchKeyPairs), + }, + vpcs: { + namespaced: true, + ...clusterDropdownStore(awsServices.fetchVpcs), + }, + subnets: { + namespaced: true, + ...clusterDropdownStore(awsServices.fetchSubnets), + }, + securityGroups: { + namespaced: true, + ...clusterDropdownStore(awsServices.fetchSecurityGroups), + }, + }, }); export default createStore; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js index e69de29bb2d..82eb512ac07 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js @@ -0,0 +1,10 @@ +export const SET_CLUSTER_NAME = 'SET_CLUSTER_NAME'; +export const SET_ENVIRONMENT_SCOPE = 'SET_ENVIRONMENT_SCOPE'; +export const SET_KUBERNETES_VERSION = 'SET_KUBERNETES_VERSION'; +export const SET_REGION = 'SET_REGION'; +export const SET_VPC = 'SET_VPC'; +export const SET_KEY_PAIR = 'SET_KEY_PAIR'; +export const SET_SUBNET = 'SET_SUBNET'; +export const SET_ROLE = 'SET_ROLE'; +export const SET_SECURITY_GROUP = 'SET_SECURITY_GROUP'; +export const SET_GITLAB_MANAGED_CLUSTER = 'SET_GITLAB_MANAGED_CLUSTER'; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js index e69de29bb2d..79950ac7dce 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js @@ -0,0 +1,34 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_CLUSTER_NAME](state, { clusterName }) { + state.clusterName = clusterName; + }, + [types.SET_ENVIRONMENT_SCOPE](state, { environmentScope }) { + state.environmentScope = environmentScope; + }, + [types.SET_KUBERNETES_VERSION](state, { kubernetesVersion }) { + state.kubernetesVersion = kubernetesVersion; + }, + [types.SET_REGION](state, { region }) { + state.selectedRegion = region; + }, + [types.SET_KEY_PAIR](state, { keyPair }) { + state.selectedKeyPair = keyPair; + }, + [types.SET_VPC](state, { vpc }) { + state.selectedVpc = vpc; + }, + [types.SET_SUBNET](state, { subnet }) { + state.selectedSubnet = subnet; + }, + [types.SET_ROLE](state, { role }) { + state.selectedRole = role; + }, + [types.SET_SECURITY_GROUP](state, { securityGroup }) { + state.selectedSecurityGroup = securityGroup; + }, + [types.SET_GITLAB_MANAGED_CLUSTER](state, { gitlabManagedCluster }) { + state.gitlabManagedCluster = gitlabManagedCluster; + }, +}; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js index 9754ccfeeaf..bf74213bdce 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js @@ -1,19 +1,18 @@ +import { KUBERNETES_VERSIONS } from '../constants'; + export default () => ({ isValidatingCredentials: false, validCredentials: false, - isLoadingRoles: false, - isLoadingVPCs: false, - isLoadingSubnets: false, - isLoadingSecurityGroups: false, - - roles: [], - vpcs: [], - subnets: [], - securityGroups: [], - + clusterName: '', + environmentScope: '*', + kubernetesVersion: [KUBERNETES_VERSIONS].value, + selectedRegion: '', selectedRole: '', - selectedVPC: '', + selectedKeyPair: '', + selectedVpc: '', selectedSubnet: '', selectedSecurityGroup: '', + + gitlabManagedCluster: true, }); diff --git a/app/assets/javascripts/create_item_dropdown.js b/app/assets/javascripts/create_item_dropdown.js index fa0f04c7d82..95b890b04c1 100644 --- a/app/assets/javascripts/create_item_dropdown.js +++ b/app/assets/javascripts/create_item_dropdown.js @@ -1,4 +1,5 @@ import _ from 'underscore'; +import '~/gl_dropdown'; export default class CreateItemDropdown { /** diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js index eac0e37bcaa..9c0ed7f79d4 100644 --- a/app/assets/javascripts/create_label.js +++ b/app/assets/javascripts/create_label.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, prefer-arrow-callback */ +/* eslint-disable func-names */ import $ from 'jquery'; import Api from './api'; @@ -50,7 +50,7 @@ export default class CreateLabelDropdown { this.$dropdownBack.on('click', this.resetForm.bind(this)); - this.$cancelButton.on('click', function(e) { + this.$cancelButton.on('click', e => { e.preventDefault(); e.stopPropagation(); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue index 63549596fac..fc6d83bf96c 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue @@ -34,7 +34,7 @@ export default { class="more-actions-toggle btn btn-transparent p-0" data-toggle="dropdown" > - <icon css-classes="icon" name="ellipsis_v" /> + <icon class="icon" name="ellipsis_v" /> </gl-button> <ul class="more-actions-dropdown dropdown-menu dropdown-open-left"> <slot name="dropdown-options"></slot> diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index 7744984edfc..cd67ba5fab8 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -3,7 +3,6 @@ import Vue from 'vue'; import Cookies from 'js-cookie'; import { GlEmptyState } from '@gitlab/ui'; import filterMixins from 'ee_else_ce/analytics/cycle_analytics/mixins/filter_mixins'; -import addStageMixin from 'ee_else_ce/analytics/cycle_analytics/mixins/add_stage_mixin'; import Flash from '../flash'; import { __ } from '~/locale'; import Translate from '../vue_shared/translate'; @@ -44,12 +43,8 @@ export default () => { DateRangeDropdown: () => import('ee_component/analytics/shared/components/date_range_dropdown.vue'), 'stage-nav-item': stageNavItem, - CustomStageForm: () => - import('ee_component/analytics/cycle_analytics/components/custom_stage_form.vue'), - AddStageButton: () => - import('ee_component/analytics/cycle_analytics/components/add_stage_button.vue'), }, - mixins: [filterMixins, addStageMixin], + mixins: [filterMixins], data() { return { store: CycleAnalyticsStore, @@ -129,7 +124,6 @@ export default () => { return; } - this.hideAddStageForm(); this.isLoadingStage = true; this.store.setStageEvents([], stage); this.store.setActiveStage(stage); diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js index 0687028ca54..27990b0a45e 100644 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ b/app/assets/javascripts/diff_notes/services/resolve.js @@ -2,7 +2,6 @@ import Vue from 'vue'; import Flash from '../../flash'; -import '../../vue_shared/vue_resource_interceptor'; import { __ } from '~/locale'; window.gl = window.gl || {}; diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 761fd1583ed..43a7703f611 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -121,7 +121,7 @@ export default { <div class="label label-monospace monospace" v-text="commit.short_id"></div> <clipboard-button :text="commit.id" - :title="__('Copy commit SHA to clipboard')" + :title="__('Copy commit SHA')" class="btn btn-default" /> </div> diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index bfcc726a030..665328eb234 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -209,7 +209,7 @@ export default { </a> <clipboard-button - :title="__('Copy file path to clipboard')" + :title="__('Copy file path')" :text="diffFile.file_path" :gfm="gfmCopyText" css-class="btn-default btn-transparent btn-clipboard" diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index aee01409db7..1eb17588376 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -45,12 +45,11 @@ export default { :data-commit-id="commitId" class="code diff-wrap-lines js-syntax-highlight text-file js-diff-inline-view" > - <!-- Need to insert an empty row to solve "table-layout:fixed" equal width when expansion row is the first line --> - <tr> - <td style="width: 50px;"></td> - <td style="width: 50px;"></td> - <td></td> - </tr> + <colgroup> + <col style="width: 50px;" /> + <col style="width: 50px;" /> + <col /> + </colgroup> <tbody> <template v-for="(line, index) in diffLines"> <inline-diff-expansion-row diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index d400eb2c586..88baac092a1 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -45,13 +45,12 @@ export default { :data-commit-id="commitId" class="code diff-wrap-lines js-syntax-highlight text-file" > - <!-- Need to insert an empty row to solve "table-layout:fixed" equal width when expansion row is the first line --> - <tr> - <td style="width: 50px;"></td> - <td></td> - <td style="width: 50px;"></td> - <td></td> - </tr> + <colgroup> + <col style="width: 50px;" /> + <col /> + <col style="width: 50px;" /> + <col /> + </colgroup> <tbody> <template v-for="(line, index) in diffLines"> <parallel-diff-expansion-row diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue index 2cc3412e075..1ea4e30a7c1 100644 --- a/app/assets/javascripts/environments/components/stop_environment_modal.vue +++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue @@ -1,7 +1,7 @@ <script> /* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { GlTooltipDirective } from '@gitlab/ui'; -import GlModal from '~/vue_shared/components/gl_modal.vue'; +import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { s__, sprintf } from '~/locale'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; import eventHub from '../event_hub'; @@ -11,7 +11,7 @@ export default { name: 'StopEnvironmentModal', components: { - GlModal, + GlModal: DeprecatedModal2, LoadingButton, }, diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index b1d568532a6..cd298e2c692 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -4,13 +4,15 @@ import { GlEmptyState, GlButton, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ import Icon from '~/vue_shared/components/icon.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { __ } from '~/locale'; +import TrackEventDirective from '~/vue_shared/directives/track_event'; +import { trackViewInSentryOptions, trackClickErrorLinkToSentryOptions } from '../utils'; export default { fields: [ - { key: 'error', label: __('Open errors') }, + { key: 'error', label: __('Open errors'), thClass: 'w-70p' }, { key: 'events', label: __('Events') }, { key: 'users', label: __('Users') }, - { key: 'lastSeen', label: __('Last seen') }, + { key: 'lastSeen', label: __('Last seen'), thClass: 'w-15p' }, ], components: { GlEmptyState, @@ -21,6 +23,9 @@ export default { Icon, TimeAgo, }, + directives: { + TrackEvent: TrackEventDirective, + }, props: { indexPath: { type: String, @@ -53,6 +58,8 @@ export default { }, methods: { ...mapActions(['startPolling', 'restartPolling']), + trackViewInSentryOptions, + trackClickErrorLinkToSentryOptions, }, }; </script> @@ -65,42 +72,52 @@ export default { </div> <div v-else> <div class="d-flex justify-content-end"> - <gl-button class="my-3 ml-auto" variant="primary" :href="externalUrl" target="_blank"> + <gl-button + v-track-event="trackViewInSentryOptions(externalUrl)" + class="my-3 ml-auto" + variant="primary" + :href="externalUrl" + target="_blank" + > {{ __('View in Sentry') }} - <icon name="external-link" /> + <icon name="external-link" class="flex-shrink-0" /> </gl-button> </div> - <gl-table :items="errors" :fields="$options.fields" :show-empty="true"> + + <gl-table :items="errors" :fields="$options.fields" :show-empty="true" fixed stacked="sm"> <template slot="HEAD_events" slot-scope="data"> - <div class="text-right">{{ data.label }}</div> + <div class="text-md-right">{{ data.label }}</div> </template> <template slot="HEAD_users" slot-scope="data"> - <div class="text-right">{{ data.label }}</div> + <div class="text-md-right">{{ data.label }}</div> </template> <template slot="error" slot-scope="errors"> <div class="d-flex flex-column"> - <div class="d-flex"> - <gl-link :href="errors.item.externalUrl" class="d-flex text-dark" target="_blank"> - <strong>{{ errors.item.title.trim() }}</strong> - <icon name="external-link" class="ml-1" /> - </gl-link> - <span class="text-secondary ml-2">{{ errors.item.culprit }}</span> - </div> - {{ errors.item.message || __('No details available') }} + <gl-link + v-track-event="trackClickErrorLinkToSentryOptions(errors.item.externalUrl)" + :href="errors.item.externalUrl" + class="d-flex text-dark" + target="_blank" + > + <strong class="text-truncate">{{ errors.item.title.trim() }}</strong> + <icon name="external-link" class="ml-1 flex-shrink-0" /> + </gl-link> + <span class="text-secondary text-truncate"> + {{ errors.item.culprit }} + </span> </div> </template> <template slot="events" slot-scope="errors"> - <div class="text-right">{{ errors.item.count }}</div> + <div class="text-md-right">{{ errors.item.count }}</div> </template> <template slot="users" slot-scope="errors"> - <div class="text-right">{{ errors.item.userCount }}</div> + <div class="text-md-right">{{ errors.item.userCount }}</div> </template> <template slot="lastSeen" slot-scope="errors"> <div class="d-flex align-items-center"> - <icon name="calendar" css-classes="text-secondary mr-1" /> <time-ago :time="errors.item.lastSeen" class="text-secondary" /> </div> </template> diff --git a/app/assets/javascripts/error_tracking/utils.js b/app/assets/javascripts/error_tracking/utils.js new file mode 100644 index 00000000000..b832b1371b1 --- /dev/null +++ b/app/assets/javascripts/error_tracking/utils.js @@ -0,0 +1,23 @@ +/* eslint-disable @gitlab/i18n/no-non-i18n-strings */ + +/** + * Tracks snowplow event when user clicks View in Sentry btn + * @param {String} externalUrl that will be send as a property for the event + */ +export const trackViewInSentryOptions = url => ({ + category: 'Error Tracking', + action: 'click_view_in_sentry', + label: 'External Url', + property: url, +}); + +/** + * Tracks snowplow event when User clicks on error link to Sentry + * @param {String} externalUrl that will be send as a property for the event + */ +export const trackClickErrorLinkToSentryOptions = url => ({ + category: 'Error Tracking', + action: 'click_error_link_to_sentry', + label: 'Error Link', + property: url, +}); diff --git a/app/assets/javascripts/event_tracking/issue_sidebar.js b/app/assets/javascripts/event_tracking/issue_sidebar.js deleted file mode 100644 index 6909f82c66f..00000000000 --- a/app/assets/javascripts/event_tracking/issue_sidebar.js +++ /dev/null @@ -1,2 +0,0 @@ -export const initSidebarTracking = () => {}; -export const trackEvent = () => {}; diff --git a/app/assets/javascripts/event_tracking/notes.js b/app/assets/javascripts/event_tracking/notes.js deleted file mode 100644 index 1f70290c397..00000000000 --- a/app/assets/javascripts/event_tracking/notes.js +++ /dev/null @@ -1,2 +0,0 @@ -// Noop function which has a EE counter-part -export default () => {}; diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js index 77080691dcb..c21fba06d42 100644 --- a/app/assets/javascripts/filterable_list.js +++ b/app/assets/javascripts/filterable_list.js @@ -22,6 +22,7 @@ export default class FilterableList { getPagePath() { const action = this.filterForm.getAttribute('action'); + // eslint-disable-next-line no-jquery/no-serialize const params = $(this.filterForm).serialize(); return `${action}${action.indexOf('?') > 0 ? '&' : '?'}${params}`; } diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 660f0f0ba3e..fc9c5827ed4 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -40,13 +40,17 @@ const createFlashEl = (message, type) => ` <div class="flash-content flash-${type} rounded"> <div class="flash-text"> ${_.escape(message)} - ${spriteIcon('close', 'close-icon')} + <div class="close-icon-wrapper js-close-icon"> + ${spriteIcon('close', 'close-icon')} + </div> </div> </div> `; const removeFlashClickListener = (flashEl, fadeTransition) => { - flashEl.addEventListener('click', () => hideFlash(flashEl, fadeTransition)); + flashEl + .querySelector('.js-close-icon') + .addEventListener('click', () => hideFlash(flashEl, fadeTransition)); }; /* @@ -78,7 +82,6 @@ const createFlash = function createFlash( flashContainer.innerHTML = createFlashEl(message, type); const flashEl = flashContainer.querySelector(`.flash-${type}`); - removeFlashClickListener(flashEl, fadeTransition); if (actionConfig) { flashEl.innerHTML += createAction(actionConfig); @@ -90,6 +93,8 @@ const createFlash = function createFlash( } } + removeFlashClickListener(flashEl, fadeTransition); + flashContainer.style.display = 'block'; if (addBodyClass) document.body.classList.add('flash-shown'); diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index b308cd9c236..db3ad0bb4c9 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -337,6 +337,7 @@ class GfmAutoComplete { }, // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${title}', + limit: 20, callbacks: { ...this.getDefaultCallbacks(), beforeSave(merges) { diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index f49246cf07b..4e1b4f2652c 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-underscore-dangle, no-var, one-var, vars-on-top, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func */ +/* eslint-disable func-names, no-underscore-dangle, no-var, one-var, vars-on-top, no-shadow, no-cond-assign, no-return-assign, no-else-return, camelcase, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, no-param-reassign, no-loop-func */ import $ from 'jquery'; import _ from 'underscore'; @@ -35,13 +35,13 @@ GitLabDropdownInput = (function() { ); this.input - .on('keydown', function(e) { + .on('keydown', e => { var keyCode = e.which; if (keyCode === 13 && !options.elIsInput) { e.preventDefault(); } }) - .on('input', function(e) { + .on('input', e => { var val = e.currentTarget.value || _this.options.inputFieldName; val = val .split(' ') @@ -95,42 +95,33 @@ GitLabDropdownFilter = (function() { // Key events timeout = ''; this.input - .on('keydown', function(e) { + .on('keydown', e => { var keyCode = e.which; if (keyCode === 13 && !options.elIsInput) { e.preventDefault(); } }) - .on( - 'input', - function() { - if (this.input.val() !== '' && !$inputContainer.hasClass(HAS_VALUE_CLASS)) { - $inputContainer.addClass(HAS_VALUE_CLASS); - } else if (this.input.val() === '' && $inputContainer.hasClass(HAS_VALUE_CLASS)) { - $inputContainer.removeClass(HAS_VALUE_CLASS); - } - // Only filter asynchronously only if option remote is set - if (this.options.remote) { - clearTimeout(timeout); - return (timeout = setTimeout( - function() { - $inputContainer.parent().addClass('is-loading'); - - return this.options.query( - this.input.val(), - function(data) { - $inputContainer.parent().removeClass('is-loading'); - return this.options.callback(data); - }.bind(this), - ); - }.bind(this), - 250, - )); - } else { - return this.filter(this.input.val()); - } - }.bind(this), - ); + .on('input', () => { + if (this.input.val() !== '' && !$inputContainer.hasClass(HAS_VALUE_CLASS)) { + $inputContainer.addClass(HAS_VALUE_CLASS); + } else if (this.input.val() === '' && $inputContainer.hasClass(HAS_VALUE_CLASS)) { + $inputContainer.removeClass(HAS_VALUE_CLASS); + } + // Only filter asynchronously only if option remote is set + if (this.options.remote) { + clearTimeout(timeout); + return (timeout = setTimeout(() => { + $inputContainer.parent().addClass('is-loading'); + + return this.options.query(this.input.val(), data => { + $inputContainer.parent().removeClass('is-loading'); + return this.options.callback(data); + }); + }, 250)); + } else { + return this.filter(this.input.val()); + } + }); } GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) { @@ -175,9 +166,7 @@ GitLabDropdownFilter = (function() { key: this.options.keys, }); if (tmp.length) { - results[key] = tmp.map(function(item) { - return item; - }); + results[key] = tmp.map(item => item); } } } @@ -283,7 +272,7 @@ GitLabDropdown = (function() { NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item'; - SELECTABLE_CLASSES = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ', .option-hidden)'; + SELECTABLE_CLASSES = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES}, .option-hidden)`; CURSOR_SELECT_SCROLL_PADDING = 5; @@ -370,9 +359,9 @@ GitLabDropdown = (function() { instance: this, elements: (function(_this) { return function() { - selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')'; + selector = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`; if (_this.dropdown.find('.dropdown-toggle-page').length) { - selector = '.dropdown-page-one ' + selector; + selector = `.dropdown-page-one ${selector}`; } return $(selector, this.instance.dropdown); }; @@ -388,7 +377,7 @@ GitLabDropdown = (function() { if (_this.filterInput.val() !== '') { selector = SELECTABLE_CLASSES; if (_this.dropdown.find('.dropdown-toggle-page').length) { - selector = '.dropdown-page-one ' + selector; + selector = `.dropdown-page-one ${selector}`; } if ($(_this.el).is('input')) { currentIndex = -1; @@ -453,32 +442,28 @@ GitLabDropdown = (function() { if (this.dropdown.find('.dropdown-toggle-page').length) { selector = '.dropdown-page-one .dropdown-content a'; } - this.dropdown.on( - 'click', - selector, - function(e) { - var $el, selected, selectedObj, isMarking; - $el = $(e.currentTarget); - selected = self.rowClicked($el); - selectedObj = selected ? selected[0] : null; - isMarking = selected ? selected[1] : null; - if (this.options.clicked) { - this.options.clicked.call(this, { - selectedObj, - $el, - e, - isMarking, - }); - } + this.dropdown.on('click', selector, e => { + var $el, selected, selectedObj, isMarking; + $el = $(e.currentTarget); + selected = self.rowClicked($el); + selectedObj = selected ? selected[0] : null; + isMarking = selected ? selected[1] : null; + if (this.options.clicked) { + this.options.clicked.call(this, { + selectedObj, + $el, + e, + isMarking, + }); + } - // Update label right after all modifications in dropdown has been done - if (this.options.toggleLabel) { - this.updateLabel(selectedObj, $el, this); - } + // Update label right after all modifications in dropdown has been done + if (this.options.toggleLabel) { + this.updateLabel(selectedObj, $el, this); + } - $el.trigger('blur'); - }.bind(this), - ); + $el.trigger('blur'); + }); } } @@ -525,9 +510,7 @@ GitLabDropdown = (function() { name, ), ); - this.renderData(groupData, name).map(function(item) { - return html.push(item); - }); + this.renderData(groupData, name).map(item => html.push(item)); } } else { // Render each row @@ -708,9 +691,9 @@ GitLabDropdown = (function() { return text .split('') - .map(function(character, i) { + .map((character, i) => { if (indexOf.call(occurrences, i) !== -1) { - return '<b>' + character + '</b>'; + return `<b>${character}</b>`; } else { return character; } @@ -734,6 +717,7 @@ GitLabDropdown = (function() { selectedObject = this.renderedData[groupName][selectedIndex]; } else { selectedIndex = el.closest('li').index(); + this.selectedIndex = selectedIndex; selectedObject = this.renderedData[selectedIndex]; } } @@ -755,9 +739,7 @@ GitLabDropdown = (function() { } else if (value != null) { field = this.dropdown .parent() - .find( - "input[name='" + fieldName + "'][value='" + value.toString().replace(/'/g, "\\'") + "']", - ); + .find(`input[name='${fieldName}'][value='${value.toString().replace(/'/g, "\\'")}']`); } if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) { @@ -783,11 +765,11 @@ GitLabDropdown = (function() { } else { isMarking = true; if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) { - this.dropdown.find('.' + ACTIVE_CLASS).removeClass(ACTIVE_CLASS); + this.dropdown.find(`.${ACTIVE_CLASS}`).removeClass(ACTIVE_CLASS); if (!isInput) { this.dropdown .parent() - .find("input[name='" + fieldName + "']") + .find(`input[name='${fieldName}']`) .remove(); } } @@ -826,7 +808,7 @@ GitLabDropdown = (function() { var $input; // Create hidden input for form if (single) { - $('input[name="' + fieldName + '"]').remove(); + $(`input[name="${fieldName}"]`).remove(); } $input = $('<input>') @@ -854,12 +836,12 @@ GitLabDropdown = (function() { var $el, selector; // If we pass an option index if (typeof index !== 'undefined') { - selector = SELECTABLE_CLASSES + ':eq(' + index + ') a'; + selector = `${SELECTABLE_CLASSES}:eq(${index}) a`; } else { selector = '.dropdown-content .is-focused'; } if (this.dropdown.find('.dropdown-toggle-page').length) { - selector = '.dropdown-page-one ' + selector; + selector = `.dropdown-page-one ${selector}`; } // simulate a click on the first link $el = $(selector, this.dropdown); @@ -878,7 +860,7 @@ GitLabDropdown = (function() { ARROW_KEY_CODES = [38, 40]; selector = SELECTABLE_CLASSES; if (this.dropdown.find('.dropdown-toggle-page').length) { - selector = '.dropdown-page-one ' + selector; + selector = `.dropdown-page-one ${selector}`; } return $('body').on( 'keydown', diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 830385941d8..ede74d18ed4 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -104,11 +104,11 @@ export default { /> <div :class="{ 'd-sm-flex': !group.isChildrenLoading }" - class="avatar-container rect-avatar s32 d-none flex-grow-0 flex-shrink-0 " + class="avatar-container rect-avatar s40 d-none flex-grow-0 flex-shrink-0 " > <a :href="group.relativePath" class="no-expand"> - <img v-if="hasAvatar" :src="group.avatarUrl" class="avatar s32" /> - <identicon v-else :entity-id="group.id" :entity-name="group.name" size-class="s32" /> + <img v-if="hasAvatar" :src="group.avatarUrl" class="avatar s40" /> + <identicon v-else :entity-id="group.id" :entity-name="group.name" size-class="s40" /> </a> </div> <div class="group-text-container d-flex flex-fill align-items-center"> diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue index cafd22731b1..4b569970204 100644 --- a/app/assets/javascripts/groups/components/item_actions.vue +++ b/app/assets/javascripts/groups/components/item_actions.vue @@ -56,7 +56,7 @@ export default { class="leave-group btn btn-xs no-expand" @click.prevent="onLeaveGroup" > - <icon name="leave" css-classes="position-top-0" /> + <icon name="leave" class="position-top-0" /> </a> <a v-if="group.canEdit" @@ -68,7 +68,7 @@ export default { data-placement="bottom" class="edit-group btn btn-xs no-expand" > - <icon name="settings" css-classes="position-top-0" /> + <icon name="settings" class="position-top-0" /> </a> </div> </template> diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index f1cc6756583..a5e38022b8d 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -4,96 +4,97 @@ import Api from './api'; import { normalizeHeaders } from './lib/utils/common_utils'; import { __ } from '~/locale'; -export default function groupsSelect() { - import(/* webpackChunkName: 'select2' */ 'select2/select2') - .then(() => { - // Needs to be accessible in rspec - window.GROUP_SELECT_PER_PAGE = 20; - $('.ajax-groups-select').each(function setAjaxGroupsSelect2() { - const $select = $(this); - const allAvailable = $select.data('allAvailable'); - const skipGroups = $select.data('skipGroups') || []; - const parentGroupID = $select.data('parentId'); - const groupsPath = parentGroupID - ? Api.subgroupsPath.replace(':id', parentGroupID) - : Api.groupsPath; +const groupsSelect = () => { + // Needs to be accessible in rspec + window.GROUP_SELECT_PER_PAGE = 20; + $('.ajax-groups-select').each(function setAjaxGroupsSelect2() { + const $select = $(this); + const allAvailable = $select.data('allAvailable'); + const skipGroups = $select.data('skipGroups') || []; + const parentGroupID = $select.data('parentId'); + const groupsPath = parentGroupID + ? Api.subgroupsPath.replace(':id', parentGroupID) + : Api.groupsPath; - $select.select2({ - placeholder: __('Search for a group'), - allowClear: $select.hasClass('allowClear'), - multiple: $select.hasClass('multiselect'), - minimumInputLength: 0, - ajax: { - url: Api.buildUrl(groupsPath), - dataType: 'json', - quietMillis: 250, - transport(params) { - axios[params.type.toLowerCase()](params.url, { - params: params.data, - }) - .then(res => { - const results = res.data || []; - const headers = normalizeHeaders(res.headers); - const currentPage = parseInt(headers['X-PAGE'], 10) || 0; - const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; - const more = currentPage < totalPages; + $select.select2({ + placeholder: __('Search for a group'), + allowClear: $select.hasClass('allowClear'), + multiple: $select.hasClass('multiselect'), + minimumInputLength: 0, + ajax: { + url: Api.buildUrl(groupsPath), + dataType: 'json', + quietMillis: 250, + transport(params) { + axios[params.type.toLowerCase()](params.url, { + params: params.data, + }) + .then(res => { + const results = res.data || []; + const headers = normalizeHeaders(res.headers); + const currentPage = parseInt(headers['X-PAGE'], 10) || 0; + const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; + const more = currentPage < totalPages; - params.success({ - results, - pagination: { - more, - }, - }); - }) - .catch(params.error); - }, - data(search, page) { - return { - search, - page, - per_page: window.GROUP_SELECT_PER_PAGE, - all_available: allAvailable, - }; - }, - results(data, page) { - if (data.length) return { results: [] }; + params.success({ + results, + pagination: { + more, + }, + }); + }) + .catch(params.error); + }, + data(search, page) { + return { + search, + page, + per_page: window.GROUP_SELECT_PER_PAGE, + all_available: allAvailable, + }; + }, + results(data, page) { + if (data.length) return { results: [] }; - const groups = data.length ? data : data.results || []; - const more = data.pagination ? data.pagination.more : false; - const results = groups.filter(group => skipGroups.indexOf(group.id) === -1); + const groups = data.length ? data : data.results || []; + const more = data.pagination ? data.pagination.more : false; + const results = groups.filter(group => skipGroups.indexOf(group.id) === -1); - return { - results, - page, - more, - }; - }, - }, - // eslint-disable-next-line consistent-return - initSelection(element, callback) { - const id = $(element).val(); - if (id !== '') { - return Api.group(id, callback); - } - }, - formatResult(object) { - return `<div class='group-result'> <div class='group-name'>${object.full_name}</div> <div class='group-path'>${object.full_path}</div> </div>`; - }, - formatSelection(object) { - return object.full_name; - }, - dropdownCssClass: 'ajax-groups-dropdown select2-infinite', - // we do not want to escape markup since we are displaying html in results - escapeMarkup(m) { - return m; - }, - }); + return { + results, + page, + more, + }; + }, + }, + // eslint-disable-next-line consistent-return + initSelection(element, callback) { + const id = $(element).val(); + if (id !== '') { + return Api.group(id, callback); + } + }, + formatResult(object) { + return `<div class='group-result'> <div class='group-name'>${object.full_name}</div> <div class='group-path'>${object.full_path}</div> </div>`; + }, + formatSelection(object) { + return object.full_name; + }, + dropdownCssClass: 'ajax-groups-dropdown select2-infinite', + // we do not want to escape markup since we are displaying html in results + escapeMarkup(m) { + return m; + }, + }); - $select.on('select2-loaded', () => { - const dropdown = document.querySelector('.select2-infinite .select2-results'); - dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`; - }); - }); - }) + $select.on('select2-loaded', () => { + const dropdown = document.querySelector('.select2-infinite .select2-results'); + dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`; + }); + }); +}; + +export default () => + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(groupsSelect) .catch(() => {}); -} diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index 3d846310008..fdd27e08793 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -15,11 +15,10 @@ import { parseBoolean } from '~/lib/utils/common_utils'; */ export default function initTodoToggle() { $(document).on('todo:toggle', (e, count) => { - const parsedCount = parseInt(count, 10); const $todoPendingCount = $('.todos-count'); - $todoPendingCount.text(highCountTrim(parsedCount)); - $todoPendingCount.toggleClass('hidden', parsedCount === 0); + $todoPendingCount.text(highCountTrim(count)); + $todoPendingCount.toggleClass('hidden', count === 0); }); } diff --git a/app/assets/javascripts/ide/.eslintrc.yml b/app/assets/javascripts/ide/.eslintrc.yml new file mode 100644 index 00000000000..92b96d717be --- /dev/null +++ b/app/assets/javascripts/ide/.eslintrc.yml @@ -0,0 +1,3 @@ +rules: + # https://gitlab.com/gitlab-org/gitlab/issues/33024 + promise/no-nesting: off diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue index 11d5d9639b6..6b2ef34c960 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue @@ -43,7 +43,12 @@ export default { <template> <div class="d-flex ide-commit-editor-header align-items-center"> <file-icon :file-name="activeFile.name" :size="16" class="mr-2" /> - <strong class="mr-2"> {{ activeFile.path }} </strong> + <strong class="mr-2"> + <template v-if="activeFile.prevPath && activeFile.prevPath !== activeFile.path"> + {{ activeFile.prevPath }} → + </template> + {{ activeFile.path }} + </strong> <changed-file-icon :file="activeFile" :is-centered="false" /> <div class="ml-auto"> <button diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index 4f1260de0bc..e16918ae025 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -3,7 +3,7 @@ import $ from 'jquery'; import { mapActions } from 'vuex'; import { __, sprintf } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; -import GlModal from '~/vue_shared/components/gl_modal.vue'; +import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import ListItem from './list_item.vue'; @@ -11,7 +11,7 @@ export default { components: { Icon, ListItem, - GlModal, + GlModal: DeprecatedModal2, }, directives: { tooltip, diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue index 3156a398113..b6fc567f8cc 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue @@ -86,7 +86,7 @@ export default { data-placement="left" class="append-bottom-10" > - <icon :name="additionIconName" :size="18" :css-classes="addedFilesIconClass" /> + <icon :name="additionIconName" :size="18" :class="addedFilesIconClass" /> </div> {{ addedFilesLength }} <div @@ -96,7 +96,7 @@ export default { data-placement="left" class="prepend-top-10 append-bottom-10" > - <icon :name="modifiedIconName" :size="18" :css-classes="modifiedFilesClass" /> + <icon :name="modifiedIconName" :size="18" :class="modifiedFilesClass" /> </div> {{ modifiedFilesLength }} </div> 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 302adccd759..230dfaf047b 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -110,11 +110,14 @@ export default { > <span class="multi-file-commit-list-file-path d-flex align-items-center"> <file-icon :file-name="file.name" class="append-right-8" /> + <template v-if="file.prevName && file.prevName !== file.name"> + {{ file.prevName }} → + </template> {{ file.name }} </span> <div class="ml-auto d-flex align-items-center"> <div class="d-flex align-items-center ide-commit-list-changed-icon"> - <icon :name="iconName" :size="16" :css-classes="iconClass" /> + <icon :name="iconName" :size="16" :class="iconClass" /> </div> </div> </div> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue index 09c9d135614..c14b8a47841 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue @@ -4,12 +4,12 @@ import { mapActions } from 'vuex'; import { sprintf, __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; -import GlModal from '~/vue_shared/components/gl_modal.vue'; +import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; export default { components: { Icon, - GlModal, + GlModal: DeprecatedModal2, }, directives: { tooltip, diff --git a/app/assets/javascripts/ide/components/external_link.vue b/app/assets/javascripts/ide/components/external_link.vue index d1857f0176a..558da9b706e 100644 --- a/app/assets/javascripts/ide/components/external_link.vue +++ b/app/assets/javascripts/ide/components/external_link.vue @@ -28,7 +28,7 @@ export default { rel="noopener noreferrer" > <span class="vertical-align-middle">{{ __('Open in file view') }}</span> - <icon :size="16" name="external-link" css-classes="vertical-align-middle space-right" /> + <icon :size="16" name="external-link" class="vertical-align-middle space-right" /> </a> </div> </template> diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index 48be97c8952..f0bedcfbd6b 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -34,6 +34,9 @@ export default { 'getUnstagedFilesCountForPath', 'getStagedFilesCountForPath', ]), + isTree() { + return this.file.type === 'tree'; + }, folderUnstagedCount() { return this.getUnstagedFilesCountForPath(this.file.path); }, @@ -58,10 +61,13 @@ export default { }); }, showTreeChangesCount() { - return this.file.type === 'tree' && this.changesCount > 0 && !this.file.opened; + return this.isTree && this.changesCount > 0 && !this.file.opened; + }, + isModified() { + return this.file.changed || this.file.tempFile || this.file.staged || this.file.prevPath; }, showChangedFileIcon() { - return this.file.changed || this.file.tempFile || this.file.staged; + return !this.isTree && this.isModified; }, }, }; @@ -79,7 +85,7 @@ export default { data-container="body" data-placement="right" name="file-modified" - css-classes="prepend-left-5 ide-file-modified" + class="prepend-left-5 ide-file-modified" /> </span> <changed-file-icon diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index 1af86a94482..95782b2c88a 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -30,9 +30,6 @@ export default { showLoading() { return !this.currentTree || this.currentTree.loading; }, - actualTreeList() { - return this.currentTree.tree.filter(entry => !entry.moved); - }, }, mounted() { this.updateViewer(this.viewerType); @@ -57,9 +54,9 @@ export default { <slot name="header"></slot> </header> <div class="ide-tree-body h-100"> - <template v-if="actualTreeList.length"> + <template v-if="currentTree.tree.length"> <file-row - v-for="file in actualTreeList" + v-for="file in currentTree.tree" :key="file.key" :file="file" :level="0" diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index b1be25ea602..9ad9d4455b5 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -77,7 +77,7 @@ export default { <div v-if="!stage.isLoading || stage.jobs.length" class="append-right-8 prepend-left-4"> <span class="badge badge-pill"> {{ jobsCount }} </span> </div> - <icon :name="collapseIcon" css-classes="ide-stage-collapse-icon" /> + <icon :name="collapseIcon" class="ide-stage-collapse-icon" /> </div> <div v-show="!stage.isCollapsed" class="card-body"> <gl-loading-icon v-if="showLoadingIcon" /> diff --git a/app/assets/javascripts/ide/components/mr_file_icon.vue b/app/assets/javascripts/ide/components/mr_file_icon.vue index 821be319cce..cf8a1abbde4 100644 --- a/app/assets/javascripts/ide/components/mr_file_icon.vue +++ b/app/assets/javascripts/ide/components/mr_file_icon.vue @@ -18,6 +18,6 @@ export default { :title="__('Part of merge request changes')" :size="12" name="git-merge" - css-classes="append-right-8" + class="append-right-8" /> </template> diff --git a/app/assets/javascripts/ide/components/new_dropdown/button.vue b/app/assets/javascripts/ide/components/new_dropdown/button.vue index 062a64a19d7..5bd6642930c 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/button.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/button.vue @@ -52,7 +52,7 @@ export default { class="btn-blank" @click.stop.prevent="clicked" > - <icon :name="icon" :css-classes="iconClasses" /> + <icon :name="icon" :class="iconClasses" /> <template v-if="showLabel"> {{ label }} </template> diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index f67666f1fbf..d2ed1fe3e55 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -3,12 +3,12 @@ import $ from 'jquery'; import flash from '~/flash'; import { __, sprintf, s__ } from '~/locale'; import { mapActions, mapState, mapGetters } from 'vuex'; -import GlModal from '~/vue_shared/components/gl_modal.vue'; +import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { modalTypes } from '../../constants'; export default { components: { - GlModal, + GlModal: DeprecatedModal2, }, data() { return { @@ -91,7 +91,6 @@ export default { this.renameEntry({ path: this.entryModal.entry.path, name: entryName, - entryPath: null, parentPath, }), ) diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 802b7f1fa6f..3bf8308ccea 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -155,15 +155,7 @@ export default { this.editor.clearEditor(); - this.getFileData({ - path: this.file.path, - makeFileActive: false, - }) - .then(() => - this.getRawFileData({ - path: this.file.path, - }), - ) + this.fetchFileData() .then(() => { this.createEditorInstance(); }) @@ -179,6 +171,20 @@ export default { throw err; }); }, + fetchFileData() { + if (this.file.tempFile) { + return Promise.resolve(); + } + + return this.getFileData({ + path: this.file.path, + makeFileActive: false, + }).then(() => + this.getRawFileData({ + path: this.file.path, + }), + ); + }, createEditorInstance() { this.editor.dispose(); diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue index 84a962bfc7d..9773e835a5c 100644 --- a/app/assets/javascripts/ide/components/repo_file_status_icon.vue +++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue @@ -29,6 +29,6 @@ export default { <template> <span v-if="file.file_lock" v-tooltip :title="lockTooltip" data-container="body"> - <icon name="lock" css-classes="file-status-icon" /> + <icon name="lock" class="file-status-icon" /> </span> </template> diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js index 51278640b5b..e86dac20104 100644 --- a/app/assets/javascripts/ide/lib/files.js +++ b/app/assets/javascripts/ide/lib/files.js @@ -1,7 +1,5 @@ import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; -import { decorateData, sortTree } from '../stores/utils'; - -export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/'); +import { decorateData, sortTree, escapeFileUrl } from '../stores/utils'; export const splitParent = path => { const idx = path.lastIndexOf('/'); diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 8c0119a1fed..4e18ec58feb 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -9,6 +9,7 @@ import { decorateFiles } from '../lib/files'; import { stageKeys } from '../constants'; import service from '../services'; import router from '../ide_router'; +import eventHub from '../eventhub'; export const redirectToUrl = (self, url) => visitUrl(url); @@ -171,8 +172,10 @@ export const setCurrentBranchId = ({ commit }, currentBranchId) => { export const updateTempFlagForEntry = ({ commit, dispatch, state }, { file, tempFile }) => { commit(types.UPDATE_TEMP_FLAG, { path: file.path, tempFile }); - if (file.parentPath) { - dispatch('updateTempFlagForEntry', { file: state.entries[file.parentPath], tempFile }); + const parent = file.parentPath && state.entries[file.parentPath]; + + if (parent) { + dispatch('updateTempFlagForEntry', { file: parent, tempFile }); } }; @@ -199,51 +202,71 @@ export const openNewEntryModal = ({ commit }, { type, path = '' }) => { export const deleteEntry = ({ commit, dispatch, state }, path) => { const entry = state.entries[path]; - + const { prevPath, prevName, prevParentPath } = entry; + const isTree = entry.type === 'tree'; + + if (prevPath) { + dispatch('renameEntry', { + path, + name: prevName, + parentPath: prevParentPath, + }); + dispatch('deleteEntry', prevPath); + return; + } if (state.unusedSeal) dispatch('burstUnusedSeal'); if (entry.opened) dispatch('closeFile', entry); - if (entry.type === 'tree') { + if (isTree) { entry.tree.forEach(f => dispatch('deleteEntry', f.path)); } commit(types.DELETE_ENTRY, path); - dispatch('stageChange', path); + + // Only stage if we're not a directory or a new file + if (!isTree && !entry.tempFile) { + dispatch('stageChange', path); + } dispatch('triggerFilesChange'); }; export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES); -export const renameEntry = ( - { dispatch, commit, state }, - { path, name, entryPath = null, parentPath }, -) => { - const entry = state.entries[entryPath || path]; +export const renameEntry = ({ dispatch, commit, state }, { path, name, parentPath }) => { + const entry = state.entries[path]; + const newPath = parentPath ? `${parentPath}/${name}` : name; - commit(types.RENAME_ENTRY, { path, name, entryPath, parentPath }); + commit(types.RENAME_ENTRY, { path, name, parentPath }); if (entry.type === 'tree') { - const slashedParentPath = parentPath ? `${parentPath}/` : ''; - const targetEntry = entryPath ? entryPath.split('/').pop() : name; - const newParentPath = `${slashedParentPath}${targetEntry}`; - - state.entries[entryPath || path].tree.forEach(f => { + state.entries[newPath].tree.forEach(f => { dispatch('renameEntry', { - path, - name, - entryPath: f.path, - parentPath: newParentPath, + path: f.path, + name: f.name, + parentPath: newPath, }); }); } else { - const newPath = parentPath ? `${parentPath}/${name}` : name; const newEntry = state.entries[newPath]; - commit(types.TOGGLE_FILE_CHANGED, { file: newEntry, changed: true }); + const isRevert = newPath === entry.prevPath; + const isReset = isRevert && !newEntry.changed && !newEntry.tempFile; + const isInChanges = state.changedFiles + .concat(state.stagedFiles) + .some(({ key }) => key === newEntry.key); + + if (isReset) { + commit(types.REMOVE_FILE_FROM_STAGED_AND_CHANGED, newEntry); + } else if (!isInChanges) { + commit(types.ADD_FILE_TO_CHANGED, newPath); + } + + if (!newEntry.tempFile) { + eventHub.$emit(`editor.update.model.dispose.${entry.key}`); + } - if (entry.opened) { + if (newEntry.opened) { router.push(`/project${newEntry.url}`); - commit(types.TOGGLE_FILE_OPEN, entry.path); } } diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 7627b6e03af..59445afc7a4 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -5,7 +5,7 @@ import eventHub from '../../eventhub'; import service from '../../services'; import * as types from '../mutation_types'; import router from '../../ide_router'; -import { setPageTitle } from '../utils'; +import { setPageTitle, replaceFileUrl } from '../utils'; import { viewerTypes, stageKeys } from '../../constants'; export const closeFile = ({ commit, state, dispatch }, file) => { @@ -67,7 +67,7 @@ export const getFileData = ( commit(types.TOGGLE_LOADING, { entry: file }); - const url = file.prevPath ? file.url.replace(file.path, file.prevPath) : file.url; + const url = file.prevPath ? replaceFileUrl(file.url, file.path, file.prevPath) : file.url; return service .getFileData(joinPaths(gon.relative_url_root || '', url.replace('/-/', '/'))) @@ -186,11 +186,6 @@ export const discardFileChanges = ({ dispatch, state, commit, getters }, path) = dispatch('restoreTree', file.parentPath); } - if (file.movedPath) { - commit(types.DISCARD_FILE_CHANGES, file.movedPath); - commit(types.REMOVE_FILE_FROM_CHANGED, file.movedPath); - } - commit(types.DISCARD_FILE_CHANGES, path); commit(types.REMOVE_FILE_FROM_CHANGED, path); diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index dd8f17e4f3a..20887e7d0ac 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -92,13 +92,27 @@ export const showEmptyState = ({ commit, state }, { projectId, branchId }) => { }); }; -export const openBranch = ({ dispatch, state, getters }, { projectId, branchId, basePath }) => { - dispatch('setCurrentBranchId', branchId); +export const loadFile = ({ dispatch, state }, { basePath }) => { + if (basePath) { + const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath; + const treeEntryKey = Object.keys(state.entries).find( + key => key === path && !state.entries[key].pending, + ); + const treeEntry = state.entries[treeEntryKey]; - if (getters.emptyRepo) { - return dispatch('showEmptyState', { projectId, branchId }); + if (treeEntry) { + dispatch('handleTreeEntryAction', treeEntry); + } else { + dispatch('createTempEntry', { + name: path, + type: 'blob', + }); + } } - return dispatch('getBranchData', { +}; + +export const loadBranch = ({ dispatch }, { projectId, branchId }) => + dispatch('getBranchData', { projectId, branchId, }) @@ -107,42 +121,38 @@ export const openBranch = ({ dispatch, state, getters }, { projectId, branchId, projectId, branchId, }); - dispatch('getFiles', { + return dispatch('getFiles', { projectId, branchId, - }) - .then(() => { - if (basePath) { - const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath; - const treeEntryKey = Object.keys(state.entries).find( - key => key === path && !state.entries[key].pending, - ); - const treeEntry = state.entries[treeEntryKey]; - - if (treeEntry) { - dispatch('handleTreeEntryAction', treeEntry); - } else { - dispatch('createTempEntry', { - name: path, - type: 'blob', - }); - } - } - }) - .catch( - () => - new Error( - sprintf( - __('An error occurred whilst getting files for - %{branchId}'), - { - branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`, - }, - false, - ), - ), - ); + }); }) .catch(() => { dispatch('showBranchNotFoundError', branchId); + return Promise.reject(); }); + +export const openBranch = ({ dispatch, state, getters }, { projectId, branchId, basePath }) => { + const currentProject = state.projects[projectId]; + if (getters.emptyRepo) { + return dispatch('showEmptyState', { projectId, branchId }); + } + if (!currentProject || !currentProject.branches[branchId]) { + dispatch('setCurrentBranchId', branchId); + + return dispatch('loadBranch', { projectId, branchId }) + .then(() => dispatch('loadFile', { basePath })) + .catch( + () => + new Error( + sprintf( + __('An error occurred whilst getting files for - %{branchId}'), + { + branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`, + }, + false, + ), + ), + ); + } + return Promise.resolve(dispatch('loadFile', { basePath })); }; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 23caf2d48ed..e89ed49318b 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -152,6 +152,12 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo branch: getters.branchName, }) .then(() => { + commit(rootTypes.CLEAR_STAGED_CHANGES, null, { root: true }); + + setTimeout(() => { + commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true }); + }, 5000); + if (state.shouldCreateMR) { const { currentProject } = rootGetters; const targetBranch = getters.isCreatingNewBranch @@ -164,14 +170,6 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo { root: true }, ); } - - commit(rootTypes.CLEAR_STAGED_CHANGES, null, { root: true }); - - commit(rootTypes.CLEAR_REPLACED_FILES, null, { root: true }); - - setTimeout(() => { - commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true }); - }, 5000); }) .then(() => { if (rootGetters.lastOpenedFile) { diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index f021729c451..f0b4718d025 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -59,8 +59,7 @@ export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE'; export const CLEAR_STAGED_CHANGES = 'CLEAR_STAGED_CHANGES'; export const STAGE_CHANGE = 'STAGE_CHANGE'; export const UNSTAGE_CHANGE = 'UNSTAGE_CHANGE'; - -export const CLEAR_REPLACED_FILES = 'CLEAR_REPLACED_FILES'; +export const REMOVE_FILE_FROM_STAGED_AND_CHANGED = 'REMOVE_FILE_FROM_STAGED_AND_CHANGED'; export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT'; export const ADD_PENDING_TAB = 'ADD_PENDING_TAB'; @@ -79,5 +78,6 @@ export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE'; export const OPEN_NEW_ENTRY_MODAL = 'OPEN_NEW_ENTRY_MODAL'; export const DELETE_ENTRY = 'DELETE_ENTRY'; export const RENAME_ENTRY = 'RENAME_ENTRY'; +export const REVERT_RENAME_ENTRY = 'REVERT_RENAME_ENTRY'; export const RESTORE_TREE = 'RESTORE_TREE'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index ea125214ebb..e84e2782e46 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -5,7 +5,14 @@ import mergeRequestMutation from './mutations/merge_request'; import fileMutations from './mutations/file'; import treeMutations from './mutations/tree'; import branchMutations from './mutations/branch'; -import { sortTree } from './utils'; +import { + sortTree, + replaceFileUrl, + swapInParentTreeWithSorting, + updateFileCollections, + removeFromParentTree, + pathsAreEqual, +} from './utils'; export default { [types.SET_INITIAL_DATA](state, data) { @@ -56,11 +63,6 @@ export default { stagedFiles: [], }); }, - [types.CLEAR_REPLACED_FILES](state) { - Object.assign(state, { - replacedFiles: [], - }); - }, [types.SET_ENTRIES](state, entries) { Object.assign(state, { entries, @@ -71,16 +73,15 @@ export default { const entry = data.entries[key]; const foundEntry = state.entries[key]; + // NOTE: We can't clone `entry` in any of the below assignments because + // we need `state.entries` and the `entry.tree` to reference the same object. if (!foundEntry) { Object.assign(state.entries, { [key]: entry, }); } else if (foundEntry.deleted) { Object.assign(state.entries, { - [key]: { - ...entry, - replaces: true, - }, + [key]: Object.assign(entry, { replaces: true }), }); } else { const tree = entry.tree.filter( @@ -157,9 +158,14 @@ export default { changed: Boolean(changedFile), staged: false, replaces: false, - prevPath: '', - moved: false, lastCommitSha: lastCommit.commit.id, + + prevId: undefined, + prevPath: undefined, + prevName: undefined, + prevUrl: undefined, + prevKey: undefined, + prevParentPath: undefined, }); if (prevPath) { @@ -209,7 +215,9 @@ export default { entry.deleted = true; - parent.tree = parent.tree.filter(f => f.path !== entry.path); + if (parent) { + parent.tree = parent.tree.filter(f => f.path !== entry.path); + } if (entry.type === 'blob') { if (tempFile) { @@ -219,51 +227,61 @@ export default { } } }, - [types.RENAME_ENTRY](state, { path, name, entryPath = null, parentPath }) { - const oldEntry = state.entries[entryPath || path]; - const slashedParentPath = parentPath ? `${parentPath}/` : ''; - const newPath = entryPath - ? `${slashedParentPath}${oldEntry.name}` - : `${slashedParentPath}${name}`; + [types.RENAME_ENTRY](state, { path, name, parentPath }) { + const oldEntry = state.entries[path]; + const newPath = parentPath ? `${parentPath}/${name}` : name; + const isRevert = newPath === oldEntry.prevPath; - Vue.set(state.entries, newPath, { + const newUrl = replaceFileUrl(oldEntry.url, oldEntry.path, newPath); + + const newKey = oldEntry.key.replace(new RegExp(oldEntry.path, 'g'), newPath); + + const baseProps = { ...oldEntry, + name, id: newPath, - key: `${newPath}-${oldEntry.type}-${oldEntry.path}`, path: newPath, - name: entryPath ? oldEntry.name : name, - tempFile: true, - prevPath: oldEntry.tempFile ? null : oldEntry.path, - url: oldEntry.url.replace(new RegExp(`${oldEntry.path}/?$`), newPath), - tree: [], - raw: '', - opened: false, - parentPath, - }); + url: newUrl, + key: newKey, + parentPath: parentPath || '', + }; - oldEntry.moved = true; - oldEntry.movedPath = newPath; + const prevProps = + oldEntry.tempFile || isRevert + ? { + prevId: undefined, + prevPath: undefined, + prevName: undefined, + prevUrl: undefined, + prevKey: undefined, + prevParentPath: undefined, + } + : { + prevId: oldEntry.prevId || oldEntry.id, + prevPath: oldEntry.prevPath || oldEntry.path, + prevName: oldEntry.prevName || oldEntry.name, + prevUrl: oldEntry.prevUrl || oldEntry.url, + prevKey: oldEntry.prevKey || oldEntry.key, + prevParentPath: oldEntry.prevParentPath || oldEntry.parentPath, + }; - const parent = parentPath - ? state.entries[parentPath] - : state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; - const newEntry = state.entries[newPath]; - - parent.tree = sortTree(parent.tree.concat(newEntry)); + Vue.set(state.entries, newPath, { + ...baseProps, + ...prevProps, + }); - if (newEntry.type === 'blob') { - state.changedFiles = state.changedFiles.concat(newEntry); + if (pathsAreEqual(oldEntry.parentPath, parentPath)) { + swapInParentTreeWithSorting(state, oldEntry.key, newPath, parentPath); + } else { + removeFromParentTree(state, oldEntry.key, oldEntry.parentPath); + swapInParentTreeWithSorting(state, oldEntry.key, newPath, parentPath); } - if (oldEntry.tempFile) { - const filterMethod = f => f.path !== oldEntry.path; - - state.openFiles = state.openFiles.filter(filterMethod); - state.changedFiles = state.changedFiles.filter(filterMethod); - parent.tree = parent.tree.filter(filterMethod); - - Vue.delete(state.entries, oldEntry.path); + if (oldEntry.type === 'blob') { + updateFileCollections(state, oldEntry.key, newPath); } + + Vue.delete(state.entries, oldEntry.path); }, ...projectMutations, diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 1442ea7dbfa..8caeb2d73b2 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -138,8 +138,6 @@ export default { content: stagedFile ? stagedFile.content : state.entries[path].raw, changed: false, deleted: false, - moved: false, - movedPath: '', }); if (deleted) { @@ -179,11 +177,6 @@ export default { }); if (stagedFile) { - Object.assign(state, { - replacedFiles: state.replacedFiles.concat({ - ...stagedFile, - }), - }); Object.assign(stagedFile, { ...state.entries[path], }); @@ -252,4 +245,15 @@ export default { openFiles: state.openFiles.filter(f => f.key !== file.key), }); }, + [types.REMOVE_FILE_FROM_STAGED_AND_CHANGED](state, file) { + Object.assign(state, { + changedFiles: state.changedFiles.filter(f => f.key !== file.key), + stagedFiles: state.stagedFiles.filter(f => f.key !== file.key), + }); + + Object.assign(state.entries[file.path], { + changed: false, + staged: false, + }); + }, }; diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index c4da482bf0a..d400b9831a9 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -6,7 +6,6 @@ export default () => ({ currentMergeRequestId: '', changedFiles: [], stagedFiles: [], - replacedFiles: [], endpoints: {}, lastCommitMsg: '', lastCommitPath: '', diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 52200ce7847..a8d8ff31afe 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -50,9 +50,7 @@ export const dataStructure = () => ({ lastOpenedAt: 0, mrChange: null, deleted: false, - prevPath: '', - movedPath: '', - moved: false, + prevPath: undefined, }); export const decorateData = entity => { @@ -129,7 +127,7 @@ export const commitActionForFile = file => { export const getCommitFiles = stagedFiles => stagedFiles.reduce((acc, file) => { - if (file.moved || file.type === 'tree') return acc; + if (file.type === 'tree') return acc; return acc.concat({ ...file, @@ -148,9 +146,9 @@ export const createCommitPayload = ({ commit_message: state.commitMessage || getters.preBuiltCommitMessage, actions: getCommitFiles(rootState.stagedFiles).map(f => ({ action: commitActionForFile(f), - file_path: f.moved ? f.movedPath : f.path, - previous_path: f.prevPath === '' ? undefined : f.prevPath, - content: f.prevPath ? null : f.content || undefined, + file_path: f.path, + previous_path: f.prevPath || undefined, + content: f.prevPath && !f.changed ? null : f.content || undefined, encoding: f.base64 ? 'base64' : 'text', last_commit_id: newBranch || f.deleted || f.prevPath || f.replaces ? undefined : f.lastCommitSha, @@ -213,3 +211,61 @@ export const mergeTrees = (fromTree, toTree) => { return toTree; }; + +export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/'); + +export const replaceFileUrl = (url, oldPath, newPath) => { + // Add `/-/` so that we don't accidentally replace project path + const result = url.replace(`/-/${escapeFileUrl(oldPath)}`, `/-/${escapeFileUrl(newPath)}`); + + return result; +}; + +export const swapInStateArray = (state, arr, key, entryPath) => + Object.assign(state, { + [arr]: state[arr].map(f => (f.key === key ? state.entries[entryPath] : f)), + }); + +export const getEntryOrRoot = (state, path) => + path ? state.entries[path] : state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; + +export const swapInParentTreeWithSorting = (state, oldKey, newPath, parentPath) => { + if (!newPath) { + return; + } + + const parent = getEntryOrRoot(state, parentPath); + + if (parent) { + const tree = parent.tree + // filter out old entry && new entry + .filter(({ key, path }) => key !== oldKey && path !== newPath) + // concat new entry + .concat(state.entries[newPath]); + + parent.tree = sortTree(tree); + } +}; + +export const removeFromParentTree = (state, oldKey, parentPath) => { + const parent = getEntryOrRoot(state, parentPath); + + if (parent) { + parent.tree = sortTree(parent.tree.filter(({ key }) => key !== oldKey)); + } +}; + +export const updateFileCollections = (state, key, entryPath) => { + ['openFiles', 'changedFiles', 'stagedFiles'].forEach(fileCollection => { + swapInStateArray(state, fileCollection, key, entryPath); + }); +}; + +export const cleanTrailingSlash = path => path.replace(/\/$/, ''); + +export const pathsAreEqual = (a, b) => { + const cleanA = a ? cleanTrailingSlash(a) : ''; + const cleanB = b ? cleanTrailingSlash(b) : ''; + + return cleanA === cleanB; +}; diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js index 000157efad0..7921650e8a0 100644 --- a/app/assets/javascripts/image_diff/helpers/badge_helper.js +++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js @@ -1,3 +1,5 @@ +import { spriteIcon } from '~/lib/utils/common_utils'; + export function createImageBadge(noteId, { x, y }, classNames = []) { const buttonEl = document.createElement('button'); const classList = classNames.concat(['js-image-badge']); @@ -20,7 +22,7 @@ export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) { export function addImageCommentBadge(containerEl, { coordinate, noteId }) { const buttonEl = createImageBadge(noteId, coordinate, ['image-comment-badge']); - buttonEl.innerHTML = gl.utils.spriteIcon('image-comment-dark'); + buttonEl.innerHTML = spriteIcon('image-comment-dark'); containerEl.appendChild(buttonEl); } diff --git a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js index 7051a968dac..df3d90cff68 100644 --- a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js +++ b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js @@ -1,3 +1,5 @@ +import { spriteIcon } from '~/lib/utils/common_utils'; + export function addCommentIndicator(containerEl, { x, y }) { const buttonEl = document.createElement('button'); buttonEl.classList.add('btn-transparent'); @@ -6,7 +8,7 @@ export function addCommentIndicator(containerEl, { x, y }) { buttonEl.style.left = `${x}px`; buttonEl.style.top = `${y}px`; - buttonEl.innerHTML = gl.utils.spriteIcon('image-comment-dark'); + buttonEl.innerHTML = spriteIcon('image-comment-dark'); containerEl.appendChild(buttonEl); } diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_projects/components/import_projects_table.vue index 00eb0afb3bf..e5ac3cbafe5 100644 --- a/app/assets/javascripts/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_projects/components/import_projects_table.vue @@ -1,4 +1,5 @@ <script> +import _ from 'underscore'; import { mapActions, mapState, mapGetters } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; @@ -7,6 +8,8 @@ import ImportedProjectTableRow from './imported_project_table_row.vue'; import ProviderRepoTableRow from './provider_repo_table_row.vue'; import eventHub from '../event_hub'; +const reposFetchThrottleDelay = 1000; + export default { name: 'ImportProjectsTable', components: { @@ -23,11 +26,11 @@ export default { }, computed: { - ...mapState(['importedProjects', 'providerRepos', 'isLoadingRepos']), + ...mapState(['importedProjects', 'providerRepos', 'isLoadingRepos', 'filter']), ...mapGetters(['isImportingAnyRepo', 'hasProviderRepos', 'hasImportedProjects']), emptyStateText() { - return sprintf(__('No %{providerTitle} repositories available to import'), { + return sprintf(__('No %{providerTitle} repositories found'), { providerTitle: this.providerTitle, }); }, @@ -47,21 +50,38 @@ export default { }, methods: { - ...mapActions(['fetchRepos', 'fetchJobs', 'stopJobsPolling', 'clearJobsEtagPoll']), + ...mapActions([ + 'fetchRepos', + 'fetchReposFiltered', + 'fetchJobs', + 'stopJobsPolling', + 'clearJobsEtagPoll', + 'setFilter', + ]), importAll() { eventHub.$emit('importAll'); }, + + handleFilterInput({ target }) { + this.setFilter(target.value); + }, + + throttledFetchRepos: _.throttle(function fetch() { + eventHub.$off('importAll'); + this.fetchRepos(); + }, reposFetchThrottleDelay), }, }; </script> <template> <div> + <p class="light text-nowrap mt-2"> + {{ s__('ImportProjects|Select the projects you want to import') }} + </p> + <div class="d-flex justify-content-between align-items-end flex-wrap mb-3"> - <p class="light text-nowrap mt-2 my-sm-0"> - {{ s__('ImportProjects|Select the projects you want to import') }} - </p> <loading-button container-class="btn btn-success js-import-all" :loading="isImportingAnyRepo" @@ -70,6 +90,19 @@ export default { type="button" @click="importAll" /> + <form novalidate @submit.prevent> + <input + :value="filter" + data-qa-selector="githubish_import_filter_field" + class="form-control" + name="filter" + :placeholder="__('Filter your projects by name')" + autofocus + size="40" + @input="handleFilterInput($event)" + @keyup.enter="throttledFetchRepos" + /> + </form> </div> <gl-loading-icon v-if="isLoadingRepos" diff --git a/app/assets/javascripts/import_projects/index.js b/app/assets/javascripts/import_projects/index.js index 2d99d716609..b069dcb7766 100644 --- a/app/assets/javascripts/import_projects/index.js +++ b/app/assets/javascripts/import_projects/index.js @@ -38,7 +38,7 @@ export default function mountImportProjectsTable(mountElement) { }, methods: { - ...mapActions(['setInitialData']), + ...mapActions(['setInitialData', 'setFilter']), }, render(createElement) { diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_projects/store/actions.js index c44500937cc..0fb9a4cdfd4 100644 --- a/app/assets/javascripts/import_projects/store/actions.js +++ b/app/assets/javascripts/import_projects/store/actions.js @@ -5,6 +5,7 @@ import Poll from '~/lib/utils/poll'; import createFlash from '~/flash'; import { s__, sprintf } from '~/locale'; import axios from '~/lib/utils/axios_utils'; +import { jobsPathWithFilter, reposPathWithFilter } from './getters'; let eTagPoll; @@ -19,16 +20,20 @@ export const restartJobsPolling = () => { }; export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); +export const setFilter = ({ commit }, filter) => commit(types.SET_FILTER, filter); export const requestRepos = ({ commit }, repos) => commit(types.REQUEST_REPOS, repos); export const receiveReposSuccess = ({ commit }, repos) => commit(types.RECEIVE_REPOS_SUCCESS, repos); export const receiveReposError = ({ commit }) => commit(types.RECEIVE_REPOS_ERROR); export const fetchRepos = ({ state, dispatch }) => { + dispatch('stopJobsPolling'); dispatch('requestRepos'); + const { provider } = state; + return axios - .get(state.reposPath) + .get(reposPathWithFilter(state)) .then(({ data }) => dispatch('receiveReposSuccess', convertObjectPropsToCamelCase(data, { deep: true })), ) @@ -36,7 +41,7 @@ export const fetchRepos = ({ state, dispatch }) => { .catch(() => { createFlash( sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), { - provider: state.provider, + provider, }), ); @@ -77,16 +82,23 @@ export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, rep export const receiveJobsSuccess = ({ commit }, updatedProjects) => commit(types.RECEIVE_JOBS_SUCCESS, updatedProjects); export const fetchJobs = ({ state, dispatch }) => { - if (eTagPoll) return; + const { filter } = state; + + if (eTagPoll) { + stopJobsPolling(); + clearJobsEtagPoll(); + } eTagPoll = new Poll({ resource: { - fetchJobs: () => axios.get(state.jobsPath), + fetchJobs: () => axios.get(jobsPathWithFilter(state)), }, method: 'fetchJobs', successCallback: ({ data }) => dispatch('receiveJobsSuccess', convertObjectPropsToCamelCase(data, { deep: true })), - errorCallback: () => createFlash(s__('ImportProjects|Updating the imported projects failed')), + errorCallback: () => + createFlash(s__('ImportProjects|Update of imported projects with realtime changes failed')), + data: { filter }, }); if (!Visibility.hidden()) { diff --git a/app/assets/javascripts/import_projects/store/getters.js b/app/assets/javascripts/import_projects/store/getters.js index 727b80765bd..b107c293181 100644 --- a/app/assets/javascripts/import_projects/store/getters.js +++ b/app/assets/javascripts/import_projects/store/getters.js @@ -20,3 +20,8 @@ export const isImportingAnyRepo = state => state.reposBeingImported.length > 0; export const hasProviderRepos = state => state.providerRepos.length > 0; export const hasImportedProjects = state => state.importedProjects.length > 0; + +export const reposPathWithFilter = ({ reposPath, filter = '' }) => + filter ? `${reposPath}?filter=${filter}` : reposPath; +export const jobsPathWithFilter = ({ jobsPath, filter = '' }) => + filter ? `${jobsPath}?filter=${filter}` : jobsPath; diff --git a/app/assets/javascripts/import_projects/store/mutation_types.js b/app/assets/javascripts/import_projects/store/mutation_types.js index 6ba3fd6f29e..16574f4450f 100644 --- a/app/assets/javascripts/import_projects/store/mutation_types.js +++ b/app/assets/javascripts/import_projects/store/mutation_types.js @@ -9,3 +9,5 @@ export const RECEIVE_IMPORT_SUCCESS = 'RECEIVE_IMPORT_SUCCESS'; export const RECEIVE_IMPORT_ERROR = 'RECEIVE_IMPORT_ERROR'; export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS'; + +export const SET_FILTER = 'SET_FILTER'; diff --git a/app/assets/javascripts/import_projects/store/mutations.js b/app/assets/javascripts/import_projects/store/mutations.js index b88de0268e7..6c56cfa8298 100644 --- a/app/assets/javascripts/import_projects/store/mutations.js +++ b/app/assets/javascripts/import_projects/store/mutations.js @@ -6,6 +6,10 @@ export default { Object.assign(state, data); }, + [types.SET_FILTER](state, filter) { + state.filter = filter; + }, + [types.REQUEST_REPOS](state) { state.isLoadingRepos = true; }, diff --git a/app/assets/javascripts/import_projects/store/state.js b/app/assets/javascripts/import_projects/store/state.js index 637fef6e53c..829f3aa4fbb 100644 --- a/app/assets/javascripts/import_projects/store/state.js +++ b/app/assets/javascripts/import_projects/store/state.js @@ -12,4 +12,5 @@ export default () => ({ isLoadingRepos: false, canSelectNamespace: false, ciCdOnly: false, + filter: '', }); diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js index a7746bb3a0b..1c9b94ade8a 100644 --- a/app/assets/javascripts/integrations/integration_settings_form.js +++ b/app/assets/javascripts/integrations/integration_settings_form.js @@ -42,6 +42,7 @@ export default class IntegrationSettingsForm { // and test the service using provided configuration. if (this.$form.get(0).checkValidity() && this.canTestService) { e.preventDefault(); + // eslint-disable-next-line no-jquery/no-serialize this.testSettings(this.$form.serialize()); } } diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js index c855f3973b0..45de287d44d 100644 --- a/app/assets/javascripts/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -1,4 +1,4 @@ -/* eslint-disable consistent-return, func-names, array-callback-return, prefer-arrow-callback */ +/* eslint-disable consistent-return, func-names, array-callback-return */ import $ from 'jquery'; import _ from 'underscore'; @@ -45,7 +45,7 @@ export default { this.getSelectedIssues().map(function() { const labelsData = $(this).data('labels'); if (labelsData) { - return labelsData.map(function(labelId) { + return labelsData.map(labelId => { if (labels.indexOf(labelId) === -1) { return labels.push(labelId); } diff --git a/app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue b/app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue new file mode 100644 index 00000000000..06c50f62aab --- /dev/null +++ b/app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue @@ -0,0 +1,23 @@ +<script> +export default { + props: { + signedIn: { + type: Boolean, + required: true, + }, + sidebarStatusClass: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> + +<template> + <aside + :class="sidebarStatusClass" + class="right-sidebar js-right-sidebar js-issuable-sidebar" + aria-live="polite" + ></aside> +</template> diff --git a/app/assets/javascripts/issuable_sidebar/sidebar_bundle.js b/app/assets/javascripts/issuable_sidebar/sidebar_bundle.js new file mode 100644 index 00000000000..c8acafa8cd8 --- /dev/null +++ b/app/assets/javascripts/issuable_sidebar/sidebar_bundle.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; + +import SidebarApp from './components/sidebar_app.vue'; + +export default () => { + const el = document.getElementById('js-vue-issuable-sidebar'); + + if (!el) { + return false; + } + + const { sidebarStatusClass } = el.dataset; + // An empty string is present when user is signed in. + const signedIn = el.dataset.signedIn === ''; + + return new Vue({ + el, + components: { SidebarApp }, + render: createElement => + createElement('sidebar-app', { + props: { + signedIn, + sidebarStatusClass, + }, + }), + }); +}; diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 88975c2cc73..b8b3a4f44fd 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -102,10 +102,10 @@ export default { required: false, default: '', }, - issuableTemplates: { - type: Array, + issuableTemplateNamesPath: { + type: String, required: false, - default: () => [], + default: '', }, markdownPreviewPath: { type: String, @@ -156,9 +156,13 @@ export default { store, state: store.state, showForm: false, + templatesRequested: false, }; }, computed: { + issuableTemplates() { + return this.store.formState.issuableTemplates; + }, formState() { return this.store.formState; }, @@ -233,6 +237,7 @@ export default { } return undefined; }, + updateStoreState() { return this.service .getData() @@ -245,7 +250,7 @@ export default { }); }, - openForm() { + updateAndShowForm(templates = []) { if (!this.showForm) { this.showForm = true; this.store.setFormState({ @@ -254,9 +259,32 @@ export default { lock_version: this.state.lock_version, lockedWarningVisible: false, updateLoading: false, + issuableTemplates: templates, + }); + } + }, + + requestTemplatesAndShowForm() { + return this.service + .loadTemplates(this.issuableTemplateNamesPath) + .then(res => { + this.updateAndShowForm(res.data); + }) + .catch(() => { + createFlash(this.defaultErrorMessage); + this.updateAndShowForm(); }); + }, + + openForm() { + if (!this.templatesRequested) { + this.templatesRequested = true; + this.requestTemplatesAndShowForm(); + } else { + this.updateAndShowForm(this.issuableTemplates); } }, + closeForm() { this.showForm = false; }, diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index 5a9dd91817e..e170d338408 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,8 +1,6 @@ import Vue from 'vue'; -import { initSidebarTracking } from 'ee_else_ce/event_tracking/issue_sidebar'; import issuableApp from './components/app.vue'; import { parseIssuableData } from './utils/parse_data'; -import '../vue_shared/vue_resource_interceptor'; export default function initIssueableApp() { return new Vue({ @@ -10,9 +8,6 @@ export default function initIssueableApp() { components: { issuableApp, }, - mounted() { - initSidebarTracking(); - }, render(createElement) { return createElement('issuable-app', { props: parseIssuableData(), diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js index 3c8334bee50..b1deeaae0fc 100644 --- a/app/assets/javascripts/issue_show/services/index.js +++ b/app/assets/javascripts/issue_show/services/index.js @@ -17,4 +17,13 @@ export default class Service { updateIssuable(data) { return axios.put(this.endpoint, data); } + + // eslint-disable-next-line class-methods-use-this + loadTemplates(templateNamesEndpoint) { + if (!templateNamesEndpoint) { + return Promise.resolve([]); + } + + return axios.get(templateNamesEndpoint); + } } diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js index 3c17e73ccec..688ba7b268d 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -1,4 +1,6 @@ +import _ from 'underscore'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import updateDescription from '../utils/update_description'; export default class Store { constructor(initialState) { @@ -9,6 +11,7 @@ export default class Store { lockedWarningVisible: false, updateLoading: false, lock_version: 0, + issuableTemplates: [], }; } @@ -18,8 +21,15 @@ export default class Store { } Object.assign(this.state, convertObjectPropsToCamelCase(data)); + // find if there is an open details node inside of the issue description. + const descriptionSection = document.body.querySelector( + '.detail-page-description.content-block', + ); + const details = + !_.isNull(descriptionSection) && descriptionSection.getElementsByTagName('details'); + + this.state.descriptionHtml = updateDescription(data.description, details); this.state.titleHtml = data.title; - this.state.descriptionHtml = data.description; this.state.lock_version = data.lock_version; } diff --git a/app/assets/javascripts/issue_show/utils/update_description.js b/app/assets/javascripts/issue_show/utils/update_description.js new file mode 100644 index 00000000000..315f6c23b02 --- /dev/null +++ b/app/assets/javascripts/issue_show/utils/update_description.js @@ -0,0 +1,38 @@ +import _ from 'underscore'; + +/** + * Function that replaces the open attribute for the <details> element. + * + * @param {String} descriptionHtml - The html string passed back from the server as a result of polling + * @param {Array} details - All detail nodes inside of the issue description. + */ + +const updateDescription = (descriptionHtml = '', details) => { + let detailNodes = details; + + if (_.isEmpty(details)) { + detailNodes = []; + } + + const placeholder = document.createElement('div'); + placeholder.innerHTML = descriptionHtml; + + const newDetails = placeholder.getElementsByTagName('details'); + + if (newDetails.length !== detailNodes.length) { + return descriptionHtml; + } + + Array.from(newDetails).forEach((el, i) => { + /* + * <details> has an open attribute that can have a value, "", "true", "false" + * and will show the dropdown, which is why we are setting the attribute + * explicitly to true. + */ + if (detailNodes[i].open) el.setAttribute('open', true); + }); + + return placeholder.innerHTML; +}; + +export default updateDescription; diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue index 9fac880c5f8..8156f26ffb1 100644 --- a/app/assets/javascripts/jobs/components/commit_block.vue +++ b/app/assets/javascripts/jobs/components/commit_block.vue @@ -41,7 +41,7 @@ export default { <clipboard-button :text="commit.id" - :title="__('Copy commit SHA to clipboard')" + :title="__('Copy commit SHA')" css-class="btn btn-clipboard btn-transparent" /> diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue index 8cda7dac51f..163849d3c40 100644 --- a/app/assets/javascripts/jobs/components/environments_block.vue +++ b/app/assets/javascripts/jobs/components/environments_block.vue @@ -19,69 +19,18 @@ export default { }, computed: { environment() { - let environmentText; switch (this.deploymentStatus.status) { case 'last': - environmentText = sprintf( - __('This job is the most recent deployment to %{link}.'), - { link: this.environmentLink }, - false, - ); - break; + return this.lastEnvironmentMessage(); case 'out_of_date': - if (this.hasLastDeployment) { - environmentText = sprintf( - __( - 'This job is an out-of-date deployment to %{environmentLink}. View the most recent deployment %{deploymentLink}.', - ), - { - environmentLink: this.environmentLink, - deploymentLink: this.deploymentLink(`#${this.lastDeployment.iid}`), - }, - false, - ); - } else { - environmentText = sprintf( - __('This job is an out-of-date deployment to %{environmentLink}.'), - { environmentLink: this.environmentLink }, - false, - ); - } - - break; + return this.outOfDateEnvironmentMessage(); case 'failed': - environmentText = sprintf( - __('The deployment of this job to %{environmentLink} did not succeed.'), - { environmentLink: this.environmentLink }, - false, - ); - break; + return this.failedEnvironmentMessage(); case 'creating': - if (this.hasLastDeployment) { - environmentText = sprintf( - __( - 'This job is creating a deployment to %{environmentLink} and will overwrite the %{deploymentLink}.', - ), - { - environmentLink: this.environmentLink, - deploymentLink: this.deploymentLink(__('latest deployment')), - }, - false, - ); - } else { - environmentText = sprintf( - __('This job is creating a deployment to %{environmentLink}.'), - { environmentLink: this.environmentLink }, - false, - ); - } - break; + return this.creatingEnvironmentMessage(); default: - break; + return ''; } - return environmentText && this.hasCluster - ? `${environmentText} ${this.clusterText}` - : environmentText; }, environmentLink() { if (this.hasEnvironment) { @@ -137,11 +86,6 @@ export default { false, ); }, - clusterText() { - return this.hasCluster - ? sprintf(__('Cluster %{cluster} was used.'), { cluster: this.clusterNameOrLink }, false) - : ''; - }, }, methods: { deploymentLink(name) { @@ -155,6 +99,91 @@ export default { false, ); }, + failedEnvironmentMessage() { + const { environmentLink } = this; + + return sprintf( + __('The deployment of this job to %{environmentLink} did not succeed.'), + { environmentLink }, + false, + ); + }, + lastEnvironmentMessage() { + const { environmentLink, clusterNameOrLink, hasCluster } = this; + + const message = hasCluster + ? __('This job is deployed to %{environmentLink} using cluster %{clusterNameOrLink}.') + : __('This job is deployed to %{environmentLink}.'); + + return sprintf(message, { environmentLink, clusterNameOrLink }, false); + }, + outOfDateEnvironmentMessage() { + const { hasLastDeployment, hasCluster, environmentLink, clusterNameOrLink } = this; + + if (hasLastDeployment) { + const message = hasCluster + ? __( + 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink}. View the %{deploymentLink}.', + ) + : __( + 'This job is an out-of-date deployment to %{environmentLink}. View the %{deploymentLink}.', + ); + + return sprintf( + message, + { + environmentLink, + clusterNameOrLink, + deploymentLink: this.deploymentLink(__('most recent deployment')), + }, + false, + ); + } + + const message = hasCluster + ? __( + 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink}.', + ) + : __('This job is an out-of-date deployment to %{environmentLink}.'); + + return sprintf( + message, + { + environmentLink, + clusterNameOrLink, + }, + false, + ); + }, + creatingEnvironmentMessage() { + const { hasLastDeployment, hasCluster, environmentLink, clusterNameOrLink } = this; + + if (hasLastDeployment) { + const message = hasCluster + ? __( + 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink}. This will overwrite the %{deploymentLink}.', + ) + : __( + 'This job is creating a deployment to %{environmentLink}. This will overwrite the %{deploymentLink}.', + ); + + return sprintf( + message, + { + environmentLink, + clusterNameOrLink, + deploymentLink: this.deploymentLink(__('latest deployment')), + }, + false, + ); + } + + return sprintf( + __('This job is creating a deployment to %{environmentLink}.'), + { environmentLink }, + false, + ); + }, }, }; </script> diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index 36701a95673..859f839741f 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -30,7 +30,7 @@ export default { EnvironmentsBlock, ErasedBlock, Icon, - Log: () => (isNewJobLogActive() ? import('./job_log_json.vue') : import('./job_log.vue')), + Log: () => (isNewJobLogActive() ? import('./log/log.vue') : import('./job_log.vue')), LogTopBar, StuckBlock, UnmetPrerequisitesBlock, @@ -130,6 +130,10 @@ export default { return title; }, + + shouldRenderHeaderCallout() { + return this.shouldRenderCalloutMessage && !this.hasUnmetPrerequisitesFailure; + }, }, watch: { // Once the job log is loaded, @@ -239,10 +243,9 @@ export default { /> </div> - <callout - v-if="shouldRenderCalloutMessage && !hasUnmetPrerequisitesFailure" - :message="job.callout_message" - /> + <callout v-if="shouldRenderHeaderCallout"> + <div v-html="job.callout_message"></div> + </callout> </header> <!-- EO Header Section --> diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue index a55dffbe488..7bd299bcfa0 100644 --- a/app/assets/javascripts/jobs/components/job_container_item.vue +++ b/app/assets/javascripts/jobs/components/job_container_item.vue @@ -54,7 +54,7 @@ export default { :href="job.status.details_path" :title="tooltipText" data-boundary="viewport" - class="js-job-link" + class="js-job-link d-flex" > <icon v-if="isActive" @@ -64,7 +64,7 @@ export default { <ci-icon :status="job.status" /> - <span>{{ job.name ? job.name : job.id }}</span> + <span class="text-truncate w-100">{{ job.name ? job.name : job.id }}</span> <icon v-if="job.retried" name="retry" class="js-retry-icon" /> </gl-link> diff --git a/app/assets/javascripts/jobs/components/job_log.vue b/app/assets/javascripts/jobs/components/job_log.vue index a3fbe9338ee..20888c0af42 100644 --- a/app/assets/javascripts/jobs/components/job_log.vue +++ b/app/assets/javascripts/jobs/components/job_log.vue @@ -19,18 +19,13 @@ export default { updated() { this.$nextTick(() => { this.handleScrollDown(); - this.handleCollapsibleRows(); }); }, mounted() { this.$nextTick(() => { this.handleScrollDown(); - this.handleCollapsibleRows(); }); }, - destroyed() { - this.removeEventListener(); - }, methods: { ...mapActions(['scrollBottom']), /** @@ -47,53 +42,6 @@ export default { }, 0); } }, - removeEventListener() { - this.$el.querySelectorAll('.js-section-start').forEach(el => { - const titleSection = el.nextSibling; - titleSection.removeEventListener( - 'click', - this.handleHeaderClick.bind(this, el, el.dataset.section), - ); - el.removeEventListener('click', this.handleSectionClick); - }); - }, - /** - * The collapsible rows are sent in HTML from the backend - * We need tos add a onclick handler for the divs that match `.js-section-start` - * - */ - handleCollapsibleRows() { - this.$el.querySelectorAll('.js-section-start').forEach(el => { - const titleSection = el.nextSibling; - titleSection.addEventListener( - 'click', - this.handleHeaderClick.bind(this, el, el.dataset.section), - ); - el.addEventListener('click', this.handleSectionClick); - }); - }, - - handleHeaderClick(arrowElement, section) { - this.updateToggleSection(arrowElement, section); - }, - - updateToggleSection(arrow, section) { - // toggle the arrow class - arrow.classList.toggle('fa-caret-right'); - arrow.classList.toggle('fa-caret-down'); - - // hide the sections - const sibilings = this.$el.querySelectorAll(`.js-s-${section}:not(.js-section-header)`); - sibilings.forEach(row => row.classList.toggle('hidden')); - }, - /** - * On click, we toggle the hidden class of - * all the rows that match the `data-section` selector - */ - handleSectionClick(evt) { - const clickedArrow = evt.currentTarget; - this.updateToggleSection(clickedArrow, clickedArrow.dataset.section); - }, }, }; </script> diff --git a/app/assets/javascripts/jobs/components/job_log_json.vue b/app/assets/javascripts/jobs/components/job_log_json.vue deleted file mode 100644 index 2198b20eb8f..00000000000 --- a/app/assets/javascripts/jobs/components/job_log_json.vue +++ /dev/null @@ -1,10 +0,0 @@ -<script> -export default { - name: 'JobLogJSON', -}; -</script> -<template> - <pre> - {{ __('This feature is in development. Please disable the `job_log_json` feature flag') }} - </pre> -</template> diff --git a/app/assets/javascripts/jobs/components/log/collapsible_section.vue b/app/assets/javascripts/jobs/components/log/collapsible_section.vue new file mode 100644 index 00000000000..0c7b78a3da7 --- /dev/null +++ b/app/assets/javascripts/jobs/components/log/collapsible_section.vue @@ -0,0 +1,51 @@ +<script> +import LogLine from './line.vue'; +import LogLineHeader from './line_header.vue'; + +export default { + name: 'CollpasibleLogSection', + components: { + LogLine, + LogLineHeader, + }, + props: { + section: { + type: Object, + required: true, + }, + traceEndpoint: { + type: String, + required: true, + }, + }, + computed: { + badgeDuration() { + return this.section.line && this.section.line.section_duration; + }, + }, + methods: { + handleOnClickCollapsibleLine(section) { + this.$emit('onClickCollapsibleLine', section); + }, + }, +}; +</script> +<template> + <div> + <log-line-header + :line="section.line" + :duration="badgeDuration" + :path="traceEndpoint" + :is-closed="section.isClosed" + @toggleLine="handleOnClickCollapsibleLine(section)" + /> + <template v-if="!section.isClosed"> + <log-line + v-for="line in section.lines" + :key="line.offset" + :line="line" + :path="traceEndpoint" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/log/duration_badge.vue b/app/assets/javascripts/jobs/components/log/duration_badge.vue index 31a101d2c95..8e5dcdcc902 100644 --- a/app/assets/javascripts/jobs/components/log/duration_badge.vue +++ b/app/assets/javascripts/jobs/components/log/duration_badge.vue @@ -9,7 +9,7 @@ export default { }; </script> <template> - <div class="log-duration-badge rounded align-self-start px-2 ml-2 flex-shrink-0"> + <div class="log-duration-badge rounded align-self-start px-2 ml-2 flex-shrink-0 ws-normal"> {{ duration }} </div> </template> diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue index 4e09c85b25a..33ee84bd4ee 100644 --- a/app/assets/javascripts/jobs/components/log/line.vue +++ b/app/assets/javascripts/jobs/components/log/line.vue @@ -19,10 +19,14 @@ export default { </script> <template> - <div class="log-line"> + <div class="js-line log-line"> <line-number :line-number="line.lineNumber" :path="path" /> - <span v-for="(content, i) in line.content" :key="i" :class="content.style">{{ - content.text - }}</span> + <span + v-for="(content, i) in line.content" + :key="i" + :class="content.style" + class="ws-pre-wrap" + >{{ content.text }}</span + > </div> </template> diff --git a/app/assets/javascripts/jobs/components/log/line_header.vue b/app/assets/javascripts/jobs/components/log/line_header.vue index 92cf3b3cf5f..85ccd5996b5 100644 --- a/app/assets/javascripts/jobs/components/log/line_header.vue +++ b/app/assets/javascripts/jobs/components/log/line_header.vue @@ -43,15 +43,19 @@ export default { <template> <div - class="log-line collapsible-line d-flex justify-content-between" + class="log-line collapsible-line d-flex justify-content-between ws-normal" role="button" @click="handleOnClick" > <icon :name="iconName" class="arrow position-absolute" /> <line-number :line-number="line.lineNumber" :path="path" /> - <span v-for="(content, i) in line.content" :key="i" class="line-text" :class="content.style">{{ - content.text - }}</span> + <span + v-for="(content, i) in line.content" + :key="i" + class="line-text w-100 ws-pre-wrap" + :class="content.style" + >{{ content.text }}</span + > <duration-badge v-if="duration" :duration="duration" /> </div> </template> diff --git a/app/assets/javascripts/jobs/components/log/line_number.vue b/app/assets/javascripts/jobs/components/log/line_number.vue index 6c76bef13d3..ae96c32874b 100644 --- a/app/assets/javascripts/jobs/components/log/line_number.vue +++ b/app/assets/javascripts/jobs/components/log/line_number.vue @@ -48,7 +48,7 @@ export default { <template> <gl-link :id="lineNumberId" - class="d-inline-block text-right position-absolute line-number" + class="d-inline-block text-right line-number flex-shrink-0" :href="buildLineNumber" >{{ parsedLineNumber }}</gl-link > diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue index 429796aeb4e..ef126166e8b 100644 --- a/app/assets/javascripts/jobs/components/log/log.vue +++ b/app/assets/javascripts/jobs/components/log/log.vue @@ -1,12 +1,12 @@ <script> import { mapState, mapActions } from 'vuex'; +import CollpasibleLogSection from './collapsible_section.vue'; import LogLine from './line.vue'; -import LogLineHeader from './line_header.vue'; export default { components: { + CollpasibleLogSection, LogLine, - LogLineHeader, }, computed: { ...mapState(['traceEndpoint', 'trace', 'isTraceComplete']), @@ -22,24 +22,13 @@ export default { <template> <code class="job-log d-block"> <template v-for="(section, index) in trace"> - <template v-if="section.isHeader"> - <log-line-header - :key="`collapsible-${index}`" - :line="section.line" - :duration="section.section_duration" - :path="traceEndpoint" - :is-closed="section.isClosed" - @toggleLine="handleOnClickCollapsibleLine(section)" - /> - <template v-if="!section.isClosed"> - <log-line - v-for="line in section.lines" - :key="line.offset" - :line="line" - :path="traceEndpoint" - /> - </template> - </template> + <collpasible-log-section + v-if="section.isHeader" + :key="`collapsible-${index}`" + :section="section" + :trace-endpoint="traceEndpoint" + @onClickCollapsibleLine="handleOnClickCollapsibleLine" + /> <log-line v-else :key="section.offset" :line="section" :path="traceEndpoint" /> </template> diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js index 540c3e2ad69..77c68cac4a6 100644 --- a/app/assets/javascripts/jobs/store/mutations.js +++ b/app/assets/javascripts/jobs/store/mutations.js @@ -19,15 +19,14 @@ export default { state.isSidebarOpen = true; }, - [types.RECEIVE_TRACE_SUCCESS](state, log) { + [types.RECEIVE_TRACE_SUCCESS](state, log = {}) { if (log.state) { state.traceState = log.state; } if (log.append) { if (isNewJobLogActive()) { - state.originalTrace = state.originalTrace.concat(log.trace); - state.trace = updateIncrementalTrace(state.originalTrace, state.trace, log.lines); + state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace; } else { state.trace += log.html; } @@ -36,10 +35,9 @@ export default { // When the job still does not have a trace // the trace response will not have a defined // html or size. We keep the old value otherwise these - // will be set to `undefined` + // will be set to `null` if (isNewJobLogActive()) { - state.originalTrace = log.lines || state.trace; - state.trace = logLinesParser(log.lines) || state.trace; + state.trace = log.lines ? logLinesParser(log.lines) : state.trace; } else { state.trace = log.html || state.trace; } diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js index 585878f8240..cdc1780f3d6 100644 --- a/app/assets/javascripts/jobs/store/state.js +++ b/app/assets/javascripts/jobs/store/state.js @@ -19,7 +19,6 @@ export default () => ({ isScrolledToBottomBeforeReceivingTrace: true, trace: isNewJobLogActive() ? [] : '', - originalTrace: [], isTraceComplete: false, traceSize: 0, isTraceSizeVisible: false, diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js index 261ec90cd12..58e49f54d96 100644 --- a/app/assets/javascripts/jobs/store/utils.js +++ b/app/assets/javascripts/jobs/store/utils.js @@ -9,6 +9,85 @@ export const parseLine = (line = {}, lineNumber) => ({ }); /** + * When a line has `section_header` set to true, we create a new + * structure to allow to nest the lines that belong to the + * collpasible section + * + * @param Object line + * @param Number lineNumber + */ +export const parseHeaderLine = (line = {}, lineNumber) => ({ + isClosed: true, + isHeader: true, + line: parseLine(line, lineNumber), + lines: [], +}); + +/** + * Finds the matching header section + * for the section_duration object and adds it to it + * + * { + * isHeader: true, + * line: { + * content: [], + * lineNumber: 0, + * section_duration: "", + * }, + * lines: [] + * } + * + * @param Array data + * @param Object durationLine + */ +export function addDurationToHeader(data, durationLine) { + data.forEach(el => { + if (el.line && el.line.section === durationLine.section) { + el.line.section_duration = durationLine.section_duration; + } + }); +} + +/** + * Check is the current section belongs to a collapsible section + * + * @param Array acc + * @param Object last + * @param Object section + * + * @returns Boolean + */ +export const isCollapsibleSection = (acc = [], last = {}, section = {}) => + acc.length > 0 && + last.isHeader === true && + !section.section_duration && + section.section === last.line.section; + +/** + * Returns the lineNumber of the last line in + * a parsed log + * + * @param Array acc + * @returns Number + */ +export const getIncrementalLineNumber = acc => { + let lineNumberValue; + const lastIndex = acc.length - 1; + const lastElement = acc[lastIndex]; + const nestedLines = lastElement.lines; + + if (lastElement.isHeader && !nestedLines.length && lastElement.line) { + lineNumberValue = lastElement.line.lineNumber; + } else if (lastElement.isHeader && nestedLines.length) { + lineNumberValue = nestedLines[nestedLines.length - 1].lineNumber; + } else { + lineNumberValue = lastElement.lineNumber; + } + + return lineNumberValue === 0 ? 1 : lineNumberValue + 1; +}; + +/** * Parses the job log content into a structure usable by the template * * For collaspible lines (section_header = true): @@ -17,33 +96,71 @@ export const parseLine = (line = {}, lineNumber) => ({ * - adds a isHeader property to handle template logic * - adds the section_duration * For each line: - * - adds the index as lineNumber + * - adds the index as lineNumber * - * @param {Array} lines - * @returns {Array} + * @param Array lines + * @param Array accumulator + * @returns Array parsed log lines */ -export const logLinesParser = (lines = [], lineNumberStart) => - lines.reduce((acc, line, index) => { - const lineNumber = lineNumberStart ? lineNumberStart + index : index; - const last = acc[acc.length - 1]; - - if (line.section_header) { - acc.push({ - isClosed: true, - isHeader: true, - line: parseLine(line, lineNumber), - lines: [], - }); - } else if (acc.length && last.isHeader && !line.section_duration && line.content.length) { - last.lines.push(parseLine(line, lineNumber)); - } else if (acc.length && last.isHeader && line.section_duration) { - last.section_duration = line.section_duration; - } else if (line.content.length) { - acc.push(parseLine(line, lineNumber)); +export const logLinesParser = (lines = [], accumulator = []) => + lines.reduce( + (acc, line, index) => { + const lineNumber = accumulator.length > 0 ? getIncrementalLineNumber(acc) : index; + + const last = acc[acc.length - 1]; + + // If the object is an header, we parse it into another structure + if (line.section_header) { + acc.push(parseHeaderLine(line, lineNumber)); + } else if (isCollapsibleSection(acc, last, line)) { + // if the object belongs to a nested section, we append it to the new `lines` array of the + // previously formated header + last.lines.push(parseLine(line, lineNumber)); + } else if (line.section_duration) { + // if the line has section_duration, we look for the correct header to add it + addDurationToHeader(acc, line); + } else { + // otherwise it's a regular line + acc.push(parseLine(line, lineNumber)); + } + + return acc; + }, + [...accumulator], + ); + +/** + * Finds the repeated offset, removes the old one + * + * Returns a new array with the updated log without + * the repeated offset + * + * @param Array newLog + * @param Array oldParsed + * @returns Array + * + */ +export const findOffsetAndRemove = (newLog = [], oldParsed = []) => { + const cloneOldLog = [...oldParsed]; + const lastIndex = cloneOldLog.length - 1; + const last = cloneOldLog[lastIndex]; + + const firstNew = newLog[0]; + + if (last && firstNew) { + if (last.offset === firstNew.offset || (last.line && last.line.offset === firstNew.offset)) { + cloneOldLog.splice(lastIndex); + } else if (last.lines && last.lines.length) { + const lastNestedIndex = last.lines.length - 1; + const lastNested = last.lines[lastNestedIndex]; + if (lastNested.offset === firstNew.offset) { + last.lines.splice(lastNestedIndex); + } } + } - return acc; - }, []); + return cloneOldLog; +}; /** * When the trace is not complete, backend may send the last received line @@ -52,40 +169,13 @@ export const logLinesParser = (lines = [], lineNumberStart) => * We need to check if that is the case by looking for the offset property * before parsing the incremental part * - * @param array originalTrace * @param array oldLog * @param array newLog */ -export const updateIncrementalTrace = (originalTrace = [], oldLog = [], newLog = []) => { - const firstLine = newLog[0]; - const firstLineOffset = firstLine.offset; - - // We are going to return a new array, - // let's make a shallow copy to make sure we - // are not updating the state outside of a mutation first. - const cloneOldLog = [...oldLog]; +export const updateIncrementalTrace = (newLog = [], oldParsed = []) => { + const parsedLog = findOffsetAndRemove(newLog, oldParsed); - const lastIndex = cloneOldLog.length - 1; - const lastLine = cloneOldLog[lastIndex]; - - // The last line may be inside a collpasible section - // If it is, we use the not parsed saved log, remove the last element - // and parse the first received part togheter with the incremental log - if ( - lastLine.isHeader && - (lastLine.line.offset === firstLineOffset || - (lastLine.lines.length && - lastLine.lines[lastLine.lines.length - 1].offset === firstLineOffset)) - ) { - const cloneOriginal = [...originalTrace]; - cloneOriginal.splice(cloneOriginal.length - 1); - return logLinesParser(cloneOriginal.concat(newLog)); - } else if (lastLine.offset === firstLineOffset) { - cloneOldLog.splice(lastIndex); - return cloneOldLog.concat(logLinesParser(newLog, cloneOldLog.length)); - } - // there are no matches, let's parse the new log and return them together - return cloneOldLog.concat(logLinesParser(newLog, cloneOldLog.length)); + return logLinesParser(newLog, parsedLog); }; export const isNewJobLogActive = () => gon && gon.features && gon.features.jobLogJson; diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index b028e9564c9..72de3b5d726 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -1,4 +1,4 @@ -/* eslint-disable no-useless-return, func-names, no-var, no-underscore-dangle, prefer-arrow-callback, one-var, prefer-template, no-new, consistent-return, no-shadow, no-param-reassign, vars-on-top, no-lonely-if, no-else-return, dot-notation, no-empty */ +/* eslint-disable no-useless-return, func-names, no-var, no-underscore-dangle, one-var, no-new, consistent-return, no-shadow, no-param-reassign, vars-on-top, no-lonely-if, no-else-return, dot-notation, no-empty */ /* global Issuable */ /* global ListLabel */ @@ -24,7 +24,7 @@ export default class LabelsSelect { $els = $('.js-label-select'); } - $els.each(function(i, dropdown) { + $els.each((i, dropdown) => { var $block, $dropdown, $form, @@ -32,6 +32,7 @@ export default class LabelsSelect { $selectbox, $sidebarCollapsedValue, $value, + $dropdownMenu, abilityName, defaultLabel, issueUpdateURL, @@ -67,10 +68,11 @@ export default class LabelsSelect { $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip'); $value = $block.find('.value'); + $dropdownMenu = $dropdown.parent().find('.dropdown-menu'); $loading = $block.find('.block-loading').fadeOut(); fieldName = $dropdown.data('fieldName'); initialSelected = $selectbox - .find('input[name="' + $dropdown.data('fieldName') + '"]') + .find(`input[name="${$dropdown.data('fieldName')}"]`) .map(function() { return this.value; }) @@ -92,7 +94,7 @@ export default class LabelsSelect { var data, selected; selected = $dropdown .closest('.selectbox') - .find("input[name='" + fieldName + "']") + .find(`input[name='${fieldName}']`) .map(function() { return this.value; }) @@ -120,7 +122,7 @@ export default class LabelsSelect { labelCount = 0; if (data.labels.length && issueUpdateURL) { template = LabelsSelect.getLabelTemplate({ - labels: data.labels, + labels: _.sortBy(data.labels, 'title'), issueUpdateURL, enableScopedLabels: scopedLabels, scopedLabelsDocumentationLink, @@ -172,9 +174,7 @@ export default class LabelsSelect { $sidebarCollapsedValue.text(labelCount); if (data.labels.length) { - labelTitles = data.labels.map(function(label) { - return label.title; - }); + labelTitles = data.labels.map(label => label.title); if (labelTitles.length > 5) { labelTitles = labelTitles.slice(0, 5); @@ -269,11 +269,7 @@ export default class LabelsSelect { if ( $form.find( - "input[type='hidden'][name='" + - this.fieldName + - "'][value='" + - dropdownValue + - "']", + `input[type='hidden'][name='${this.fieldName}'][value='${dropdownValue}']`, ).length ) { selectedClass.push('is-active'); @@ -286,8 +282,7 @@ export default class LabelsSelect { } if (label.color) { - colorEl = - "<span class='dropdown-label-box' style='background: " + label.color + "'></span>"; + colorEl = `<span class='dropdown-label-box' style='background: ${label.color}'></span>`; } else { colorEl = ''; } @@ -456,16 +451,26 @@ export default class LabelsSelect { ); } else { var { labels } = boardsStore.detail.issue; - labels = labels.filter(function(selectedLabel) { - return selectedLabel.id !== label.id; - }); + labels = labels.filter(selectedLabel => selectedLabel.id !== label.id); boardsStore.detail.issue.labels = labels; } $loading.fadeIn(); + const oldLabels = boardsStore.detail.issue.labels; boardsStore.detail.issue .update($dropdown.attr('data-issue-update')) + .then(() => { + if (isScopedLabel(label)) { + const prevIds = oldLabels.map(label => label.id); + const newIds = boardsStore.detail.issue.labels.map(label => label.id); + const differentIds = _.difference(prevIds, newIds); + $dropdown.data('marked', newIds); + $dropdownMenu + .find(differentIds.map(id => `[data-label-id="${id}"]`).join(',')) + .removeClass('is-active'); + } + }) .then(fadeOutLoader) .catch(fadeOutLoader); } else if (handleClick) { diff --git a/app/assets/javascripts/lib/utils/axios_utils.js b/app/assets/javascripts/lib/utils/axios_utils.js index 37721cd030c..a04fe609015 100644 --- a/app/assets/javascripts/lib/utils/axios_utils.js +++ b/app/assets/javascripts/lib/utils/axios_utils.js @@ -1,5 +1,6 @@ import axios from 'axios'; import csrf from './csrf'; +import suppressAjaxErrorsDuringNavigation from './suppress_ajax_errors_during_navigation'; axios.defaults.headers.common[csrf.headerKey] = csrf.token; // Used by Rails to check if it is a valid XHR request @@ -8,23 +9,37 @@ axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; // Maintain a global counter for active requests // see: spec/support/wait_for_requests.rb axios.interceptors.request.use(config => { - window.activeVueResources = window.activeVueResources || 0; - window.activeVueResources += 1; + window.pendingRequests = window.pendingRequests || 0; + window.pendingRequests += 1; return config; }); // Remove the global counter axios.interceptors.response.use( response => { - window.activeVueResources -= 1; + window.pendingRequests -= 1; return response; }, err => { - window.activeVueResources -= 1; + window.pendingRequests -= 1; return Promise.reject(err); }, ); +let isUserNavigating = false; +window.addEventListener('beforeunload', () => { + isUserNavigating = true; +}); + +// Ignore AJAX errors caused by requests +// being cancelled due to browser navigation +const { gon } = window; +const featureFlagEnabled = gon && gon.features && gon.features.suppressAjaxNavigationErrors; +axios.interceptors.response.use( + response => response, + err => suppressAjaxErrorsDuringNavigation(err, isUserNavigating, featureFlagEnabled), +); + export default axios; /** diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 6e8f63a10a4..177ae4f9838 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -15,6 +15,8 @@ export const getPagePath = (index = 0) => { return page.split(':')[index]; }; +export const getDashPath = (path = window.location.pathname) => path.split('/-/')[1] || null; + export const isInGroupsPage = () => getPagePath() === 'groups'; export const isInProjectPage = () => getPagePath() === 'projects'; @@ -175,6 +177,15 @@ export const urlParamsToArray = (path = '') => export const getUrlParamsArray = () => urlParamsToArray(window.location.search); +/** + * Accepts encoding string which includes query params being + * sent to URL. + * + * @param {string} path Query param string + * + * @returns {object} Query params object containing key-value pairs + * with both key and values decoded into plain string. + */ export const urlParamsToObject = (path = '') => splitPath(path).reduce((dataParam, filterParam) => { if (filterParam === '') { @@ -183,6 +194,7 @@ export const urlParamsToObject = (path = '') => const data = dataParam; let [key, value] = filterParam.split('='); + key = /%\w+/g.test(key) ? decodeURIComponent(key) : key; const isArray = key.includes('[]'); key = key.replace('[]', ''); value = decodeURIComponent(value.replace(/\+/g, ' ')); diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index a4715789337..37b0215f6f9 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -537,13 +537,6 @@ export const stringifyTime = (timeObject, fullNameFormat = false) => { }; /** - * Accepts a time string of any size (e.g. '1w 2d 3h 5m' or '1w 2d') and returns - * the first non-zero unit/value pair. - */ -export const abbreviateTime = timeStr => - timeStr.split(' ').filter(unitStr => unitStr.charAt(0) !== '0')[0]; - -/** * Calculates the milliseconds between now and a given date string. * The result cannot become negative. * @@ -554,3 +547,20 @@ export const calculateRemainingMilliseconds = endDate => { const remainingMilliseconds = new Date(endDate).getTime() - Date.now(); return Math.max(remainingMilliseconds, 0); }; + +/** + * Subtracts a given number of days from a given date and returns the new date. + * + * @param {Date} date the date that we will substract days from + * @param {number} daysInPast number of days that are subtracted from a given date + * @returns {String} Date string in ISO format + */ +export const getDateInPast = (date, daysInPast) => { + const dateClone = newDate(date); + return new Date( + dateClone.setTime(dateClone.getTime() - daysInPast * 24 * 60 * 60 * 1000), + ).toISOString(); +}; + +export const beginOfDayTime = 'T00:00:00Z'; +export const endOfDayTime = 'T23:59:59Z'; diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js index 3439db1e326..cd509a13193 100644 --- a/app/assets/javascripts/lib/utils/notify.js +++ b/app/assets/javascripts/lib/utils/notify.js @@ -1,12 +1,14 @@ -/* eslint-disable func-names, no-var, consistent-return, prefer-arrow-callback, no-return-assign */ +/* eslint-disable no-var, consistent-return, no-return-assign */ function notificationGranted(message, opts, onclick) { var notification; notification = new Notification(message, opts); - setTimeout(function() { - // Hide the notification after X amount of seconds - return notification.close(); - }, 8000); + setTimeout( + () => + // Hide the notification after X amount of seconds + notification.close(), + 8000, + ); return (notification.onclick = onclick || notification.close); } @@ -32,7 +34,7 @@ function notifyMe(message, body, icon, onclick) { // If it's okay let's create a notification return notificationGranted(message, opts, onclick); } else if (Notification.permission !== 'denied') { - return Notification.requestPermission(function(permission) { + return Notification.requestPermission(permission => { // If the user accepts, let's create a notification if (permission === 'granted') { return notificationGranted(message, opts, onclick); diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index 61c8b8803d7..0f2cc57b1f9 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -106,3 +106,14 @@ export const sum = (a = 0, b = 0) => a + b; * @param {Int} number */ export const isOdd = (number = 0) => number % 2; + +/** + * Computes the median for a given array. + * @param {Array} arr An array of numbers + * @returns {Number} The median of the given array + */ +export const median = arr => { + const middle = Math.floor(arr.length / 2); + const sorted = arr.sort((a, b) => a - b); + return arr.length % 2 !== 0 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; +}; diff --git a/app/assets/javascripts/lib/utils/set.js b/app/assets/javascripts/lib/utils/set.js new file mode 100644 index 00000000000..3845d648b61 --- /dev/null +++ b/app/assets/javascripts/lib/utils/set.js @@ -0,0 +1,9 @@ +/** + * Checks if the first argument is a subset of the second argument. + * @param {Set} subset The set to be considered as the subset. + * @param {Set} superset The set to be considered as the superset. + * @returns {boolean} + */ +// eslint-disable-next-line import/prefer-default-export +export const isSubset = (subset, superset) => + Array.from(subset).every(value => superset.has(value)); diff --git a/app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js b/app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js new file mode 100644 index 00000000000..4c61da9b862 --- /dev/null +++ b/app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js @@ -0,0 +1,16 @@ +/** + * An Axios error interceptor that suppresses AJAX errors caused + * by the request being cancelled when the user navigates to a new page + */ +export default (err, isUserNavigating, featureFlagEnabled) => { + if (featureFlagEnabled && isUserNavigating && err.code === 'ECONNABORTED') { + // If the user is navigating away from the current page, + // prevent .then() and .catch() handlers from being + // called by returning a Promise that never resolves + return new Promise(() => {}); + } + + // The error is not related to browser navigation, + // so propagate the error + return Promise.reject(err); +}; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 7873eaf059f..2e0270ee42f 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, no-param-reassign, one-var, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, consistent-return */ +/* eslint-disable func-names, no-var, no-param-reassign, one-var, operator-assignment, no-else-return, consistent-return */ import $ from 'jquery'; import { insertText } from '~/lib/utils/common_utils'; @@ -218,7 +218,7 @@ export function insertMarkdownText({ : blockTagText(text, textArea, blockTag, selected); } else { textToInsert = selectedSplit - .map(function(val) { + .map(val => { if (tag.indexOf(textPlaceholder) > -1) { return tag.replace(textPlaceholder, val); } @@ -237,7 +237,7 @@ export function insertMarkdownText({ } if (removedFirstNewLine) { - textToInsert = '\n' + textToInsert; + textToInsert = `\n${textToInsert}`; } if (removedLastNewLine) { @@ -301,7 +301,7 @@ export function addMarkdownListeners(form) { export function addEditorMarkdownListeners(editor) { $('.js-md') .off('click') - .on('click', function(e) { + .on('click', e => { const { mdTag, mdBlock, mdPrepend, mdSelect } = $(e.currentTarget).data(); insertMarkdownText({ diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 7ead9d46fbb..4be0d05a9b7 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -88,6 +88,14 @@ export function getLocationHash(url = window.location.href) { } /** + * Returns a boolean indicating whether the URL hash contains the given string value + */ +export function doesHashExistInUrl(hashName) { + const hash = getLocationHash(); + return hash && hash.includes(hashName); +} + +/** * Apply the fragment to the given url by returning a new url string that includes * the fragment. If the given url already contains a fragment, the original fragment * will be removed. diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index 4db63c841a9..b6b96fe7bd5 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, no-underscore-dangle, no-param-reassign, prefer-template, consistent-return, one-var, no-else-return */ +/* eslint-disable func-names, no-var, no-underscore-dangle, no-param-reassign, consistent-return, one-var, no-else-return */ import $ from 'jquery'; @@ -106,7 +106,7 @@ LineHighlighter.prototype.clickHandler = function(event) { }; LineHighlighter.prototype.clearHighlight = function() { - return $('.' + this.highlightLineClass).removeClass(this.highlightLineClass); + return $(`.${this.highlightLineClass}`).removeClass(this.highlightLineClass); }; // Convert a URL hash String into line numbers @@ -137,7 +137,7 @@ LineHighlighter.prototype.hashToRange = function(hash) { // // lineNumber - Line number to highlight LineHighlighter.prototype.highlightLine = function(lineNumber) { - return $('#LC' + lineNumber).addClass(this.highlightLineClass); + return $(`#LC${lineNumber}`).addClass(this.highlightLineClass); }; // Highlight all lines within a range @@ -162,9 +162,9 @@ LineHighlighter.prototype.highlightRange = function(range) { LineHighlighter.prototype.setHash = function(firstLineNumber, lastLineNumber) { var hash; if (lastLineNumber) { - hash = '#L' + firstLineNumber + '-' + lastLineNumber; + hash = `#L${firstLineNumber}-${lastLineNumber}`; } else { - hash = '#L' + firstLineNumber; + hash = `#L${firstLineNumber}`; } this._hash = hash; return this.__setLocationHash__(hash); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 0ddf40b0405..c19a845eb69 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -37,6 +37,7 @@ import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; import { initUserTracking } from './tracking'; import { __ } from './locale'; +import initPrivacyPolicyUpdateCallout from './privacy_policy_update_callout'; import 'ee_else_ce/main_ee'; @@ -96,6 +97,7 @@ function deferredInitialisation() { initUsagePingConsent(); initUserPopovers(); initUserTracking(); + initPrivacyPolicyUpdateCallout(); if (document.querySelector('.search')) initSearchAutocomplete(); @@ -312,6 +314,7 @@ document.addEventListener('DOMContentLoaded', () => { const action = `${this.action}${link.search === '' ? '?' : '&'}`; event.preventDefault(); + // eslint-disable-next-line no-jquery/no-serialize visitUrl(`${action}${$(this).serialize()}`); }); diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 3b42a154af8..7223b5c0d43 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, no-underscore-dangle, one-var, consistent-return, prefer-arrow-callback */ +/* eslint-disable func-names, no-var, no-underscore-dangle, one-var, consistent-return */ import $ from 'jquery'; import { __ } from '~/locale'; @@ -105,7 +105,7 @@ MergeRequest.prototype.submitNoteForm = function(form, $button) { }; MergeRequest.prototype.initCommitMessageListeners = function() { - $(document).on('click', 'a.js-with-description-link', function(e) { + $(document).on('click', 'a.js-with-description-link', e => { var textarea = $('textarea.js-commit-message'); e.preventDefault(); @@ -114,7 +114,7 @@ MergeRequest.prototype.initCommitMessageListeners = function() { $('.js-without-description-hint').show(); }); - $(document).on('click', 'a.js-without-description-link', function(e) { + $(document).on('click', 'a.js-without-description-link', e => { var textarea = $('textarea.js-commit-message'); e.preventDefault(); diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index f3f3bf15295..78fe575717a 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -1,6 +1,6 @@ <script> -import { __ } from '~/locale'; -import { GlLink, GlButton } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import { GlLink, GlButton, GlTooltip } from '@gitlab/ui'; import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; import { debounceByAnimationFrame, roundOffFloat } from '~/lib/utils/common_utils'; @@ -16,6 +16,7 @@ export default { components: { GlAreaChart, GlLineChart, + GlTooltip, GlButton, GlChartSeriesLabel, GlLink, @@ -52,6 +53,21 @@ export default { required: false, default: () => [], }, + legendAverageText: { + type: String, + required: false, + default: s__('Metrics|Avg'), + }, + legendMaxText: { + type: String, + required: false, + default: s__('Metrics|Max'), + }, + groupId: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -62,6 +78,7 @@ export default { isDeployment: false, sha: '', }, + showTitleTooltip: false, width: 0, height: chartHeight, svgs: {}, @@ -122,7 +139,7 @@ export default { }, }, series: this.scatterSeries, - dataZoom: this.dataZoomConfig, + dataZoom: [this.dataZoomConfig], }; }, dataZoomConfig() { @@ -192,6 +209,12 @@ export default { watch: { containerWidth: 'onResize', }, + mounted() { + const graphTitleEl = this.$refs.graphTitle; + if (graphTitleEl && graphTitleEl.scrollWidth > graphTitleEl.offsetWidth) { + this.showTitleTooltip = true; + } + }, beforeDestroy() { window.removeEventListener('resize', debouncedResize); }, @@ -255,22 +278,32 @@ export default { <template> <div class="prometheus-graph"> <div class="prometheus-graph-header"> - <h5 class="prometheus-graph-title js-graph-title">{{ graphData.title }}</h5> - <div class="prometheus-graph-widgets js-graph-widgets"> + <h5 + ref="graphTitle" + class="prometheus-graph-title js-graph-title text-truncate append-right-8" + > + {{ graphData.title }} + </h5> + <gl-tooltip :target="() => $refs.graphTitle" :disabled="!showTitleTooltip"> + {{ graphData.title }} + </gl-tooltip> + <div class="prometheus-graph-widgets js-graph-widgets flex-fill"> <slot></slot> </div> </div> - <component :is="glChartComponent" ref="chart" v-bind="$attrs" + :group-id="groupId" :data="chartData" :option="chartOptions" :format-tooltip-text="formatTooltipText" :thresholds="thresholds" :width="width" :height="height" + :average-text="legendAverageText" + :max-text="legendMaxText" @updated="onChartUpdated" > <template v-if="tooltip.isDeployment"> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 12a4c83e053..b4ea415bb51 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,4 +1,7 @@ <script> +import _ from 'underscore'; +import { mapActions, mapState } from 'vuex'; +import VueDraggable from 'vuedraggable'; import { GlButton, GlDropdown, @@ -8,24 +11,26 @@ import { GlModalDirective, GlTooltipDirective, } from '@gitlab/ui'; -import _ from 'underscore'; -import { mapActions, mapState } from 'vuex'; import { __, s__ } from '~/locale'; +import createFlash from '~/flash'; import Icon from '~/vue_shared/components/icon.vue'; -import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility'; +import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; +import DateTimePicker from './date_time_picker/date_time_picker.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; -import { sidebarAnimationDuration, timeWindows } from '../constants'; -import { getTimeDiff, getTimeWindow } from '../utils'; +import { sidebarAnimationDuration } from '../constants'; +import TrackEventDirective from '~/vue_shared/directives/track_event'; +import { getTimeDiff, isValidDate, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; let sidebarMutationObserver; export default { components: { + VueDraggable, MonitorTimeSeriesChart, MonitorSingleStatChart, PanelType, @@ -37,10 +42,12 @@ export default { GlDropdownItem, GlFormGroup, GlModal, + DateTimePicker, }, directives: { GlModal: GlModalDirective, GlTooltip: GlTooltipDirective, + TrackEvent: TrackEventDirective, }, props: { externalDashboardUrl: { @@ -151,15 +158,19 @@ export default { required: false, default: false, }, + rearrangePanelsAvailable: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { state: 'gettingStarted', elWidth: 0, - selectedTimeWindow: '', - selectedTimeWindowKey: '', formIsValid: null, - timeWindows: {}, + selectedTimeWindow: {}, + isRearrangingPanels: false, }; }, computed: { @@ -175,7 +186,6 @@ export default { 'metricsWithData', 'useDashboardEndpoint', 'allDashboards', - 'multipleDashboardsEnabled', 'additionalPanelTypesEnabled', ]), firstDashboard() { @@ -184,6 +194,9 @@ export default { selectedDashboardText() { return this.currentDashboard || this.firstDashboard.display_name; }, + showRearrangePanelsBtn() { + return !this.showEmptyState && this.rearrangePanelsAvailable; + }, addingMetricsAvailable() { return IS_EE && this.canAddMetrics && !this.showEmptyState; }, @@ -219,11 +232,13 @@ export default { end, }; - this.timeWindows = timeWindows; - this.selectedTimeWindowKey = getTimeWindow(range); - this.selectedTimeWindow = this.timeWindows[this.selectedTimeWindowKey]; + this.selectedTimeWindow = range; - this.fetchData(range); + if (!isValidDate(start) || !isValidDate(end)) { + this.showInvalidDateError(); + } else { + this.fetchData(range); + } sidebarMutationObserver = new MutationObserver(this.onSidebarMutation); sidebarMutationObserver.observe(document.querySelector('.layout-page'), { @@ -272,9 +287,17 @@ export default { return Object.values(this.getGraphAlerts(queries)); }, showToast() { - this.$toast.show(__('Link copied to clipboard')); + this.$toast.show(__('Link copied')); }, // TODO: END + removeGraph(metrics, graphIndex) { + // At present graphs will not be removed, they should removed using the vuex store + // See https://gitlab.com/gitlab-org/gitlab/issues/27835 + metrics.splice(graphIndex, 1); + }, + showInvalidDateError() { + createFlash(s__('Metrics|Link contains an invalid time window.')); + }, generateLink(group, title, yLabel) { const dashboard = this.currentDashboard || this.firstDashboard.path; const params = _.pick({ dashboard, group, title, y_label: yLabel }, value => value != null); @@ -288,22 +311,23 @@ export default { this.elWidth = this.$el.clientWidth; }, sidebarAnimationDuration); }, + toggleRearrangingPanels() { + this.isRearrangingPanels = !this.isRearrangingPanels; + }, setFormValidity(isValid) { this.formIsValid = isValid; }, submitCustomMetricsForm() { this.$refs.customMetricsForm.submit(); }, - activeTimeWindow(key) { - return this.timeWindows[key] === this.selectedTimeWindow; - }, - setTimeWindowParameter(key) { - const { start, end } = getTimeDiff(key); - return `?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`; - }, groupHasData(group) { return this.chartsWithData(group.metrics).length > 0; }, + onDateTimePickerApply(timeWindowUrlParams) { + return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href)); + }, + downloadCSVOptions, + generateLinkToChartOptions, }, addMetric: { title: s__('Metrics|Add metric'), @@ -314,15 +338,14 @@ export default { <template> <div class="prometheus-graphs"> - <div class="gl-p-3 pb-0 border-bottom bg-gray-light"> + <div class="prometheus-graphs-header gl-p-3 pb-0 border-bottom bg-gray-light"> <div class="row"> <template v-if="environmentsEndpoint"> <gl-form-group - v-if="multipleDashboardsEnabled" :label="__('Dashboard')" label-size="sm" label-for="monitor-dashboards-dropdown" - class="col-sm-12 col-md-4 col-lg-2" + class="col-sm-12 col-md-6 col-lg-2" > <gl-dropdown id="monitor-dashboards-dropdown" @@ -345,7 +368,7 @@ export default { :label="s__('Metrics|Environment')" label-size="sm" label-for="monitor-environments-dropdown" - class="col-sm-6 col-md-4 col-lg-2" + class="col-sm-6 col-md-6 col-lg-2" > <gl-dropdown id="monitor-environments-dropdown" @@ -370,36 +393,35 @@ export default { :label="s__('Metrics|Show last')" label-size="sm" label-for="monitor-time-window-dropdown" - class="col-sm-6 col-md-4 col-lg-2" + class="col-sm-6 col-md-6 col-lg-4" > - <gl-dropdown - id="monitor-time-window-dropdown" - class="mb-0 d-flex js-time-window-dropdown" - toggle-class="dropdown-menu-toggle" - :text="selectedTimeWindow" - > - <gl-dropdown-item - v-for="(value, key) in timeWindows" - :key="key" - :active="activeTimeWindow(key)" - :href="setTimeWindowParameter(key)" - active-class="active" - >{{ value }}</gl-dropdown-item - > - </gl-dropdown> + <date-time-picker + :selected-time-window="selectedTimeWindow" + @onApply="onDateTimePickerApply" + /> </gl-form-group> </template> <gl-form-group - v-if="addingMetricsAvailable || externalDashboardUrl.length" + v-if="addingMetricsAvailable || showRearrangePanelsBtn || externalDashboardUrl.length" label-for="prometheus-graphs-dropdown-buttons" - class="dropdown-buttons col-lg d-lg-flex align-items-end" + class="dropdown-buttons col-md d-md-flex col-lg d-lg-flex align-items-end" > <div id="prometheus-graphs-dropdown-buttons"> <gl-button + v-if="showRearrangePanelsBtn" + :pressed="isRearrangingPanels" + variant="default" + class="mr-2 mt-1 js-rearrange-button" + @click="toggleRearrangingPanels" + > + {{ __('Arrange charts') }} + </gl-button> + <gl-button v-if="addingMetricsAvailable" v-gl-modal="$options.addMetric.modalId" - class="mr-2 mt-1 js-add-metric-button text-success border-success" + variant="outline-success" + class="mr-2 mt-1 js-add-metric-button" > {{ $options.addMetric.title }} </gl-button> @@ -453,17 +475,42 @@ export default { :collapse-group="groupHasData(groupData)" > <template v-if="additionalPanelTypesEnabled"> - <panel-type - v-for="(graphData, graphIndex) in groupData.metrics" - :key="`panel-type-${graphIndex}`" - class="col-12 col-lg-6 pb-3" - :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)" - :graph-data="graphData" - :dashboard-width="elWidth" - :alerts-endpoint="alertsEndpoint" - :prometheus-alerts-available="prometheusAlertsAvailable" - :index="`${index}-${graphIndex}`" - /> + <vue-draggable + :list="groupData.metrics" + group="metrics-dashboard" + :component-data="{ attrs: { class: 'row mx-0 w-100' } }" + :disabled="!isRearrangingPanels" + > + <div + v-for="(graphData, graphIndex) in groupData.metrics" + :key="`panel-type-${graphIndex}`" + class="col-12 col-lg-6 px-2 mb-2 draggable" + :class="{ 'draggable-enabled': isRearrangingPanels }" + > + <div class="position-relative draggable-panel js-draggable-panel"> + <div + v-if="isRearrangingPanels" + class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" + @click="removeGraph(groupData.metrics, graphIndex)" + > + <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')" + ><icon name="close" + /></a> + </div> + + <panel-type + :clipboard-text=" + generateLink(groupData.group, graphData.title, graphData.y_label) + " + :graph-data="graphData" + :dashboard-width="elWidth" + :alerts-endpoint="alertsEndpoint" + :prometheus-alerts-available="prometheusAlertsAvailable" + :index="`${index}-${graphIndex}`" + /> + </div> + </div> + </vue-draggable> </template> <template v-else> <monitor-time-series-chart @@ -477,7 +524,10 @@ export default { :project-path="projectPath" group-id="monitor-time-series-chart" > - <div class="d-flex align-items-center"> + <div + class="d-flex align-items-center" + :class="alertWidgetAvailable ? 'justify-content-between' : 'justify-content-end'" + > <alert-widget v-if="alertWidgetAvailable && graphData" :modal-id="`alert-modal-${index}-${graphIndex}`" @@ -488,7 +538,7 @@ export default { /> <gl-dropdown v-gl-tooltip - class="mx-2" + class="ml-2 mr-3" toggle-class="btn btn-transparent border-0" :right="true" :no-caret="true" @@ -497,10 +547,19 @@ export default { <template slot="button-content"> <icon name="ellipsis_v" class="text-secondary" /> </template> - <gl-dropdown-item :href="downloadCsv(graphData)" download="chart_metrics.csv"> + <gl-dropdown-item + v-track-event="downloadCSVOptions(graphData.title)" + :href="downloadCsv(graphData)" + download="chart_metrics.csv" + > {{ __('Download CSV') }} </gl-dropdown-item> <gl-dropdown-item + v-track-event=" + generateLinkToChartOptions( + generateLink(groupData.group, graphData.title, graphData.y_label), + ) + " class="js-chart-link" :data-clipboard-text=" generateLink(groupData.group, graphData.title, graphData.y_label) diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue new file mode 100644 index 00000000000..4616a767295 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue @@ -0,0 +1,151 @@ +<script> +import { GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import DateTimePickerInput from './date_time_picker_input.vue'; +import { + getTimeDiff, + getTimeWindow, + stringToISODate, + ISODateToString, + truncateZerosInDateTime, + isDateTimePickerInputValid, +} from '~/monitoring/utils'; +import { timeWindows } from '~/monitoring/constants'; + +export default { + components: { + Icon, + DateTimePickerInput, + GlFormGroup, + GlButton, + GlDropdown, + GlDropdownItem, + }, + props: { + timeWindows: { + type: Object, + required: false, + default: () => timeWindows, + }, + selectedTimeWindow: { + type: Object, + required: false, + default: () => {}, + }, + }, + data() { + return { + selectedTimeWindowText: '', + customTime: { + from: null, + to: null, + }, + }; + }, + computed: { + applyEnabled() { + return Boolean(this.inputState.from && this.inputState.to); + }, + inputState() { + const { from, to } = this.customTime; + return { + from: from && isDateTimePickerInputValid(from), + to: to && isDateTimePickerInputValid(to), + }; + }, + }, + mounted() { + const range = getTimeWindow(this.selectedTimeWindow); + if (range) { + this.selectedTimeWindowText = this.timeWindows[range]; + } else { + this.customTime = { + from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)), + to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)), + }; + this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime); + } + }, + methods: { + activeTimeWindow(key) { + return this.timeWindows[key] === this.selectedTimeWindowText; + }, + setCustomTimeWindowParameter() { + this.$emit('onApply', { + start: stringToISODate(this.customTime.from), + end: stringToISODate(this.customTime.to), + }); + }, + setTimeWindowParameter(key) { + const { start, end } = getTimeDiff(key); + this.$emit('onApply', { + start, + end, + }); + }, + closeDropdown() { + this.$refs.dropdown.hide(); + }, + }, +}; +</script> +<template> + <gl-dropdown + ref="dropdown" + :text="selectedTimeWindowText" + menu-class="time-window-dropdown-menu" + class="js-time-window-dropdown" + > + <div class="d-flex justify-content-between time-window-dropdown-menu-container"> + <gl-form-group + :label="__('Custom range')" + label-for="custom-from-time" + class="custom-time-range-form-group col-md-7 p-0 m-0" + > + <date-time-picker-input + id="custom-time-from" + v-model="customTime.from" + :label="__('From')" + :state="inputState.from" + /> + <date-time-picker-input + id="custom-time-to" + v-model="customTime.to" + :label="__('To')" + :state="inputState.to" + /> + <gl-form-group> + <gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button> + <gl-button + variant="success" + :disabled="!applyEnabled" + @click="setCustomTimeWindowParameter" + >{{ __('Apply') }}</gl-button + > + </gl-form-group> + </gl-form-group> + <gl-form-group + :label="__('Quick range')" + label-for="group-id-dropdown" + label-align="center" + class="col-md-4 p-0 m-0" + > + <gl-dropdown-item + v-for="(value, key) in timeWindows" + :key="key" + :active="activeTimeWindow(key)" + active-class="active" + @click="setTimeWindowParameter(key)" + > + <icon + name="mobile-issue-close" + class="align-bottom" + :class="{ invisible: !activeTimeWindow(key) }" + /> + {{ value }} + </gl-dropdown-item> + </gl-form-group> + </div> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue new file mode 100644 index 00000000000..0388a6190d9 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue @@ -0,0 +1,77 @@ +<script> +import _ from 'underscore'; +import { s__, sprintf } from '~/locale'; +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { dateFormats } from '~/monitoring/constants'; + +const inputGroupText = { + invalidFeedback: sprintf(s__('Format: %{dateFormat}'), { + dateFormat: dateFormats.dateTimePicker.format, + }), + placeholder: dateFormats.dateTimePicker.format, +}; + +export default { + components: { + GlFormGroup, + GlFormInput, + }, + props: { + state: { + default: null, + required: true, + validator: prop => typeof prop === 'boolean' || prop === null, + }, + value: { + default: null, + required: false, + validator: prop => typeof prop === 'string' || prop === null, + }, + label: { + type: String, + default: '', + required: true, + }, + id: { + type: String, + required: false, + default: () => _.uniqueId('dateTimePicker_'), + }, + }, + data() { + return { + inputGroupText, + }; + }, + computed: { + invalidFeedback() { + return this.state ? '' : this.inputGroupText.invalidFeedback; + }, + inputState() { + // When the state is valid we want to show no + // green outline. Hence passing null and not true. + if (this.state === true) { + return null; + } + return this.state; + }, + }, + methods: { + onInputBlur(e) { + this.$emit('input', e.target.value.trim() || null); + }, + }, +}; +</script> + +<template> + <gl-form-group :label="label" label-size="sm" :label-for="id" :invalid-feedback="invalidFeedback"> + <gl-form-input + :id="id" + :value="value" + :state="inputState" + :placeholder="inputGroupText.placeholder" + @blur="onInputBlur" + /> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue index da1e88071ab..7857aaa6ecc 100644 --- a/app/assets/javascripts/monitoring/components/embed.vue +++ b/app/assets/javascripts/monitoring/components/embed.vue @@ -98,7 +98,7 @@ export default { class="w-100" :graph-data="graphData" :container-width="elWidth" - group-id="monitor-area-chart" + :group-id="dashboardUrl" :project-path="null" :show-border="true" :single-embed="isSingleChart" diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue index 72ddd8d4fcf..ee3a2bae79b 100644 --- a/app/assets/javascripts/monitoring/components/graph_group.vue +++ b/app/assets/javascripts/monitoring/components/graph_group.vue @@ -52,7 +52,7 @@ export default { <div v-if="collapseGroup" v-show="collapseGroup && showGroup" - class="card-body prometheus-graph-group" + class="card-body prometheus-graph-group p-0" > <slot></slot> </div> diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue index 73ff651d510..1a14d06f4c8 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/panel_type.vue @@ -13,6 +13,8 @@ import Icon from '~/vue_shared/components/icon.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; import MonitorEmptyChart from './charts/empty_chart.vue'; +import TrackEventDirective from '~/vue_shared/directives/track_event'; +import { downloadCSVOptions, generateLinkToChartOptions } from '../utils'; export default { components: { @@ -27,6 +29,7 @@ export default { directives: { GlModal: GlModalDirective, GlTooltip: GlTooltipDirective, + TrackEvent: TrackEventDirective, }, props: { clipboardText: { @@ -82,8 +85,10 @@ export default { return this.graphData.type && this.graphData.type === type; }, showToast() { - this.$toast.show(__('Link copied to clipboard')); + this.$toast.show(__('Link copied')); }, + downloadCSVOptions, + generateLinkToChartOptions, }, }; </script> @@ -121,13 +126,18 @@ export default { <template slot="button-content"> <icon name="ellipsis_v" class="text-secondary" /> </template> - <gl-dropdown-item :href="downloadCsv" download="chart_metrics.csv"> + <gl-dropdown-item + v-track-event="downloadCSVOptions(graphData.title)" + :href="downloadCsv" + download="chart_metrics.csv" + > {{ __('Download CSV') }} </gl-dropdown-item> <gl-dropdown-item + v-track-event="generateLinkToChartOptions(clipboardText)" class="js-chart-link" :data-clipboard-text="clipboardText" - @click="showToast" + @click="showToast(clipboardText)" > {{ __('Generate link to chart') }} </gl-dropdown-item> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 13aba3d9f44..2836fe4fc26 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -3,6 +3,11 @@ import { __ } from '~/locale'; export const sidebarAnimationDuration = 300; // milliseconds. export const chartHeight = 300; +/** + * Valid strings for this regex are + * 2019-10-01 and 2019-10-01 01:02:03 + */ +export const dateTimePickerRegex = /^(\d{4})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])(?: (0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]))?$/; export const graphTypes = { deploymentData: 'scatter', @@ -28,6 +33,11 @@ export const timeWindows = { export const dateFormats = { timeOfDay: 'h:MM TT', default: 'dd mmm yyyy, h:MMTT', + dateTimePicker: { + format: 'yyyy-mm-dd hh:mm:ss', + ISODate: "yyyy-mm-dd'T'HH:MM:ss'Z'", + stringDate: 'yyyy-mm-dd HH:MM:ss', + }, }; export const secondsIn = { diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index 51cef20455c..6aa1fb5e9c6 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -14,7 +14,6 @@ export default (props = {}) => { if (gon.features) { store.dispatch('monitoringDashboard/setFeatureFlags', { prometheusEndpointEnabled: gon.features.environmentMetricsUsePrometheusEndpoint, - multipleDashboardsEnabled: gon.features.environmentMetricsShowMultipleDashboards, additionalPanelTypesEnabled: gon.features.environmentMetricsAdditionalPanelTypes, }); } diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 0cbad179f17..2cf34ddb45b 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -37,10 +37,9 @@ export const setEndpoints = ({ commit }, endpoints) => { export const setFeatureFlags = ( { commit }, - { prometheusEndpointEnabled, multipleDashboardsEnabled, additionalPanelTypesEnabled }, + { prometheusEndpointEnabled, additionalPanelTypesEnabled }, ) => { commit(types.SET_DASHBOARD_ENABLED, prometheusEndpointEnabled); - commit(types.SET_MULTIPLE_DASHBOARDS_ENABLED, multipleDashboardsEnabled); commit(types.SET_ADDITIONAL_PANEL_TYPES_ENABLED, additionalPanelTypesEnabled); }; @@ -51,13 +50,8 @@ export const setShowErrorBanner = ({ commit }, enabled) => { export const requestMetricsDashboard = ({ commit }) => { commit(types.REQUEST_METRICS_DATA); }; -export const receiveMetricsDashboardSuccess = ( - { state, commit, dispatch }, - { response, params }, -) => { - if (state.multipleDashboardsEnabled) { - commit(types.SET_ALL_DASHBOARDS, response.all_dashboards); - } +export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response, params }) => { + commit(types.SET_ALL_DASHBOARDS, response.all_dashboards); commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard.panel_groups); dispatch('fetchPrometheusMetrics', params); }; diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 4b1aadbcf05..9c546427c6e 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -10,7 +10,6 @@ export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAIL export const SET_QUERY_RESULT = 'SET_QUERY_RESULT'; export const SET_TIME_WINDOW = 'SET_TIME_WINDOW'; export const SET_DASHBOARD_ENABLED = 'SET_DASHBOARD_ENABLED'; -export const SET_MULTIPLE_DASHBOARDS_ENABLED = 'SET_MULTIPLE_DASHBOARDS_ENABLED'; export const SET_ADDITIONAL_PANEL_TYPES_ENABLED = 'SET_ADDITIONAL_PANEL_TYPES_ENABLED'; export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS'; export const SET_ENDPOINTS = 'SET_ENDPOINTS'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index b19520d6638..320b33d3d69 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,6 +1,8 @@ import Vue from 'vue'; import * as types from './mutation_types'; -import { normalizeMetrics, sortMetrics, normalizeQueryResult } from './utils'; +import { normalizeMetrics, sortMetrics, normalizeMetric, normalizeQueryResult } from './utils'; + +const normalizePanel = panel => panel.metrics.map(normalizeMetric); export default { [types.REQUEST_METRICS_DATA](state) { @@ -9,13 +11,19 @@ export default { }, [types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) { state.groups = groupData.map(group => { - let { metrics } = group; + let { metrics = [], panels = [] } = group; + + // each panel has metric information that needs to be normalized + panels = panels.map(panel => ({ + ...panel, + metrics: normalizePanel(panel), + })); // for backwards compatibility, and to limit Vue template changes: // for each group alias panels to metrics // for each panel alias metrics to queries if (state.useDashboardEndpoint) { - metrics = group.panels.map(panel => ({ + metrics = panels.map(panel => ({ ...panel, queries: panel.metrics, })); @@ -23,6 +31,7 @@ export default { return { ...group, + panels, metrics: normalizeMetrics(sortMetrics(metrics)), }; }); @@ -80,9 +89,6 @@ export default { [types.SET_DASHBOARD_ENABLED](state, enabled) { state.useDashboardEndpoint = enabled; }, - [types.SET_MULTIPLE_DASHBOARDS_ENABLED](state, enabled) { - state.multipleDashboardsEnabled = enabled; - }, [types.SET_GETTING_STARTED_EMPTY_STATE](state) { state.emptyState = 'gettingStarted'; }, diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index 440bdc951e0..e894e988f6a 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -8,7 +8,6 @@ export default () => ({ deploymentsEndpoint: null, dashboardEndpoint: invalidUrl, useDashboardEndpoint: false, - multipleDashboardsEnabled: false, additionalPanelTypesEnabled: false, emptyState: 'gettingStarted', showEmptyState: true, diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index 938ee2f0a9a..a19829f0c65 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -63,6 +63,25 @@ export function groupQueriesByChartInfo(metrics) { return Object.values(metricsByChart); } +export const uniqMetricsId = metric => `${metric.metric_id}_${metric.id}`; + +/** + * Not to confuse with normalizeMetrics (plural) + * Metrics loaded from project-defined dashboards do not have a metric_id. + * This method creates a unique ID combining metric_id and id, if either is present. + * This is hopefully a temporary solution until BE processes metrics before passing to fE + * @param {Object} metric - metric + * @returns {Object} - normalized metric with a uniqueID + */ +export const normalizeMetric = (metric = {}) => + _.omit( + { + ...metric, + metric_id: uniqMetricsId(metric), + }, + 'id', + ); + export const sortMetrics = metrics => _.chain(metrics) .sortBy('title') diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 46b01f753f8..4c72f5226b7 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -1,4 +1,7 @@ -import { secondsIn, timeWindowsKeyNames } from './constants'; +import dateformat from 'dateformat'; +import { secondsIn, dateTimePickerRegex, dateFormats } from './constants'; + +const secondsToMilliseconds = seconds => seconds * 1000; export const getTimeDiff = timeWindow => { const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds @@ -6,18 +9,60 @@ export const getTimeDiff = timeWindow => { const start = end - difference; return { - start: new Date(start * 1000).toISOString(), - end: new Date(end * 1000).toISOString(), + start: new Date(secondsToMilliseconds(start)).toISOString(), + end: new Date(secondsToMilliseconds(end)).toISOString(), }; }; export const getTimeWindow = ({ start, end }) => Object.entries(secondsIn).reduce((acc, [timeRange, value]) => { - if (end - start === value) { + if (new Date(end) - new Date(start) === secondsToMilliseconds(value)) { return timeRange; } return acc; - }, timeWindowsKeyNames.eightHours); + }, null); + +export const isDateTimePickerInputValid = val => dateTimePickerRegex.test(val); + +export const truncateZerosInDateTime = datetime => datetime.replace(' 00:00:00', ''); + +/** + * The URL params start and end need to be validated + * before passing them down to other components. + * + * @param {string} dateString + */ +export const isValidDate = dateString => { + try { + // dateformat throws error that can be caught. + // This is better than using `new Date()` + if (dateString && dateString.trim()) { + dateformat(dateString, 'isoDateTime'); + return true; + } + return false; + } catch { + return false; + } +}; + +/** + * Convert the input in Time picker component to ISO date. + * + * @param {string} val + * @returns {string} + */ +export const stringToISODate = val => + dateformat(new Date(val.replace(/-/g, '/')), dateFormats.dateTimePicker.ISODate, true); + +/** + * Convert the ISO date received from the URL to string + * for the Time picker component. + * + * @param {Date} date + * @returns {string} + */ +export const ISODateToString = date => dateformat(date, dateFormats.dateTimePicker.stringDate); /** * This method is used to validate if the graph data format for a chart component @@ -43,4 +88,47 @@ export const graphDataValidatorForValues = (isValues, graphData) => { ); }; +/* eslint-disable @gitlab/i18n/no-non-i18n-strings */ +/** + * Checks that element that triggered event is located on cluster health check dashboard + * @param {HTMLElement} element to check against + * @returns {boolean} + */ +const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show'); + +/** + * Tracks snowplow event when user generates link to metric chart + * @param {String} chart link that will be sent as a property for the event + * @return {Object} config object for event tracking + */ +export const generateLinkToChartOptions = chartLink => { + const isCLusterHealthBoard = isClusterHealthBoard(); + + const category = isCLusterHealthBoard + ? 'Cluster Monitoring' + : 'Incident Management::Embedded metrics'; + const action = isCLusterHealthBoard + ? 'generate_link_to_cluster_metric_chart' + : 'generate_link_to_metrics_chart'; + + return { category, action, label: 'Chart link', property: chartLink }; +}; + +/** + * Tracks snowplow event when user downloads CSV of cluster metric + * @param {String} chart title that will be sent as a property for the event + */ +export const downloadCSVOptions = title => { + const isCLusterHealthBoard = isClusterHealthBoard(); + + const category = isCLusterHealthBoard + ? 'Cluster Monitoring' + : 'Incident Management::Embedded metrics'; + const action = isCLusterHealthBoard + ? 'download_csv_of_cluster_metric_chart' + : 'download_csv_of_metrics_dashboard_chart'; + + return { category, action, label: 'Chart title', property: title }; +}; + export default {}; diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js index 4ddbec71ba6..8671f0fd783 100644 --- a/app/assets/javascripts/namespace_select.js +++ b/app/assets/javascripts/namespace_select.js @@ -1,6 +1,7 @@ -/* eslint-disable func-names, no-else-return, prefer-template, prefer-arrow-callback */ +/* eslint-disable no-else-return */ import $ from 'jquery'; +import '~/gl_dropdown'; import Api from './api'; import { mergeUrlParams } from './lib/utils/url_utility'; import { parseBoolean } from '~/lib/utils/common_utils'; @@ -23,11 +24,11 @@ export default class NamespaceSelect { if (selected.id == null) { return selected.text; } else { - return selected.kind + ': ' + selected.full_path; + return `${selected.kind}: ${selected.full_path}`; } }, data(term, dataCallback) { - return Api.namespaces(term, function(namespaces) { + return Api.namespaces(term, namespaces => { if (isFilter) { const anyNamespace = { text: __('Any namespace'), @@ -43,7 +44,7 @@ export default class NamespaceSelect { if (namespace.id == null) { return namespace.text; } else { - return namespace.kind + ': ' + namespace.full_path; + return `${namespace.kind}: ${namespace.full_path}`; } }, renderRow: this.renderRow, diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js index fcfc2570b3d..a0ba2193d90 100644 --- a/app/assets/javascripts/network/branch_graph.js +++ b/app/assets/javascripts/network/branch_graph.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, one-var, no-loop-func, consistent-return, prefer-template, prefer-arrow-callback, camelcase */ +/* eslint-disable func-names, no-var, one-var, no-loop-func, consistent-return, camelcase */ import $ from 'jquery'; import { __ } from '../locale'; @@ -223,7 +223,7 @@ export default (function() { shortrefs = commit.refs; // Truncate if longer than 15 chars if (shortrefs.length > 17) { - shortrefs = shortrefs.substr(0, 15) + '…'; + shortrefs = `${shortrefs.substr(0, 15)}…`; } text = r.text(x + 4, y, shortrefs).attr({ 'text-anchor': 'start', @@ -259,9 +259,7 @@ export default (function() { opacity: 0, cursor: 'pointer', }) - .click(function() { - return window.open(options.commit_url.replace('%s', commit.id), '_blank'); - }) + .click(() => window.open(options.commit_url.replace('%s', commit.id), '_blank')) .hover( function() { this.tooltip = r.commitTooltip(x + 5, y, commit); diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index 98522c67696..9f9db21d65b 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, one-var, consistent-return, no-return-assign, prefer-arrow-callback, prefer-template, no-shadow, no-else-return, @gitlab/i18n/no-non-i18n-strings */ +/* eslint-disable func-names, no-var, one-var, consistent-return, no-return-assign, no-shadow, no-else-return, @gitlab/i18n/no-non-i18n-strings */ import $ from 'jquery'; import RefSelectDropdown from './ref_select_dropdown'; @@ -63,17 +63,17 @@ export default class NewBranchForm { }; formatter = function(values, restriction) { var formatted; - formatted = values.map(function(value) { + formatted = values.map(value => { switch (false) { case !/\s/.test(value): return 'spaces'; case !/\/{2,}/g.test(value): return 'consecutive slashes'; default: - return "'" + value + "'"; + return `'${value}'`; } }); - return restriction.prefix + ' ' + formatted.join(restriction.conjunction); + return `${restriction.prefix} ${formatted.join(restriction.conjunction)}`; }; validator = (function(_this) { return function(errors, restriction) { diff --git a/app/assets/javascripts/notebook/cells/code.vue b/app/assets/javascripts/notebook/cells/code.vue index eefc801ed7a..1782e5bfe5a 100644 --- a/app/assets/javascripts/notebook/cells/code.vue +++ b/app/assets/javascripts/notebook/cells/code.vue @@ -49,6 +49,7 @@ export default { v-if="hasOutput" :count="cell.execution_count" :outputs="outputs" + :metadata="cell.metadata" :code-css-class="codeCssClass" /> </div> diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue index 98b6cdd0944..470d8c87d59 100644 --- a/app/assets/javascripts/notebook/cells/code/index.vue +++ b/app/assets/javascripts/notebook/cells/code/index.vue @@ -26,6 +26,10 @@ export default { type: String, required: true, }, + metadata: { + type: Object, + default: () => ({}), + }, }, computed: { code() { @@ -36,6 +40,12 @@ export default { return type.charAt(0).toUpperCase() + type.slice(1); }, + cellCssClass() { + return { + [this.codeCssClass]: true, + 'jupyter-notebook-scrolled': this.metadata.scrolled, + }; + }, }, mounted() { Prism.highlightElement(this.$refs.code); @@ -46,6 +56,6 @@ export default { <template> <div :class="type"> <prompt :type="promptType" :count="count" /> - <pre ref="code" :class="codeCssClass" class="language-python" v-text="code"></pre> + <pre ref="code" :class="cellCssClass" class="language-python" v-text="code"></pre> </div> </template> diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue index b59ddd0d57a..d8b0e099bc4 100644 --- a/app/assets/javascripts/notebook/cells/output/index.vue +++ b/app/assets/javascripts/notebook/cells/output/index.vue @@ -19,6 +19,10 @@ export default { type: Array, required: true, }, + metadata: { + type: Object, + default: () => ({}), + }, }, methods: { outputType(output) { @@ -78,6 +82,7 @@ export default { :count="count" :index="index" :raw-code="rawCode(output)" + :metadata="metadata" :code-css-class="codeCssClass" /> </div> diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 9cc56b34c75..3715a91d599 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1,7 +1,7 @@ /* eslint-disable no-restricted-properties, func-names, no-var, camelcase, no-unused-expressions, one-var, default-case, -prefer-template, consistent-return, no-alert, no-return-assign, -no-param-reassign, prefer-arrow-callback, no-else-return, vars-on-top, +consistent-return, no-alert, no-return-assign, +no-param-reassign, no-else-return, vars-on-top, no-shadow, no-useless-escape, class-methods-use-this */ /* global ResolveService */ @@ -490,7 +490,7 @@ export default class Notes { diffAvatarContainer = row .prevAll('.line_holder') .first() - .find('.js-avatar-container.' + lineType + '_line'); + .find(`.js-avatar-container.${lineType}_line`); // is this the first note of discussion? discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); if (!discussionContainer.length) { @@ -506,16 +506,14 @@ export default class Notes { } else { // Merge new discussion HTML in var $notes = $discussion.find(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); - var contentContainerClass = - '.' + - $notes - .closest('.notes-content') - .attr('class') - .split(' ') - .join('.'); + var contentContainerClass = $notes + .closest('.notes-content') + .attr('class') + .split(' ') + .join('.'); row - .find(contentContainerClass + ' .content') + .find(`.${contentContainerClass} .content`) .append($notes.closest('.content').children()); } } else { @@ -722,7 +720,7 @@ export default class Notes { this.revertNoteEditForm($targetNote); $noteEntityEl.renderGFM(); // Find the note's `li` element by ID and replace it with the updated HTML - $note_li = $('.note-row-' + noteEntity.id); + $note_li = $(`.note-row-${noteEntity.id}`); $note_li.replaceWith($noteEntityEl); this.setupNewNote($noteEntityEl); @@ -1370,7 +1368,7 @@ export default class Notes { .find('li.system-note') .has('ul'); - $.each(systemNotes, function(index, systemNote) { + $.each(systemNotes, (index, systemNote) => { const $systemNote = $(systemNote); const headerMessage = $systemNote .find('.note-text') @@ -1461,6 +1459,7 @@ export default class Notes { getFormData($form) { const content = $form.find('.js-note-text').val(); return { + // eslint-disable-next-line no-jquery/no-serialize formData: $form.serialize(), formContent: _.escape(content), formAction: $form.attr('action'), diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue index 6bbf2fa6ee4..fad1bc67be7 100644 --- a/app/assets/javascripts/notes/components/discussion_actions.vue +++ b/app/assets/javascripts/notes/components/discussion_actions.vue @@ -58,6 +58,7 @@ export default { <div class="btn-group"> <resolve-discussion-button v-if="discussion.resolvable" + data-qa-selector="resolve_discussion_button" :is-resolving="isResolving" :button-title="resolveButtonTitle" @onClick="$emit('resolve')" diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index 743684e7046..6b1e3298f9a 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -1,13 +1,14 @@ <script> import $ from 'jquery'; import { mapGetters, mapActions } from 'vuex'; -import { getLocationHash } from '../../lib/utils/url_utility'; +import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility'; import Icon from '~/vue_shared/components/icon.vue'; import { DISCUSSION_FILTERS_DEFAULT_VALUE, HISTORY_ONLY_FILTER_VALUE, DISCUSSION_TAB_LABEL, DISCUSSION_FILTER_TYPES, + NOTE_UNDERSCORE, } from '../constants'; import notesEventHub from '../event_hub'; @@ -28,7 +29,9 @@ export default { }, data() { return { - currentValue: this.selectedValue, + currentValue: doesHashExistInUrl(NOTE_UNDERSCORE) + ? DISCUSSION_FILTERS_DEFAULT_VALUE + : this.selectedValue, defaultValue: DISCUSSION_FILTERS_DEFAULT_VALUE, displayFilters: true, }; @@ -50,7 +53,6 @@ export default { notesEventHub.$on('dropdownSelect', this.selectFilter); window.addEventListener('hashchange', this.handleLocationHash); - this.handleLocationHash(); }, mounted() { this.toggleCommentsForm(); diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 6cc873359da..89d434a60ba 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -149,9 +149,9 @@ export default { title="Add reaction" data-position="right" > - <icon css-classes="link-highlight award-control-icon-neutral" name="slight-smile" /> - <icon css-classes="link-highlight award-control-icon-positive" name="smiley" /> - <icon css-classes="link-highlight award-control-icon-super-positive" name="smiley" /> + <icon class="link-highlight award-control-icon-neutral" name="slight-smile" /> + <icon class="link-highlight award-control-icon-positive" name="smiley" /> + <icon class="link-highlight award-control-icon-super-positive" name="smiley" /> </a> </div> <reply-button @@ -168,7 +168,7 @@ export default { class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button" @click="onEdit" > - <icon name="pencil" css-classes="link-highlight" /> + <icon name="pencil" class="link-highlight" /> </button> </div> <div v-if="showDeleteAction" class="note-actions-item"> @@ -191,7 +191,7 @@ export default { data-toggle="dropdown" @click="closeTooltip" > - <icon css-classes="icon" name="ellipsis_v" /> + <icon class="icon" name="ellipsis_v" /> </button> <ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> <li v-if="canReportAsAbuse"> diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue index 1aeb07d6608..20551279aec 100644 --- a/app/assets/javascripts/notes/components/note_actions/reply_button.vue +++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue @@ -19,12 +19,14 @@ export default { <gl-button ref="button" v-gl-tooltip - class="note-action-button js-note-action-reply" + class="note-action-button" + data-track-event="click_button" + data-track-label="reply_comment_button" variant="transparent" :title="__('Reply to comment')" @click="$emit('startReplying')" > - <icon name="comment" css-classes="link-highlight" /> + <icon name="comment" class="link-highlight" /> </gl-button> </div> </template> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index ac743d9f4b8..cb1975a8962 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -306,7 +306,11 @@ export default { <template> <timeline-entry-item class="note note-discussion"> <div class="timeline-content"> - <div :data-discussion-id="discussion.id" class="discussion js-discussion-container"> + <div + :data-discussion-id="discussion.id" + class="discussion js-discussion-container" + data-qa-selector="discussion_content" + > <div v-if="shouldRenderDiffs" class="discussion-header note-wrapper"> <div v-once class="timeline-icon align-self-start flex-shrink-0"> <user-avatar-link diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 16a0fb3f33a..c6c97489e5e 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -1,7 +1,7 @@ <script> import { __ } from '~/locale'; import { mapGetters, mapActions } from 'vuex'; -import { getLocationHash } from '../../lib/utils/url_utility'; +import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility'; import Flash from '../../flash'; import * as constants from '../constants'; import eventHub from '../event_hub'; @@ -69,6 +69,7 @@ export default { 'commentsDisabled', 'getNoteableData', 'userCanReply', + 'discussionTabCounter', ]), noteableType() { return this.noteableData.noteableType; @@ -95,13 +96,13 @@ export default { } }, allDiscussions() { - if (this.discussonsCount) { - this.discussonsCount.textContent = this.allDiscussions.length; + if (this.discussionsCount && !this.isLoading) { + this.discussionsCount.textContent = this.discussionTabCounter; } }, }, created() { - this.discussonsCount = document.querySelector('.js-discussions-count'); + this.discussionsCount = document.querySelector('.js-discussions-count'); this.setNotesData(this.notesData); this.setNoteableData(this.noteableData); @@ -155,19 +156,17 @@ export default { this.isFetching = true; - return this.fetchDiscussions({ path: this.getNotesDataByProp('discussionsPath') }) - .then(() => { - this.initPolling(); - }) + return this.fetchDiscussions(this.getFetchDiscussionsConfig()) + .then(this.initPolling) .then(() => { this.setLoadingState(false); this.setNotesFetchedState(true); eventHub.$emit('fetchedNotesData'); this.isFetching = false; }) - .then(() => this.$nextTick()) - .then(() => this.startTaskList()) - .then(() => this.checkLocationHash()) + .then(this.$nextTick) + .then(this.startTaskList) + .then(this.checkLocationHash) .catch(() => { this.setLoadingState(false); this.setNotesFetchedState(true); @@ -198,9 +197,20 @@ export default { }, startReplying(discussionId) { return this.convertToDiscussion(discussionId) - .then(() => this.$nextTick()) + .then(this.$nextTick) .then(() => eventHub.$emit('startReplying', discussionId)); }, + getFetchDiscussionsConfig() { + const defaultConfig = { path: this.getNotesDataByProp('discussionsPath') }; + + if (doesHashExistInUrl(constants.NOTE_UNDERSCORE)) { + return Object.assign({}, defaultConfig, { + filter: constants.DISCUSSION_FILTERS_DEFAULT_VALUE, + persistFilter: false, + }); + } + return defaultConfig; + }, }, systemNote: constants.SYSTEM_NOTE, }; diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index bdfb6b8f105..68c117183a1 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -8,8 +8,6 @@ export const OPENED = 'opened'; export const REOPENED = 'reopened'; export const CLOSED = 'closed'; export const MERGED = 'merged'; -export const EMOJI_THUMBSUP = 'thumbsup'; -export const EMOJI_THUMBSDOWN = 'thumbsdown'; export const ISSUE_NOTEABLE_TYPE = 'issue'; export const EPIC_NOTEABLE_TYPE = 'epic'; export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest'; @@ -19,6 +17,7 @@ export const DESCRIPTION_TYPE = 'changed the description'; export const HISTORY_ONLY_FILTER_VALUE = 2; export const DISCUSSION_FILTERS_DEFAULT_VALUE = 0; export const DISCUSSION_TAB_LABEL = 'show'; +export const NOTE_UNDERSCORE = 'note_'; export const NOTEABLE_TYPE_MAPPING = { Issue: ISSUE_NOTEABLE_TYPE, diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index c70c0e4095c..30372103590 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import initNoteStats from 'ee_else_ce/event_tracking/notes'; import notesApp from './components/notes_app.vue'; import initDiscussionFilters from './discussion_filters'; import createStore from './stores'; @@ -39,9 +38,6 @@ document.addEventListener('DOMContentLoaded', () => { notesData: JSON.parse(notesDataset.notesData), }; }, - mounted() { - initNoteStats(); - }, render(createElement) { return createElement('notes-app', { props: { diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 6c236981a24..004035ea1d4 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -251,58 +251,80 @@ export const saveNote = ({ commit, dispatch }, noteData) => { } } - return dispatch(methodToDispatch, postData, { root: true }).then(res => { + const processErrors = res => { const { errors } = res; - const commandsChanges = res.commands_changes; + if (!errors || !Object.keys(errors).length) { + return res; + } - if (errors && Object.keys(errors).length) { - /* - The following reply means that quick actions have been successfully applied: + /* + The following reply means that quick actions have been successfully applied: - {"commands_changes":{},"valid":false,"errors":{"commands_only":["Commands applied"]}} - */ - if (hasQuickActions) { - eTagPoll.makeRequest(); + {"commands_changes":{},"valid":false,"errors":{"commands_only":["Commands applied"]}} + */ + if (hasQuickActions) { + eTagPoll.makeRequest(); - $('.js-gfm-input').trigger('clear-commands-cache.atwho'); - Flash(__('Commands applied'), 'notice', noteData.flashContainer); - } else { - throw new Error(__('Failed to save comment!')); - } + $('.js-gfm-input').trigger('clear-commands-cache.atwho'); + + const { commands_only: message } = errors; + Flash(message || __('Commands applied'), 'notice', noteData.flashContainer); + + return res; } - if (commandsChanges) { - if (commandsChanges.emoji_award) { - const votesBlock = $('.js-awards-block').eq(0); - - loadAwardsHandler() - .then(awardsHandler => { - awardsHandler.addAwardToEmojiBar(votesBlock, commandsChanges.emoji_award); - awardsHandler.scrollToAwards(); - }) - .catch(() => { - Flash( - __('Something went wrong while adding your award. Please try again.'), - 'alert', - noteData.flashContainer, - ); - }); - } + throw new Error(__('Failed to save comment!')); + }; - if (commandsChanges.spend_time != null || commandsChanges.time_estimate != null) { - sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res); - } + const processEmojiAward = res => { + const { commands_changes: commandsChanges } = res; + const { emoji_award: emojiAward } = commandsChanges || {}; + if (!emojiAward) { + return res; } - if (errors && errors.commands_only) { - Flash(errors.commands_only, 'notice', noteData.flashContainer); + const votesBlock = $('.js-awards-block').eq(0); + + return loadAwardsHandler() + .then(awardsHandler => { + awardsHandler.addAwardToEmojiBar(votesBlock, emojiAward); + awardsHandler.scrollToAwards(); + }) + .catch(() => { + Flash( + __('Something went wrong while adding your award. Please try again.'), + 'alert', + noteData.flashContainer, + ); + }) + .then(() => res); + }; + + const processTimeTracking = res => { + const { commands_changes: commandsChanges } = res; + const { spend_time: spendTime, time_estimate: timeEstimate } = commandsChanges || {}; + if (spendTime != null || timeEstimate != null) { + sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', { + commands_changes: commandsChanges, + }); } + + return res; + }; + + const removePlaceholder = res => { if (replyId) { commit(types.REMOVE_PLACEHOLDER_NOTES); } return res; - }); + }; + + return dispatch(methodToDispatch, postData, { root: true }) + .then(processErrors) + .then(processEmojiAward) + .then(processTimeTracking) + .then(removePlaceholder); }; const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => { @@ -430,10 +452,13 @@ export const updateResolvableDiscussionsCounts = ({ commit }) => export const submitSuggestion = ( { commit, dispatch }, { discussionId, noteId, suggestionId, flashContainer }, -) => - Api.applySuggestion(suggestionId) +) => { + const dispatchResolveDiscussion = () => + dispatch('resolveDiscussion', { discussionId }).catch(() => {}); + + return Api.applySuggestion(suggestionId) .then(() => commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId })) - .then(() => dispatch('resolveDiscussion', { discussionId }).catch(() => {})) + .then(dispatchResolveDiscussion) .catch(err => { const defaultMessage = __( 'Something went wrong while applying the suggestion. Please try again.', @@ -442,6 +467,7 @@ export const submitSuggestion = ( Flash(__(flashMessage), 'alert', flashContainer); }); +}; export const convertToDiscussion = ({ commit }, noteId) => commit(types.CONVERT_TO_DISCUSSION, noteId); diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index fa44ef2d057..e70f0238316 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -33,10 +33,11 @@ export default { }, [types.ADD_NEW_REPLY_TO_DISCUSSION](state, note) { - const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id); + const discussion = utils.findNoteObjectById(state.discussions, note.discussion_id); + const existingNote = discussion && utils.findNoteObjectById(discussion.notes, note.id); - if (noteObj) { - noteObj.notes.push(note); + if (discussion && !existingNote) { + discussion.notes.push(note); } }, diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js index 386a9b2c740..46e80ba72e3 100644 --- a/app/assets/javascripts/pager.js +++ b/app/assets/javascripts/pager.js @@ -56,6 +56,10 @@ export default { $('.content_list').append(html); if (count > 0) { this.offset += count; + + if (count < this.limit) { + this.disable = true; + } } else { this.disable = true; } diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue index e2fec3c7172..eb03baf4894 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue +++ b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue @@ -1,13 +1,13 @@ <script> import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; -import GlModal from '~/vue_shared/components/gl_modal.vue'; +import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; export default { components: { - GlModal, + GlModal: DeprecatedModal2, }, props: { url: { diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue index e8905b479ee..78aaa9df0ec 100644 --- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue +++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue @@ -1,37 +1,46 @@ <script> import _ from 'underscore'; -import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; +import { GlModal, GlButton, GlFormInput } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; export default { components: { - DeprecatedModal, + GlModal, + GlButton, + GlFormInput, }, props: { + title: { + type: String, + required: true, + }, + content: { + type: String, + required: true, + }, + action: { + type: String, + required: true, + }, + secondaryAction: { + type: String, + required: true, + }, deleteUserUrl: { type: String, - required: false, - default: '', + required: true, }, blockUserUrl: { type: String, - required: false, - default: '', - }, - deleteContributions: { - type: Boolean, - required: false, - default: false, + required: true, }, username: { type: String, - required: false, - default: '', + required: true, }, csrfToken: { type: String, - required: false, - default: '', + required: true, }, }, data() { @@ -40,32 +49,12 @@ export default { }; }, computed: { - title() { - const keepContributionsTitle = s__('AdminUsers|Delete User %{username}?'); - const deleteContributionsTitle = s__('AdminUsers|Delete User %{username} and contributions?'); - - return sprintf( - this.deleteContributions ? deleteContributionsTitle : keepContributionsTitle, - { - username: `'${_.escape(this.username)}'`, - }, - false, - ); + modalTitle() { + return sprintf(this.title, { username: this.username }); }, text() { - const keepContributionsText = s__(`AdminArea| - You are about to permanently delete the user %{username}. - Issues, merge requests, and groups linked to them will be transferred to a system-wide "Ghost-user". - To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead. - Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`); - - const deleteContributionsText = s__(`AdminArea| - You are about to permanently delete the user %{username}. - This will delete all of the issues, merge requests, and groups linked to them. - To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead. - Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`); return sprintf( - this.deleteContributions ? deleteContributionsText : keepContributionsText, + this.content, { username: `<strong>${_.escape(this.username)}</strong>`, strong_start: '<strong>', @@ -83,12 +72,7 @@ export default { false, ); }, - primaryButtonLabel() { - const keepContributionsLabel = s__('AdminUsers|Delete user'); - const deleteContributionsLabel = s__('AdminUsers|Delete user and contributions'); - return this.deleteContributions ? deleteContributionsLabel : keepContributionsLabel; - }, secondaryButtonLabel() { return s__('AdminUsers|Block user'); }, @@ -97,8 +81,12 @@ export default { }, }, methods: { + show() { + this.$refs.modal.show(); + }, onCancel() { this.enteredUsername = ''; + this.$refs.modal.hide(); }, onSecondaryAction() { const { form } = this.$refs; @@ -117,43 +105,28 @@ export default { </script> <template> - <deprecated-modal - id="delete-user-modal" - :title="title" - :text="text" - :primary-button-label="primaryButtonLabel" - :secondary-button-label="secondaryButtonLabel" - :submit-disabled="!canSubmit" - kind="danger" - @submit="onSubmit" - @cancel="onCancel" - > - <template slot="body" slot-scope="props"> - <p v-html="props.text"></p> + <gl-modal ref="modal" modal-id="delete-user-modal" :title="modalTitle" kind="danger"> + <template> + <p v-html="text"></p> <p v-html="confirmationTextLabel"></p> <form ref="form" :action="deleteUserUrl" method="post"> <input ref="method" type="hidden" name="_method" value="delete" /> <input :value="csrfToken" type="hidden" name="authenticity_token" /> - <input + <gl-form-input v-model="enteredUsername" + autofocus type="text" name="username" - class="form-control" - aria-labelledby="input-label" autocomplete="off" /> </form> </template> - <template slot="secondary-button"> - <button - :disabled="!canSubmit" - type="button" - class="btn js-secondary-button btn-warning" - data-dismiss="modal" - @click="onSecondaryAction" - > - {{ secondaryButtonLabel }} - </button> + <template slot="modal-footer"> + <gl-button variant="secondary" @click="onCancel">{{ s__('Cancel') }}</gl-button> + <gl-button :disabled="!canSubmit" variant="warning" @click="onSecondaryAction"> + {{ secondaryAction }} + </gl-button> + <gl-button :disabled="!canSubmit" variant="danger" @click="onSubmit">{{ action }}</gl-button> </template> - </deprecated-modal> + </gl-modal> </template> diff --git a/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue b/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue new file mode 100644 index 00000000000..a08d32028c3 --- /dev/null +++ b/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue @@ -0,0 +1,77 @@ +<script> +export default { + props: { + modalConfiguration: { + required: true, + type: Object, + }, + actionModals: { + required: true, + type: Object, + }, + csrfToken: { + required: true, + type: String, + }, + }, + data() { + return { + currentModalData: null, + }; + }, + computed: { + activeModal() { + if (!this.currentModalData) return null; + const { glModalAction: action } = this.currentModalData; + + return this.actionModals[action]; + }, + + modalProps() { + const { glModalAction: requestedAction } = this.currentModalData; + return { + ...this.modalConfiguration[requestedAction], + ...this.currentModalData, + csrfToken: this.csrfToken, + }; + }, + }, + + mounted() { + document.addEventListener('click', this.handleClick); + }, + + beforeDestroy() { + document.removeEventListener('click', this.handleClick); + }, + + methods: { + handleClick(e) { + const { glModalAction: action } = e.target.dataset; + if (!action) return; + + this.show(e.target.dataset); + e.preventDefault(); + }, + + show(modalData) { + const { glModalAction: requestedAction } = modalData; + if (!this.actionModals[requestedAction]) { + throw new Error(`Requested non-existing modal action ${requestedAction}`); + } + if (!this.modalConfiguration[requestedAction]) { + throw new Error(`Modal action ${requestedAction} has no configuration in HTML`); + } + + this.currentModalData = modalData; + + return this.$nextTick().then(() => { + this.$refs.modal.show(); + }); + }, + }, +}; +</script> +<template> + <div :is="activeModal" v-if="activeModal" ref="modal" v-bind="modalProps" /> +</template> diff --git a/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue b/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue new file mode 100644 index 00000000000..4c335cfb018 --- /dev/null +++ b/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue @@ -0,0 +1,70 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { sprintf } from '~/locale'; + +export default { + components: { + GlModal, + }, + props: { + title: { + type: String, + required: true, + }, + content: { + type: String, + required: true, + }, + action: { + type: String, + required: true, + }, + url: { + type: String, + required: true, + }, + username: { + type: String, + required: true, + }, + csrfToken: { + type: String, + required: true, + }, + method: { + type: String, + required: false, + default: 'put', + }, + }, + computed: { + modalTitle() { + return sprintf(this.title, { username: this.username }); + }, + }, + methods: { + show() { + this.$refs.modal.show(); + }, + submit() { + this.$refs.form.submit(); + }, + }, +}; +</script> +<template> + <gl-modal + ref="modal" + modal-id="user-operation-modal" + :title="modalTitle" + ok-variant="warning" + :ok-title="action" + @ok="submit" + > + <form ref="form" :action="url" method="post"> + <span v-html="content"></span> + <input ref="method" type="hidden" name="_method" :value="method" /> + <input :value="csrfToken" type="hidden" name="authenticity_token" /> + </form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js index 45046688b57..bc96e88351b 100644 --- a/app/assets/javascripts/pages/admin/users/index.js +++ b/app/assets/javascripts/pages/admin/users/index.js @@ -1,46 +1,65 @@ -import $ from 'jquery'; import Vue from 'vue'; import Translate from '~/vue_shared/translate'; +import ModalManager from './components/user_modal_manager.vue'; +import DeleteUserModal from './components/delete_user_modal.vue'; +import UserOperationConfirmationModal from './components/user_operation_confirmation_modal.vue'; import csrf from '~/lib/utils/csrf'; -import deleteUserModal from './components/delete_user_modal.vue'; +const MODAL_TEXTS_CONTAINER_SELECTOR = '#modal-texts'; +const MODAL_MANAGER_SELECTOR = '#user-modal'; +const ACTION_MODALS = { + deactivate: UserOperationConfirmationModal, + block: UserOperationConfirmationModal, + delete: DeleteUserModal, + 'delete-with-contributions': DeleteUserModal, +}; + +function loadModalsConfigurationFromHtml(modalsElement) { + const modalsConfiguration = {}; + + if (!modalsElement) { + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + throw new Error('Modals content element not found!'); + } + + Array.from(modalsElement.children).forEach(node => { + const { modal, ...config } = node.dataset; + modalsConfiguration[modal] = { + title: node.dataset.title, + ...config, + content: node.innerHTML, + }; + }); + + return modalsConfiguration; +} document.addEventListener('DOMContentLoaded', () => { Vue.use(Translate); - const deleteUserModalEl = document.getElementById('delete-user-modal'); + const modalConfiguration = loadModalsConfigurationFromHtml( + document.querySelector(MODAL_TEXTS_CONTAINER_SELECTOR), + ); - const deleteModal = new Vue({ - el: deleteUserModalEl, - data: { - deleteUserUrl: '', - blockUserUrl: '', - deleteContributions: '', - username: '', + // eslint-disable-next-line no-new + new Vue({ + el: MODAL_MANAGER_SELECTOR, + functional: true, + methods: { + show(...args) { + this.$refs.manager.show(...args); + }, }, - render(createElement) { - return createElement(deleteUserModal, { + render(h) { + return h(ModalManager, { + ref: 'manager', props: { - deleteUserUrl: this.deleteUserUrl, - blockUserUrl: this.blockUserUrl, - deleteContributions: this.deleteContributions, - username: this.username, + modalConfiguration, + actionModals: ACTION_MODALS, csrfToken: csrf.token, }, }); }, }); - - $(document).on('shown.bs.modal', event => { - if (event.relatedTarget.classList.contains('delete-user-button')) { - const buttonProps = event.relatedTarget.dataset; - deleteModal.deleteUserUrl = buttonProps.deleteUserUrl; - deleteModal.blockUserUrl = buttonProps.blockUserUrl; - deleteModal.deleteContributions = event.relatedTarget.hasAttribute( - 'data-delete-contributions', - ); - deleteModal.username = buttonProps.username; - } - }); }); diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index d51d411f3c6..5230bdf9cdd 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -1,9 +1,11 @@ /* eslint-disable class-methods-use-this, no-unneeded-ternary */ import $ from 'jquery'; +import '~/gl_dropdown'; import { visitUrl } from '~/lib/utils/url_utility'; import UsersSelect from '~/users_select'; import { isMetaClick } from '~/lib/utils/common_utils'; +import { addDelimiter } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; import flash from '~/flash'; import axios from '~/lib/utils/axios_utils'; @@ -145,8 +147,8 @@ export default class Todos { updateBadges(data) { $(document).trigger('todo:toggle', data.count); - document.querySelector('.todos-pending .badge').innerHTML = data.count; - document.querySelector('.todos-done .badge').innerHTML = data.done_count; + document.querySelector('.todos-pending .badge').innerHTML = addDelimiter(data.count); + document.querySelector('.todos-done .badge').innerHTML = addDelimiter(data.done_count); } goToTodoUrl(e) { diff --git a/app/assets/javascripts/pages/groups/registry/repositories/index.js b/app/assets/javascripts/pages/groups/registry/repositories/index.js new file mode 100644 index 00000000000..b663defad0e --- /dev/null +++ b/app/assets/javascripts/pages/groups/registry/repositories/index.js @@ -0,0 +1,3 @@ +import initRegistryImages from '~/registry'; + +document.addEventListener('DOMContentLoaded', initRegistryImages); diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js index 01ef3f1db2b..37b253d7c48 100644 --- a/app/assets/javascripts/pages/groups/shared/group_details.js +++ b/app/assets/javascripts/pages/groups/shared/group_details.js @@ -1,6 +1,6 @@ /* eslint-disable no-new */ -import { getPagePath } from '~/lib/utils/common_utils'; +import { getPagePath, getDashPath } from '~/lib/utils/common_utils'; import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants'; import NewGroupChild from '~/groups/new_group_child'; import notificationsDropdown from '~/notifications_dropdown'; @@ -12,9 +12,8 @@ import GroupTabs from './group_tabs'; export default function initGroupDetails(actionName = 'show') { const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup'); const loadableActions = [ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED]; - const paths = window.location.pathname.split('/'); - const subpath = paths[paths.length - 1]; - let action = loadableActions.includes(subpath) ? subpath : getPagePath(1); + const dashPath = getDashPath(); + let action = loadableActions.includes(dashPath) ? dashPath : getPagePath(1); if (actionName && action === actionName) { action = 'show'; // 'show' resets GroupTabs to default action through base class } diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue index c563514d36b..26adf4cbbe0 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue +++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue @@ -1,14 +1,14 @@ <script> import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; -import GlModal from '~/vue_shared/components/gl_modal.vue'; +import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { s__, sprintf } from '~/locale'; import { visitUrl } from '~/lib/utils/url_utility'; import eventHub from '../event_hub'; export default { components: { - GlModal, + GlModal: DeprecatedModal2, }, props: { milestoneTitle: { diff --git a/app/assets/javascripts/pages/projects/clusters/new/index.js b/app/assets/javascripts/pages/projects/clusters/new/index.js index 55aa29c9797..14d5ab21555 100644 --- a/app/assets/javascripts/pages/projects/clusters/new/index.js +++ b/app/assets/javascripts/pages/projects/clusters/new/index.js @@ -1,7 +1,13 @@ document.addEventListener('DOMContentLoaded', () => { if (gon.features.createEksClusters) { import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster') - .then(({ default: initCreateEKSCluster }) => initCreateEKSCluster()) + .then(({ default: initCreateEKSCluster }) => { + const el = document.querySelector('.js-create-eks-cluster-form-container'); + + if (el) { + initCreateEKSCluster(el); + } + }) .catch(() => {}); } }); diff --git a/app/assets/javascripts/pages/projects/commit/pipelines/index.js b/app/assets/javascripts/pages/projects/commit/pipelines/index.js index 8cc3cb0a57c..9f08260c3d6 100644 --- a/app/assets/javascripts/pages/projects/commit/pipelines/index.js +++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js @@ -6,6 +6,7 @@ document.addEventListener('DOMContentLoaded', () => { new MiniPipelineGraph({ container: '.js-commit-pipeline-graph', }).bindEvents(); + // eslint-disable-next-line no-jquery/no-load $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath); initPipelines(); }); diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index 6fc982967eb..5aa4734244e 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -21,6 +21,7 @@ document.addEventListener('DOMContentLoaded', () => { }).bindEvents(); initNotes(); initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight); + // eslint-disable-next-line no-jquery/no-load $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath); fetchCommitMergeRequests(); initDiffNotes(); diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js index 76613394af6..5b873e6b909 100644 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js +++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, prefer-template, no-return-assign */ +/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign */ import $ from 'jquery'; import _ from 'underscore'; @@ -66,8 +66,8 @@ export default (function() { class: 'person', style: 'display: block;', }); - author_name = $('<h4>' + author.author_name + '</h4>'); - author_email = $('<p class="graph-author-email">' + author.author_email + '</p>'); + author_name = $(`<h4>${author.author_name}</h4>`); + author_email = $(`<p class="graph-author-email">${author.author_email}</p>`); author_commit_info_span = $('<span/>', { class: 'commits', }); diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js index 506e6075d16..86794800f87 100644 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js +++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-restricted-syntax, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, no-return-assign, prefer-arrow-callback, prefer-template, no-else-return, no-shadow */ +/* eslint-disable func-names, no-restricted-syntax, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, no-return-assign, no-else-return, no-shadow */ import $ from 'jquery'; import _ from 'underscore'; @@ -69,24 +69,18 @@ export const ContributorsGraph = (function() { ContributorsGraph.set_y_domain = function(data) { return (ContributorsGraph.prototype.y_domain = [ 0, - d3.max(data, function(d) { - return (d.commits = d.commits || d.additions || d.deletions); - }), + d3.max(data, d => (d.commits = d.commits || d.additions || d.deletions)), ]); }; ContributorsGraph.init_x_domain = function(data) { - return (ContributorsGraph.prototype.x_domain = d3.extent(data, function(d) { - return d.date; - })); + return (ContributorsGraph.prototype.x_domain = d3.extent(data, d => d.date)); }; ContributorsGraph.init_y_domain = function(data) { return (ContributorsGraph.prototype.y_domain = [ 0, - d3.max(data, function(d) { - return (d.commits = d.commits || d.additions || d.deletions); - }), + d3.max(data, d => (d.commits = d.commits || d.additions || d.deletions)), ]); }; @@ -124,14 +118,11 @@ export const ContributorsGraph = (function() { }; ContributorsGraph.prototype.draw_x_axis = function() { - return ( - this.svg - .append('g') - .attr('class', 'x axis') - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - .attr('transform', 'translate(0, ' + this.height + ')') - .call(this.x_axis) - ); + return this.svg + .append('g') + .attr('class', 'x axis') + .attr('transform', `translate(0, ${this.height})`) + .call(this.x_axis); }; ContributorsGraph.prototype.draw_y_axis = function() { @@ -180,9 +171,7 @@ export const ContributorsMasterGraph = (function(superClass) { ContributorsMasterGraph.prototype.parse_dates = function(data) { const parseDate = d3.timeParse('%Y-%m-%d'); - return data.forEach(function(d) { - return (d.date = parseDate(d.date)); - }); + return data.forEach(d => (d.date = parseDate(d.date))); }; ContributorsMasterGraph.prototype.create_scale = function() { @@ -208,19 +197,16 @@ export const ContributorsMasterGraph = (function(superClass) { .attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom) .attr('class', 'tint-box') .append('g') - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - .attr('transform', 'translate(' + this.MARGIN.left + ',' + this.MARGIN.top + ')'); + .attr('transform', `translate(${this.MARGIN.left},${this.MARGIN.top})`); return this.svg; }; ContributorsMasterGraph.prototype.create_area = function(x, y) { return (this.area = d3 .area() - .x(function(d) { - return x(d.date); - }) + .x(d => x(d.date)) .y0(this.height) - .y1(function(d) { + .y1(d => { d.commits = d.commits || d.additions || d.deletions; return y(d.commits); })); @@ -330,7 +316,7 @@ export const ContributorsAuthorGraph = (function(superClass) { ContributorsAuthorGraph.prototype.create_area = function(x, y) { return (this.area = d3 .area() - .x(function(d) { + .x(d => { const parseDate = d3.timeParse('%Y-%m-%d'); return x(parseDate(d)); }) @@ -358,8 +344,7 @@ export const ContributorsAuthorGraph = (function(superClass) { .attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom) .attr('class', 'spark') .append('g') - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - .attr('transform', 'translate(' + this.MARGIN.left + ',' + this.MARGIN.top + ')'); + .attr('transform', `translate(${this.MARGIN.left},${this.MARGIN.top})`); return this.svg; }; diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js index 505ca938f40..a89a13fe37a 100644 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js +++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign, prefer-arrow-callback, consistent-return, no-cond-assign, no-else-return */ +/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign, consistent-return, no-cond-assign, no-else-return */ import _ from 'underscore'; export default { @@ -76,16 +76,12 @@ export default { var log, total_data; log = parsed_log.total; total_data = this.pick_field(log, field); - return _.sortBy(total_data, function(d) { - return d.date; - }); + return _.sortBy(total_data, d => d.date); }, pick_field(log, field) { var total_data; total_data = []; - _.each(log, function(d) { - return total_data.push(_.pick(d, [field, 'date'])); - }); + _.each(log, d => total_data.push(_.pick(d, [field, 'date']))); return total_data; }, get_author_data(parsed_log, field, date_range) { @@ -107,9 +103,7 @@ export default { }; })(this), ); - return _.sortBy(author_data, function(d) { - return d[field]; - }).reverse(); + return _.sortBy(author_data, d => d[field]).reverse(); }, parse_log_entry(log_entry, field, date_range) { var parsed_entry; diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js index 2205a7bafe3..96e47187fed 100644 --- a/app/assets/javascripts/pages/projects/issues/form.js +++ b/app/assets/javascripts/pages/projects/issues/form.js @@ -15,7 +15,9 @@ export default () => { new IssuableForm($('.issue-form')); new LabelsSelect(); new MilestoneSelect(); - new IssuableTemplateSelectors(); + new IssuableTemplateSelectors({ + warnTemplateOverride: true, + }); initSuggestions(); }; diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 0447d1f79fb..28a136a5fa5 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -5,6 +5,7 @@ import ZenMode from '~/zen_mode'; import '~/notes/index'; import initIssueableApp from '~/issue_show'; import initRelatedMergeRequestsApp from '~/related_merge_requests'; +import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; export default function() { initIssueableApp(); @@ -12,5 +13,9 @@ export default function() { new Issue(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new - initIssuableSidebar(); + if (gon.features && gon.features.vueIssuableSidebar) { + initVueIssuableSidebarApp(); + } else { + initIssuableSidebar(); + } } diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js index 7968dfd7a12..ce74a6de11f 100644 --- a/app/assets/javascripts/pages/projects/issues/show/index.js +++ b/app/assets/javascripts/pages/projects/issues/show/index.js @@ -3,5 +3,7 @@ import initShow from '../show'; document.addEventListener('DOMContentLoaded', () => { initShow(); - initSidebarBundle(); + if (gon.features && !gon.features.vueIssuableSidebar) { + initSidebarBundle(); + } }); diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue index e723cd3fea9..bb95f33c838 100644 --- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue +++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue @@ -2,14 +2,14 @@ import _ from 'underscore'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; -import GlModal from '~/vue_shared/components/gl_modal.vue'; +import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { s__, sprintf } from '~/locale'; import { visitUrl } from '~/lib/utils/url_utility'; import eventHub from '../event_hub'; export default { components: { - GlModal, + GlModal: DeprecatedModal2, }, props: { url: { diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js index 8f0dc8554e2..e51ab79a51d 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js @@ -16,5 +16,7 @@ export default () => { new IssuableForm($('.merge-request-form')); new LabelsSelect(); new MilestoneSelect(); - new IssuableTemplateSelectors(); + new IssuableTemplateSelectors({ + warnTemplateOverride: true, + }); }; diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index 7bfb83a2204..fa1de1f13cb 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -4,11 +4,16 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import { handleLocationHash } from '~/lib/utils/common_utils'; import howToMerge from '~/how_to_merge'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; +import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; import initWidget from '../../../vue_merge_request_widget'; export default function() { new ZenMode(); // eslint-disable-line no-new - initIssuableSidebar(); + if (gon.features && gon.features.vueIssuableSidebar) { + initVueIssuableSidebarApp(); + } else { + initIssuableSidebar(); + } initPipelines(); new ShortcutsIssuable(true); // eslint-disable-line no-new handleLocationHash(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index f61f4db78d5..ddc648702f1 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -4,6 +4,8 @@ import initShow from '../init_merge_request_show'; document.addEventListener('DOMContentLoaded', () => { initShow(); - initSidebarBundle(); + if (gon.features && !gon.features.vueIssuableSidebar) { + initSidebarBundle(); + } initMrNotes(); }); diff --git a/app/assets/javascripts/pages/projects/network/network.js b/app/assets/javascripts/pages/projects/network/network.js index 226d63f05c4..43417fa9702 100644 --- a/app/assets/javascripts/pages/projects/network/network.js +++ b/app/assets/javascripts/pages/projects/network/network.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, prefer-template */ +/* eslint-disable func-names, no-var */ import $ from 'jquery'; import BranchGraph from '../../../network/branch_graph'; @@ -14,7 +14,7 @@ export default (function() { this.branch_graph = new BranchGraph($('.network-graph'), opts); vph = $(window).height() - 250; $('.network-graph').css({ - height: vph + 'px', + height: `${vph}px`, }); } diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index b99408e3609..435e8705803 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -1,9 +1,9 @@ -/* eslint-disable func-names, no-var, no-return-assign, vars-on-top */ +/* eslint-disable func-names, no-var, no-return-assign */ import $ from 'jquery'; import Cookies from 'js-cookie'; import { __ } from '~/locale'; -import { visitUrl, mergeUrlParams } from '~/lib/utils/url_utility'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; import { serializeForm } from '~/lib/utils/forms'; import axios from '~/lib/utils/axios_utils'; import flash from '~/flash'; @@ -105,6 +105,10 @@ export default class Project { var selected = $dropdown.data('selected'); var fieldName = $dropdown.data('fieldName'); var shouldVisit = Boolean($dropdown.data('visit')); + var $form = $dropdown.closest('form'); + var action = $form.attr('action'); + var linkTarget = mergeUrlParams(serializeForm($form[0]), action); + return $dropdown.glDropdown({ data(term, callback) { axios @@ -126,21 +130,18 @@ export default class Project { renderRow(ref) { var li = refListItem.cloneNode(false); - if (ref.header != null) { - li.className = 'dropdown-header'; - li.textContent = ref.header; - } else { - var link = refLink.cloneNode(false); - - if (ref === selected) { - link.className = 'is-active'; - } - - link.textContent = ref; - link.dataset.ref = ref; + var link = refLink.cloneNode(false); - li.appendChild(link); + if (ref === selected) { + link.className = 'is-active'; } + link.textContent = ref; + link.dataset.ref = ref; + if (ref.length > 0 && shouldVisit) { + link.href = mergeUrlParams({ [fieldName]: ref }, linkTarget); + } + + li.appendChild(link); return li; }, @@ -152,15 +153,11 @@ export default class Project { }, clicked(options) { const { e } = options; - e.preventDefault(); - if ($(`input[name="${fieldName}"]`).length) { - var $form = $dropdown.closest('form'); - var action = $form.attr('action'); - - if (shouldVisit) { - visitUrl(mergeUrlParams(serializeForm($form[0]), action)); - } + if (!shouldVisit) { + e.preventDefault(); } + /* The actual process is removed since `link.href` in `RenderRow` contains the full target. + * It makes the visitable link can be visited when opening on a new tab of browser */ }, }); }); diff --git a/app/assets/javascripts/pages/projects/releases/edit/index.js b/app/assets/javascripts/pages/projects/releases/edit/index.js index c70271b09c4..98ec196fc37 100644 --- a/app/assets/javascripts/pages/projects/releases/edit/index.js +++ b/app/assets/javascripts/pages/projects/releases/edit/index.js @@ -1,4 +1,7 @@ -import $ from 'jquery'; -import initForm from '~/pages/projects/init_form'; +import ZenMode from '~/zen_mode'; +import initEditRelease from '~/releases/detail'; -document.addEventListener('DOMContentLoaded', () => initForm($('.release-form'))); +document.addEventListener('DOMContentLoaded', () => { + new ZenMode(); // eslint-disable-line no-new + initEditRelease(); +}); diff --git a/app/assets/javascripts/pages/projects/releases/index/index.js b/app/assets/javascripts/pages/projects/releases/index/index.js index c183fbb9610..6402023149f 100644 --- a/app/assets/javascripts/pages/projects/releases/index/index.js +++ b/app/assets/javascripts/pages/projects/releases/index/index.js @@ -1,3 +1,3 @@ -import initReleases from '~/releases'; +import initReleases from '~/releases/list'; document.addEventListener('DOMContentLoaded', initReleases); diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js new file mode 100644 index 00000000000..a33d11f3613 --- /dev/null +++ b/app/assets/javascripts/pages/registrations/new/index.js @@ -0,0 +1,9 @@ +import LengthValidator from '~/pages/sessions/new/length_validator'; +import UsernameValidator from '~/pages/sessions/new/username_validator'; +import NoEmojiValidator from '~/emoji/no_emoji_validator'; + +document.addEventListener('DOMContentLoaded', () => { + new UsernameValidator(); // eslint-disable-line no-new + new LengthValidator(); // eslint-disable-line no-new + new NoEmojiValidator(); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/registrations/welcome/index.js b/app/assets/javascripts/pages/registrations/welcome/index.js new file mode 100644 index 00000000000..2d555fa7977 --- /dev/null +++ b/app/assets/javascripts/pages/registrations/welcome/index.js @@ -0,0 +1,7 @@ +import LengthValidator from '~/pages/sessions/new/length_validator'; +import NoEmojiValidator from '~/emoji/no_emoji_validator'; + +document.addEventListener('DOMContentLoaded', () => { + new LengthValidator(); // eslint-disable-line no-new + new NoEmojiValidator(); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js index 8f6c48ab065..dff9d855b67 100644 --- a/app/assets/javascripts/pages/search/show/search.js +++ b/app/assets/javascripts/pages/search/show/search.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import '~/gl_dropdown'; import Flash from '~/flash'; import Api from '~/api'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index a271284dd89..7ce32032ed3 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -1,10 +1,13 @@ <script> -import GlModal from '~/vue_shared/components/gl_modal.vue'; +import RequestWarning from './request_warning.vue'; + +import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import Icon from '~/vue_shared/components/icon.vue'; export default { components: { - GlModal, + RequestWarning, + GlModal: DeprecatedModal2, Icon, }, props: { @@ -39,6 +42,16 @@ export default { detailsList() { return this.metricDetails.details; }, + warnings() { + return this.metricDetails.warnings || []; + }, + htmlId() { + if (this.currentRequest) { + return `performance-bar-warning-${this.currentRequest.id}-${this.metric}`; + } + + return ''; + }, }, }; </script> @@ -105,5 +118,6 @@ export default { <div slot="footer"></div> </gl-modal> {{ title }} + <request-warning :html-id="htmlId" :warnings="warnings" /> </div> </template> diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index 9ad6e75b86b..3b07eba02b7 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -1,14 +1,14 @@ <script> import { glEmojiTag } from '~/emoji'; -import detailedMetric from './detailed_metric.vue'; -import requestSelector from './request_selector.vue'; +import DetailedMetric from './detailed_metric.vue'; +import RequestSelector from './request_selector.vue'; import { s__ } from '~/locale'; export default { components: { - detailedMetric, - requestSelector, + DetailedMetric, + RequestSelector, }, props: { store: { diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue index 297507b85af..793aba3189b 100644 --- a/app/assets/javascripts/performance_bar/components/request_selector.vue +++ b/app/assets/javascripts/performance_bar/components/request_selector.vue @@ -1,5 +1,12 @@ <script> +import { glEmojiTag } from '~/emoji'; +import { n__ } from '~/locale'; +import { GlPopover } from '@gitlab/ui'; + export default { + components: { + GlPopover, + }, props: { currentRequest: { type: Object, @@ -15,6 +22,18 @@ export default { currentRequestId: this.currentRequest.id, }; }, + computed: { + requestsWithWarnings() { + return this.requests.filter(request => request.hasWarnings); + }, + warningMessage() { + return n__( + '%d request with warnings', + '%d requests with warnings', + this.requestsWithWarnings.length, + ); + }, + }, watch: { currentRequestId(newRequestId) { this.$emit('change-current-request', newRequestId); @@ -31,6 +50,7 @@ export default { return truncated; }, + glEmojiTag, }, }; </script> @@ -44,7 +64,16 @@ export default { class="qa-performance-bar-request" > {{ truncatedUrl(request.url) }} + <span v-if="request.hasWarnings">(!)</span> </option> </select> + <span v-if="requestsWithWarnings.length"> + <span id="performance-bar-request-selector-warning" v-html="glEmojiTag('warning')"></span> + <gl-popover + target="performance-bar-request-selector-warning" + :content="warningMessage" + triggers="hover focus" + /> + </span> </div> </template> diff --git a/app/assets/javascripts/performance_bar/components/request_warning.vue b/app/assets/javascripts/performance_bar/components/request_warning.vue new file mode 100644 index 00000000000..0da3c271214 --- /dev/null +++ b/app/assets/javascripts/performance_bar/components/request_warning.vue @@ -0,0 +1,41 @@ +<script> +import { glEmojiTag } from '~/emoji'; +import { GlPopover } from '@gitlab/ui'; + +export default { + components: { + GlPopover, + }, + props: { + htmlId: { + type: String, + required: true, + }, + warnings: { + type: Array, + required: true, + }, + }, + computed: { + hasWarnings() { + return this.warnings && this.warnings.length; + }, + warningMessage() { + if (!this.hasWarnings) { + return ''; + } + + return this.warnings.join('\n'); + }, + }, + methods: { + glEmojiTag, + }, +}; +</script> +<template> + <span v-if="hasWarnings"> + <span :id="htmlId" v-html="glEmojiTag('warning')"></span> + <gl-popover :target="htmlId" :content="warningMessage" triggers="hover focus" /> + </span> +</template> diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js index 29bfb7ee5df..1ae9487f391 100644 --- a/app/assets/javascripts/performance_bar/index.js +++ b/app/assets/javascripts/performance_bar/index.js @@ -6,7 +6,7 @@ export default ({ container }) => new Vue({ el: container, components: { - performanceBarApp: () => import('./components/performance_bar_app.vue'), + PerformanceBarApp: () => import('./components/performance_bar_app.vue'), }, data() { const performanceBarData = document.querySelector(this.$options.el).dataset; @@ -41,7 +41,7 @@ export default ({ container }) => PerformanceBarService.fetchRequestDetails(this.peekUrl, requestId) .then(res => { - this.store.addRequestDetails(requestId, res.data.data); + this.store.addRequestDetails(requestId, res.data); }) .catch(() => // eslint-disable-next-line no-console diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js index 031e774d533..64f4f5e0c76 100644 --- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js +++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js @@ -3,12 +3,13 @@ export default class PerformanceBarStore { this.requests = []; } - addRequest(requestId, requestUrl, requestDetails) { + addRequest(requestId, requestUrl) { if (!this.findRequest(requestId)) { this.requests.push({ id: requestId, url: requestUrl, - details: requestDetails, + details: {}, + hasWarnings: false, }); } @@ -22,7 +23,8 @@ export default class PerformanceBarStore { addRequestDetails(requestId, requestDetails) { const request = this.findRequest(requestId); - request.details = requestDetails; + request.details = requestDetails.data; + request.hasWarnings = requestDetails.has_warnings; return request; } diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index cfc72327ef7..e29509ce074 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -1,20 +1,120 @@ <script> +import _ from 'underscore'; import { GlLoadingIcon } from '@gitlab/ui'; import StageColumnComponent from './stage_column_component.vue'; import GraphMixin from '../../mixins/graph_component_mixin'; -import GraphWidthMixin from '~/pipelines/mixins/graph_width_mixin'; +import GraphWidthMixin from '../../mixins/graph_width_mixin'; +import LinkedPipelinesColumn from './linked_pipelines_column.vue'; +import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin'; export default { + name: 'PipelineGraph', components: { StageColumnComponent, GlLoadingIcon, + LinkedPipelinesColumn, + }, + mixins: [GraphMixin, GraphWidthMixin, GraphBundleMixin], + props: { + isLoading: { + type: Boolean, + required: true, + }, + pipeline: { + type: Object, + required: true, + }, + isLinkedPipeline: { + type: Boolean, + required: false, + default: false, + }, + mediator: { + type: Object, + required: true, + }, + type: { + type: String, + required: false, + default: 'main', + }, + }, + upstream: 'upstream', + downstream: 'downstream', + data() { + return { + triggeredTopIndex: 1, + }; + }, + computed: { + hasTriggeredBy() { + return ( + this.type !== this.$options.downstream && + this.triggeredByPipelines && + this.pipeline.triggered_by !== null + ); + }, + triggeredByPipelines() { + return this.pipeline.triggered_by; + }, + hasTriggered() { + return ( + this.type !== this.$options.upstream && + this.triggeredPipelines && + this.pipeline.triggered.length > 0 + ); + }, + triggeredPipelines() { + return this.pipeline.triggered; + }, + expandedTriggeredBy() { + return ( + this.pipeline.triggered_by && + _.isArray(this.pipeline.triggered_by) && + this.pipeline.triggered_by.find(el => el.isExpanded) + ); + }, + expandedTriggered() { + return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded); + }, + + /** + * Calculates the margin top of the clicked downstream pipeline by + * adding the height of each linked pipeline and the margin + */ + marginTop() { + return `${this.triggeredTopIndex * 52}px`; + }, + pipelineTypeUpstream() { + return this.type !== this.$options.downstream && this.expandedTriggeredBy; + }, + pipelineTypeDownstream() { + return this.type !== this.$options.upstream && this.expandedTriggered; + }, + }, + methods: { + handleClickedDownstream(pipeline, clickedIndex) { + this.triggeredTopIndex = clickedIndex; + this.$emit('onClickTriggered', this.pipeline, pipeline); + }, + hasOnlyOneJob(stage) { + return stage.groups.length === 1; + }, + hasDownstream(index, length) { + return index === length - 1 && this.hasTriggered; + }, + hasUpstream(index) { + return index === 0 && this.hasTriggeredBy; + }, }, - mixins: [GraphMixin, GraphWidthMixin], }; </script> <template> <div class="build-content middle-block js-pipeline-graph"> - <div class="pipeline-visualization pipeline-graph pipeline-tab-content"> + <div + class="pipeline-visualization pipeline-graph" + :class="{ 'pipeline-tab-content': !isLinkedPipeline }" + > <div :style="{ paddingLeft: `${graphLeftPadding}px`, @@ -23,21 +123,80 @@ export default { > <gl-loading-icon v-if="isLoading" class="m-auto" :size="3" /> - <ul v-if="!isLoading" class="stage-column-list"> + <pipeline-graph + v-if="pipelineTypeUpstream" + type="upstream" + class="d-inline-block upstream-pipeline" + :class="`js-upstream-pipeline-${expandedTriggeredBy.id}`" + :is-loading="false" + :pipeline="expandedTriggeredBy" + :is-linked-pipeline="true" + :mediator="mediator" + @onClickTriggeredBy=" + (parentPipeline, pipeline) => clickTriggeredByPipeline(parentPipeline, pipeline) + " + @refreshPipelineGraph="requestRefreshPipelineGraph" + /> + + <linked-pipelines-column + v-if="hasTriggeredBy" + :linked-pipelines="triggeredByPipelines" + :column-title="__('Upstream')" + graph-position="left" + @linkedPipelineClick=" + linkedPipeline => $emit('onClickTriggeredBy', pipeline, linkedPipeline) + " + /> + + <ul + v-if="!isLoading" + :class="{ + 'inline js-has-linked-pipelines': hasTriggered || hasTriggeredBy, + }" + class="stage-column-list align-top" + > <stage-column-component v-for="(stage, index) in graph" :key="stage.name" :class="{ - 'append-right-48': shouldAddRightMargin(index), + 'has-upstream prepend-left-64': hasUpstream(index), + 'has-downstream': hasDownstream(index, graph.length), + 'has-only-one-job': hasOnlyOneJob(stage), + 'append-right-46': shouldAddRightMargin(index), }" :title="capitalizeStageName(stage.name)" :groups="stage.groups" :stage-connector-class="stageConnectorClass(index, stage)" :is-first-column="isFirstColumn(index)" + :has-triggered-by="hasTriggeredBy" :action="stage.status.action" @refreshPipelineGraph="refreshPipelineGraph" /> </ul> + + <linked-pipelines-column + v-if="hasTriggered" + :linked-pipelines="triggeredPipelines" + :column-title="__('Downstream')" + graph-position="right" + @linkedPipelineClick="handleClickedDownstream" + /> + + <pipeline-graph + v-if="pipelineTypeDownstream" + type="downstream" + class="d-inline-block" + :class="`js-downstream-pipeline-${expandedTriggered.id}`" + :is-loading="false" + :pipeline="expandedTriggered" + :is-linked-pipeline="true" + :style="{ 'margin-top': marginTop }" + :mediator="mediator" + @onClickTriggered=" + (parentPipeline, pipeline) => clickTriggeredPipeline(parentPipeline, pipeline) + " + @refreshPipelineGraph="requestRefreshPipelineGraph" + /> </div> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue new file mode 100644 index 00000000000..4e7d77863b9 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -0,0 +1,65 @@ +<script> +import { GlLoadingIcon, GlTooltipDirective, GlButton } from '@gitlab/ui'; +import CiStatus from '~/vue_shared/components/ci_icon.vue'; + +export default { + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + CiStatus, + GlLoadingIcon, + GlButton, + }, + props: { + pipeline: { + type: Object, + required: true, + }, + }, + computed: { + tooltipText() { + return `${this.projectName} - ${this.pipelineStatus.label}`; + }, + buttonId() { + return `js-linked-pipeline-${this.pipeline.id}`; + }, + pipelineStatus() { + return this.pipeline.details.status; + }, + projectName() { + return this.pipeline.project.name; + }, + }, + methods: { + onClickLinkedPipeline() { + this.$root.$emit('bv::hide::tooltip', this.buttonId); + this.$emit('pipelineClicked'); + }, + }, +}; +</script> + +<template> + <li class="linked-pipeline build"> + <div class="curve"></div> + <gl-button + :id="buttonId" + v-gl-tooltip + :title="tooltipText" + class="js-linked-pipeline-content linked-pipeline-content" + data-qa-selector="linked_pipeline_button" + :class="`js-pipeline-expand-${pipeline.id}`" + @click="onClickLinkedPipeline" + > + <gl-loading-icon v-if="pipeline.isLoading" class="js-linked-pipeline-loading d-inline" /> + <ci-status + v-else + :status="pipelineStatus" + css-classes="position-top-0" + class="js-linked-pipeline-status" + /> + <span class="str-truncated align-bottom"> {{ projectName }} • #{{ pipeline.id }} </span> + </gl-button> + </li> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue new file mode 100644 index 00000000000..6efdde2b17e --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -0,0 +1,52 @@ +<script> +import LinkedPipeline from './linked_pipeline.vue'; + +export default { + components: { + LinkedPipeline, + }, + props: { + columnTitle: { + type: String, + required: true, + }, + linkedPipelines: { + type: Array, + required: true, + }, + graphPosition: { + type: String, + required: true, + }, + }, + computed: { + columnClass() { + const positionValues = { + right: 'prepend-left-64', + left: 'append-right-32', + }; + return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; + }, + }, +}; +</script> + +<template> + <div :class="columnClass" class="stage-column linked-pipelines-column"> + <div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div> + <div class="cross-project-triangle"></div> + <ul> + <linked-pipeline + v-for="(pipeline, index) in linkedPipelines" + :key="pipeline.id" + :class="{ + 'flat-connector-before': index === 0 && graphPosition === 'right', + active: pipeline.isExpanded, + 'left-connector': pipeline.isExpanded && graphPosition === 'left', + }" + :pipeline="pipeline" + @pipelineClicked="$emit('linkedPipelineClick', pipeline, index)" + /> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index d5c124dc0ca..db7714808fd 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -1,6 +1,6 @@ <script> import _ from 'underscore'; -import stageColumnMixin from 'ee_else_ce/pipelines/mixins/stage_column_mixin'; +import stageColumnMixin from '../../mixins/stage_column_mixin'; import JobItem from './job_item.vue'; import JobGroupDropdown from './job_group_dropdown.vue'; import ActionComponent from './action_component.vue'; diff --git a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue index 4cafd147511..2e71b3c637b 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue @@ -1,6 +1,6 @@ <script> import _ from 'underscore'; -import GlModal from '~/vue_shared/components/gl_modal.vue'; +import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { GlLink } from '@gitlab/ui'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; @@ -13,7 +13,7 @@ import { s__, sprintf } from '~/locale'; */ export default { components: { - GlModal, + GlModal: DeprecatedModal2, GlLink, ClipboardButton, CiIcon, diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index a08f732dda7..30c830d78f9 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -104,7 +104,7 @@ export default { v-gl-tooltip :title=" __( - 'Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more on the documentation for Pipelines for Merged Results.', + 'Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for Pipelines for Merged Results.', ) " class="js-pipeline-url-detached badge badge-info" diff --git a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js index dd79ade5bc9..c76869d90d5 100644 --- a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js @@ -1,16 +1,68 @@ -import Flash from '~/flash'; +import flash from '~/flash'; import { __ } from '~/locale'; export default { methods: { - clickTriggeredByPipeline() {}, - clickTriggeredPipeline() {}, + getExpandedPipelines(pipeline) { + this.mediator.service + .getPipeline(this.mediator.getExpandedParameters()) + .then(response => { + this.mediator.store.toggleLoading(pipeline); + this.mediator.store.storePipeline(response.data); + this.mediator.poll.enable({ data: this.mediator.getExpandedParameters() }); + }) + .catch(() => { + this.mediator.store.toggleLoading(pipeline); + flash(__('An error occurred while fetching the pipeline.')); + }); + }, + /** + * Called when a linked pipeline is clicked. + * + * If the pipeline is collapsed we will start polling it & we will reset the other pipelines. + * If the pipeline is expanded we will close it. + * + * @param {String} method Method to fetch the pipeline + * @param {String} storeKey Store property that will be updates + * @param {String} resetStoreKey Store key for the visible pipeline that will need to be reset + * @param {Object} pipeline The clicked pipeline + */ + clickPipeline(parentPipeline, pipeline, openMethod, closeMethod) { + if (!pipeline.isExpanded) { + this.mediator.store[openMethod](parentPipeline, pipeline); + this.mediator.store.toggleLoading(pipeline); + this.mediator.poll.stop(); + + this.getExpandedPipelines(pipeline); + } else { + this.mediator.store[closeMethod](pipeline); + this.mediator.poll.stop(); + + this.mediator.poll.enable({ data: this.mediator.getExpandedParameters() }); + } + }, + clickTriggeredByPipeline(parentPipeline, pipeline) { + this.clickPipeline( + parentPipeline, + pipeline, + 'openTriggeredByPipeline', + 'closeTriggeredByPipeline', + ); + }, + clickTriggeredPipeline(parentPipeline, pipeline) { + this.clickPipeline( + parentPipeline, + pipeline, + 'openTriggeredPipeline', + 'closeTriggeredPipeline', + ); + }, requestRefreshPipelineGraph() { // When an action is clicked // (wether in the dropdown or in the main nodes, we refresh the big graph) this.mediator .refreshPipeline() - .catch(() => Flash(__('An error occurred while making the request.'))); + .catch(() => flash(__('An error occurred while making the request.'))); }, }, }; diff --git a/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js b/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js index 64283ed0e58..3f3007ba11a 100644 --- a/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js @@ -1,7 +1,14 @@ export default { + props: { + hasTriggeredBy: { + type: Boolean, + required: false, + default: false, + }, + }, methods: { buildConnnectorClass(index) { - return index === 0 && !this.isFirstColumn ? 'left-connector' : ''; + return index === 0 && (!this.isFirstColumn || this.hasTriggeredBy) ? 'left-connector' : ''; }, }, }; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index b8976f77bac..b6f8716d37d 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -2,8 +2,8 @@ import Vue from 'vue'; import Flash from '~/flash'; import Translate from '~/vue_shared/translate'; import { __ } from '~/locale'; -import pipelineGraph from 'ee_else_ce/pipelines/components/graph/graph_component.vue'; -import GraphEEMixin from 'ee_else_ce/pipelines/mixins/graph_pipeline_bundle_mixin'; +import pipelineGraph from './components/graph/graph_component.vue'; +import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import PipelinesMediator from './pipeline_details_mediator'; import pipelineHeader from './components/header_component.vue'; import eventHub from './event_hub'; @@ -23,7 +23,7 @@ export default () => { components: { pipelineGraph, }, - mixins: [GraphEEMixin], + mixins: [GraphBundleMixin], data() { return { mediator, diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js index c8819cf35cf..bf021a0b447 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js +++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js @@ -1,5 +1,5 @@ import Visibility from 'visibilityjs'; -import PipelineStore from 'ee_else_ce/pipelines/stores/pipeline_store'; +import PipelineStore from './stores/pipeline_store'; import Flash from '../flash'; import Poll from '../lib/utils/poll'; import { __ } from '../locale'; diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js index 259278b6410..441c9f3c25f 100644 --- a/app/assets/javascripts/pipelines/stores/pipeline_store.js +++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js @@ -1,10 +1,196 @@ +import Vue from 'vue'; +import _ from 'underscore'; + export default class PipelineStore { constructor() { this.state = {}; this.state.pipeline = {}; + this.state.expandedPipelines = []; } - + /** + * For the triggered pipelines adds the `isExpanded` key + * + * For the triggered_by pipeline adds the `isExpanded` key + * and saves it as an array + * + * @param {Object} pipeline + */ storePipeline(pipeline = {}) { - this.state.pipeline = pipeline; + const pipelineCopy = Object.assign({}, pipeline); + + if (pipelineCopy.triggered_by) { + pipelineCopy.triggered_by = [pipelineCopy.triggered_by]; + + const oldTriggeredBy = + this.state.pipeline && + this.state.pipeline.triggered_by && + this.state.pipeline.triggered_by[0]; + + this.parseTriggeredByPipelines(oldTriggeredBy, pipelineCopy.triggered_by[0]); + } + + if (pipelineCopy.triggered && pipelineCopy.triggered.length) { + pipelineCopy.triggered.forEach(el => { + const oldPipeline = + this.state.pipeline && + this.state.pipeline.triggered && + this.state.pipeline.triggered.find(element => element.id === el.id); + + this.parseTriggeredPipelines(oldPipeline, el); + }); + } + + this.state.pipeline = pipelineCopy; + } + + /** + * Recursiverly parses the triggered by pipelines. + * + * Sets triggered_by as an array, there is always only 1 triggered_by pipeline. + * Adds key `isExpanding` + * Keeps old isExpading value due to polling + * + * @param {Array} parentPipeline + * @param {Object} pipeline + */ + parseTriggeredByPipelines(oldPipeline = {}, newPipeline) { + // keep old value in case it's opened because we're polling + + Vue.set(newPipeline, 'isExpanded', oldPipeline.isExpanded || false); + // add isLoading property + Vue.set(newPipeline, 'isLoading', false); + + if (newPipeline.triggered_by) { + if (!_.isArray(newPipeline.triggered_by)) { + Object.assign(newPipeline, { triggered_by: [newPipeline.triggered_by] }); + } + this.parseTriggeredByPipelines(oldPipeline, newPipeline.triggered_by[0]); + } + } + + /** + * Recursively parses the triggered pipelines + * @param {Array} parentPipeline + * @param {Object} pipeline + */ + parseTriggeredPipelines(oldPipeline = {}, newPipeline) { + // keep old value in case it's opened because we're polling + Vue.set(newPipeline, 'isExpanded', oldPipeline.isExpanded || false); + + // add isLoading property + Vue.set(newPipeline, 'isLoading', false); + + if (newPipeline.triggered && newPipeline.triggered.length > 0) { + newPipeline.triggered.forEach(el => { + const oldTriggered = + oldPipeline.triggered && oldPipeline.triggered.find(element => element.id === el.id); + this.parseTriggeredPipelines(oldTriggered, el); + }); + } + } + + /** + * Recursively resets all triggered by pipelines + * + * @param {Object} pipeline + */ + resetTriggeredByPipeline(parentPipeline, pipeline) { + parentPipeline.triggered_by.forEach(el => this.closePipeline(el)); + + if (pipeline.triggered_by && pipeline.triggered_by) { + this.resetTriggeredByPipeline(pipeline, pipeline.triggered_by); + } + } + + /** + * Opens the clicked pipeline and closes all other ones. + * @param {Object} pipeline + */ + openTriggeredByPipeline(parentPipeline, pipeline) { + // first we need to reset all triggeredBy pipelines + this.resetTriggeredByPipeline(parentPipeline, pipeline); + + this.openPipeline(pipeline); + } + + /** + * On click, will close the given pipeline and all nested triggered by pipelines + * + * @param {Object} pipeline + */ + closeTriggeredByPipeline(pipeline) { + this.closePipeline(pipeline); + + if (pipeline.triggered_by && pipeline.triggered_by.length) { + pipeline.triggered_by.forEach(triggeredBy => this.closeTriggeredByPipeline(triggeredBy)); + } + } + + /** + * Recursively closes all triggered pipelines for the given one. + * + * @param {Object} pipeline + */ + resetTriggeredPipelines(parentPipeline, pipeline) { + parentPipeline.triggered.forEach(el => this.closePipeline(el)); + + if (pipeline.triggered && pipeline.triggered.length) { + pipeline.triggered.forEach(el => this.resetTriggeredPipelines(pipeline, el)); + } + } + + /** + * Opens the clicked triggered pipeline and closes all other ones. + * + * @param {Object} pipeline + */ + openTriggeredPipeline(parentPipeline, pipeline) { + this.resetTriggeredPipelines(parentPipeline, pipeline); + + this.openPipeline(pipeline); + } + + /** + * On click, will close the given pipeline and all the nested triggered ones + * @param {Object} pipeline + */ + closeTriggeredPipeline(pipeline) { + this.closePipeline(pipeline); + + if (pipeline.triggered && pipeline.triggered.length) { + pipeline.triggered.forEach(triggered => this.closeTriggeredPipeline(triggered)); + } + } + + /** + * Utility function, Closes the given pipeline + * @param {Object} pipeline + */ + closePipeline(pipeline) { + Vue.set(pipeline, 'isExpanded', false); + // remove the pipeline from the parameters + this.removeExpandedPipelineToRequestData(pipeline.id); + } + + /** + * Utility function, Opens the given pipeline + * @param {Object} pipeline + */ + openPipeline(pipeline) { + Vue.set(pipeline, 'isExpanded', true); + // add the pipeline to the parameters + this.addExpandedPipelineToRequestData(pipeline.id); + } + // eslint-disable-next-line class-methods-use-this + toggleLoading(pipeline) { + Vue.set(pipeline, 'isLoading', !pipeline.isLoading); + } + + addExpandedPipelineToRequestData(id) { + this.state.expandedPipelines.push(id); + } + + removeExpandedPipelineToRequestData(id) { + this.state.expandedPipelines.splice(this.state.expandedPipelines.findIndex(el => el === id), 1); } } diff --git a/app/assets/javascripts/privacy_policy_update_callout.js b/app/assets/javascripts/privacy_policy_update_callout.js index 126b1ee1132..97f41deb30f 100644 --- a/app/assets/javascripts/privacy_policy_update_callout.js +++ b/app/assets/javascripts/privacy_policy_update_callout.js @@ -1,7 +1,7 @@ import PersistentUserCallout from '~/persistent_user_callout'; function initPrivacyPolicyUpdateCallout() { - const callout = document.querySelector('.privacy-policy-update-64341'); + const callout = document.querySelector('.js-privacy-policy-update'); PersistentUserCallout.factory(callout); } diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue index e1085c0a44d..72867ecd709 100644 --- a/app/assets/javascripts/profile/account/components/update_username.vue +++ b/app/assets/javascripts/profile/account/components/update_username.vue @@ -1,13 +1,13 @@ <script> import _ from 'underscore'; import axios from '~/lib/utils/axios_utils'; -import GlModal from '~/vue_shared/components/gl_modal.vue'; +import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { s__, sprintf } from '~/locale'; import Flash from '~/flash'; export default { components: { - GlModal, + GlModal: DeprecatedModal2, }, props: { actionUrl: { diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index e73a828c0ae..2c375b39c1f 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, consistent-return, one-var, no-cond-assign, prefer-template, no-return-assign */ +/* eslint-disable func-names, no-var, consistent-return, one-var, no-cond-assign, no-return-assign */ import $ from 'jquery'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; @@ -81,7 +81,7 @@ export default class ProjectFindFile { // find file } - // files pathes load + // files paths load load(url) { axios .get(url) @@ -112,10 +112,13 @@ export default class ProjectFindFile { if (searchText) { matches = fuzzaldrinPlus.match(filePath, searchText); } - blobItemUrl = this.options.blobUrlTemplate + '/' + encodeURIComponent(filePath); + blobItemUrl = `${this.options.blobUrlTemplate}/${encodeURIComponent(filePath)}`; html = ProjectFindFile.makeHtml(filePath, matches, blobItemUrl); results.push(this.element.find('.tree-table > tbody').append(html)); } + + this.element.find('.empty-state').toggleClass('hidden', Boolean(results.length)); + return results; } diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index 88665ed2ab7..0fbb7e5ca42 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -5,102 +5,103 @@ import Api from './api'; import ProjectSelectComboButton from './project_select_combo_button'; import { s__ } from './locale'; -export default function projectSelect() { - import(/* webpackChunkName: 'select2' */ 'select2/select2') - .then(() => { - $('.ajax-project-select').each(function(i, select) { - var placeholder; - const simpleFilter = $(select).data('simpleFilter') || false; - this.groupId = $(select).data('groupId'); - this.includeGroups = $(select).data('includeGroups'); - this.allProjects = $(select).data('allProjects') || false; - this.orderBy = $(select).data('orderBy') || 'id'; - this.withIssuesEnabled = $(select).data('withIssuesEnabled'); - this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled'); - this.withShared = - $(select).data('withShared') === undefined ? true : $(select).data('withShared'); - this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false; - this.allowClear = $(select).data('allowClear') || false; +const projectSelect = () => { + $('.ajax-project-select').each(function(i, select) { + var placeholder; + const simpleFilter = $(select).data('simpleFilter') || false; + this.groupId = $(select).data('groupId'); + this.includeGroups = $(select).data('includeGroups'); + this.allProjects = $(select).data('allProjects') || false; + this.orderBy = $(select).data('orderBy') || 'id'; + this.withIssuesEnabled = $(select).data('withIssuesEnabled'); + this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled'); + this.withShared = + $(select).data('withShared') === undefined ? true : $(select).data('withShared'); + this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false; + this.allowClear = $(select).data('allowClear') || false; - placeholder = s__('ProjectSelect|Search for project'); - if (this.includeGroups) { - placeholder += s__('ProjectSelect| or group'); - } + placeholder = s__('ProjectSelect|Search for project'); + if (this.includeGroups) { + placeholder += s__('ProjectSelect| or group'); + } - $(select).select2({ - placeholder, - minimumInputLength: 0, - query: (function(_this) { - return function(query) { - var finalCallback, projectsCallback; - finalCallback = function(projects) { + $(select).select2({ + placeholder, + minimumInputLength: 0, + query: (function(_this) { + return function(query) { + var finalCallback, projectsCallback; + finalCallback = function(projects) { + var data; + data = { + results: projects, + }; + return query.callback(data); + }; + if (_this.includeGroups) { + projectsCallback = function(projects) { + var groupsCallback; + groupsCallback = function(groups) { var data; - data = { - results: projects, - }; - return query.callback(data); + data = groups.concat(projects); + return finalCallback(data); }; - if (_this.includeGroups) { - projectsCallback = function(projects) { - var groupsCallback; - groupsCallback = function(groups) { - var data; - data = groups.concat(projects); - return finalCallback(data); - }; - return Api.groups(query.term, {}, groupsCallback); - }; - } else { - projectsCallback = finalCallback; - } - if (_this.groupId) { - return Api.groupProjects( - _this.groupId, - query.term, - { - with_issues_enabled: _this.withIssuesEnabled, - with_merge_requests_enabled: _this.withMergeRequestsEnabled, - with_shared: _this.withShared, - include_subgroups: _this.includeProjectsInSubgroups, - }, - projectsCallback, - ); - } else { - return Api.projects( - query.term, - { - order_by: _this.orderBy, - with_issues_enabled: _this.withIssuesEnabled, - with_merge_requests_enabled: _this.withMergeRequestsEnabled, - membership: !_this.allProjects, - }, - projectsCallback, - ); - } + return Api.groups(query.term, {}, groupsCallback); }; - })(this), - id(project) { - if (simpleFilter) return project.id; - return JSON.stringify({ - name: project.name, - url: project.web_url, - }); - }, - text(project) { - return project.name_with_namespace || project.name; - }, + } else { + projectsCallback = finalCallback; + } + if (_this.groupId) { + return Api.groupProjects( + _this.groupId, + query.term, + { + with_issues_enabled: _this.withIssuesEnabled, + with_merge_requests_enabled: _this.withMergeRequestsEnabled, + with_shared: _this.withShared, + include_subgroups: _this.includeProjectsInSubgroups, + }, + projectsCallback, + ); + } else { + return Api.projects( + query.term, + { + order_by: _this.orderBy, + with_issues_enabled: _this.withIssuesEnabled, + with_merge_requests_enabled: _this.withMergeRequestsEnabled, + membership: !_this.allProjects, + }, + projectsCallback, + ); + } + }; + })(this), + id(project) { + if (simpleFilter) return project.id; + return JSON.stringify({ + name: project.name, + url: project.web_url, + }); + }, + text(project) { + return project.name_with_namespace || project.name; + }, - initSelection(el, callback) { - return Api.project(el.val()).then(({ data }) => callback(data)); - }, + initSelection(el, callback) { + return Api.project(el.val()).then(({ data }) => callback(data)); + }, - allowClear: this.allowClear, + allowClear: this.allowClear, - dropdownCssClass: 'ajax-project-dropdown', - }); - if (simpleFilter) return select; - return new ProjectSelectComboButton(select); - }); - }) + dropdownCssClass: 'ajax-project-dropdown', + }); + if (simpleFilter) return select; + return new ProjectSelectComboButton(select); + }); +}; + +export default () => + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(projectSelect) .catch(() => {}); -} diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue index 12ee1ce2f0c..60fd3ed5ea7 100644 --- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue +++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue @@ -21,14 +21,6 @@ export default { type: String, required: true, }, - /* This prop can be used to replace some of the `render_commit_status` - used across GitLab, this way we could use this vue component and add a - realtime status where it makes sense - realtime: { - type: Boolean, - required: false, - default: true, - }, */ }, data() { return { @@ -47,6 +39,9 @@ export default { this.service = new CommitPipelineService(this.endpoint); this.initPolling(); }, + beforeDestroy() { + this.poll.stop(); + }, methods: { successCallback(res) { const { pipelines } = res.data; @@ -95,9 +90,6 @@ export default { .catch(this.errorCallback); }, }, - destroy() { - this.poll.stop(); - }, }; </script> <template> diff --git a/app/assets/javascripts/ref_select_dropdown.js b/app/assets/javascripts/ref_select_dropdown.js index 75bac035aca..2e0113271df 100644 --- a/app/assets/javascripts/ref_select_dropdown.js +++ b/app/assets/javascripts/ref_select_dropdown.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import '~/gl_dropdown'; class RefSelectDropdown { constructor($dropdownButton, availableRefs) { diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue index 346dc470a59..11b2c3b7016 100644 --- a/app/assets/javascripts/registry/components/app.vue +++ b/app/assets/javascripts/registry/components/app.vue @@ -2,28 +2,34 @@ import { mapGetters, mapActions } from 'vuex'; import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; import store from '../stores'; -import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; import CollapsibleContainer from './collapsible_container.vue'; +import ProjectEmptyState from './project_empty_state.vue'; +import GroupEmptyState from './group_empty_state.vue'; import { s__, sprintf } from '../../locale'; export default { name: 'RegistryListApp', components: { - clipboardButton, CollapsibleContainer, GlEmptyState, GlLoadingIcon, + ProjectEmptyState, + GroupEmptyState, }, props: { - endpoint: { - type: String, - required: true, - }, characterError: { type: Boolean, required: false, default: false, }, + containersErrorImage: { + type: String, + required: true, + }, + endpoint: { + type: String, + required: true, + }, helpPagePath: { type: String, required: true, @@ -32,14 +38,30 @@ export default { type: String, required: true, }, - containersErrorImage: { + personalAccessTokensHelpLink: { type: String, - required: true, + required: false, + default: null, + }, + registryHostUrlWithPort: { + type: String, + required: false, + default: null, }, repositoryUrl: { type: String, required: true, }, + isGroupPage: { + type: Boolean, + default: false, + required: false, + }, + twoFactorAuthHelpLink: { + type: String, + required: false, + default: null, + }, }, store, computed: { @@ -47,7 +69,7 @@ export default { dockerConnectionErrorText() { return sprintf( s__(`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an - issue with your project name or path. + issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`), { docLinkStart: `<a href="${this.helpPagePath}#docker-connection-error" target="_blank">`, @@ -58,8 +80,8 @@ export default { }, introText() { return sprintf( - s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every - project can have its own space to store its Docker images. + s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every + project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`), { docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`, @@ -79,17 +101,10 @@ export default { false, ); }, - dockerBuildCommand() { - // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings - return `docker build -t ${this.repositoryUrl} .`; - }, - dockerPushCommand() { - // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings - return `docker push ${this.repositoryUrl}`; - }, }, created() { this.setMainEndpoint(this.endpoint); + this.setIsDeleteDisabled(this.isGroupPage); }, mounted() { if (!this.characterError) { @@ -97,7 +112,7 @@ export default { } }, methods: { - ...mapActions(['setMainEndpoint', 'fetchRepos']), + ...mapActions(['setMainEndpoint', 'fetchRepos', 'setIsDeleteDisabled']), }, }; </script> @@ -109,7 +124,7 @@ export default { :svg-path="containersErrorImage" > <template #description> - <p v-html="dockerConnectionErrorText"></p> + <p class="js-character-error-text" v-html="dockerConnectionErrorText"></p> </template> </gl-empty-state> @@ -120,46 +135,19 @@ export default { <p v-html="introText"></p> <collapsible-container v-for="item in repos" :key="item.id" :repo="item" /> </div> - - <gl-empty-state - v-else - :title="s__('ContainerRegistry|There are no container images stored for this project')" - :svg-path="noContainersImage" - class="container-message" - > - <template #description> - <p class="js-no-container-images-text" v-html="noContainerImagesText"></p> - <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5> - <p> - {{ - s__( - 'ContainerRegistry|You can add an image to this registry with the following commands:', - ) - }} - </p> - - <div class="input-group append-bottom-10"> - <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly /> - <span class="input-group-append"> - <clipboard-button - :text="dockerBuildCommand" - :title="s__('ContainerRegistry|Copy build command to clipboard')" - class="input-group-text" - /> - </span> - </div> - - <div class="input-group"> - <input :value="dockerPushCommand" type="text" class="form-control monospace" readonly /> - <span class="input-group-append"> - <clipboard-button - :text="dockerPushCommand" - :title="s__('ContainerRegistry|Copy push command to clipboard')" - class="input-group-text" - /> - </span> - </div> - </template> - </gl-empty-state> + <project-empty-state + v-else-if="!isGroupPage" + :no-containers-image="noContainersImage" + :help-page-path="helpPagePath" + :repository-url="repositoryUrl" + :two-factor-auth-help-link="twoFactorAuthHelpLink" + :personal-access-tokens-help-link="personalAccessTokensHelpLink" + :registry-host-url-with-port="registryHostUrlWithPort" + /> + <group-empty-state + v-else-if="isGroupPage" + :no-containers-image="noContainersImage" + :help-page-path="helpPagePath" + /> </div> </template> diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue index bfb2305c48c..95f8270b5d0 100644 --- a/app/assets/javascripts/registry/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -1,6 +1,13 @@ <script> -import { mapActions } from 'vuex'; -import { GlLoadingIcon, GlButton, GlTooltipDirective, GlModal, GlModalDirective } from '@gitlab/ui'; +import { mapActions, mapGetters } from 'vuex'; +import { + GlLoadingIcon, + GlButton, + GlTooltipDirective, + GlModal, + GlModalDirective, + GlEmptyState, +} from '@gitlab/ui'; import createFlash from '../../flash'; import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; import Icon from '../../vue_shared/components/icon.vue'; @@ -17,6 +24,7 @@ export default { GlButton, Icon, GlModal, + GlEmptyState, }, directives: { GlTooltip: GlTooltipDirective, @@ -35,9 +43,13 @@ export default { }; }, computed: { + ...mapGetters(['isDeleteDisabled']), iconName() { return this.isOpen ? 'angle-up' : 'angle-right'; }, + canDeleteRepo() { + return this.repo.canDelete && !this.isDeleteDisabled; + }, }, methods: { ...mapActions(['fetchRepos', 'fetchList', 'deleteItem']), @@ -49,7 +61,7 @@ export default { } }, handleDeleteRepository() { - this.deleteItem(this.repo) + return this.deleteItem(this.repo) .then(() => { createFlash(__('This container registry has been scheduled for deletion.'), 'notice'); this.fetchRepos(); @@ -67,7 +79,8 @@ export default { <div class="container-image"> <div class="container-image-head"> <gl-button class="js-toggle-repo btn-link align-baseline" @click="toggleRepo"> - <icon :name="iconName" /> {{ repo.name }} + <icon :name="iconName" /> + {{ repo.name }} </gl-button> <clipboard-button @@ -79,11 +92,13 @@ export default { <div class="controls d-none d-sm-block float-right"> <gl-button - v-if="repo.canDelete" + v-if="canDeleteRepo" v-gl-tooltip v-gl-modal="modalId" :title="s__('ContainerRegistry|Remove repository')" :aria-label="s__('ContainerRegistry|Remove repository')" + data-track-event="click_button" + data-track-label="registry_repository_delete" class="js-remove-repo btn-inverted" variant="danger" > @@ -95,11 +110,19 @@ export default { <gl-loading-icon v-if="repo.isLoading" size="md" class="append-bottom-20" /> <div v-else-if="!repo.isLoading && isOpen" class="container-image-tags"> - <table-registry v-if="repo.list.length" :repo="repo" /> - - <div v-else class="nothing-here-block"> - {{ s__('ContainerRegistry|No tags in Container Registry for this container image.') }} - </div> + <table-registry v-if="repo.list.length" :repo="repo" :can-delete-repo="canDeleteRepo" /> + <gl-empty-state + v-else + :title="s__('ContainerRegistry|This image has no active tags')" + :description=" + s__( + `ContainerRegistry|The last tag related to this image was recently removed. + This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. + If you have any questions, contact your administrator.`, + ) + " + class="mx-auto my-0" + /> </div> <gl-modal :modal-id="modalId" ok-variant="danger" @ok="handleDeleteRepository"> <template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template> diff --git a/app/assets/javascripts/registry/components/group_empty_state.vue b/app/assets/javascripts/registry/components/group_empty_state.vue new file mode 100644 index 00000000000..7885fd2146d --- /dev/null +++ b/app/assets/javascripts/registry/components/group_empty_state.vue @@ -0,0 +1,46 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; + +export default { + name: 'GroupEmptyState', + components: { + GlEmptyState, + }, + props: { + noContainersImage: { + type: String, + required: true, + }, + helpPagePath: { + type: String, + required: true, + }, + }, + computed: { + noContainerImagesText() { + return sprintf( + s__( + `ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. %{docLinkStart}More Information%{docLinkEnd}`, + ), + { + docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`, + docLinkEnd: '</a>', + }, + false, + ); + }, + }, +}; +</script> +<template> + <gl-empty-state + :title="s__('ContainerRegistry|There are no container images available in this group')" + :svg-path="noContainersImage" + class="container-message" + > + <template #description> + <p class="js-no-container-images-text" v-html="noContainerImagesText"></p> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/registry/components/project_empty_state.vue b/app/assets/javascripts/registry/components/project_empty_state.vue new file mode 100644 index 00000000000..80ef31004c8 --- /dev/null +++ b/app/assets/javascripts/registry/components/project_empty_state.vue @@ -0,0 +1,133 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { s__, sprintf } from '~/locale'; + +export default { + name: 'ProjectEmptyState', + components: { + ClipboardButton, + GlEmptyState, + }, + props: { + noContainersImage: { + type: String, + required: true, + }, + repositoryUrl: { + type: String, + required: true, + }, + helpPagePath: { + type: String, + required: true, + }, + twoFactorAuthHelpLink: { + type: String, + required: true, + }, + personalAccessTokensHelpLink: { + type: String, + required: true, + }, + registryHostUrlWithPort: { + type: String, + required: true, + }, + }, + computed: { + dockerBuildCommand() { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + return `docker build -t ${this.repositoryUrl} .`; + }, + dockerPushCommand() { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + return `docker push ${this.repositoryUrl}`; + }, + dockerLoginCommand() { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + return `docker login ${this.registryHostUrlWithPort}`; + }, + noContainerImagesText() { + return sprintf( + s__(`ContainerRegistry|With the Container Registry, every project can have its own space to + store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`), + { + docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`, + docLinkEnd: '</a>', + }, + false, + ); + }, + notLoggedInToRegistryText() { + return sprintf( + s__(`ContainerRegistry|If you are not already logged in, you need to authenticate to + the Container Registry by using your GitLab username and password. If you have + %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a + %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd} + instead of a password.`), + { + twofaDocLinkStart: `<a href="${this.twoFactorAuthHelpLink}" target="_blank">`, + twofaDocLinkEnd: '</a>', + personalAccessTokensDocLinkStart: `<a href="${this.personalAccessTokensHelpLink}" target="_blank">`, + personalAccessTokensDocLinkEnd: '</a>', + }, + false, + ); + }, + }, +}; +</script> +<template> + <gl-empty-state + :title="s__('ContainerRegistry|There are no container images stored for this project')" + :svg-path="noContainersImage" + class="container-message" + > + <template #description> + <p class="js-no-container-images-text" v-html="noContainerImagesText"></p> + <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5> + <p class="js-not-logged-in-to-registry-text" v-html="notLoggedInToRegistryText"></p> + <div class="input-group append-bottom-10"> + <input :value="dockerLoginCommand" type="text" class="form-control monospace" readonly /> + <span class="input-group-append"> + <clipboard-button + :text="dockerLoginCommand" + :title="s__('ContainerRegistry|Copy login command')" + class="input-group-text" + /> + </span> + </div> + <p></p> + <p> + {{ + s__( + 'ContainerRegistry|You can add an image to this registry with the following commands:', + ) + }} + </p> + + <div class="input-group append-bottom-10"> + <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly /> + <span class="input-group-append"> + <clipboard-button + :text="dockerBuildCommand" + :title="s__('ContainerRegistry|Copy build command')" + class="input-group-text" + /> + </span> + </div> + + <div class="input-group"> + <input :value="dockerPushCommand" type="text" class="form-control monospace" readonly /> + <span class="input-group-append"> + <clipboard-button + :text="dockerPushCommand" + :title="s__('ContainerRegistry|Copy push command')" + class="input-group-text" + /> + </span> + </div> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index e9067bc2b56..8470fbc2b59 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -1,5 +1,5 @@ <script> -import { mapActions } from 'vuex'; +import { mapActions, mapGetters } from 'vuex'; import { GlButton, GlFormCheckbox, @@ -35,9 +35,15 @@ export default { type: Object, required: true, }, + canDeleteRepo: { + type: Boolean, + default: false, + required: false, + }, }, data() { return { + selectedItems: [], itemsToBeDeleted: [], modalId: `confirm-image-deletion-modal-${this.repo.id}`, selectAllChecked: false, @@ -45,16 +51,17 @@ export default { }; }, computed: { + ...mapGetters(['isDeleteDisabled']), bulkDeletePath() { return this.repo.tagsPath ? this.repo.tagsPath.replace('?format=json', '/bulk_destroy') : ''; }, shouldRenderPagination() { return this.repo.pagination.total > this.repo.pagination.perPage; }, - modalTitle() { + modalAction() { return n__( - 'ContainerRegistry|Remove image', - 'ContainerRegistry|Remove images', + 'ContainerRegistry|Remove tag', + 'ContainerRegistry|Remove tags', this.itemsToBeDeleted.length === 0 ? 1 : this.itemsToBeDeleted.length, ); }, @@ -67,16 +74,14 @@ export default { setModalDescription(itemIndex = -1) { if (itemIndex === -1) { this.modalDescription = sprintf( - s__(`ContainerRegistry|You are about to delete <b>%{count}</b> images. This will - delete the images and all tags pointing to them.`), + s__(`ContainerRegistry|You are about to remove <b>%{count}</b> tags. Are you sure?`), { count: this.itemsToBeDeleted.length }, ); } else { const { tag } = this.repo.list[itemIndex]; this.modalDescription = sprintf( - s__(`ContainerRegistry|You are about to delete the image <b>%{title}</b>. This will - delete the image and all tags pointing to this image.`), + s__(`ContainerRegistry|You are about to remove <b>%{title}</b>. Are you sure?`), { title: `${this.repo.name}:${tag}` }, ); } @@ -92,6 +97,7 @@ export default { }, deleteSingleItem(index) { this.setModalDescription(index); + this.itemsToBeDeleted = [index]; this.$refs.deleteModal.$refs.modal.$once('ok', () => { this.removeModalEvents(); @@ -99,9 +105,10 @@ export default { }); }, deleteMultipleItems() { - if (this.itemsToBeDeleted.length === 1) { + this.itemsToBeDeleted = [...this.selectedItems]; + if (this.selectedItems.length === 1) { this.setModalDescription(this.itemsToBeDeleted[0]); - } else if (this.itemsToBeDeleted.length > 1) { + } else if (this.selectedItems.length > 1) { this.setModalDescription(); } @@ -111,6 +118,7 @@ export default { }); }, handleSingleDelete(itemToDelete) { + this.itemsToBeDeleted = []; this.deleteItem(itemToDelete) .then(() => this.fetchList({ repo: this.repo })) .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); @@ -118,6 +126,7 @@ export default { handleMultipleDelete() { const { itemsToBeDeleted } = this; this.itemsToBeDeleted = []; + this.selectedItems = []; if (this.bulkDeletePath) { this.multiDeleteItems({ @@ -146,27 +155,30 @@ export default { } }, selectAll() { - this.itemsToBeDeleted = this.repo.list.map((x, index) => index); + this.selectedItems = this.repo.list.map((x, index) => index); this.selectAllChecked = true; }, deselectAll() { - this.itemsToBeDeleted = []; + this.selectedItems = []; this.selectAllChecked = false; }, - updateItemsToBeDeleted(index) { - const delIndex = this.itemsToBeDeleted.findIndex(x => x === index); + updateselectedItems(index) { + const delIndex = this.selectedItems.findIndex(x => x === index); if (delIndex > -1) { - this.itemsToBeDeleted.splice(delIndex, 1); + this.selectedItems.splice(delIndex, 1); this.selectAllChecked = false; } else { - this.itemsToBeDeleted.push(index); + this.selectedItems.push(index); - if (this.itemsToBeDeleted.length === this.repo.list.length) { + if (this.selectedItems.length === this.repo.list.length) { this.selectAllChecked = true; } } }, + canDeleteRow(item) { + return item && item.canDelete && !this.isDeleteDisabled; + }, }, }; </script> @@ -177,7 +189,7 @@ export default { <tr> <th> <gl-form-checkbox - v-if="repo.canDelete" + v-if="canDeleteRepo" class="js-select-all-checkbox" :checked="selectAllChecked" @change="onSelectAllChange" @@ -189,17 +201,20 @@ export default { <th>{{ s__('ContainerRegistry|Last Updated') }}</th> <th> <gl-button - v-if="repo.canDelete" + v-if="canDeleteRepo" v-gl-tooltip v-gl-modal="modalId" - :disabled="!itemsToBeDeleted || itemsToBeDeleted.length === 0" + :disabled="!selectedItems || selectedItems.length === 0" class="js-delete-registry float-right" + data-track-event="click_button" + data-track-label="bulk_registry_tag_delete" variant="danger" - :title="s__('ContainerRegistry|Remove selected images')" - :aria-label="s__('ContainerRegistry|Remove selected images')" + :title="s__('ContainerRegistry|Remove selected tags')" + :aria-label="s__('ContainerRegistry|Remove selected tags')" @click="deleteMultipleItems()" - ><icon name="remove" - /></gl-button> + > + <icon name="remove" /> + </gl-button> </th> </tr> </thead> @@ -207,10 +222,10 @@ export default { <tr v-for="(item, index) in repo.list" :key="item.tag" class="registry-image-row"> <td class="check"> <gl-form-checkbox - v-if="item.canDelete" + v-if="canDeleteRow(item)" class="js-select-checkbox" - :checked="itemsToBeDeleted && itemsToBeDeleted.includes(index)" - @change="updateItemsToBeDeleted(index)" + :checked="selectedItems && selectedItems.includes(index)" + @change="updateselectedItems(index)" /> </td> <td class="monospace"> @@ -223,9 +238,9 @@ export default { /> </td> <td> - <span v-gl-tooltip.bottom class="monospace" :title="item.revision"> - {{ item.shortRevision }} - </span> + <span v-gl-tooltip.bottom class="monospace" :title="item.revision">{{ + item.shortRevision + }}</span> </td> <td> {{ formatSize(item.size) }} @@ -236,17 +251,19 @@ export default { </td> <td> - <span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)"> - {{ timeFormated(item.createdAt) }} - </span> + <span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">{{ + timeFormated(item.createdAt) + }}</span> </td> <td class="content action-buttons"> <gl-button - v-if="item.canDelete" + v-if="canDeleteRow(item)" v-gl-modal="modalId" - :title="s__('ContainerRegistry|Remove image')" - :aria-label="s__('ContainerRegistry|Remove image')" + :title="s__('ContainerRegistry|Remove tag')" + :aria-label="s__('ContainerRegistry|Remove tag')" + data-track-event="click_button" + data-track-label="registry_tag_delete" variant="danger" class="js-delete-registry-row float-right btn-inverted btn-border-color btn-icon" @click="deleteSingleItem(index)" @@ -262,11 +279,12 @@ export default { v-if="shouldRenderPagination" :change="onPageChange" :page-info="repo.pagination" + class="js-registry-pagination" /> <gl-modal ref="deleteModal" :modal-id="modalId" ok-variant="danger"> - <template v-slot:modal-title>{{ modalTitle }}</template> - <template v-slot:modal-ok>{{ s__('ContainerRegistry|Remove image(s) and tags') }}</template> + <template v-slot:modal-title>{{ modalAction }}</template> + <template v-slot:modal-ok>{{ modalAction }}</template> <p v-html="modalDescription"></p> </gl-modal> </div> diff --git a/app/assets/javascripts/registry/index.js b/app/assets/javascripts/registry/index.js index d8daec29fda..18fd360f586 100644 --- a/app/assets/javascripts/registry/index.js +++ b/app/assets/javascripts/registry/index.js @@ -13,23 +13,24 @@ export default () => data() { const { dataset } = document.querySelector(this.$options.el); return { - endpoint: dataset.endpoint, - characterError: Boolean(dataset.characterError), - helpPagePath: dataset.helpPagePath, - noContainersImage: dataset.noContainersImage, - containersErrorImage: dataset.containersErrorImage, - repositoryUrl: dataset.repositoryUrl, + registryData: { + endpoint: dataset.endpoint, + characterError: Boolean(dataset.characterError), + helpPagePath: dataset.helpPagePath, + noContainersImage: dataset.noContainersImage, + containersErrorImage: dataset.containersErrorImage, + repositoryUrl: dataset.repositoryUrl, + isGroupPage: dataset.isGroupPage, + personalAccessTokensHelpLink: dataset.personalAccessTokensHelpLink, + registryHostUrlWithPort: dataset.registryHostUrlWithPort, + twoFactorAuthHelpLink: dataset.twoFactorAuthHelpLink, + }, }; }, render(createElement) { return createElement('registry-app', { props: { - endpoint: this.endpoint, - characterError: this.characterError, - helpPagePath: this.helpPagePath, - noContainersImage: this.noContainersImage, - containersErrorImage: this.containersErrorImage, - repositoryUrl: this.repositoryUrl, + ...this.registryData, }, }); }, diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js index a2e0130e79e..2121f518a7a 100644 --- a/app/assets/javascripts/registry/stores/actions.js +++ b/app/assets/javascripts/registry/stores/actions.js @@ -20,7 +20,6 @@ export const fetchRepos = ({ commit, state }) => { export const fetchList = ({ commit }, { repo, page }) => { commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); - return axios .get(repo.tagsPath, { params: { page } }) .then(response => { @@ -40,6 +39,7 @@ export const multiDeleteItems = (_, { path, items }) => axios.delete(path, { params: { ids: items } }); export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data); +export const setIsDeleteDisabled = ({ commit }, data) => commit(types.SET_IS_DELETE_DISABLED, data); export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING); // prevent babel-plugin-rewire from generating an invalid default during karma tests diff --git a/app/assets/javascripts/registry/stores/getters.js b/app/assets/javascripts/registry/stores/getters.js index f4923512578..ac90bde1b2a 100644 --- a/app/assets/javascripts/registry/stores/getters.js +++ b/app/assets/javascripts/registry/stores/getters.js @@ -1,5 +1,6 @@ export const isLoading = state => state.isLoading; export const repos = state => state.repos; +export const isDeleteDisabled = state => state.isDeleteDisabled; // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/registry/stores/mutation_types.js b/app/assets/javascripts/registry/stores/mutation_types.js index 2c69bf11807..6740bfede1a 100644 --- a/app/assets/javascripts/registry/stores/mutation_types.js +++ b/app/assets/javascripts/registry/stores/mutation_types.js @@ -1,4 +1,5 @@ export const SET_MAIN_ENDPOINT = 'SET_MAIN_ENDPOINT'; +export const SET_IS_DELETE_DISABLED = 'SET_IS_DELETE_DISABLED'; export const SET_REPOS_LIST = 'SET_REPOS_LIST'; export const TOGGLE_MAIN_LOADING = 'TOGGLE_MAIN_LOADING'; diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js index 8ace6657ad1..ea5925247d1 100644 --- a/app/assets/javascripts/registry/stores/mutations.js +++ b/app/assets/javascripts/registry/stores/mutations.js @@ -6,6 +6,10 @@ export default { Object.assign(state, { endpoint }); }, + [types.SET_IS_DELETE_DISABLED](state, isDeleteDisabled) { + Object.assign(state, { isDeleteDisabled }); + }, + [types.SET_REPOS_LIST](state, list) { Object.assign(state, { repos: list.map(el => ({ @@ -17,6 +21,7 @@ export default { location: el.location, name: el.path, tagsPath: el.tags_path, + projectId: el.project_id, })), }); }, diff --git a/app/assets/javascripts/registry/stores/state.js b/app/assets/javascripts/registry/stores/state.js index feeac10cbe1..724c64b4994 100644 --- a/app/assets/javascripts/registry/stores/state.js +++ b/app/assets/javascripts/registry/stores/state.js @@ -1,6 +1,7 @@ export default () => ({ isLoading: false, endpoint: '', // initial endpoint to fetch the repos list + isDeleteDisabled: false, // controls the delete buttons in the registry /** * Each object in `repos` has the following strucure: * { diff --git a/app/assets/javascripts/releases/components/milestone_list.vue b/app/assets/javascripts/releases/components/milestone_list.vue deleted file mode 100644 index 53416f0ab4d..00000000000 --- a/app/assets/javascripts/releases/components/milestone_list.vue +++ /dev/null @@ -1,45 +0,0 @@ -<script> -import { GlLink, GlTooltipDirective } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; -import { s__ } from '~/locale'; - -export default { - name: 'MilestoneList', - components: { - GlLink, - Icon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - milestones: { - type: Array, - required: true, - }, - }, - computed: { - labelText() { - return this.milestones.length === 1 ? s__('Milestone') : s__('Milestones'); - }, - }, -}; -</script> -<template> - <div> - <icon name="flag" class="align-middle" /> <span class="js-label-text">{{ labelText }}</span> - <template v-for="(milestone, index) in milestones"> - <gl-link - :key="milestone.id" - v-gl-tooltip - :title="milestone.description" - :href="milestone.web_url" - > - {{ milestone.title }} - </gl-link> - <template v-if="index !== milestones.length - 1"> - • - </template> - </template> - </div> -</template> diff --git a/app/assets/javascripts/releases/detail/components/app.vue b/app/assets/javascripts/releases/detail/components/app.vue new file mode 100644 index 00000000000..54a441de886 --- /dev/null +++ b/app/assets/javascripts/releases/detail/components/app.vue @@ -0,0 +1,156 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; + +export default { + name: 'ReleaseDetailApp', + components: { + GlFormInput, + GlFormGroup, + GlButton, + MarkdownField, + }, + directives: { + autofocusonshow, + }, + computed: { + ...mapState([ + 'isFetchingRelease', + 'fetchError', + 'markdownDocsPath', + 'markdownPreviewPath', + 'releasesPagePath', + ]), + showForm() { + return !this.isFetchingRelease && !this.fetchError; + }, + subtitleText() { + return sprintf( + __( + 'Releases are based on Git tags. We recommend naming tags that fit within semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}.', + ), + { + codeStart: '<code>', + codeEnd: '</code>', + }, + false, + ); + }, + tagName() { + return this.$store.state.release.tagName; + }, + releaseTitle: { + get() { + return this.$store.state.release.name; + }, + set(title) { + this.updateReleaseTitle(title); + }, + }, + releaseNotes: { + get() { + return this.$store.state.release.description; + }, + set(notes) { + this.updateReleaseNotes(notes); + }, + }, + }, + created() { + this.fetchRelease(); + }, + methods: { + ...mapActions([ + 'fetchRelease', + 'updateRelease', + 'updateReleaseTitle', + 'updateReleaseNotes', + 'navigateToReleasesPage', + ]), + }, +}; +</script> +<template> + <div class="d-flex flex-column"> + <p class="pt-3 js-subtitle-text" v-html="subtitleText"></p> + <form v-if="showForm" @submit.prevent="updateRelease()"> + <div class="row"> + <gl-form-group class="col-md-6 col-lg-5 col-xl-4"> + <label for="git-ref">{{ __('Tag name') }}</label> + <gl-form-input + id="git-ref" + v-model="tagName" + type="text" + class="form-control" + aria-describedby="tag-name-help" + disabled + /> + <div id="tag-name-help" class="form-text text-muted"> + {{ __('Choose an existing tag, or create a new one') }} + </div> + </gl-form-group> + </div> + <gl-form-group> + <label for="release-title">{{ __('Release title') }}</label> + <gl-form-input + id="release-title" + ref="releaseTitleInput" + v-model="releaseTitle" + v-autofocusonshow + autofocus + type="text" + class="form-control" + /> + </gl-form-group> + <gl-form-group> + <label for="release-notes">{{ __('Release notes') }}</label> + <div class="bordered-box pr-3 pl-3"> + <markdown-field + :can-attach-file="true" + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + :add-spacing-classes="false" + class="prepend-top-10 append-bottom-10" + > + <textarea + id="release-notes" + slot="textarea" + v-model="releaseNotes" + class="note-textarea js-gfm-input js-autosize markdown-area" + dir="auto" + data-supports-quick-actions="false" + :aria-label="__('Release notes')" + :placeholder="__('Write your release notes or drag your files here…')" + @keydown.meta.enter="updateRelease()" + @keydown.ctrl.enter="updateRelease()" + > + </textarea> + </markdown-field> + </div> + </gl-form-group> + + <div class="d-flex pt-3"> + <gl-button + class="mr-auto js-submit-button" + variant="success" + type="submit" + :aria-label="__('Save changes')" + > + {{ __('Save changes') }} + </gl-button> + <gl-button + class="js-cancel-button" + variant="default" + type="button" + :aria-label="__('Cancel')" + @click="navigateToReleasesPage()" + > + {{ __('Cancel') }} + </gl-button> + </div> + </form> + </div> +</template> diff --git a/app/assets/javascripts/releases/detail/index.js b/app/assets/javascripts/releases/detail/index.js new file mode 100644 index 00000000000..3da971e6d90 --- /dev/null +++ b/app/assets/javascripts/releases/detail/index.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +import ReleaseDetailApp from './components/app.vue'; +import createStore from './store'; + +export default () => { + const el = document.getElementById('js-edit-release-page'); + + const store = createStore(el.dataset); + store.dispatch('setInitialState', el.dataset); + + return new Vue({ + el, + store, + components: { ReleaseDetailApp }, + render(createElement) { + return createElement('release-detail-app'); + }, + }); +}; diff --git a/app/assets/javascripts/releases/detail/store/actions.js b/app/assets/javascripts/releases/detail/store/actions.js new file mode 100644 index 00000000000..c9749582f5c --- /dev/null +++ b/app/assets/javascripts/releases/detail/store/actions.js @@ -0,0 +1,62 @@ +import * as types from './mutation_types'; +import api from '~/api'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +export const setInitialState = ({ commit }, initialState) => + commit(types.SET_INITIAL_STATE, initialState); + +export const requestRelease = ({ commit }) => commit(types.REQUEST_RELEASE); +export const receiveReleaseSuccess = ({ commit }, data) => + commit(types.RECEIVE_RELEASE_SUCCESS, data); +export const receiveReleaseError = ({ commit }, error) => { + commit(types.RECEIVE_RELEASE_ERROR, error); + createFlash(s__('Release|Something went wrong while getting the release details')); +}; + +export const fetchRelease = ({ dispatch, state }) => { + dispatch('requestRelease'); + + return api + .release(state.projectId, state.tagName) + .then(({ data: release }) => { + const camelCasedRelease = convertObjectPropsToCamelCase(release, { deep: true }); + dispatch('receiveReleaseSuccess', camelCasedRelease); + }) + .catch(error => { + dispatch('receiveReleaseError', error); + }); +}; + +export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title); +export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes); + +export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE); +export const receiveUpdateReleaseSuccess = ({ commit, dispatch }) => { + commit(types.RECEIVE_UPDATE_RELEASE_SUCCESS); + dispatch('navigateToReleasesPage'); +}; +export const receiveUpdateReleaseError = ({ commit }, error) => { + commit(types.RECEIVE_UPDATE_RELEASE_ERROR, error); + createFlash(s__('Release|Something went wrong while saving the release details')); +}; + +export const updateRelease = ({ dispatch, state }) => { + dispatch('requestUpdateRelease'); + + return api + .updateRelease(state.projectId, state.tagName, { + name: state.release.name, + description: state.release.description, + }) + .then(() => dispatch('receiveUpdateReleaseSuccess')) + .catch(error => { + dispatch('receiveUpdateReleaseError', error); + }); +}; + +export const navigateToReleasesPage = ({ state }) => { + redirectTo(state.releasesPagePath); +}; diff --git a/app/assets/javascripts/releases/detail/store/index.js b/app/assets/javascripts/releases/detail/store/index.js new file mode 100644 index 00000000000..e8623a49356 --- /dev/null +++ b/app/assets/javascripts/releases/detail/store/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + actions, + mutations, + state, + }); diff --git a/app/assets/javascripts/releases/detail/store/mutation_types.js b/app/assets/javascripts/releases/detail/store/mutation_types.js new file mode 100644 index 00000000000..75e1d78a645 --- /dev/null +++ b/app/assets/javascripts/releases/detail/store/mutation_types.js @@ -0,0 +1,12 @@ +export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; + +export const REQUEST_RELEASE = 'REQUEST_RELEASE'; +export const RECEIVE_RELEASE_SUCCESS = 'RECEIVE_RELEASE_SUCCESS'; +export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR'; + +export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE'; +export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES'; + +export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE'; +export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS'; +export const RECEIVE_UPDATE_RELEASE_ERROR = 'RECEIVE_UPDATE_RELEASE_ERROR'; diff --git a/app/assets/javascripts/releases/detail/store/mutations.js b/app/assets/javascripts/releases/detail/store/mutations.js new file mode 100644 index 00000000000..d739978d755 --- /dev/null +++ b/app/assets/javascripts/releases/detail/store/mutations.js @@ -0,0 +1,42 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_INITIAL_STATE](state, initialState) { + Object.keys(state).forEach(key => { + state[key] = initialState[key]; + }); + }, + + [types.REQUEST_RELEASE](state) { + state.isFetchingRelease = true; + }, + [types.RECEIVE_RELEASE_SUCCESS](state, data) { + state.fetchError = undefined; + state.isFetchingRelease = false; + state.release = data; + }, + [types.RECEIVE_RELEASE_ERROR](state, error) { + state.fetchError = error; + state.isFetchingRelease = false; + state.release = undefined; + }, + + [types.UPDATE_RELEASE_TITLE](state, title) { + state.release.name = title; + }, + [types.UPDATE_RELEASE_NOTES](state, notes) { + state.release.description = notes; + }, + + [types.REQUEST_UPDATE_RELEASE](state) { + state.isUpdatingRelease = true; + }, + [types.RECEIVE_UPDATE_RELEASE_SUCCESS](state) { + state.updateError = undefined; + state.isUpdatingRelease = false; + }, + [types.RECEIVE_UPDATE_RELEASE_ERROR](state, error) { + state.updateError = error; + state.isUpdatingRelease = false; + }, +}; diff --git a/app/assets/javascripts/releases/detail/store/state.js b/app/assets/javascripts/releases/detail/store/state.js new file mode 100644 index 00000000000..ff98e2bed78 --- /dev/null +++ b/app/assets/javascripts/releases/detail/store/state.js @@ -0,0 +1,15 @@ +export default () => ({ + projectId: null, + tagName: null, + releasesPagePath: null, + markdownDocsPath: null, + markdownPreviewPath: null, + + release: null, + + isFetchingRelease: false, + fetchError: null, + + isUpdatingRelease: false, + updateError: null, +}); diff --git a/app/assets/javascripts/releases/components/app.vue b/app/assets/javascripts/releases/list/components/app.vue index 5a06c4fec58..5a06c4fec58 100644 --- a/app/assets/javascripts/releases/components/app.vue +++ b/app/assets/javascripts/releases/list/components/app.vue diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/list/components/release_block.vue index 2dacd8549ad..8d4b32e9dc0 100644 --- a/app/assets/javascripts/releases/components/release_block.vue +++ b/app/assets/javascripts/releases/list/components/release_block.vue @@ -1,26 +1,29 @@ <script> /* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import _ from 'underscore'; -import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui'; +import { GlTooltipDirective, GlLink, GlBadge, GlButton } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; -import MilestoneList from './milestone_list.vue'; -import { __, sprintf } from '../../locale'; +import { __, n__, sprintf } from '~/locale'; +import { slugify } from '~/lib/utils/text_utility'; +import { getLocationHash } from '~/lib/utils/url_utility'; +import { scrollToElement } from '~/lib/utils/common_utils'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'ReleaseBlock', components: { GlLink, GlBadge, + GlButton, Icon, UserAvatarLink, - MilestoneList, }, directives: { GlTooltip: GlTooltipDirective, }, - mixins: [timeagoMixin], + mixins: [timeagoMixin, glFeatureFlagsMixin()], props: { release: { type: Object, @@ -28,7 +31,15 @@ export default { default: () => ({}), }, }, + data() { + return { + isHighlighted: false, + }; + }, computed: { + id() { + return slugify(this.release.tag_name); + }, releasedTimeAgo() { return sprintf(__('released %{time}'), { time: this.timeFormated(this.release.released_at), @@ -42,6 +53,12 @@ export default { commit() { return this.release.commit || {}; }, + commitUrl() { + return this.release.commit_path; + }, + tagUrl() { + return this.release.tag_path; + }, assets() { return this.release.assets || {}; }, @@ -51,49 +68,90 @@ export default { hasAuthor() { return !_.isEmpty(this.author); }, - milestones() { - // At the moment, a release can only be associated to - // one milestone. This will be expanded to be many-to-many - // in the near future, so we pass the milestone as an - // array here in anticipation of this change. - return [this.release.milestone]; - }, shouldRenderMilestones() { - // Similar to the `milestones` computed above, - // this check will need to be updated once - // the API begins sending an array of milestones - // instead of just a single object. - return Boolean(this.release.milestone); + return !_.isEmpty(this.release.milestones); + }, + labelText() { + return n__('Milestone', 'Milestones', this.release.milestones.length); + }, + shouldShowEditButton() { + return Boolean( + this.glFeatures.releaseEditPage && this.release._links && this.release._links.edit, + ); }, }, + mounted() { + const hash = getLocationHash(); + if (hash && slugify(hash) === this.id) { + this.isHighlighted = true; + setTimeout(() => { + this.isHighlighted = false; + }, 2000); + + scrollToElement(this.$el); + } + }, }; </script> <template> - <div :id="release.tag_name" class="card"> + <div :id="id" :class="{ 'bg-line-target-blue': isHighlighted }" class="card release-block"> <div class="card-body"> - <h2 class="card-title mt-0"> - {{ release.name }} - <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{ - __('Upcoming Release') - }}</gl-badge> - </h2> + <div class="d-flex align-items-start"> + <h2 class="card-title mt-0 mr-auto"> + {{ release.name }} + <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{ + __('Upcoming Release') + }}</gl-badge> + </h2> + <gl-link + v-if="shouldShowEditButton" + v-gl-tooltip + class="btn btn-default js-edit-button ml-2" + :title="__('Edit this release')" + :href="release._links.edit" + > + <icon name="pencil" /> + </gl-link> + </div> <div class="card-subtitle d-flex flex-wrap text-secondary"> <div class="append-right-8"> <icon name="commit" class="align-middle" /> - <span v-gl-tooltip.bottom :title="commit.title">{{ commit.short_id }}</span> + <gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl"> + {{ commit.short_id }} + </gl-link> + <span v-else v-gl-tooltip.bottom :title="commit.title">{{ commit.short_id }}</span> </div> <div class="append-right-8"> <icon name="tag" class="align-middle" /> - <span v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span> + <gl-link v-if="tagUrl" v-gl-tooltip.bottom :title="__('Tag')" :href="tagUrl"> + {{ release.tag_name }} + </gl-link> + <span v-else v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span> </div> - <milestone-list - v-if="shouldRenderMilestones" - class="append-right-4 js-milestone-list" - :milestones="milestones" - /> + <template v-if="shouldRenderMilestones"> + <div class="js-milestone-list-label"> + <icon name="flag" class="align-middle" /> + <span class="js-label-text">{{ labelText }}</span> + </div> + + <template v-for="(milestone, index) in release.milestones"> + <gl-link + :key="milestone.id" + v-gl-tooltip + :title="milestone.description" + :href="milestone.web_url" + class="append-right-4 prepend-left-4 js-milestone-link" + > + {{ milestone.title }} + </gl-link> + <template v-if="index !== release.milestones.length - 1"> + • + </template> + </template> + </template> <div class="append-right-4"> • diff --git a/app/assets/javascripts/releases/index.js b/app/assets/javascripts/releases/list/index.js index adbed3cb8e2..adbed3cb8e2 100644 --- a/app/assets/javascripts/releases/index.js +++ b/app/assets/javascripts/releases/list/index.js diff --git a/app/assets/javascripts/releases/store/actions.js b/app/assets/javascripts/releases/list/store/actions.js index e0a922d5ef6..e0a922d5ef6 100644 --- a/app/assets/javascripts/releases/store/actions.js +++ b/app/assets/javascripts/releases/list/store/actions.js diff --git a/app/assets/javascripts/releases/store/index.js b/app/assets/javascripts/releases/list/store/index.js index 968b94f0e0d..968b94f0e0d 100644 --- a/app/assets/javascripts/releases/store/index.js +++ b/app/assets/javascripts/releases/list/store/index.js diff --git a/app/assets/javascripts/releases/store/mutation_types.js b/app/assets/javascripts/releases/list/store/mutation_types.js index a74bf15c515..a74bf15c515 100644 --- a/app/assets/javascripts/releases/store/mutation_types.js +++ b/app/assets/javascripts/releases/list/store/mutation_types.js diff --git a/app/assets/javascripts/releases/store/mutations.js b/app/assets/javascripts/releases/list/store/mutations.js index b97dc6cb0ab..b97dc6cb0ab 100644 --- a/app/assets/javascripts/releases/store/mutations.js +++ b/app/assets/javascripts/releases/list/store/mutations.js diff --git a/app/assets/javascripts/releases/store/state.js b/app/assets/javascripts/releases/list/store/state.js index bf25e651c99..bf25e651c99 100644 --- a/app/assets/javascripts/releases/store/state.js +++ b/app/assets/javascripts/releases/list/store/state.js diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/components/modal.vue index cb9c1642608..6019af2dfe0 100644 --- a/app/assets/javascripts/reports/components/modal.vue +++ b/app/assets/javascripts/reports/components/modal.vue @@ -1,13 +1,13 @@ <script> // import { sprintf, __ } from '~/locale'; -import Modal from '~/vue_shared/components/gl_modal.vue'; +import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; import CodeBlock from '~/vue_shared/components/code_block.vue'; import { fieldTypes } from '../constants'; export default { components: { - Modal, + Modal: DeprecatedModal2, LoadingButton, CodeBlock, }, diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index 24612c8681a..45c890769a0 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -179,7 +179,7 @@ export default { <button v-if="isCollapsible" type="button" - class="js-collapse-btn btn float-right btn-sm qa-expand-report-button" + class="js-collapse-btn btn float-right btn-sm align-self-start qa-expand-report-button" @click="toggleCollapsed" > {{ collapseText }} diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/store/utils.js index 10560d0ae8e..7381f038eaf 100644 --- a/app/assets/javascripts/reports/store/utils.js +++ b/app/assets/javascripts/reports/store/utils.js @@ -11,7 +11,7 @@ const textBuilder = results => { const { failed, resolved, total } = results; const failedString = failed - ? n__('%d failed test result', '%d failed test results', failed) + ? n__('%d failed/error test result', '%d failed/error test results', failed) : null; const resolvedString = resolved ? n__('%d fixed test result', '%d fixed test results', resolved) diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index e2060d4aeec..19a2db2db25 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -1,5 +1,4 @@ <script> -/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { GlTooltipDirective, GlLink, GlButton, GlLoadingIcon } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; import Icon from '../../vue_shared/components/icon.vue'; @@ -113,7 +112,7 @@ export default { > {{ commit.author.name }} </gl-link> - authored + {{ s__('LastCommit|authored') }} <timeago-tooltip :time="commit.authoredDate" tooltip-placement="bottom" /> </div> <pre @@ -125,6 +124,7 @@ export default { </pre> </div> <div class="commit-actions flex-row"> + <div v-if="commit.signatureHtml" v-html="commit.signatureHtml"></div> <gl-link v-if="commit.latestPipeline" v-gl-tooltip @@ -144,7 +144,7 @@ export default { </div> <clipboard-button :text="commit.sha" - :title="__('Copy commit SHA to clipboard')" + :title="__('Copy commit SHA')" tooltip-placement="bottom" /> </div> diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql index 3bdfd979fa4..71c1bf12749 100644 --- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql +++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql @@ -13,6 +13,7 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { avatarUrl webUrl } + signatureHtml latestPipeline { detailedStatus { detailsPath diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 0cc7a22325b..87454ee056f 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, consistent-return, one-var, prefer-template, no-else-return, no-param-reassign */ +/* eslint-disable func-names, no-var, consistent-return, one-var, no-else-return, no-param-reassign */ import $ from 'jquery'; import _ from 'underscore'; @@ -247,7 +247,7 @@ Sidebar.prototype.isOpen = function() { }; Sidebar.prototype.getBlock = function(name) { - return this.sidebar.find('.block.' + name); + return this.sidebar.find(`.block.${name}`); }; export default Sidebar; diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 2f37dcec197..f6722ff7bca 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -1,4 +1,4 @@ -/* eslint-disable no-return-assign, one-var, no-var, consistent-return, prefer-template, class-methods-use-this, no-lonely-if, vars-on-top */ +/* eslint-disable no-return-assign, one-var, no-var, consistent-return, class-methods-use-this, no-lonely-if, vars-on-top */ import $ from 'jquery'; import { escape, throttle } from 'underscore'; @@ -416,7 +416,7 @@ export class SearchAutocomplete { inputs = Object.keys(this.originalState); for (i = 0, len = inputs.length; i < len; i += 1) { input = inputs[i]; - this.getElement('#' + input).val(this.originalState[input]); + this.getElement(`#${input}`).val(this.originalState[input]); } } @@ -426,7 +426,7 @@ export class SearchAutocomplete { results = []; for (i = 0, len = inputs.length; i < len; i += 1) { input = inputs[i]; - results.push(this.getElement('#' + input).val('')); + results.push(this.getElement(`#${input}`).val('')); } return results; } diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue index e47a03f1939..5e30c8d614e 100644 --- a/app/assets/javascripts/serverless/components/url.vue +++ b/app/assets/javascripts/serverless/components/url.vue @@ -23,7 +23,7 @@ export default { <div class="url-text-field label label-monospace monospace">{{ uri }}</div> <clipboard-button :text="uri" - :title="s__('ServerlessURL|Copy URL to clipboard')" + :title="s__('ServerlessURL|Copy URL')" class="input-group-text js-clipboard-btn" /> <gl-button diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index 35eba266625..df950e79690 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -163,7 +163,7 @@ export default { :ok-title="s__('SetStatusModal|Set status')" :cancel-title="s__('SetStatusModal|Remove status')" ok-variant="success" - class="set-user-status-modal" + modal-class="set-user-status-modal" @shown="setupEmojiListAndAutocomplete" @hide="hideEmojiMenu" @ok="setStatus" @@ -194,9 +194,9 @@ export default { v-show="noEmoji" class="js-no-emoji-placeholder no-emoji-placeholder position-relative" > - <icon name="slight-smile" css-classes="award-control-icon-neutral" /> - <icon name="smiley" css-classes="award-control-icon-positive" /> - <icon name="smile" css-classes="award-control-icon-super-positive" /> + <icon name="slight-smile" class="award-control-icon-neutral" /> + <icon name="smiley" class="award-control-icon-positive" /> + <icon name="smile" class="award-control-icon-super-positive" /> </span> </button> </span> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue index 71a1fc31315..052bb3dcb53 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue @@ -42,6 +42,7 @@ export default { :width="imgSize" :class="`s${imgSize}`" class="avatar avatar-inline m-0" + data-qa-selector="avatar_image" /> <i v-if="hasMergeIcon" aria-hidden="true" class="fa fa-exclamation-triangle merge-icon"></i> </span> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue index 63b93a80ead..b107e9789a7 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue @@ -1,6 +1,5 @@ <script> import { n__ } from '~/locale'; -import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar'; export default { name: 'AssigneeTitle', @@ -30,22 +29,20 @@ export default { return n__('Assignee', `%d Assignees`, assignees); }, }, - methods: { - trackEdit() { - trackEvent('click_edit_button', 'assignee'); - }, - }, }; </script> <template> - <div class="title hide-collapsed"> + <div class="title hide-collapsed" data-qa-selector="assignee_title"> {{ assigneeTitle }} <i v-if="loading" aria-hidden="true" class="fa fa-spinner fa-spin block-loading"></i> <a v-if="editable" class="js-sidebar-dropdown-toggle edit-link float-right" href="#" - @click.prevent="trackEdit" + data-qa-selector="assignee_edit_link" + data-track-event="click_edit_button" + data-track-label="right_sidebar" + data-track-property="assignee" > {{ __('Edit') }} </a> diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue index 3a4623121f4..3d112bba668 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -85,7 +85,12 @@ export default { </div> </div> <div v-if="renderShowMoreSection" class="user-list-more"> - <button type="button" class="btn-link" @click="toggleShowLess"> + <button + type="button" + class="btn-link" + data-qa-selector="more_assignees_link" + @click="toggleShowLess" + > <template v-if="showLess"> {{ hiddenAssigneesLabel }} </template> diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue index 1c75b6148e8..5b3c3642290 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -5,7 +5,7 @@ import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; import eventHub from '~/sidebar/event_hub'; import editForm from './edit_form.vue'; -import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar'; +import recaptchaModalImplementor from '~/vue_shared/mixins/recaptcha_modal_implementor'; export default { components: { @@ -15,6 +15,7 @@ export default { directives: { tooltip, }, + mixins: [recaptchaModalImplementor], props: { isConfidential: { required: true, @@ -52,17 +53,17 @@ export default { toggleForm() { this.edit = !this.edit; }, - onEditClick() { - this.toggleForm(); - - trackEvent('click_edit_button', 'confidentiality'); - }, updateConfidentialAttribute(confidential) { this.service .update('issue', { confidential }) + .then(({ data }) => this.checkForSpam(data)) .then(() => window.location.reload()) - .catch(() => { - Flash(__('Something went wrong trying to change the confidentiality of this issue')); + .catch(error => { + if (error.name === 'SpamError') { + this.openRecaptcha(); + } else { + Flash(__('Something went wrong trying to change the confidentiality of this issue')); + } }); }, }, @@ -72,6 +73,7 @@ export default { <template> <div class="block issuable-sidebar-item confidentiality"> <div + ref="collapseIcon" v-tooltip :title="tooltipLabel" class="sidebar-collapsed-icon" @@ -86,9 +88,13 @@ export default { {{ __('Confidentiality') }} <a v-if="isEditable" + ref="editLink" class="float-right confidential-edit" href="#" - @click.prevent="onEditClick" + data-track-event="click_edit_button" + data-track-label="right_sidebar" + data-track-property="confidentiality" + @click.prevent="toggleForm" > {{ __('Edit') }} </a> @@ -113,5 +119,7 @@ export default { {{ __('This issue is confidential') }} </div> </div> + + <recaptcha-modal v-if="showRecaptcha" :html="recaptchaHTML" @close="closeRecaptcha" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue index ec2a7b93a98..c7c5e0e20f1 100644 --- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue @@ -6,7 +6,6 @@ import issuableMixin from '~/vue_shared/mixins/issuable'; import Icon from '~/vue_shared/components/icon.vue'; import eventHub from '~/sidebar/event_hub'; import editForm from './edit_form.vue'; -import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar'; export default { components: { @@ -66,11 +65,6 @@ export default { toggleForm() { this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen; }, - onEditClick() { - this.toggleForm(); - - trackEvent('click_edit_button', 'lock_issue'); - }, updateLockedAttribute(locked) { this.mediator.service .update(this.issuableType, { @@ -114,7 +108,10 @@ export default { v-if="isEditable" class="float-right lock-edit" type="button" - @click.prevent="onEditClick" + data-track-event="click_edit_button" + data-track-label="right_sidebar" + data-track-property="lock_issue" + @click.prevent="toggleForm" > {{ __('Edit') }} </button> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue index 1f5f19d1931..ea5edb3ce3f 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -1,10 +1,10 @@ <script> import { __ } from '~/locale'; +import Tracking from '~/tracking'; import icon from '~/vue_shared/components/icon.vue'; import toggleButton from '~/vue_shared/components/toggle_button.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import eventHub from '../../event_hub'; -import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar'; const ICON_ON = 'notifications'; const ICON_OFF = 'notifications-off'; @@ -19,6 +19,7 @@ export default { icon, toggleButton, }, + mixins: [Tracking.mixin({ label: 'right_sidebar' })], props: { loading: { type: Boolean, @@ -65,7 +66,10 @@ export default { // Component event emission. this.$emit('toggleSubscription', this.id); - trackEvent('toggle_button', 'notifications', this.subscribed ? 0 : 1); + this.track('toggle_button', { + property: 'notifications', + value: this.subscribed ? 0 : 1, + }); }, onClickCollapsedIcon() { this.$emit('toggleSidebar'); diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue index 24d5b14ded9..65ecd5be05d 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue @@ -1,6 +1,5 @@ <script> import { __, sprintf } from '~/locale'; -import { abbreviateTime } from '~/lib/utils/datetime_utility'; import icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; @@ -41,12 +40,6 @@ export default { }, }, computed: { - timeSpent() { - return this.abbreviateTime(this.timeSpentHumanReadable); - }, - timeEstimate() { - return this.abbreviateTime(this.timeEstimateHumanReadable); - }, divClass() { if (this.showComparisonState) { return 'compare'; @@ -73,11 +66,11 @@ export default { }, text() { if (this.showComparisonState) { - return `${this.timeSpent} / ${this.timeEstimate}`; + return `${this.timeSpentHumanReadable} / ${this.timeEstimateHumanReadable}`; } else if (this.showEstimateOnlyState) { - return `-- / ${this.timeEstimate}`; + return `-- / ${this.timeEstimateHumanReadable}`; } else if (this.showSpentOnlyState) { - return `${this.timeSpent} / --`; + return `${this.timeSpentHumanReadable} / --`; } else if (this.showNoTimeTrackingState) { return __('None'); } @@ -100,11 +93,6 @@ export default { return this.showNoTimeTrackingState ? __('Time tracking') : this.timeTrackedTooltipText; }, }, - methods: { - abbreviateTime(timeStr) { - return abbreviateTime(timeStr); - }, - }, }; </script> diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue index e6f2fe2b5fc..3d96405896d 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue @@ -82,11 +82,7 @@ export default { data-boundary="viewport" @click="handleButtonClick" > - <icon - v-show="collapsed" - :css-classes="collapsedButtonIconClasses" - :name="collapsedButtonIcon" - /> + <icon v-show="collapsed" :class="collapsedButtonIconClasses" :name="collapsedButtonIcon" /> <span v-show="!collapsed" class="issuable-todo-inner"> {{ buttonLabel }} </span> <gl-loading-icon v-show="isActionActive" :inline="true" /> </button> diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js index 110175a6779..66d1fed7d31 100644 --- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js +++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import '~/gl_dropdown'; import _ from 'underscore'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index b70e384fae5..de4a7f89449 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, prefer-arrow-callback, consistent-return, */ +/* eslint-disable consistent-return */ import $ from 'jquery'; import { __ } from './locale'; @@ -40,12 +40,9 @@ export default class SingleFileDiff { this.$toggleIcon.addClass('fa-caret-down'); } - $('.js-file-title, .click-to-expand', this.file).on( - 'click', - function(e) { - this.toggleDiff($(e.target)); - }.bind(this), - ); + $('.js-file-title, .click-to-expand', this.file).on('click', e => { + this.toggleDiff($(e.target)); + }); } toggleDiff($target, cb) { diff --git a/app/assets/javascripts/snippet/snippet_embed.js b/app/assets/javascripts/snippet/snippet_embed.js index fe08d2c7ebb..6606271c4fa 100644 --- a/app/assets/javascripts/snippet/snippet_embed.js +++ b/app/assets/javascripts/snippet/snippet_embed.js @@ -1,25 +1,30 @@ import { __ } from '~/locale'; export default () => { - const { protocol, host, pathname } = window.location; const shareBtn = document.querySelector('.js-share-btn'); - const embedBtn = document.querySelector('.js-embed-btn'); - const snippetUrlArea = document.querySelector('.js-snippet-url-area'); - const embedAction = document.querySelector('.js-embed-action'); - const url = `${protocol}//${host + pathname}`; - shareBtn.addEventListener('click', () => { - shareBtn.classList.add('is-active'); - embedBtn.classList.remove('is-active'); - snippetUrlArea.value = url; - embedAction.innerText = __('Share'); - }); + if (shareBtn) { + const { protocol, host, pathname } = window.location; - embedBtn.addEventListener('click', () => { - embedBtn.classList.add('is-active'); - shareBtn.classList.remove('is-active'); - const scriptTag = `<script src="${url}.js"></script>`; - snippetUrlArea.value = scriptTag; - embedAction.innerText = __('Embed'); - }); + const embedBtn = document.querySelector('.js-embed-btn'); + + const snippetUrlArea = document.querySelector('.js-snippet-url-area'); + const embedAction = document.querySelector('.js-embed-action'); + const url = `${protocol}//${host + pathname}`; + + shareBtn.addEventListener('click', () => { + shareBtn.classList.add('is-active'); + embedBtn.classList.remove('is-active'); + snippetUrlArea.value = url; + embedAction.innerText = __('Share'); + }); + + embedBtn.addEventListener('click', () => { + embedBtn.classList.add('is-active'); + shareBtn.classList.remove('is-active'); + const scriptTag = `<script src="${url}.js"></script>`; + snippetUrlArea.value = scriptTag; + embedAction.innerText = __('Embed'); + }); + } }; diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/templates/issuable_template_selector.js index 78609ce0610..10ad4170930 100644 --- a/app/assets/javascripts/templates/issuable_template_selector.js +++ b/app/assets/javascripts/templates/issuable_template_selector.js @@ -8,10 +8,13 @@ import { __ } from '~/locale'; export default class IssuableTemplateSelector extends TemplateSelector { constructor(...args) { super(...args); + this.projectPath = this.dropdown.data('projectPath'); this.namespacePath = this.dropdown.data('namespacePath'); this.issuableType = this.$dropdownContainer.data('issuableType'); this.titleInput = $(`#${this.issuableType}_title`); + this.templateWarningEl = $('.js-issuable-template-warning'); + this.warnTemplateOverride = args[0].warnTemplateOverride; const initialQuery = { name: this.dropdown.data('selected'), @@ -24,14 +27,62 @@ export default class IssuableTemplateSelector extends TemplateSelector { }); $('.no-template', this.dropdown.parent()).on('click', () => { - this.currentTemplate.content = ''; - this.setInputValueToTemplateContent(); - $('.dropdown-toggle-text', this.dropdown).text(__('Choose a template')); + this.reset(); + }); + + this.templateWarningEl.find('.js-close-btn').on('click', () => { + // Explicitly check against 0 value + if (this.previousSelectedIndex !== undefined) { + this.dropdown.data('glDropdown').selectRowAtIndex(this.previousSelectedIndex); + } else { + this.reset(); + } + + this.templateWarningEl.addClass('hidden'); }); + + this.templateWarningEl.find('.js-override-template').on('click', () => { + this.requestFile(this.overridingTemplate); + this.setSelectedIndex(); + + this.templateWarningEl.addClass('hidden'); + this.overridingTemplate = null; + }); + } + + reset() { + if (this.currentTemplate) { + this.currentTemplate.content = ''; + } + + this.setInputValueToTemplateContent(); + $('.dropdown-toggle-text', this.dropdown).text(__('Choose a template')); + this.previousSelectedIndex = null; + } + + setSelectedIndex() { + this.previousSelectedIndex = this.dropdown.data('glDropdown').selectedIndex; + } + + onDropdownClicked(query) { + const content = this.getEditorContent(); + const isContentUnchanged = + content === '' || (this.currentTemplate && content === this.currentTemplate.content); + + if (!this.warnTemplateOverride || isContentUnchanged) { + super.onDropdownClicked(query); + this.setSelectedIndex(); + + return; + } + + this.overridingTemplate = query.selectedObj; + this.templateWarningEl.removeClass('hidden'); } requestFile(query) { this.startLoadingSpinner(); + Api.issueTemplate( this.namespacePath, this.projectPath, @@ -59,6 +110,7 @@ export default class IssuableTemplateSelector extends TemplateSelector { } else { this.setEditorContent(this.currentTemplate, { skipFocus: false }); } + return; } } diff --git a/app/assets/javascripts/templates/issuable_template_selectors.js b/app/assets/javascripts/templates/issuable_template_selectors.js index 50e58ec5c46..443b3084113 100644 --- a/app/assets/javascripts/templates/issuable_template_selectors.js +++ b/app/assets/javascripts/templates/issuable_template_selectors.js @@ -4,7 +4,7 @@ import $ from 'jquery'; import IssuableTemplateSelector from './issuable_template_selector'; export default class IssuableTemplateSelectors { - constructor({ $dropdowns, editor } = {}) { + constructor({ $dropdowns, editor, warnTemplateOverride } = {}) { this.$dropdowns = $dropdowns || $('.js-issuable-selector'); this.editor = editor || this.initEditor(); @@ -16,6 +16,7 @@ export default class IssuableTemplateSelectors { wrapper: $dropdown.closest('.js-issuable-selector-wrap'), dropdown: $dropdown, editor: this.editor, + warnTemplateOverride, }); }); } diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js index 1e75ee60671..1a1f3e8d0a8 100644 --- a/app/assets/javascripts/test_utils/index.js +++ b/app/assets/javascripts/test_utils/index.js @@ -1,8 +1,10 @@ import 'core-js/es/map'; import 'core-js/es/set'; +import { Sortable } from 'sortablejs'; import simulateDrag from './simulate_drag'; import simulateInput from './simulate_input'; // Export to global space for rspec to use window.simulateDrag = simulateDrag; window.simulateInput = simulateInput; +window.Sortable = Sortable; diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js index 1b4ca1d5741..7c0097fbe37 100644 --- a/app/assets/javascripts/tracking.js +++ b/app/assets/javascripts/tracking.js @@ -1,4 +1,4 @@ -import $ from 'jquery'; +import _ from 'underscore'; const DEFAULT_SNOWPLOW_OPTIONS = { namespace: 'gl', @@ -14,18 +14,31 @@ const DEFAULT_SNOWPLOW_OPTIONS = { linkClickTracking: false, }; -const extractData = (el, opts = {}) => { - const { trackEvent, trackLabel = '', trackProperty = '' } = el.dataset; - let trackValue = el.dataset.trackValue || el.value || ''; - if (el.type === 'checkbox' && !el.checked) trackValue = false; - return [ - trackEvent + (opts.suffix || ''), - { - label: trackLabel, - property: trackProperty, - value: trackValue, - }, - ]; +const eventHandler = (e, func, opts = {}) => { + const el = e.target.closest('[data-track-event]'); + const action = el && el.dataset.trackEvent; + if (!action) return; + + let value = el.dataset.trackValue || el.value || undefined; + if (el.type === 'checkbox' && !el.checked) value = false; + + const data = { + label: el.dataset.trackLabel, + property: el.dataset.trackProperty, + value, + context: el.dataset.trackContext, + }; + + func(opts.category, action + (opts.suffix || ''), _.omit(data, _.isUndefined)); +}; + +const eventHandlers = (category, func) => { + const handler = opts => e => eventHandler(e, func, { ...{ category }, ...opts }); + const handlers = []; + handlers.push({ name: 'click', func: handler() }); + handlers.push({ name: 'show.bs.dropdown', func: handler({ suffix: '_show' }) }); + handlers.push({ name: 'hide.bs.dropdown', func: handler({ suffix: '_hide' }) }); + return handlers; }; export default class Tracking { @@ -39,49 +52,43 @@ export default class Tracking { return typeof window.snowplow === 'function' && this.trackable(); } - static event(category = document.body.dataset.page, event = 'generic', data = {}) { + static event(category = document.body.dataset.page, action = 'generic', data = {}) { if (!this.enabled()) return false; // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings if (!category) throw new Error('Tracking: no category provided for tracking.'); - return window.snowplow( - 'trackStructEvent', - category, - event, - Object.assign({}, { label: '', property: '', value: '' }, data), - ); + const { label, property, value, context } = data; + const contexts = context ? [context] : undefined; + return window.snowplow('trackStructEvent', category, action, label, property, value, contexts); } - constructor(category = document.body.dataset.page) { - this.category = category; - } - - bind(container = document) { - if (!this.constructor.enabled()) return; - container.querySelectorAll(`[data-track-event]`).forEach(el => { - if (this.customHandlingFor(el)) return; - // jquery is required for select2, so we use it always - // see: https://github.com/select2/select2/issues/4686 - $(el).on('click', this.eventHandler(this.category)); - }); - } + static bindDocument(category = document.body.dataset.page, documentOverride = null) { + const el = documentOverride || document; + if (!this.enabled() || el.trackingBound) return []; - customHandlingFor(el) { - const classes = el.classList; + el.trackingBound = true; - // bootstrap dropdowns - if (classes.contains('dropdown')) { - $(el).on('show.bs.dropdown', this.eventHandler(this.category, { suffix: '_show' })); - $(el).on('hide.bs.dropdown', this.eventHandler(this.category, { suffix: '_hide' })); - return true; - } - - return false; + const handlers = eventHandlers(category, (...args) => this.event(...args)); + handlers.forEach(event => el.addEventListener(event.name, event.func)); + return handlers; } - eventHandler(category = null, opts = {}) { - return e => { - this.constructor.event(category || this.category, ...extractData(e.currentTarget, opts)); + static mixin(opts) { + return { + data() { + return { + tracking: { + // eslint-disable-next-line no-underscore-dangle + category: this.$options.name || this.$options._componentTag, + }, + }; + }, + methods: { + track(action, data) { + const category = opts.category || data.category || this.tracking.category; + Tracking.event(category || 'unspecified', action, { ...opts, ...this.tracking, ...data }); + }, + }, }; } } @@ -89,7 +96,7 @@ export default class Tracking { export function initUserTracking() { if (!Tracking.enabled()) return; - const opts = Object.assign({}, DEFAULT_SNOWPLOW_OPTIONS, window.snowplowOptions); + const opts = { ...DEFAULT_SNOWPLOW_OPTIONS, ...window.snowplowOptions }; window.snowplow('newTracker', opts.namespace, opts.hostname, opts); window.snowplow('enableActivityTracking', 30, 30); @@ -97,4 +104,6 @@ export function initUserTracking() { if (opts.formTracking) window.snowplow('enableFormTracking'); if (opts.linkClickTracking) window.snowplow('enableLinkClickTracking'); + + Tracking.bindDocument(); } diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js index 3e659c9e7ea..69b3d20914a 100644 --- a/app/assets/javascripts/tree.js +++ b/app/assets/javascripts/tree.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, consistent-return, no-var, one-var, no-else-return, prefer-arrow-callback, class-methods-use-this */ +/* eslint-disable func-names, consistent-return, no-var, one-var, no-else-return, class-methods-use-this */ import $ from 'jquery'; import { visitUrl } from './lib/utils/url_utility'; @@ -29,7 +29,7 @@ export default class TreeView { var li, liSelected; li = $('tr.tree-item'); liSelected = null; - return $('body').keydown(function(e) { + return $('body').keydown(e => { var next, path; if ($('input:focus').length > 0 && (e.which === 38 || e.which === 40)) { return false; diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index 948f4d5e631..c0b7587be10 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -63,7 +63,7 @@ const handleUserPopoverMouseOver = event => { UsersCache.retrieveById(userId) .then(userData => { if (!userData) { - return; + return undefined; } Object.assign(user, { @@ -76,19 +76,16 @@ const handleUserPopoverMouseOver = event => { loaded: true, }); - UsersCache.retrieveStatusById(userId) - .then(status => { - if (!status) { - return; - } + return UsersCache.retrieveStatusById(userId); + }) + .then(status => { + if (!status) { + return; + } - Object.assign(user, { - status, - }); - }) - .catch(() => { - throw new Error(`User status for "${userId}" could not be retrieved!`); - }); + Object.assign(user, { + status, + }); }) .catch(() => { renderedPopover.$destroy(); diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index e78ca56be0e..da1a7c290f8 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, one-var, no-var, prefer-rest-params, vars-on-top, prefer-arrow-callback, consistent-return, no-shadow, no-else-return, no-self-compare, prefer-template, no-unused-expressions, yoda, prefer-spread, camelcase, no-param-reassign */ +/* eslint-disable func-names, one-var, no-var, prefer-rest-params, vars-on-top, consistent-return, no-shadow, no-else-return, no-self-compare, no-unused-expressions, yoda, prefer-spread, camelcase, no-param-reassign */ /* global Issuable */ /* global emitSidebarEvent */ @@ -7,6 +7,7 @@ import _ from 'underscore'; import axios from './lib/utils/axios_utils'; import { s__, __, sprintf } from './locale'; import ModalStore from './boards/stores/modal_store'; +import { parseBoolean } from './lib/utils/common_utils'; // TODO: remove eventHub hack after code splitting refactor window.emitSidebarEvent = window.emitSidebarEvent || $.noop; @@ -250,16 +251,12 @@ function UsersSelect(currentUser, els, options = {}) { return $dropdown.glDropdown({ showMenuAbove, data(term, callback) { - return _this.users( - term, - options, - function(users) { - // GitLabDropdownFilter returns this.instance - // GitLabDropdownRemote returns this.options.instance - const glDropdown = this.instance || this.options.instance; - glDropdown.options.processData(term, users, callback); - }.bind(this), - ); + return _this.users(term, options, users => { + // GitLabDropdownFilter returns this.instance + // GitLabDropdownRemote returns this.options.instance + const glDropdown = this.instance || this.options.instance; + glDropdown.options.processData(term, users, callback); + }); }, processData(term, data, callback) { let users = data; @@ -279,12 +276,13 @@ function UsersSelect(currentUser, els, options = {}) { }) .map(input => { const userId = parseInt(input.value, 10); - const { avatarUrl, avatar_url, name, username } = input.dataset; + const { avatarUrl, avatar_url, name, username, canMerge } = input.dataset; return { avatar_url: avatarUrl || avatar_url, id: userId, name, username, + can_merge: parseBoolean(canMerge), }; }); @@ -432,8 +430,7 @@ function UsersSelect(currentUser, els, options = {}) { const isActive = $el.hasClass('is-active'); const previouslySelected = $dropdown .closest('.selectbox') - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - .find("input[name='" + $dropdown.data('fieldName') + "'][value!=0]"); + .find(`input[name='${$dropdown.data('fieldName')}'][value!=0]`); // Enables support for limiting the number of users selected // Automatically removes the first on the list if more users are selected @@ -452,7 +449,7 @@ function UsersSelect(currentUser, els, options = {}) { // Remove unassigned selection (if it was previously selected) const unassignedSelected = $dropdown .closest('.selectbox') - .find("input[name='" + $dropdown.data('fieldName') + "'][value=0]"); + .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`); if (unassignedSelected) { unassignedSelected.remove(); @@ -506,7 +503,7 @@ function UsersSelect(currentUser, els, options = {}) { } else if (!$dropdown.hasClass('js-multiselect')) { selected = $dropdown .closest('.selectbox') - .find("input[name='" + $dropdown.data('fieldName') + "']") + .find(`input[name='${$dropdown.data('fieldName')}']`) .val(); return assignTo(selected); } @@ -548,7 +545,7 @@ function UsersSelect(currentUser, els, options = {}) { updateLabel: $dropdown.data('dropdownTitle'), renderRow(user) { var avatar, img, username; - username = user.username ? '@' + user.username : ''; + username = user.username ? `@${user.username}` : ''; avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url; let selected = false; @@ -559,7 +556,7 @@ function UsersSelect(currentUser, els, options = {}) { const { fieldName } = this; const field = $dropdown .closest('.selectbox') - .find("input[name='" + fieldName + "'][value='" + user.id + "']"); + .find(`input[name='${fieldName}'][value='${user.id}']`); if (field.length) { selected = true; @@ -575,7 +572,7 @@ function UsersSelect(currentUser, els, options = {}) { )}</a></li>`; } else { // 0 margin, because it's now handled by a wrapper - img = "<img src='" + avatar + "' class='avatar avatar-inline m-0' width='32' />"; + img = `<img src='${avatar}' class='avatar avatar-inline m-0' width='32' />`; } return _this.renderRow(options.issuableType, user, selected, username, img); @@ -606,7 +603,7 @@ function UsersSelect(currentUser, els, options = {}) { multiple: $(select).hasClass('multiselect'), minimumInputLength: 0, query(query) { - return _this.users(query.term, options, function(users) { + return _this.users(query.term, options, users => { var anyUser, data, emailUser, index, len, name, nullUser, obj, ref; data = { results: users, @@ -719,7 +716,7 @@ UsersSelect.prototype.formatResult = function(user) { ${_.escape(user.name)} </div> <div class='user-username dropdown-menu-user-username text-secondary'> - ${!user.invite ? '@' + _.escape(user.username) : ''} + ${!user.invite ? `@${_.escape(user.username)}` : ''} </div> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue new file mode 100644 index 00000000000..dc766176617 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue @@ -0,0 +1,40 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +export default { + components: { + GlLink, + }, + props: { + artifacts: { + type: Array, + required: true, + }, + }, +}; +</script> +<template> + <table class="table m-0"> + <thead class="thead-white text-nowrap"> + <tr class="d-none d-sm-table-row"> + <th class="w-0"></th> + <th>{{ __('Artifact') }}</th> + <th class="w-50"></th> + <th>{{ __('Job') }}</th> + </tr> + </thead> + + <tbody> + <tr v-for="item in artifacts" :key="item.text"> + <td class="w-0"></td> + <td> + <gl-link :href="item.url" target="_blank">{{ item.text }}</gl-link> + </td> + <td class="w-0"></td> + <td> + <gl-link :href="item.job_path">{{ item.job_name }}</gl-link> + </td> + </tr> + </tbody> + </table> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue new file mode 100644 index 00000000000..730e67761be --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue @@ -0,0 +1,36 @@ +<script> +import { mapActions, mapState, mapGetters } from 'vuex'; +import ArtifactsList from './artifacts_list.vue'; +import MrCollapsibleExtension from './mr_collapsible_extension.vue'; +import createStore from '../stores/artifacts_list'; + +export default { + store: createStore(), + components: { + ArtifactsList, + MrCollapsibleExtension, + }, + props: { + endpoint: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['artifacts', 'isLoading', 'hasError']), + ...mapGetters(['title']), + }, + created() { + this.setEndpoint(this.endpoint); + this.fetchArtifacts(); + }, + methods: { + ...mapActions(['setEndpoint', 'fetchArtifacts']), + }, +}; +</script> +<template> + <mr-collapsible-extension :title="title" :is-loading="isLoading" :has-error="hasError"> + <artifacts-list :artifacts="artifacts" /> + </mr-collapsible-extension> +</template> 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 bb6921225c2..1873e09c370 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -211,7 +211,7 @@ export default { <template v-else> <review-app-link :link="deploymentExternalUrl" - css-class="js-deploy-url js-deploy-url-feature-flag deploy-link btn btn-default btn-sm inline" + css-class="js-deploy-url deploy-link btn btn-default btn-sm inline" /> </template> <visual-review-app-link diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue new file mode 100644 index 00000000000..36f291e995c --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue @@ -0,0 +1,88 @@ +<script> +import { GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + GlButton, + GlLink, + GlLoadingIcon, + Icon, + }, + props: { + title: { + type: String, + required: true, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + hasError: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isCollapsed: true, + }; + }, + + computed: { + arrowIconName() { + return this.isCollapsed ? 'angle-right' : 'angle-down'; + }, + ariaLabel() { + return this.isCollapsed ? __('Expand') : __('Collapse'); + }, + }, + methods: { + toggleCollapsed() { + this.isCollapsed = !this.isCollapsed; + }, + }, +}; +</script> +<template> + <div> + <div class="mr-widget-extension d-flex align-items-center pl-3"> + <div v-if="hasError" class="ci-widget media"> + <div class="media-body"> + <span class="gl-font-size-small mr-widget-margin-left gl-line-height-24 js-error-state">{{ + title + }}</span> + </div> + </div> + + <template v-else> + <gl-button + class="btn-blank btn s32 square append-right-default" + :aria-label="ariaLabel" + :disabled="isLoading" + @click="toggleCollapsed" + > + <gl-loading-icon v-if="isLoading" /> + <icon v-else :name="arrowIconName" class="js-icon" /> + </gl-button> + <gl-button + variant="link" + class="js-title" + :disabled="isLoading" + :class="{ 'border-0': isLoading }" + @click="toggleCollapsed" + > + <template v-if="isCollapsed">{{ title }}</template> + <template v-else>{{ __('Collapse') }}</template> + </gl-button> + </template> + </div> + + <div v-if="!isCollapsed" class="border-top js-slot-container"> + <slot></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index fb826be19f5..2aaba6e1c8a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -90,7 +90,7 @@ export default { v-html="mr.sourceBranchLink" /><clipboard-button :text="branchNameClipboardData" - :title="__('Copy branch name to clipboard')" + :title="__('Copy branch name')" css-class="btn-default btn-transparent btn-clipboard" /> {{ s__('mrWidget|into') }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 52acd1de666..7c5f35579b8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -110,9 +110,15 @@ export default { <div class="ci-widget-container d-flex"> <div class="ci-widget-content"> <div class="media-body"> - <div class="font-weight-bold js-pipeline-info-container"> + <div + class="font-weight-bold js-pipeline-info-container" + data-qa-selector="merge_request_pipeline_info_content" + > {{ pipeline.details.name }} - <gl-link :href="pipeline.path" class="pipeline-id font-weight-normal pipeline-number" + <gl-link + :href="pipeline.path" + class="pipeline-id font-weight-normal pipeline-number" + data-qa-selector="pipeline_link" >#{{ pipeline.id }}</gl-link > {{ pipeline.details.status.label }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index 8fdf61a6b8d..ffc3e0967d4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -1,5 +1,6 @@ <script> import _ from 'underscore'; +import ArtifactsApp from './artifacts_list_app.vue'; import Deployment from './deployment.vue'; import MrWidgetContainer from './mr_widget_container.vue'; import MrWidgetPipeline from './mr_widget_pipeline.vue'; @@ -15,6 +16,7 @@ import MrWidgetPipeline from './mr_widget_pipeline.vue'; export default { name: 'MrWidgetPipelineContainer', components: { + ArtifactsApp, Deployment, MrWidgetContainer, MrWidgetPipeline, @@ -79,6 +81,9 @@ export default { :troubleshooting-docs-path="mr.troubleshootingDocsPath" /> <template v-slot:footer> + <div v-if="mr.exposedArtifactsPath" class="js-exposed-artifacts"> + <artifacts-app :endpoint="mr.exposedArtifactsPath" /> + </div> <div v-if="deployments.length" class="mr-widget-extension"> <deployment v-for="deployment in deployments" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue index 457a71cab95..75f557d05dd 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue @@ -19,6 +19,6 @@ export default { </script> <template> <a :href="link" target="_blank" rel="noopener noreferrer nofollow" :class="cssClass"> - {{ __('View app') }} <icon css-classes="fgray" name="external-link" /> + {{ __('View app') }} <icon class="fgray" name="external-link" /> </a> </template> 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 fb07c03e34d..a2b5a79af36 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 @@ -170,7 +170,7 @@ export default { > </a> <clipboard-button - :title="__('Copy commit SHA to clipboard')" + :title="__('Copy commit SHA')" :text="mr.mergeCommitSha" css-class="btn-default btn-transparent btn-clipboard js-mr-merged-copy-sha" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index 91c0b40a0b5..8132b1a944b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -1,5 +1,7 @@ <script> import $ from 'jquery'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; import statusIcon from '../mr_widget_status_icon.vue'; import tooltip from '../../../vue_shared/directives/tooltip'; import eventHub from '../../event_hub'; @@ -29,12 +31,12 @@ export default { .then(res => res.data) .then(data => { eventHub.$emit('UpdateWidgetData', data); - new window.Flash('The merge request can now be merged.', 'notice'); // eslint-disable-line + createFlash(__('The merge request can now be merged.'), 'notice'); $('.merge-request .detail-page-description .title').text(this.mr.title); }) .catch(() => { this.isMakingRequest = false; - new window.Flash('Something went wrong. Please try again.'); // eslint-disable-line + createFlash(__('Something went wrong. Please try again.')); }); }, }, diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js new file mode 100644 index 00000000000..3648db795f5 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js @@ -0,0 +1,74 @@ +import Visibility from 'visibilityjs'; +import axios from '~/lib/utils/axios_utils'; +import Poll from '~/lib/utils/poll'; +import httpStatusCodes from '~/lib/utils/http_status'; + +import * as types from './mutation_types'; + +export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint); + +export const requestArtifacts = ({ commit }) => commit(types.REQUEST_ARTIFACTS); + +let eTagPoll; + +export const clearEtagPoll = () => { + eTagPoll = null; +}; + +export const stopPolling = () => { + if (eTagPoll) eTagPoll.stop(); +}; + +export const restartPolling = () => { + if (eTagPoll) eTagPoll.restart(); +}; + +export const fetchArtifacts = ({ state, dispatch }) => { + dispatch('requestArtifacts'); + + eTagPoll = new Poll({ + resource: { + getArtifacts(endpoint) { + return axios.get(endpoint); + }, + }, + data: state.endpoint, + method: 'getArtifacts', + successCallback: ({ data, status }) => { + dispatch('receiveArtifactsSuccess', { + data, + status, + }); + }, + errorCallback: () => dispatch('receiveArtifactsError'), + }); + + if (!Visibility.hidden()) { + eTagPoll.makeRequest(); + } else { + axios + .get(state.endpoint) + .then(({ data, status }) => dispatch('receiveArtifactsSuccess', { data, status })) + .catch(() => dispatch('receiveArtifactsError')); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + dispatch('restartPolling'); + } else { + dispatch('stopPolling'); + } + }); +}; + +export const receiveArtifactsSuccess = ({ commit }, response) => { + // With 204 we keep polling and don't update the state + if (response.status === httpStatusCodes.OK) { + commit(types.RECEIVE_ARTIFACTS_SUCCESS, response.data); + } +}; + +export const receiveArtifactsError = ({ commit }) => commit(types.RECEIVE_ARTIFACTS_ERROR); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/getters.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/getters.js new file mode 100644 index 00000000000..8921637b93b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/getters.js @@ -0,0 +1,16 @@ +import { s__, n__ } from '~/locale'; + +export const title = state => { + if (state.isLoading) { + return s__('BuildArtifacts|Loading artifacts'); + } + + if (state.hasError) { + return s__('BuildArtifacts|An error occurred while fetching the artifacts'); + } + + return n__('View exposed artifact', 'View %d exposed artifacts', state.artifacts.length); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js new file mode 100644 index 00000000000..f8abbc99f0f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; +import * as getters from './getters'; +import state from './state'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + actions, + mutations, + getters, + state: state(), + }); diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutation_types.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutation_types.js new file mode 100644 index 00000000000..282faf6f8a4 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutation_types.js @@ -0,0 +1,5 @@ +export const SET_ENDPOINT = 'SET_ENDPOINT'; + +export const REQUEST_ARTIFACTS = 'REQUEST_ARTIFACTS'; +export const RECEIVE_ARTIFACTS_SUCCESS = 'RECEIVE_ARTIFACTS_SUCCESS'; +export const RECEIVE_ARTIFACTS_ERROR = 'RECEIVE_ARTIFACTS_ERROR'; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutations.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutations.js new file mode 100644 index 00000000000..95a091f1bd6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutations.js @@ -0,0 +1,22 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_ENDPOINT](state, endpoint) { + state.endpoint = endpoint; + }, + [types.REQUEST_ARTIFACTS](state) { + state.isLoading = true; + }, + [types.RECEIVE_ARTIFACTS_SUCCESS](state, response) { + state.hasError = false; + state.isLoading = false; + + state.artifacts = response; + }, + [types.RECEIVE_ARTIFACTS_ERROR](state) { + state.isLoading = false; + state.hasError = true; + + state.artifacts = []; + }, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/state.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/state.js new file mode 100644 index 00000000000..92dad171b1b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/state.js @@ -0,0 +1,8 @@ +export default () => ({ + endpoint: null, + + isLoading: false, + hasError: false, + + artifacts: [], +}); 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 699d41494bf..f51d0fa4f52 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 @@ -100,6 +100,7 @@ export default class MergeRequestStore { this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false; this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null; this.testResultsPath = data.test_reports_path; + this.exposedArtifactsPath = data.exposed_artifacts_path; this.cancelAutoMergePath = data.cancel_auto_merge_path; this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path); @@ -168,6 +169,7 @@ export default class MergeRequestStore { this.mergeRequestPipelinesHelpPath = data.merge_request_pipelines_docs_path; this.conflictsDocsPath = data.conflicts_docs_path; this.ciEnvironmentsStatusPath = data.ci_environments_status_path; + this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path; } get isNothingToMergeState() { diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue index a97538d813a..75c3c544c77 100644 --- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -70,7 +70,13 @@ export default { return undefined; }, showIcon() { - return this.file.changed || this.file.tempFile || this.file.staged || this.file.deleted; + return ( + this.file.changed || + this.file.tempFile || + this.file.staged || + this.file.deleted || + this.file.prevPath + ); }, }, }; @@ -83,7 +89,7 @@ export default { :class="{ 'ml-auto': isCentered }" class="file-changed-icon d-inline-block" > - <icon v-if="showIcon" :name="changedIcon" :size="size" :css-classes="changedIconClass" /> + <icon v-if="showIcon" :name="changedIcon" :size="size" :class="changedIconClass" /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index 5d373e179b2..162cfc02959 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -66,5 +66,5 @@ export default { }; </script> <template> - <span :class="cssClass"> <icon :name="icon" :size="size" :css-classes="cssClasses" /> </span> + <span :class="cssClass"> <icon :name="icon" :size="size" :class="cssClasses" /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index a620f560b52..9f498037185 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -7,7 +7,7 @@ * * @example * <clipboard-button - * title="Copy to clipboard" + * title="Copy" * text="Content to be copied" * css-class="btn-transparent" * /> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue index c6d61d6ee62..fe1a2a092ad 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue @@ -40,7 +40,7 @@ export default { </template> </p> <gl-link :href="path" class="btn btn-default" rel="nofollow" download target="_blank"> - <icon :size="16" name="download" css-classes="float-left append-right-8" /> + <icon :size="16" name="download" class="float-left append-right-8" /> {{ __('Download') }} </gl-link> </div> diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue index d5558d93219..3f55f43edbb 100644 --- a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue +++ b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue @@ -132,6 +132,7 @@ export default { type="button" class="btn js-primary-button" data-dismiss="modal" + data-qa-selector="save_changes_button" @click="emitSubmit($event)" > {{ primaryButtonLabel }} diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue new file mode 100644 index 00000000000..543547b37fe --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue @@ -0,0 +1,118 @@ +<script> +import $ from 'jquery'; + +const buttonVariants = ['danger', 'primary', 'success', 'warning']; +const sizeVariants = ['sm', 'md', 'lg', 'xl']; + +export default { + name: 'DeprecatedModal2', // use GlModal instead + + props: { + id: { + type: String, + required: false, + default: null, + }, + modalSize: { + type: String, + required: false, + default: 'md', + validator: value => sizeVariants.includes(value), + }, + headerTitleText: { + type: String, + required: false, + default: '', + }, + footerPrimaryButtonVariant: { + type: String, + required: false, + default: 'primary', + validator: value => buttonVariants.includes(value), + }, + footerPrimaryButtonText: { + type: String, + required: false, + default: '', + }, + }, + computed: { + modalSizeClass() { + return this.modalSize === 'md' ? '' : `modal-${this.modalSize}`; + }, + }, + mounted() { + $(this.$el) + .on('shown.bs.modal', this.opened) + .on('hidden.bs.modal', this.closed); + }, + beforeDestroy() { + $(this.$el) + .off('shown.bs.modal', this.opened) + .off('hidden.bs.modal', this.closed); + }, + methods: { + emitCancel(event) { + this.$emit('cancel', event); + }, + emitSubmit(event) { + this.$emit('submit', event); + }, + opened() { + this.$emit('open'); + }, + closed() { + this.$emit('closed'); + }, + }, +}; +</script> + +<template> + <div :id="id" class="modal fade" tabindex="-1" role="dialog"> + <div :class="modalSizeClass" class="modal-dialog" role="document"> + <div class="modal-content"> + <div class="modal-header"> + <slot name="header"> + <h4 class="modal-title"> + <slot name="title"> {{ headerTitleText }} </slot> + </h4> + <button + :aria-label="s__('Modal|Close')" + type="button" + class="close js-modal-close-action" + data-dismiss="modal" + @click="emitCancel($event)" + > + <span aria-hidden="true">×</span> + </button> + </slot> + </div> + + <div class="modal-body"><slot></slot></div> + + <div class="modal-footer"> + <slot name="footer"> + <button + type="button" + class="btn js-modal-cancel-action qa-modal-cancel-button" + data-dismiss="modal" + @click="emitCancel($event)" + > + {{ s__('Modal|Cancel') }} + </button> + <button + :class="`btn-${footerPrimaryButtonVariant}`" + type="button" + class="btn js-modal-primary-action qa-modal-primary-button" + data-dismiss="modal" + @click="emitSubmit($event)" + > + {{ footerPrimaryButtonText }} + </button> + </slot> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue index b69ecc1dce6..952ffa1fa0e 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/file_icon.vue @@ -75,7 +75,7 @@ export default { <svg v-if="!loading && !folder" :class="[iconSizeClass, cssClasses]"> <use v-bind="{ 'xlink:href': spriteHref }" /> </svg> - <icon v-if="!loading && folder" :name="folderIconName" :size="size" css-classes="folder-icon" /> + <icon v-if="!loading && folder" :name="folderIconName" :size="size" class="folder-icon" /> <gl-loading-icon v-if="loading" :inline="true" /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index f49e69c473b..341c9534763 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -131,7 +131,7 @@ export default { </script> <template> - <div v-if="!file.moved"> + <div> <file-header v-if="file.isHeader" :path="file.path" /> <div v-else diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue index 438851e5ac7..4b91d4c00e3 100644 --- a/app/assets/javascripts/vue_shared/components/gl_modal.vue +++ b/app/assets/javascripts/vue_shared/components/gl_modal.vue @@ -1,117 +1,6 @@ <script> -import $ from 'jquery'; +// This file was only introduced to not break master and shall be delete soon. +import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; -const buttonVariants = ['danger', 'primary', 'success', 'warning']; -const sizeVariants = ['sm', 'md', 'lg', 'xl']; - -export default { - name: 'GlModal', - props: { - id: { - type: String, - required: false, - default: null, - }, - modalSize: { - type: String, - required: false, - default: 'md', - validator: value => sizeVariants.includes(value), - }, - headerTitleText: { - type: String, - required: false, - default: '', - }, - footerPrimaryButtonVariant: { - type: String, - required: false, - default: 'primary', - validator: value => buttonVariants.includes(value), - }, - footerPrimaryButtonText: { - type: String, - required: false, - default: '', - }, - }, - computed: { - modalSizeClass() { - return this.modalSize === 'md' ? '' : `modal-${this.modalSize}`; - }, - }, - mounted() { - $(this.$el) - .on('shown.bs.modal', this.opened) - .on('hidden.bs.modal', this.closed); - }, - beforeDestroy() { - $(this.$el) - .off('shown.bs.modal', this.opened) - .off('hidden.bs.modal', this.closed); - }, - methods: { - emitCancel(event) { - this.$emit('cancel', event); - }, - emitSubmit(event) { - this.$emit('submit', event); - }, - opened() { - this.$emit('open'); - }, - closed() { - this.$emit('closed'); - }, - }, -}; +export default DeprecatedModal2; </script> - -<template> - <div :id="id" class="modal fade" tabindex="-1" role="dialog"> - <div :class="modalSizeClass" class="modal-dialog" role="document"> - <div class="modal-content"> - <div class="modal-header"> - <slot name="header"> - <h4 class="modal-title"> - <slot name="title"> {{ headerTitleText }} </slot> - </h4> - <button - :aria-label="s__('Modal|Close')" - type="button" - class="close js-modal-close-action" - data-dismiss="modal" - @click="emitCancel($event)" - > - <span aria-hidden="true">×</span> - </button> - </slot> - </div> - - <div class="modal-body"><slot></slot></div> - - <div class="modal-footer"> - <slot name="footer"> - <button - type="button" - class="btn js-modal-cancel-action qa-modal-cancel-button" - data-dismiss="modal" - @click="emitCancel($event)" - > - {{ s__('Modal|Cancel') }} - </button> - <button - :class="`btn-${footerPrimaryButtonVariant}`" - type="button" - class="btn js-modal-primary-action qa-modal-primary-button" - data-dismiss="modal" - @click="emitSubmit($event)" - > - {{ footerPrimaryButtonText }} - </button> - </slot> - </div> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue index fa89473da62..73f4dfef062 100644 --- a/app/assets/javascripts/vue_shared/components/icon.vue +++ b/app/assets/javascripts/vue_shared/components/icon.vue @@ -27,7 +27,7 @@ if (process.env.NODE_ENV !== 'production') { * <icon * name="retry" * :size="32" - * css-classes="top" + * class="top" * /> */ export default { @@ -42,45 +42,7 @@ export default { type: Number, required: false, default: 16, - validator(value) { - return validSizes.includes(value); - }, - }, - - cssClasses: { - type: String, - required: false, - default: '', - }, - - width: { - type: Number, - required: false, - default: null, - }, - - height: { - type: Number, - required: false, - default: null, - }, - - y: { - type: Number, - required: false, - default: null, - }, - - x: { - type: Number, - required: false, - default: null, - }, - - tabIndex: { - type: String, - required: false, - default: null, + validator: value => validSizes.includes(value), }, }, @@ -99,15 +61,7 @@ export default { </script> <template> - <svg - :class="[iconSizeClass, iconTestClass, cssClasses]" - :width="width" - :height="height" - :x="x" - :y="y" - :tabindex="tabIndex" - aria-hidden="true" - > + <svg :class="[iconSizeClass, iconTestClass]" aria-hidden="true"> <use v-bind="{ 'xlink:href': spriteHref }" /> </svg> </template> diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue index b76679960ca..5d7e9557aff 100644 --- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue +++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue @@ -63,7 +63,7 @@ export default { <icon v-if="hasState" ref="iconElementXL" - :css-classes="iconClass" + :class="iconClass" :name="iconName" :size="16" :title="stateTitle" @@ -100,7 +100,7 @@ export default { <span ref="iconElement"> <icon v-if="hasState" - :css-classes="iconClass" + :class="iconClass" :name="iconName" :title="stateTitle" :aria-label="state" @@ -159,7 +159,8 @@ export default { v-gl-tooltip :disabled="removeDisabled" type="button" - class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button mr-xl-0 align-self-xl-center" + class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button mr-xl-0 align-self-xl-center" + data-qa-selector="remove_related_issue_button" :title="__('Remove')" :aria-label="__('Remove')" @click="onRemoveRequest" diff --git a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue index 1e2d4ffa7e3..e89638130f5 100644 --- a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue +++ b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue @@ -27,8 +27,7 @@ export default { /** pageInfo will come from the headers of the API call - in the `.then` clause of the VueResource API call - there should be a function that contructs the pageInfo for this component + there should be a function that constructs the pageInfo for this component This is an example: diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue index 7f0345c7ec0..478e44d104c 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue @@ -52,7 +52,7 @@ export default { this.$emit('projectClicked', project); }, isSelected(project) { - return Boolean(_.findWhere(this.selectedProjects, { id: project.id })); + return Boolean(_.find(this.selectedProjects, { id: project.id })); }, onInput: _.debounce(function debouncedOnInput() { this.$emit('searched', this.searchQuery); diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js b/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js new file mode 100644 index 00000000000..a4e004c3341 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; + +// see recaptcha_tags in app/views/shared/_recaptcha_form.html.haml +export const callbackName = 'recaptchaDialogCallback'; + +export const eventHub = new Vue(); + +const throwDuplicateCallbackError = () => { + throw new Error(`${callbackName} is already defined!`); +}; + +if (window[callbackName]) { + throwDuplicateCallbackError(); +} + +const callback = () => eventHub.$emit('submit'); + +Object.defineProperty(window, callbackName, { + get: () => callback, + set: throwDuplicateCallbackError, +}); diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue index f0aae20477b..25701df33f3 100644 --- a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue +++ b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue @@ -1,5 +1,6 @@ <script> import DeprecatedModal from './deprecated_modal.vue'; +import { eventHub } from './recaptcha_eventhub'; export default { name: 'RecaptchaModal', @@ -30,14 +31,15 @@ export default { }, mounted() { - if (window.recaptchaDialogCallback) { - throw new Error('recaptchaDialogCallback is already defined!'); + eventHub.$on('submit', this.submit); + + if (this.html) { + this.appendRecaptchaScript(); } - window.recaptchaDialogCallback = this.submit.bind(this); }, beforeDestroy() { - window.recaptchaDialogCallback = null; + eventHub.$off('submit', this.submit); }, methods: { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue index 9c258c4651f..13795eff714 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue @@ -167,7 +167,7 @@ dropdown-menu-labels dropdown-menu-selectable" <div class="dropdown-page-one"> <dropdown-header v-if="showCreate" /> <dropdown-search-input /> - <div class="dropdown-content"></div> + <div class="dropdown-content" data-qa-selector="labels_dropdown_content"></div> <div class="dropdown-loading"><gl-loading-icon /></div> <dropdown-footer v-if="showCreate" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue index cb53273c786..574b63cf8a6 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue @@ -14,7 +14,11 @@ export default { {{ __('Labels') }} <template v-if="canEdit"> <i aria-hidden="true" class="fa fa-spinner fa-spin block-loading" data-hidden="true"> </i> - <button type="button" class="edit-link btn btn-blank float-right js-sidebar-dropdown-toggle"> + <button + type="button" + class="edit-link btn btn-blank float-right js-sidebar-dropdown-toggle" + data-qa-selector="labels_edit_button" + > {{ __('Edit') }} </button> </template> diff --git a/app/assets/javascripts/vue_shared/components/toggle_button.vue b/app/assets/javascripts/vue_shared/components/toggle_button.vue index de70fa2182b..1de866bed37 100644 --- a/app/assets/javascripts/vue_shared/components/toggle_button.vue +++ b/app/assets/javascripts/vue_shared/components/toggle_button.vue @@ -74,7 +74,7 @@ export default { @click="toggleFeature" > <gl-loading-icon class="loading-icon" /> - <span class="toggle-icon"> <icon :name="toggleIcon" css-classes="toggle-icon-svg" /> </span> + <span class="toggle-icon"> <icon :name="toggleIcon" class="toggle-icon-svg" /> </span> </button> </label> </template> diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index a60d5eb491e..7c7d46ee759 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -71,15 +71,11 @@ export default { </div> <div class="text-secondary"> <div v-if="user.bio" class="js-bio d-flex mb-1"> - <icon name="profile" css-classes="category-icon flex-shrink-0" /> + <icon name="profile" class="category-icon flex-shrink-0" /> <span class="ml-1">{{ user.bio }}</span> </div> <div v-if="user.organization" class="js-organization d-flex mb-1"> - <icon - v-show="!jobInfoIsLoading" - name="work" - css-classes="category-icon flex-shrink-0" - /> + <icon v-show="!jobInfoIsLoading" name="work" class="category-icon flex-shrink-0" /> <span class="ml-1">{{ user.organization }}</span> </div> <gl-skeleton-loading @@ -92,7 +88,7 @@ export default { <icon v-show="!locationIsLoading && user.location" name="location" - css-classes="category-icon flex-shrink-0" + class="category-icon flex-shrink-0" /> <span class="ml-1">{{ user.location }}</span> <gl-skeleton-loading diff --git a/app/assets/javascripts/vue_shared/directives/track_event.js b/app/assets/javascripts/vue_shared/directives/track_event.js new file mode 100644 index 00000000000..d1c05c5c267 --- /dev/null +++ b/app/assets/javascripts/vue_shared/directives/track_event.js @@ -0,0 +1,20 @@ +import Tracking from '~/tracking'; + +export default { + bind(el, binding) { + el.dataset.trackingOptions = JSON.stringify(binding.value || {}); + + el.addEventListener('click', () => { + const { category, action, label, property, value } = JSON.parse(el.dataset.trackingOptions); + if (!category || !action) { + return; + } + Tracking.event(category, action, { label, property, value }); + }); + }, + update(el, binding) { + if (binding.value !== binding.oldValue) { + el.dataset.trackingOptions = JSON.stringify(binding.value || {}); + } + }, +}; diff --git a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js new file mode 100644 index 00000000000..3488a44bd0f --- /dev/null +++ b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js @@ -0,0 +1,7 @@ +export default Vue => { + Vue.mixin({ + provide: { + glFeatures: { ...((window.gon && window.gon.features) || {}) }, + }, + }); +}; diff --git a/app/assets/javascripts/vue_shared/mixins/gl_feature_flags_mixin.js b/app/assets/javascripts/vue_shared/mixins/gl_feature_flags_mixin.js new file mode 100644 index 00000000000..dc8a63f26ac --- /dev/null +++ b/app/assets/javascripts/vue_shared/mixins/gl_feature_flags_mixin.js @@ -0,0 +1,8 @@ +export default () => ({ + inject: { + glFeatures: { + from: 'glFeatures', + default: () => ({}), + }, + }, +}); diff --git a/app/assets/javascripts/vue_shared/plugins/global_toast.js b/app/assets/javascripts/vue_shared/plugins/global_toast.js index c0de1cdc615..7a2e5d80a5d 100644 --- a/app/assets/javascripts/vue_shared/plugins/global_toast.js +++ b/app/assets/javascripts/vue_shared/plugins/global_toast.js @@ -2,7 +2,8 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; Vue.use(GlToast); +const instance = new Vue(); export default function showGlobalToast(...args) { - return Vue.toasted.show(...args); + return instance.$toast.show(...args); } diff --git a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js deleted file mode 100644 index 754025207c8..00000000000 --- a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js +++ /dev/null @@ -1,34 +0,0 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; -import csrf from '../lib/utils/csrf'; - -Vue.use(VueResource); - -// Maintain a global counter for active requests -// see: spec/support/wait_for_requests.rb -Vue.http.interceptors.push((request, next) => { - window.activeVueResources = window.activeVueResources || 0; - window.activeVueResources += 1; - - next(() => { - window.activeVueResources -= 1; - }); -}); - -// Inject CSRF token and parse headers. -// New Vue Resource version uses Headers, we are expecting a plain object to render pagination -// and polling. -Vue.http.interceptors.push((request, next) => { - request.headers.set(csrf.headerKey, csrf.token); - - next(response => { - // Headers object has a `forEach` property that iterates through all values. - const headers = {}; - - response.headers.forEach((value, key) => { - headers[key] = value; - }); - // eslint-disable-next-line no-param-reassign - response.headers = headers; - }); -}); diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js index 5438572eadf..7a60ab1380f 100644 --- a/app/assets/javascripts/zen_mode.js +++ b/app/assets/javascripts/zen_mode.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, prefer-arrow-callback, consistent-return, camelcase, class-methods-use-this */ +/* eslint-disable func-names, consistent-return, camelcase, class-methods-use-this */ // Zen Mode (full screen) textarea // @@ -39,11 +39,11 @@ export default class ZenMode { constructor() { this.active_backdrop = null; this.active_textarea = null; - $(document).on('click', '.js-zen-enter', function(e) { + $(document).on('click', '.js-zen-enter', e => { e.preventDefault(); return $(e.currentTarget).trigger('zen_mode:enter'); }); - $(document).on('click', '.js-zen-leave', function(e) { + $(document).on('click', '.js-zen-leave', e => { e.preventDefault(); return $(e.currentTarget).trigger('zen_mode:leave'); }); @@ -67,7 +67,7 @@ export default class ZenMode { }; })(this), ); - $(document).on('keydown', function(e) { + $(document).on('keydown', e => { // Esc if (e.keyCode === 27) { e.preventDefault(); diff --git a/app/assets/stylesheets/components/release_block.scss b/app/assets/stylesheets/components/release_block.scss new file mode 100644 index 00000000000..7e82d0960d7 --- /dev/null +++ b/app/assets/stylesheets/components/release_block.scss @@ -0,0 +1,3 @@ +.release-block { + transition: background-color 1s linear; +} diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 82b4ec750ff..56a88ca44db 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -28,6 +28,7 @@ @import 'framework/issue_box'; @import 'framework/lists'; @import 'framework/logo'; +@import 'framework/job_log'; @import 'framework/markdown_area'; @import 'framework/media_object'; @import 'framework/modal'; @@ -67,3 +68,4 @@ @import 'framework/flex_grid'; @import 'framework/system_messages'; @import "framework/spinner"; +@import 'framework/card'; diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 6f5a2e561af..d222fc4aefe 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -11,25 +11,10 @@ @include webkit-prefix(animation-duration, 1s); @include webkit-prefix(animation-fill-mode, both); - &.infinite { - @include webkit-prefix(animation-iteration-count, infinite); - } - &.once { @include webkit-prefix(animation-iteration-count, 1); } - &.hinge { - @include webkit-prefix(animation-duration, 2s); - } - - &.flipOutX, - &.flipOutY, - &.bounceIn, - &.bounceOut { - @include webkit-prefix(animation-duration, 0.75s); - } - &.short { @include webkit-prefix(animation-duration, 321ms); @include webkit-prefix(animation-fill-mode, none); diff --git a/app/assets/stylesheets/framework/blank.scss b/app/assets/stylesheets/framework/blank.scss index cbd390e7145..7dd7ab339dd 100644 --- a/app/assets/stylesheets/framework/blank.scss +++ b/app/assets/stylesheets/framework/blank.scss @@ -14,13 +14,12 @@ .blank-state-row { display: flex; flex-wrap: wrap; - justify-content: space-around; - height: 100%; + justify-content: space-between; } .blank-state-welcome { text-align: center; - padding: 20px 0 40px; + padding: $gl-padding 0 ($gl-padding * 2); .blank-state-welcome-title { font-size: 24px; @@ -32,23 +31,9 @@ } .blank-state-link { - display: block; color: $gl-text-color; - flex: 0 0 100%; margin-bottom: 15px; - @include media-breakpoint-up(sm) { - flex: 0 0 49%; - - &:nth-child(odd) { - margin-right: 5px; - } - - &:nth-child(even) { - margin-left: 5px; - } - } - &:hover { background-color: $gray-light; text-decoration: none; @@ -63,15 +48,25 @@ } .blank-state { - padding: 20px; + display: flex; + align-items: center; + padding: 20px 50px; border: 1px solid $border-color; border-radius: $border-radius-default; + min-height: 240px; + margin-bottom: $gl-padding; + width: calc(50% - #{$gl-padding-8}); + + @include media-breakpoint-down(sm) { + width: 100%; + flex-direction: column; + justify-content: center; + padding: 50px 20px; + + .column-small & { + width: 100%; + } - @include media-breakpoint-up(sm) { - display: flex; - height: 100%; - align-items: center; - padding: 50px 30px; } } @@ -90,7 +85,7 @@ } .blank-state-body { - @include media-breakpoint-down(xs) { + @include media-breakpoint-down(sm) { text-align: center; margin-top: 20px; } @@ -121,9 +116,3 @@ } } } - -@include media-breakpoint-down(xs) { - .blank-state-icon svg { - width: 315px; - } -} diff --git a/app/assets/stylesheets/framework/card.scss b/app/assets/stylesheets/framework/card.scss new file mode 100644 index 00000000000..9911b926cbb --- /dev/null +++ b/app/assets/stylesheets/framework/card.scss @@ -0,0 +1,8 @@ +.card-header { + &:first-child { + // intended use case: card with only a header (for example empty related issues) + &:last-child { + @include border-radius($card-inner-border-radius); + } + } +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index b95978b6966..4b89a2f2b04 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -55,6 +55,10 @@ background-color: $gray-light; } +.bg-line-target-blue { + background: $line-target-blue; +} + .text-break-word { word-break: break-all; } @@ -210,18 +214,26 @@ li.note { @mixin message($background-color, $border-color, $text-color) { border-left: 4px solid $border-color; color: $text-color; - padding: 10px; - margin-bottom: 10px; - background: $background-color; - padding-left: 20px; + padding: $gl-padding $gl-padding-24; + margin-bottom: $gl-padding-12; + background-color: $background-color; &.centered { text-align: center; } + + .close { + svg { + width: $gl-font-size-large; + height: $gl-font-size-large; + } + + color: inherit; + } } .warning_message { - @include message($orange-100, $orange-200, $orange-700); + @include message($orange-100, $orange-200, $orange-800); } .danger_message { @@ -387,6 +399,7 @@ img.emoji { .prepend-top-16 { margin-top: 16px; } .prepend-top-20 { margin-top: 20px; } .prepend-top-32 { margin-top: 32px; } +.prepend-left-2 { margin-left: 2px; } .prepend-left-4 { margin-left: 4px; } .prepend-left-5 { margin-left: 5px; } .prepend-left-8 { margin-left: 8px; } @@ -427,6 +440,7 @@ img.emoji { .flex-no-shrink { flex-shrink: 0; } .ws-initial { white-space: initial; } .ws-normal { white-space: normal; } +.ws-pre-wrap { white-space: pre-wrap; } .overflow-auto { overflow: auto; } .d-flex-center { @@ -439,6 +453,8 @@ img.emoji { .w-0 { width: 0; } .w-8em { width: 8em; } .w-3rem { width: 3rem; } +.w-15p { width: 15%; } +.w-70p { width: 70%; } .h-12em { height: 12em; } .h-32-px { height: 32px;} @@ -544,3 +560,6 @@ img.emoji { } } } + +.gl-font-size-small { font-size: $gl-font-size-small; } +.gl-line-height-24 { line-height: $gl-line-height-24; } diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 3238b01c6c0..0e29b0b7dda 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -141,7 +141,7 @@ } .sidebar-top-level-items > li > a { - min-height: 44px; + min-height: 45px; } .fly-out-top-item { @@ -177,7 +177,7 @@ transition: padding $sidebar-transition-duration; display: flex; align-items: center; - padding: 12px 15px; + padding: 12px $gl-padding; color: $gl-text-color-secondary; } @@ -341,7 +341,7 @@ > a { margin-left: 4px; // Subtract width of left border on active element - padding-left: 11px; + padding-left: $gl-padding-12; } .badge.badge-pill { diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 29f63e9578d..ce74aa6ed02 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -326,8 +326,9 @@ } .dropdown-header { - color: $gl-text-color-secondary; + color: $black; font-size: 13px; + font-weight: $gl-font-weight-bold; line-height: $gl-line-height; padding: $dropdown-item-padding-y $dropdown-item-padding-x; } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 536a26a6ffe..487fbf0fcff 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -108,12 +108,14 @@ background: $white-light; &.image_file, + &.audio, &.video { background: $gray-darker; text-align: center; padding: 30px; img, + audio, video { max-width: 80%; } @@ -479,3 +481,8 @@ span.idiff { padding: $gl-padding; } } + +.jupyter-notebook-scrolled { + overflow-y: auto; + max-height: 20rem; +} diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index 7e7b08797b2..8fc2fd5f53b 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -1,7 +1,6 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); .flash-container { - cursor: pointer; margin: 0; margin-bottom: $gl-padding; font-size: 14px; @@ -12,19 +11,24 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); position: sticky; position: -webkit-sticky; top: $flash-container-top; - z-index: 200; + z-index: 251; .flash-content { box-shadow: 0 2px 4px 0 $notification-box-shadow-color; } } - .close-icon { - width: 16px; - height: 16px; + .close-icon-wrapper { + padding: ($gl-btn-padding + $gl-padding-4) $gl-padding $gl-btn-padding; position: absolute; - right: $gl-padding; - top: $gl-padding; + right: 0; + top: 0; + cursor: pointer; + + .close-icon { + width: 16px; + height: 16px; + } } .flash-notice, diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index ca737c53318..1195e467192 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -523,6 +523,7 @@ margin-top: 4px; color: $gl-text-color; left: auto; + max-height: $dropdown-max-height-lg; li.current-user { padding: $dropdown-item-padding-y $dropdown-item-padding-x; diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 7332c4981d2..a53f5d85949 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -31,7 +31,16 @@ } } -.ci-status-icon-preparing, +.ci-status-icon-preparing { + svg { + fill: $gray-500; + } + + &.add-border { + @include borderless-status-icon($gray-500); + } +} + .ci-status-icon-running { svg { fill: $blue-400; diff --git a/app/assets/stylesheets/framework/job_log.scss b/app/assets/stylesheets/framework/job_log.scss index 074b2405217..4a57a458c50 100644 --- a/app/assets/stylesheets/framework/job_log.scss +++ b/app/assets/stylesheets/framework/job_log.scss @@ -12,21 +12,22 @@ } .log-line { - padding: 1px $gl-padding 1px $job-log-line-padding; + padding: 1px $gl-padding-8 1px $job-log-line-padding; + min-height: $gl-line-height-20; } .line-number { - color: $gl-text-color-inverted; + color: $gl-gray-500; padding: 0 $gl-padding-8; min-width: $job-line-number-width; - margin-left: -$job-line-number-width; + margin-left: -$job-line-number-margin; padding-right: 1em; &:hover, &:active, &:visited { text-decoration: underline; - color: $gl-text-color-inverted; + color: $gl-gray-500; } } diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index 81ccea1e01f..2289f0a7011 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -245,7 +245,7 @@ .select2-highlighted { .group-result { .group-path { - color: $white-light; + color: $gray-800; } } } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 43d0e51e4c9..b9cfcf6ce5c 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -171,7 +171,7 @@ position: absolute; top: $gl-padding; bottom: $gl-padding; - left: map-get($spacers, 2) - 1px; + left: map-get($spacers, 2) - px-to-rem(1px); } &-row { @@ -187,7 +187,7 @@ * 2px extra is to give a little more height than needed * to hide timeline line before/after the element starts/ends */ - height: map-get($spacers, 4) + 2px; + height: map-get($spacers, 4) + px-to-rem(2px); z-index: 1; position: relative; top: -3px; diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index ba123ff9a67..3876d1c10d4 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -69,14 +69,6 @@ details { margin-bottom: $gl-padding; - - summary { - margin-bottom: $gl-padding; - } - - *:first-child:not(summary) { - margin-top: $gl-padding; - } } // Single code lines should wrap @@ -198,6 +190,141 @@ border-color: $gl-gray-200; } } + + &.grid-none { + > thead > tr { + > th { + border-bottom-width: 0; + border-right-width: 0; + border-left-width: 0; + + &:first-child { + border-left-width: 1px; + } + + &:last-child { + border-right-width: 1px; + } + } + } + + > tbody { + > tr > td { + border-width: 0; + + &:first-child { + border-left-width: 1px; + } + + &:last-child { + border-right-width: 1px; + } + } + + > tr:last-child > td { + border-bottom-width: 1px; + } + } + } + + + &.grid-rows { + > thead > tr > th, + > tbody > tr > td { + border-right-width: 0; + border-left-width: 0; + } + + > thead > tr { + > th:first-child { + border-left-width: 1px; + } + + > th:last-child { + border-right-width: 1px; + } + } + + > tbody > tr { + > td { + border-left-width: 0; + border-right-width: 0; + } + + > td:first-child { + border-left-width: 1px; + } + + > td:last-child { + border-right-width: 1px; + } + } + } + + &.grid-cols { + > thead > tr > th { + border-bottom-width: 0; + } + + > tbody > tr > td { + border-top-width: 0; + border-bottom-width: 0; + } + + > tbody > tr:last-child > td { + border-bottom-width: 1px; + } + } + + &.frame-sides { + > thead > tr > th { + border-top-width: 0; + } + + > tbody > tr:last-child > td { + border-bottom-width: 0; + } + } + + &.frame-topbot, + &.frame-ends { + > thead > tr > th:first-child, + > tbody > tr > td:first-child { + border-left-width: 0; + } + + > thead > tr > th:last-child, + > tbody > tr > td:last-child { + border-right-width: 0; + } + } + + &.frame-none { + > thead > tr > th { + border-top-width: 0; + } + + > tbody > tr:last-child > td { + border-bottom-width: 0; + } + + > thead > tr > th:first-child, + > tbody > tr > td:first-child { + border-left-width: 0; + } + + > thead > tr > th:last-child, + > tbody > tr > td:last-child { + border-right-width: 0; + } + } + + &.stripes-all tr, + &.stripes-odd tr:nth-of-type(odd), + &.stripes-even tr:nth-of-type(even), + &.stripes-hover tr:hover { + background: $gray-light; + } } table:dir(rtl) th { @@ -397,7 +524,7 @@ } } - .prometheus-graph-embed { + .metrics-embed { h3.popover-header { /** Override <h3> .popover-header * as embed metrics do not follow the same diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index e77527ac130..dfc39d8e03b 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -606,9 +606,10 @@ $blame-blue: #254e77; */ $builds-trace-bg: #111; $job-log-highlight-height: 18px; -$job-log-line-padding: 62px; -$job-line-number-width: 40px; -$job-arrow-margin: 50px; +$job-log-line-padding: 55px; +$job-line-number-width: 50px; +$job-line-number-margin: 43px; +$job-arrow-margin: 55px; /* * Commit Page @@ -832,6 +833,7 @@ Merge Requests */ $mr-tabs-height: 48px; $mr-version-controls-height: 56px; +$mr-widget-margin-left: 40px; /* Compare Branches diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index d540a347dde..2a7a53d8bd7 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -245,6 +245,7 @@ box-shadow: 0 1px 2px $issue-boards-card-shadow; line-height: $gl-padding; list-style: none; + position: relative; &:not(:last-child) { margin-bottom: $gl-padding-8; @@ -255,6 +256,11 @@ background-color: $blue-50; } + &.multi-select { + border-color: $blue-200; + background-color: $blue-50; + } + .badge { border: 0; outline: 0; diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 73166940146..c7d51a2093a 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -124,26 +124,6 @@ float: left; padding-left: $gl-padding-8; } - - .section-start { - display: inline; - } - - .section-start, - .section-header { - &:hover { - cursor: pointer; - - &::after { - content: ''; - background-color: rgba($white-light, 0.2); - left: 0; - right: 0; - position: absolute; - height: $job-log-highlight-height; - } - } - } } .build-header { @@ -308,12 +288,8 @@ } a { - display: block; padding: $gl-padding 10px $gl-padding 40px; width: 270px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; &:hover { color: $gl-text-color; diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index 655b297295a..65d0ce8c52e 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -47,14 +47,19 @@ margin-right: 10px; } - .new-file-name { + .new-file-name, + .new-file-path { display: inline-block; - max-width: 420px; + max-width: 250px; float: left; @media(max-width: map-get($grid-breakpoints, lg)-1) { width: 180px; } + + @media (max-width: 1360px) { + width: auto; + } } .file-buttons { @@ -98,13 +103,14 @@ } -@include media-breakpoint-down(sm) { +@include media-breakpoint-down(md) { .file-editor { .file-title { display: block; } - .new-file-name { + .new-file-name, + .new-file-path { max-width: none; width: 100%; margin-bottom: 3px; @@ -146,20 +152,17 @@ vertical-align: top; display: inline-block; - @media(max-width: map-get($grid-breakpoints, md)-1) { + @media(max-width: map-get($grid-breakpoints, lg)-1) { display: block; margin: 19px 0 12px; } } .template-selectors-menu { - display: inline-block; + display: flex; vertical-align: top; - margin: 14px 0 0 16px; - padding: 0 0 0 14px; - border-left: 1px solid $border-color; - @media(max-width: map-get($grid-breakpoints, md)-1) { + @media(max-width: map-get($grid-breakpoints, lg)-1) { display: block; width: 100%; margin: 5px 0; @@ -168,24 +171,11 @@ } } -.templates-selectors-label { - display: inline-block; - vertical-align: top; - margin-top: 6px; - line-height: 21px; - - @media(max-width: map-get($grid-breakpoints, md)-1) { - display: block; - margin: 5px 0; - } -} - .template-selector-dropdowns-wrap { display: inline-block; - margin: 5px 0 0 8px; vertical-align: top; - @media(max-width: map-get($grid-breakpoints, md)-1) { + @media(max-width: map-get($grid-breakpoints, lg)-1) { display: block; width: 100%; margin: 0 0 16px; @@ -199,9 +189,8 @@ display: inline-block; vertical-align: top; font-family: $regular_font; - margin-top: -5px; - @media(max-width: map-get($grid-breakpoints, md)-1) { + @media(max-width: map-get($grid-breakpoints, lg)-1) { display: block; width: 100%; margin: 5px 0; @@ -212,30 +201,22 @@ } .dropdown-menu-toggle { - width: 250px; + width: 200px; vertical-align: top; - @media(max-width: map-get($grid-breakpoints, md)-1) { + @media (max-width: map-get($grid-breakpoints, xl)-1) { + width: auto; + } + + @media(max-width: map-get($grid-breakpoints, lg)-1) { display: block; width: 100%; margin: 5px 0; } } - } } -.template-selectors-undo-menu { - display: inline-block; - margin: 7px 0 0 10px; - - @media(max-width: map-get($grid-breakpoints, md)-1) { - display: block; - width: 100%; - margin: 20px 0; - } - - button { - margin: -4px 0 0 15px; - } +.editor-title-row { + margin-bottom: 20px; } diff --git a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss new file mode 100644 index 00000000000..8b1ec1ced35 --- /dev/null +++ b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss @@ -0,0 +1,51 @@ +.signup-page { + .page-wrap { + background-color: $gray-light; + } + + .gitlab-logo { + width: 80px; + height: 80px; + } + + .signup-box-container { + max-width: 900px; + + &.navless-container { + // overriding .devise-layout-html.navless-container to support the sticky footer + // without having a header on size xs + @include media-breakpoint-down(xs) { + padding: 65px $gl-padding; // height of footer + padding-top: $gl-padding; + } + } + } + + .signup-heading h2 { + font-weight: $gl-font-weight-bold; + + @include media-breakpoint-down(md) { + font-size: $gl-font-size-large; + } + } + + .signup-box { + background-color: $white-light; + box-shadow: 0 0 0 1px $border-color; + border-radius: $border-radius; + } + + .form-control { + &:active, + &:focus { + background-color: $white-light; + } + } + + .devise-errors { + h2 { + font-size: $gl-font-size; + color: $red-700; + } + } +} diff --git a/app/assets/stylesheets/pages/help.scss b/app/assets/stylesheets/pages/help.scss index ef872e693e0..ab281bc7f23 100644 --- a/app/assets/stylesheets/pages/help.scss +++ b/app/assets/stylesheets/pages/help.scss @@ -37,6 +37,7 @@ .documentation { padding: 7px; + font-size: $gl-font-size-large; } .card.links-card { diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index d8aabecc036..7488a5b16a2 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -215,6 +215,12 @@ body { // offset height of fixed header + 1 to avoid scroll height: calc(100% - 51px); + + // offset without the header + &.navless { + height: calc(100% - 11px); + } + margin: 0; padding: 0; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index e6feded1d4f..971f3b2c308 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -19,6 +19,8 @@ border-top: 1px solid $border-color; } +.mr-widget-margin-left { margin-left: $mr-widget-margin-left; } + .media-section { @include media-breakpoint-down(md) { align-items: flex-start; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index d67a0f83aa2..21a9f143039 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -245,14 +245,6 @@ $note-form-margin-left: 72px; } } - .note-header { - @include notes-media('max', map-get($grid-breakpoints, xs)) { - .inline { - display: block; - } - } - } - .note-emoji-button { position: relative; line-height: 1; @@ -363,10 +355,13 @@ $note-form-margin-left: 72px; } } + .timeline-icon { + float: left; + } + .system-note, .discussion-filter-note { .timeline-icon { - float: left; display: flex; align-items: center; background-color: $white-light; @@ -635,10 +630,6 @@ $note-form-margin-left: 72px; .note-headline-light { display: inline; - - @include notes-media('max', map-get($grid-breakpoints, xs)) { - display: block; - } } .note-headline-light, diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 801e9e7204c..b2c1d0b6dc5 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -877,11 +877,16 @@ pre.light-well { flex-direction: column; // Disable Flexbox for admin page - &.admin-projects { + &.admin-projects, + &.group-settings-projects { display: block; .project-row { display: block; + + .description > p { + margin-bottom: 0; + } } } @@ -996,6 +1001,14 @@ pre.light-well { } } + &:not(.with-pipeline-status) { + .icon-wrapper:first-of-type { + @include media-breakpoint-up(lg) { + margin-left: $gl-padding-32; + } + } + } + .ci-status-link { display: inline-flex; } diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss index 72f1b5307ec..154e505f7a4 100644 --- a/app/assets/stylesheets/pages/prometheus.scss +++ b/app/assets/stylesheets/pages/prometheus.scss @@ -15,6 +15,51 @@ } } +.draggable { + &.draggable-enabled { + .draggable-panel { + border: $gray-200 1px solid; + border-radius: $border-radius-default; + margin: -1px; + cursor: grab; + } + + .prometheus-graph { + // Make dragging easier by disabling use of chart + pointer-events: none; + } + } + + &.sortable-chosen .draggable-panel { + background: $white-light; + box-shadow: 0 0 4px $gray-500; + } + + .draggable-remove { + z-index: 1; + + .draggable-remove-link { + cursor: pointer; + color: $gray-600; + background-color: $white-light; + } + } +} + +.prometheus-graphs-header { + .time-window-dropdown-menu { + padding: $gl-padding $gl-padding 0 $gl-padding-12; + } + + .time-window-dropdown-menu-container { + width: 360px; + } + + .custom-time-range-form-group > label { + padding-bottom: $gl-padding; + } +} + .prometheus-panel { margin-top: 20px; } @@ -22,11 +67,11 @@ .prometheus-graph-group { display: flex; flex-wrap: wrap; - padding: $gl-padding / 2; + margin-top: $gl-padding-8; } .prometheus-graph { - padding: $gl-padding / 2; + padding: $gl-padding-8; } .prometheus-graph-embed { @@ -39,11 +84,14 @@ align-items: center; justify-content: space-between; margin-bottom: $gl-padding-8; +} - h5 { - font-size: $gl-font-size-large; - margin: 0; - } +.prometheus-graph-title { + font-size: $gl-font-size-large; +} + +.alert-current-setting { + max-width: 240px; } .prometheus-graph-cursor { diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss index 613f643af3a..5d6a4b7cd13 100644 --- a/app/assets/stylesheets/pages/status.scss +++ b/app/assets/stylesheets/pages/status.scss @@ -37,6 +37,10 @@ } } + &.ci-preparing { + @include status-color($gray-100, $gray-500, $gray-600); + } + &.ci-pending, &.ci-failed-with-warnings, &.ci-success-with-warnings { @@ -44,7 +48,6 @@ } &.ci-info, - &.ci-preparing, &.ci-running { @include status-color($blue-100, $blue-500, $blue-600); } diff --git a/app/assets/stylesheets/pages/tags.scss b/app/assets/stylesheets/pages/tags.scss new file mode 100644 index 00000000000..a6d30522ff7 --- /dev/null +++ b/app/assets/stylesheets/pages/tags.scss @@ -0,0 +1,3 @@ +.tag-release-link { + color: $blue-600 !important; +} diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index fbf63997b15..f7e33c09928 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -6,9 +6,9 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController before_action :set_application_setting before_action :whitelist_query_limiting, only: [:usage_data] - VALID_SETTING_PANELS = %w(general integrations repository templates + VALID_SETTING_PANELS = %w(general integrations repository ci_cd reporting metrics_and_profiling - network geo preferences).freeze + network preferences).freeze VALID_SETTING_PANELS.each do |action| define_method(action) { perform_update if submitted? } @@ -145,10 +145,15 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController end def render_update_error - action = VALID_SETTING_PANELS.include?(action_name) ? action_name : :general + action = valid_setting_panels.include?(action_name) ? action_name : :general render action end + + # overridden in EE + def valid_setting_panels + VALID_SETTING_PANELS + end end Admin::ApplicationSettingsController.prepend_if_ee('EE::Admin::ApplicationSettingsController') diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index c36bbaab23b..f24ce9b5d03 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -2,6 +2,7 @@ class Admin::DashboardController < Admin::ApplicationController include CountHelper + helper_method :show_license_breakdown? COUNTED_ITEMS = [Project, User, Group].freeze @@ -13,6 +14,10 @@ class Admin::DashboardController < Admin::ApplicationController @groups = Group.order_id_desc.with_route.limit(10) end # rubocop: enable CodeReuse/ActiveRecord + + def show_license_breakdown? + false + end end Admin::DashboardController.prepend_if_ee('EE::Admin::DashboardController') diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb new file mode 100644 index 00000000000..1f946e41995 --- /dev/null +++ b/app/controllers/admin/sessions_controller.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class Admin::SessionsController < ApplicationController + include InternalRedirect + + before_action :user_is_admin! + + def new + # Renders a form in which the admin can enter their password + end + + def create + if current_user_mode.enable_admin_mode!(password: params[:password]) + redirect_location = stored_location_for(:redirect) || admin_root_path + redirect_to safe_redirect_path(redirect_location) + else + flash.now[:alert] = _('Invalid Login or password') + render :new + end + end + + def destroy + current_user_mode.disable_admin_mode! + + redirect_to root_path, status: :found, notice: _('Admin mode disabled') + end + + private + + def user_is_admin! + render_404 unless current_user&.admin? + end +end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 61d36d1efc2..4c1ac8f206a 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -58,6 +58,22 @@ class Admin::UsersController < Admin::ApplicationController end end + def activate + return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user must be unblocked to be activated")) if user.blocked? + + user.activate + redirect_back_or_admin_user(notice: _("Successfully activated")) + end + + def deactivate + return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user cannot be deactivated")) if user.blocked? + return redirect_back_or_admin_user(notice: _("Successfully deactivated")) if user.deactivated? + return redirect_back_or_admin_user(notice: _("The user you are trying to deactivate has been active in the past %{minimum_inactive_days} days and cannot be deactivated") % { minimum_inactive_days: ::User::MINIMUM_INACTIVE_DAYS }) unless user.can_be_deactivated? + + user.deactivate + redirect_back_or_admin_user(notice: _("Successfully deactivated")) + end + def block if update_user { |user| user.block } redirect_back_or_admin_user(notice: _("Successfully blocked")) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9a7859fc687..1443a71f6b1 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -13,6 +13,8 @@ class ApplicationController < ActionController::Base include WithPerformanceBar include SessionlessAuthentication include ConfirmEmailWarning + include Gitlab::Tracking::ControllerConcern + include Gitlab::Experimentation::ControllerConcern before_action :authenticate_user! before_action :enforce_terms!, if: :should_enforce_terms? @@ -24,8 +26,10 @@ class ApplicationController < ActionController::Base before_action :add_gon_variables, unless: [:peek_request?, :json_request?] before_action :configure_permitted_parameters, if: :devise_controller? before_action :require_email, unless: :devise_controller? + before_action :active_user_check, unless: :devise_controller? before_action :set_usage_stats_consent_flag before_action :check_impersonation_availability + before_action :require_role around_action :set_locale around_action :set_session_storage @@ -36,6 +40,7 @@ class ApplicationController < ActionController::Base protect_from_forgery with: :exception, prepend: true helper_method :can? + helper_method :current_user_mode helper_method :import_sources_enabled?, :github_import_enabled?, :gitea_import_enabled?, :github_import_configured?, :gitlab_import_enabled?, :gitlab_import_configured?, @@ -286,13 +291,19 @@ class ApplicationController < ActionController::Base def check_password_expiration return if session[:impersonator_id] || !current_user&.allow_password_authentication? - password_expires_at = current_user&.password_expires_at - - if password_expires_at && password_expires_at < Time.now + if current_user&.password_expired? return redirect_to new_profile_password_path end end + def active_user_check + return unless current_user && current_user.deactivated? + + sign_out current_user + flash[:alert] = _("Your account has been deactivated by your administrator. Please log back in to reactivate your account.") + redirect_to new_user_session_path + end + def ldap_security_check if current_user && current_user.requires_ldap_check? return unless current_user.try_obtain_ldap_lease @@ -533,6 +544,20 @@ class ApplicationController < ActionController::Base yield end end + + def current_user_mode + @current_user_mode ||= Gitlab::Auth::CurrentUserMode.new(current_user) + end + + # A user requires a role when they are part of the experimental signup flow (executed by the Growth team). Users + # are redirected to the welcome page when their role is required and the experiment is enabled for the current user. + def require_role + return unless current_user && current_user.role_required? && experiment_enabled?(:signup_flow) + + store_location_for :user, request.fullpath + + redirect_to users_sign_up_welcome_path + end end ApplicationController.prepend_if_ee('EE::ApplicationController') diff --git a/app/controllers/boards/application_controller.rb b/app/controllers/boards/application_controller.rb index eab908ba5ed..15ef6698472 100644 --- a/app/controllers/boards/application_controller.rb +++ b/app/controllers/boards/application_controller.rb @@ -13,7 +13,7 @@ module Boards end def board_parent - @board_parent ||= board.parent + @board_parent ||= board.resource_parent end def record_not_found(exception) diff --git a/app/controllers/boards/lists_controller.rb b/app/controllers/boards/lists_controller.rb index d0f4904c34e..880f7500708 100644 --- a/app/controllers/boards/lists_controller.rb +++ b/app/controllers/boards/lists_controller.rb @@ -9,13 +9,15 @@ module Boards skip_before_action :authenticate_user!, only: [:index] def index - lists = Boards::Lists::ListService.new(board.parent, current_user).execute(board) + lists = Boards::Lists::ListService.new(board.resource_parent, current_user).execute(board) + + List.preload_preferences_for_user(lists, current_user) render json: serialize_as_json(lists) end def create - list = Boards::Lists::CreateService.new(board.parent, current_user, create_list_params).execute(board) + list = Boards::Lists::CreateService.new(board.resource_parent, current_user, create_list_params).execute(board) if list.valid? render json: serialize_as_json(list) @@ -51,7 +53,10 @@ module Boards service = Boards::Lists::GenerateService.new(board_parent, current_user) if service.execute(board) - lists = board.lists.movable.preload_associations(current_user) + lists = board.lists.movable.preload_associations + + List.preload_preferences_for_user(lists, current_user) + render json: serialize_as_json(lists) else head :unprocessable_entity @@ -64,12 +69,16 @@ module Boards %i[label_id] end + def list_update_attrs + %i[collapsed position] + end + def create_list_params params.require(:list).permit(list_creation_attrs) end def update_list_params - params.require(:list).permit(:collapsed, :position) + params.require(:list).permit(list_update_attrs) end def serialize_as_json(resource) diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index 15f1e8284ff..993aba661f3 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -170,6 +170,7 @@ class Clusters::ClustersController < Clusters::BaseController :zone, :num_nodes, :machine_type, + :cloud_run, :legacy_abac ]).merge( provider_type: :gcp, diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb index b970bdc544e..1645af695be 100644 --- a/app/controllers/concerns/cycle_analytics_params.rb +++ b/app/controllers/concerns/cycle_analytics_params.rb @@ -3,8 +3,20 @@ module CycleAnalyticsParams extend ActiveSupport::Concern + def cycle_analytics_project_params + return {} unless params[:cycle_analytics].present? + + params[:cycle_analytics].permit(:start_date, :created_after, :created_before, :branch_name) + end + + def cycle_analytics_group_params + return {} unless params[:cycle_analytics].present? + + params[:cycle_analytics].permit(:start_date, :created_after, :created_before, project_ids: []) + end + def options(params) - @options ||= { from: start_date(params), current_user: current_user } + @options ||= { from: start_date(params), current_user: current_user }.merge(date_range(params)) end def start_date(params) @@ -17,6 +29,17 @@ module CycleAnalyticsParams 90.days.ago end end + + def date_range(params) + {}.tap do |date_range_params| + date_range_params[:from] = to_utc_time(params[:created_after]).beginning_of_day if params[:created_after] + date_range_params[:to] = to_utc_time(params[:created_before]).end_of_day if params[:created_before] + end.compact + end + + def to_utc_time(field) + Date.parse(field).to_time.utc + end end CycleAnalyticsParams.prepend_if_ee('EE::CycleAnalyticsParams') diff --git a/app/controllers/concerns/enforces_admin_authentication.rb b/app/controllers/concerns/enforces_admin_authentication.rb index 3ef92730df6..e731211f423 100644 --- a/app/controllers/concerns/enforces_admin_authentication.rb +++ b/app/controllers/concerns/enforces_admin_authentication.rb @@ -14,6 +14,16 @@ module EnforcesAdminAuthentication end def authenticate_admin! - render_404 unless current_user.admin? + return render_404 unless current_user.admin? + return unless Feature.enabled?(:user_mode_in_session) + + unless current_user_mode.admin_mode? + store_location_for(:redirect, request.fullpath) if storable_location? + redirect_to(new_admin_session_path, notice: _('Re-authentication required')) + end + end + + def storable_location? + request.path != new_admin_session_path end end diff --git a/app/controllers/concerns/invisible_captcha.rb b/app/controllers/concerns/invisible_captcha.rb index 45c0a5c58ef..d56f1d7fa5f 100644 --- a/app/controllers/concerns/invisible_captcha.rb +++ b/app/controllers/concerns/invisible_captcha.rb @@ -8,7 +8,7 @@ module InvisibleCaptcha end def on_honeypot_spam_callback - return unless Feature.enabled?(:invisible_captcha) + return unless Feature.enabled?(:invisible_captcha) || experiment_enabled?(:signup_flow) invisible_captcha_honeypot_counter.increment log_request('Invisible_Captcha_Honeypot_Request') @@ -17,7 +17,7 @@ module InvisibleCaptcha end def on_timestamp_spam_callback - return unless Feature.enabled?(:invisible_captcha) + return unless Feature.enabled?(:invisible_captcha) || experiment_enabled?(:signup_flow) invisible_captcha_timestamp_counter.increment log_request('Invisible_Captcha_Timestamp_Request') diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb new file mode 100644 index 00000000000..62efdacb710 --- /dev/null +++ b/app/controllers/concerns/metrics_dashboard.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +# Provides an action which fetches a metrics dashboard according +# to the parameters specified by the controller. +module MetricsDashboard + extend ActiveSupport::Concern + + def metrics_dashboard + result = dashboard_finder.find( + project_for_dashboard, + current_user, + metrics_dashboard_params + ) + + if include_all_dashboards? + result[:all_dashboards] = dashboard_finder.find_all_paths(project_for_dashboard) + end + + respond_to do |format| + if result[:status] == :success + format.json { render dashboard_success_response(result) } + else + format.json { render dashboard_error_response(result) } + end + end + end + + private + + # Override in class to provide arguments to the finder. + def metrics_dashboard_params + {} + end + + # Override in class if response requires complete list of + # dashboards in addition to requested dashboard body. + def include_all_dashboards? + false + end + + def dashboard_finder + ::Gitlab::Metrics::Dashboard::Finder + end + + # Project is not defined for group and admin level clusters. + def project_for_dashboard + defined?(project) ? project : nil + end + + def dashboard_success_response(result) + { + status: :ok, + json: result.slice(:all_dashboards, :dashboard, :status) + } + end + + def dashboard_error_response(result) + { + status: result[:http_status], + json: result.slice(:all_dashboards, :message, :status) + } + end +end diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb index 1ead631663e..672d31ec779 100644 --- a/app/controllers/concerns/milestone_actions.rb +++ b/app/controllers/concerns/milestone_actions.rb @@ -35,7 +35,7 @@ module MilestoneActions render json: tabs_json("shared/milestones/_labels_tab", { labels: milestone_labels.map do |label| - label.present(issuable_subject: @milestone.parent) + label.present(issuable_subject: @milestone.resource_parent) end }) end diff --git a/app/controllers/concerns/render_service_results.rb b/app/controllers/concerns/render_service_results.rb new file mode 100644 index 00000000000..0149a71d9f5 --- /dev/null +++ b/app/controllers/concerns/render_service_results.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module RenderServiceResults + extend ActiveSupport::Concern + + def success_response(result) + render({ + status: result[:http_status], + json: result[:body] + }) + end + + def continue_polling_response + render({ + status: :no_content, + json: { + status: _('processing'), + message: _('Not ready yet. Try again later.') + } + }) + end + + def error_response(result) + render({ + status: result[:http_status] || :bad_request, + json: { status: result[:status], message: result[:message] } + }) + end +end diff --git a/app/controllers/concerns/renders_assignees.rb b/app/controllers/concerns/renders_assignees.rb new file mode 100644 index 00000000000..e9583a7a530 --- /dev/null +++ b/app/controllers/concerns/renders_assignees.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module RendersAssignees + def preload_assignees_for_render(merge_request) + merge_request.project.team.max_member_access_for_user_ids(merge_request.assignees.map(&:id)) + end +end diff --git a/app/controllers/concerns/sessionless_authentication.rb b/app/controllers/concerns/sessionless_authentication.rb index ba06384a37a..f644923443b 100644 --- a/app/controllers/concerns/sessionless_authentication.rb +++ b/app/controllers/concerns/sessionless_authentication.rb @@ -5,6 +5,12 @@ # Controller concern to handle PAT, RSS, and static objects token authentication methods # module SessionlessAuthentication + extend ActiveSupport::Concern + + included do + before_action :enable_admin_mode!, if: :sessionless_user? + end + # This filter handles personal access tokens, atom requests with rss tokens, and static object tokens def authenticate_sessionless_user!(request_format) user = Gitlab::Auth::RequestAuthenticator.new(request).find_sessionless_user(request_format) @@ -25,4 +31,8 @@ module SessionlessAuthentication sign_in(user, store: false, message: :sessionless_sign_in) end end + + def enable_admin_mode! + current_user_mode.enable_admin_mode!(skip_password_validation: true) if Feature.enabled?(:user_mode_in_session) + end end diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb index 60a68cec3c3..6d9ee39f841 100644 --- a/app/controllers/concerns/uploads_actions.rb +++ b/app/controllers/concerns/uploads_actions.rb @@ -29,15 +29,17 @@ module UploadsActions def show return render_404 unless uploader&.exists? - if cache_publicly? - # We need to reset caching from the applications controller to get rid of the no-store value - headers['Cache-Control'] = '' - expires_in 5.minutes, public: true, must_revalidate: false - else - expires_in 0.seconds, must_revalidate: true, private: true - end + # We need to reset caching from the applications controller to get rid of the no-store value + headers['Cache-Control'] = '' + headers['Pragma'] = '' + + ttl, directives = *cache_settings + ttl ||= 6.months + directives ||= { private: true, must_revalidate: true } + + expires_in ttl, directives - disposition = uploader.image_or_video? ? 'inline' : 'attachment' + disposition = uploader.embeddable? ? 'inline' : 'attachment' uploaders = [uploader, *uploader.versions.values] uploader = uploaders.find { |version| version.filename == params[:filename] } @@ -91,7 +93,7 @@ module UploadsActions upload_paths = uploader.upload_paths(params[:filename]) upload = Upload.find_by(model: model, uploader: uploader_class.to_s, path: upload_paths) - upload&.build_uploader + upload&.retrieve_uploader end # rubocop: enable CodeReuse/ActiveRecord @@ -112,16 +114,16 @@ module UploadsActions uploader end - def image_or_video? - uploader && uploader.exists? && uploader.image_or_video? + def embeddable? + uploader && uploader.exists? && uploader.embeddable? end def find_model nil end - def cache_publicly? - false + def cache_settings + [] end def model diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 7012bfcefe3..80c0a0d88a8 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -78,8 +78,8 @@ class Dashboard::TodosController < Dashboard::ApplicationController def todos_counts { - count: number_with_delimiter(current_user.todos_pending_count), - done_count: number_with_delimiter(current_user.todos_done_count) + count: current_user.todos_pending_count, + done_count: current_user.todos_done_count } end diff --git a/app/controllers/explore/snippets_controller.rb b/app/controllers/explore/snippets_controller.rb index d4c6aae2ca8..61068df77d1 100644 --- a/app/controllers/explore/snippets_controller.rb +++ b/app/controllers/explore/snippets_controller.rb @@ -5,7 +5,7 @@ class Explore::SnippetsController < Explore::ApplicationController include Gitlab::NoteableMetadata def index - @snippets = SnippetsFinder.new(current_user) + @snippets = SnippetsFinder.new(current_user, explore: true) .execute .page(params[:page]) .inc_author diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index 40b8d5ed72c..3c86f3108ab 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -5,6 +5,9 @@ class Groups::BoardsController < Groups::ApplicationController include RecordUserLastActivity before_action :assign_endpoint_vars + before_action do + push_frontend_feature_flag(:multi_select_board) + end private diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 1eacae06457..1e9d51cf970 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -44,7 +44,7 @@ class Groups::MilestonesController < Groups::ApplicationController # all projects milestones states at once. milestones, update_params = get_milestones_for_update milestones.each do |milestone| - Milestones::UpdateService.new(milestone.parent, current_user, update_params).execute(milestone) + Milestones::UpdateService.new(milestone.resource_parent, current_user, update_params).execute(milestone) end redirect_to milestone_path diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb new file mode 100644 index 00000000000..e09a9e6eb21 --- /dev/null +++ b/app/controllers/groups/registry/repositories_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true +module Groups + module Registry + class RepositoriesController < Groups::ApplicationController + before_action :verify_container_registry_enabled! + before_action :authorize_read_container_image! + before_action :feature_flag_group_container_registry_browser! + + def index + track_event(:list_repositories) + + respond_to do |format| + format.html + format.json do + @images = group.container_repositories.with_api_entity_associations + + render json: ContainerRepositoriesSerializer + .new(current_user: current_user) + .represent(@images) + end + end + end + + private + + def feature_flag_group_container_registry_browser! + render_404 unless Feature.enabled?(:group_container_registry_browser, group) + end + + def verify_container_registry_enabled! + render_404 unless Gitlab.config.registry.enabled + end + + def authorize_read_container_image! + return render_404 unless can?(current_user, :read_container_image, group) + end + end + end +end diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index c465e622de0..0e83d057484 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -5,11 +5,22 @@ module Groups class CiCdController < Groups::ApplicationController skip_cross_project_access_check :show before_action :authorize_admin_group! + before_action :authorize_update_max_artifacts_size!, only: [:update] def show define_ci_variables end + def update + if update_group_service.execute + flash[:notice] = s_('GroupSettings|Pipeline settings was updated for the group') + else + flash[:alert] = s_("GroupSettings|There was a problem updating the pipeline settings: %{error_messages}." % { error_messages: group.errors.full_messages }) + end + + redirect_to group_settings_ci_cd_path + end + def reset_registration_token @group.reset_runners_token! @@ -40,6 +51,10 @@ module Groups return render_404 unless can?(current_user, :admin_group, group) end + def authorize_update_max_artifacts_size! + return render_404 unless can?(current_user, :update_max_artifacts_size, group) + end + def auto_devops_params params.require(:group).permit(:auto_devops_enabled) end @@ -47,6 +62,14 @@ module Groups def auto_devops_service Groups::AutoDevopsService.new(group, current_user, auto_devops_params) end + + def update_group_service + Groups::UpdateService.new(group, current_user, update_group_params) + end + + def update_group_params + params.require(:group).permit(:max_artifacts_size) + end end end end diff --git a/app/controllers/groups/uploads_controller.rb b/app/controllers/groups/uploads_controller.rb index 7e5cdae0ce3..3ae7e36c740 100644 --- a/app/controllers/groups/uploads_controller.rb +++ b/app/controllers/groups/uploads_controller.rb @@ -4,7 +4,7 @@ class Groups::UploadsController < Groups::ApplicationController include UploadsActions include WorkhorseRequest - skip_before_action :group, if: -> { action_name == 'show' && image_or_video? } + skip_before_action :group, if: -> { action_name == 'show' && embeddable? } before_action :authorize_upload_file!, only: [:create, :authorize] before_action :verify_workhorse_api!, only: [:authorize] diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 95a7876a055..35e364abba3 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -104,7 +104,6 @@ class GroupsController < Groups::ApplicationController redirect_to edit_group_path(@group, anchor: params[:update_section]), notice: "Group '#{@group.name}' was successfully updated." else @group.path = @group.path_before_last_save || @group.path_was - render action: "edit" end end @@ -124,7 +123,7 @@ class GroupsController < Groups::ApplicationController flash[:notice] = "Group '#{@group.name}' was successfully transferred." redirect_to group_path(@group) else - flash[:alert] = service.error + flash[:alert] = service.error.html_safe redirect_to edit_group_path(@group) end end @@ -198,15 +197,13 @@ class GroupsController < Groups::ApplicationController def load_events params[:sort] ||= 'latest_activity_desc' - options = {} - options[:include_subgroups] = true - - @projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user) - .execute - .includes(:namespace) + options = { include_subgroups: true } + projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user) + .execute + .includes(:namespace) @events = EventCollection - .new(@projects, offset: params[:offset].to_i, filter: event_filter) + .new(projects, offset: params[:offset].to_i, filter: event_filter, groups: groups) .to_a Events::RenderService @@ -228,6 +225,14 @@ class GroupsController < Groups::ApplicationController url_for(safe_params) end + + private + + def groups + if @group.supports_events? + @group.self_and_descendants.public_or_visible_to_user(current_user) + end + end end GroupsController.prepend_if_ee('EE::GroupsController') diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index dc9a52f8da5..efd5f0fc607 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -14,35 +14,25 @@ class HealthController < ActionController::Base ].freeze def readiness - results = CHECKS.map { |check| [check.name, check.readiness] } - - render_check_results(results) + # readiness check is a collection with all above application-level checks + render_checks(*CHECKS) end def liveness - results = CHECKS.map { |check| [check.name, check.liveness] } - - render_check_results(results) + # liveness check is a collection without additional checks + render_checks end private - def render_check_results(results) - flattened = results.flat_map do |name, result| - if result.is_a?(Gitlab::HealthChecks::Result) - [[name, result]] - else - result.map { |r| [name, r] } - end - end - success = flattened.all? { |name, r| r.success } - - response = flattened.map do |name, r| - info = { status: r.success ? 'ok' : 'failed' } - info['message'] = r.message if r.message - info[:labels] = r.labels if r.labels - [name, info] - end - render json: response.to_h, status: success ? :ok : :service_unavailable + def render_checks(*checks) + result = Gitlab::HealthChecks::Probes::Collection + .new(*checks) + .execute + + # disable static error pages at the gitlab-workhorse level, we want to see this error response even in production + headers["X-GitLab-Custom-Error"] = 1 unless result.success? + + render json: result.json, status: result.http_status end end diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index 837c26c630a..a58235790ad 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -40,8 +40,8 @@ class HelpController < ApplicationController end end - # Allow access to images in the doc folder - format.any(:png, :gif, :jpeg, :mp4) do + # Allow access to specific media files in the doc folder + format.any(:png, :gif, :jpeg, :mp4, :mp3) do # Note: We are purposefully NOT using `Rails.root.join` path = File.join(Rails.root, 'doc', "#{@path}.#{params[:format]}") diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index 293d76ea765..c37e799de62 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Import::BitbucketController < Import::BaseController + include ActionView::Helpers::SanitizeHelper + before_action :verify_bitbucket_import_enabled before_action :bitbucket_auth, except: :callback @@ -21,7 +23,7 @@ class Import::BitbucketController < Import::BaseController # rubocop: disable CodeReuse/ActiveRecord def status bitbucket_client = Bitbucket::Client.new(credentials) - repos = bitbucket_client.repos + repos = bitbucket_client.repos(filter: sanitized_filter_param) @repos, @incompatible_repos = repos.partition { |repo| repo.valid? } @@ -104,4 +106,8 @@ class Import::BitbucketController < Import::BaseController refresh_token: session[:bitbucket_refresh_token] } end + + def sanitized_filter_param + @filter ||= sanitize(params[:filter]) + end end diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 72f830fc9a1..c418b11ab13 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -2,6 +2,7 @@ class Import::GithubController < Import::BaseController include ImportHelper + include ActionView::Helpers::SanitizeHelper before_action :verify_import_enabled before_action :provider_auth, only: [:status, :realtime_changes, :create] @@ -55,7 +56,7 @@ class Import::GithubController < Import::BaseController def realtime_changes Gitlab::PollingInterval.set_header(response, interval: 3_000) - render json: find_jobs(provider) + render json: already_added_projects.to_json(only: [:id], methods: [:import_status]) end private @@ -82,7 +83,7 @@ class Import::GithubController < Import::BaseController end def already_added_projects - @already_added_projects ||= find_already_added_projects(provider) + @already_added_projects ||= filtered(find_already_added_projects(provider)) end def already_added_project_names @@ -104,7 +105,7 @@ class Import::GithubController < Import::BaseController end def client_repos - @client_repos ||= client.repos + @client_repos ||= filtered(client.repos) end def verify_import_enabled @@ -185,6 +186,20 @@ class Import::GithubController < Import::BaseController def extra_import_params {} end + + def sanitized_filter_param + @filter ||= sanitize(params[:filter]) + end + + def filter_attribute + :name + end + + def filtered(collection) + return collection unless sanitized_filter_param + + collection.select { |item| item[filter_attribute].include?(sanitized_filter_param) } + end end Import::GithubController.prepend_if_ee('EE::Import::GithubController') diff --git a/app/controllers/notification_settings_controller.rb b/app/controllers/notification_settings_controller.rb index 43c4f4d220e..c97fec0a6ee 100644 --- a/app/controllers/notification_settings_controller.rb +++ b/app/controllers/notification_settings_controller.rb @@ -50,8 +50,6 @@ class NotificationSettingsController < ApplicationController end def notification_setting_params_for(source) - allowed_fields = NotificationSetting.email_events(source).dup - allowed_fields << :level - params.require(:notification_setting).permit(allowed_fields) + params.require(:notification_setting).permit(NotificationSetting.allowed_fields(source)) end end diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index ab4ca56bb49..12dc2d1af1c 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -5,6 +5,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController include Gitlab::Allowable include PageLayoutHelper include OauthApplications + include Gitlab::Experimentation::ControllerConcern before_action :verify_user_oauth_applications_enabled, except: :index before_action :authenticate_user! diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index 705389749d8..e65726dffbf 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController + include Gitlab::Experimentation::ControllerConcern layout 'profile' # Overridden from Doorkeeper::AuthorizationsController to diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 755ce3463c4..b992972dfb8 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -148,6 +148,11 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController if user.two_factor_enabled? && !auth_user.bypass_two_factor? prompt_for_two_factor(user) else + if user.deactivated? + user.activate + flash[:notice] = _('Welcome back! Your account had been deactivated due to inactivity but is now reactivated.') + end + sign_in_and_redirect(user, event: :authentication) end else diff --git a/app/controllers/profiles/groups_controller.rb b/app/controllers/profiles/groups_controller.rb index c755bcb718a..04b5ee270dc 100644 --- a/app/controllers/profiles/groups_controller.rb +++ b/app/controllers/profiles/groups_controller.rb @@ -5,7 +5,7 @@ class Profiles::GroupsController < Profiles::ApplicationController def update group = find_routable!(Group, params[:id]) - notification_setting = current_user.notification_settings.find_by(source: group) # rubocop: disable CodeReuse/ActiveRecord + notification_setting = current_user.notification_settings_for(group) if notification_setting.update(update_params) flash[:notice] = "Notification settings for #{group.name} saved" diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb index 617e5bb7cb3..5f44e55f3ef 100644 --- a/app/controllers/profiles/notifications_controller.rb +++ b/app/controllers/profiles/notifications_controller.rb @@ -3,9 +3,14 @@ class Profiles::NotificationsController < Profiles::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def show - @user = current_user - @group_notifications = current_user.notification_settings.for_groups.order(:id) - @project_notifications = current_user.notification_settings.for_projects.order(:id) + @user = current_user + @group_notifications = current_user.notification_settings.for_groups.order(:id) + @group_notifications += GroupsFinder.new( + current_user, + all_available: false, + exclude_group_ids: @group_notifications.select(:source_id) + ).execute.map { |group| current_user.notification_settings_for(group, inherit: true) } + @project_notifications = current_user.notification_settings.for_projects.order(:id) @global_notification_setting = current_user.global_notification_setting end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 958a24b6c0e..2b7571e42b7 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -100,6 +100,7 @@ class ProfilesController < Profiles::ApplicationController :avatar, :bio, :email, + :role, :hide_no_password, :hide_no_ssh_key, :hide_project_limit, diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index da8a371acaa..50399a8cfbb 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -8,10 +8,37 @@ class Projects::ArtifactsController < Projects::ApplicationController layout 'project' before_action :authorize_read_build! before_action :authorize_update_build!, only: [:keep] + before_action :authorize_destroy_artifacts!, only: [:destroy] before_action :extract_ref_name_and_path - before_action :validate_artifacts!, except: [:download] + before_action :validate_artifacts!, except: [:index, :download, :destroy] before_action :entry, only: [:file] + MAX_PER_PAGE = 20 + + def index + # Loading artifacts is very expensive in projects with a lot of artifacts. + # This feature flag prevents a DOS attack vector. + # It should be removed only after resolving the underlying performance + # issues: https://gitlab.com/gitlab-org/gitlab/issues/32281 + return head :no_content unless Feature.enabled?(:artifacts_management_page, @project) + + finder = ArtifactsFinder.new(@project, artifacts_params) + all_artifacts = finder.execute + + @artifacts = all_artifacts.page(params[:page]).per(MAX_PER_PAGE) + @total_size = all_artifacts.total_size + end + + def destroy + notice = if artifact.destroy + _('Artifact was successfully deleted.') + else + _('Artifact could not be deleted.') + end + + redirect_to project_artifacts_path(@project), status: :see_other, notice: notice + end + def download return render_404 unless artifacts_file @@ -74,6 +101,10 @@ class Projects::ArtifactsController < Projects::ApplicationController @ref_name, @path = extract_ref(params[:ref_name_and_path]) end + def artifacts_params + params.permit(:sort) + end + def validate_artifacts! render_404 unless build&.artifacts? end @@ -85,6 +116,11 @@ class Projects::ArtifactsController < Projects::ApplicationController end end + def artifact + @artifact ||= + project.job_artifacts.find(params[:id]) + end + def build_from_id project.builds.find_by_id(params[:job_id]) if params[:job_id] end diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index 14b02993e6e..3b335fa4af4 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -7,6 +7,9 @@ class Projects::BoardsController < Projects::ApplicationController before_action :check_issues_available! before_action :authorize_read_board!, only: [:index, :show] before_action :assign_endpoint_vars + before_action do + push_frontend_feature_flag(:multi_select_board) + end private diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index c125ed3605a..578a3d451a7 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -11,6 +11,7 @@ class Projects::BranchesController < Projects::ApplicationController # Support legacy URLs before_action :redirect_for_legacy_index_sort_or_search, only: [:index] + before_action :limit_diverging_commit_counts!, only: [:diverging_commit_counts] def index respond_to do |format| @@ -125,6 +126,24 @@ class Projects::BranchesController < Projects::ApplicationController private + # It can be expensive to calculate the diverging counts for each + # branch. Normally the frontend should be specifying a set of branch + # names, but prior to + # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/32496, the + # frontend could omit this set. To prevent excessive I/O, we require + # that a list of names be specified. + def limit_diverging_commit_counts! + return unless Feature.enabled?(:limit_diverging_commit_counts, default_enabled: true) + + limit = Kaminari.config.default_per_page + + # If we don't have many branches in the repository, then go ahead. + return if project.repository.branch_count <= limit + return if params[:names].present? && Array(params[:names]).length <= limit + + render json: { error: "Specify at least one and at most #{limit} branch names" }, status: :unprocessable_entity + end + def ref if params[:ref] ref_escaped = strip_tags(sanitize(params[:ref])) diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb index 76705b4410c..15bb35dd0be 100644 --- a/app/controllers/projects/commits_controller.rb +++ b/app/controllers/projects/commits_controller.rb @@ -72,7 +72,9 @@ class Projects::CommitsController < Projects::ApplicationController @repository.commits(@ref, path: @path, limit: @limit, offset: @offset) end - @commits = @commits.with_pipeline_status + @commits.each(&:lazy_author) # preload authors + + @commits = @commits.with_latest_pipeline(@ref) @commits = set_commits_for_rendering(@commits) end diff --git a/app/controllers/projects/cycle_analytics/events_controller.rb b/app/controllers/projects/cycle_analytics/events_controller.rb index 926592b9681..673f53c221b 100644 --- a/app/controllers/projects/cycle_analytics/events_controller.rb +++ b/app/controllers/projects/cycle_analytics/events_controller.rb @@ -23,7 +23,7 @@ module Projects end def test - options(cycle_analytics_params)[:branch] = cycle_analytics_params[:branch_name] + options(cycle_analytics_project_params)[:branch] = cycle_analytics_project_params[:branch_name] render_events(cycle_analytics[:test].events) end @@ -50,13 +50,7 @@ module Projects end def cycle_analytics - @cycle_analytics ||= ::CycleAnalytics::ProjectLevel.new(project, options: options(cycle_analytics_params)) - end - - def cycle_analytics_params - return {} unless params[:cycle_analytics].present? - - params[:cycle_analytics].permit(:start_date, :branch_name) + @cycle_analytics ||= ::CycleAnalytics::ProjectLevel.new(project, options: options(cycle_analytics_project_params)) end end end diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb index b9d7dbd37be..f13c75ac4cc 100644 --- a/app/controllers/projects/cycle_analytics_controller.rb +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -9,7 +9,7 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController before_action :authorize_read_cycle_analytics! def show - @cycle_analytics = ::CycleAnalytics::ProjectLevel.new(@project, options: options(cycle_analytics_params)) + @cycle_analytics = ::CycleAnalytics::ProjectLevel.new(@project, options: options(cycle_analytics_project_params)) @cycle_analytics_no_data = @cycle_analytics.no_stats? @@ -27,12 +27,6 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController private - def cycle_analytics_params - return {} unless params[:cycle_analytics].present? - - params[:cycle_analytics].permit(:start_date) - end - def cycle_analytics_json { summary: @cycle_analytics.summary, diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index 514b03e23b5..f13fb4d0b3d 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -73,6 +73,10 @@ class Projects::DeployKeysController < Projects::ApplicationController @deploy_key ||= DeployKey.find(params[:id]) end + def deploy_keys_project + @deploy_keys_project ||= deploy_key.deploy_keys_project_for(@project) + end + def create_params create_params = params.require(:deploy_key) .permit(:key, :title, deploy_keys_projects_attributes: [:can_push]) @@ -81,10 +85,16 @@ class Projects::DeployKeysController < Projects::ApplicationController end def update_params - params.require(:deploy_key).permit(:title, deploy_keys_projects_attributes: [:id, :can_push]) + permitted_params = [deploy_keys_projects_attributes: [:id, :can_push]] + permitted_params << :title if can?(current_user, :update_deploy_key, deploy_key) + + params.require(:deploy_key).permit(*permitted_params) end def authorize_update_deploy_key! - access_denied! unless can?(current_user, :update_deploy_key, deploy_key) + if !can?(current_user, :update_deploy_key, deploy_key) && + !can?(current_user, :update_deploy_keys_project, deploy_keys_project) + access_denied! + end end end diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb index 32111b07a0b..766e2f86ea2 100644 --- a/app/controllers/projects/deployments_controller.rb +++ b/app/controllers/projects/deployments_controller.rb @@ -47,11 +47,9 @@ class Projects::DeploymentsController < Projects::ApplicationController @deployment_metrics ||= DeploymentMetrics.new(deployment.project, deployment) end - # rubocop: disable CodeReuse/ActiveRecord def deployment - @deployment ||= environment.deployments.find_by(iid: params[:id]) + @deployment ||= environment.deployments.find_successful_deployment!(params[:id]) end - # rubocop: enable CodeReuse/ActiveRecord def environment @environment ||= project.environments.find(params[:environment_id]) diff --git a/app/controllers/projects/environments/prometheus_api_controller.rb b/app/controllers/projects/environments/prometheus_api_controller.rb index 9c6c6513a78..e902d218c75 100644 --- a/app/controllers/projects/environments/prometheus_api_controller.rb +++ b/app/controllers/projects/environments/prometheus_api_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Projects::Environments::PrometheusApiController < Projects::ApplicationController + include RenderServiceResults + before_action :authorize_read_prometheus! before_action :environment @@ -12,21 +14,10 @@ class Projects::Environments::PrometheusApiController < Projects::ApplicationCon proxy_params ).execute - if result.nil? - return render status: :no_content, json: { - status: _('processing'), - message: _('Not ready yet. Try again later.') - } - end - - if result[:status] == :success - render status: result[:http_status], json: result[:body] - else - render( - status: result[:http_status] || :bad_request, - json: { status: result[:status], message: result[:message] } - ) - end + return continue_polling_response if result.nil? + return error_response(result) if result[:status] == :error + + success_response(result) end private diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 64de0e665d3..c053ca19a94 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Projects::EnvironmentsController < Projects::ApplicationController + include MetricsDashboard + layout 'project' before_action :authorize_read_environment! before_action :authorize_create_environment!, only: [:new, :create] @@ -12,7 +14,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :expire_etag_cache, only: [:index] before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do push_frontend_feature_flag(:environment_metrics_use_prometheus_endpoint, default_enabled: true) - push_frontend_feature_flag(:environment_metrics_show_multiple_dashboards) push_frontend_feature_flag(:environment_metrics_additional_panel_types) push_frontend_feature_flag(:prometheus_computed_alerts) end @@ -159,44 +160,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController end end - def metrics_dashboard - if params[:embedded] - result = dashboard_finder.find( - project, - current_user, - environment: environment, - dashboard_path: params[:dashboard], - **dashboard_params.to_h.symbolize_keys - ) - elsif Feature.enabled?(:environment_metrics_show_multiple_dashboards, project) - result = dashboard_finder.find( - project, - current_user, - environment: environment, - dashboard_path: params[:dashboard] - ) - - result[:all_dashboards] = dashboard_finder.find_all_paths(project) - else - result = dashboard_finder.find(project, current_user, environment: environment) - end - - respond_to do |format| - if result[:status] == :success - format.json do - render status: :ok, json: result.slice(:all_dashboards, :dashboard, :status) - end - else - format.json do - render( - status: result[:http_status], - json: result.slice(:all_dashboards, :message, :status) - ) - end - end - end - end - def search respond_to do |format| format.json do @@ -234,12 +197,15 @@ class Projects::EnvironmentsController < Projects::ApplicationController params.require([:start, :end]) end - def dashboard_params - params.permit(:embedded, :group, :title, :y_label) + def metrics_dashboard_params + params + .permit(:embedded, :group, :title, :y_label) + .to_h.symbolize_keys + .merge(dashboard_path: params[:dashboard], environment: environment) end - def dashboard_finder - Gitlab::Metrics::Dashboard::Finder + def include_all_dashboards? + !params[:embedded] end def search_environment_names diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb index a597cc9af32..ccfc38d97b2 100644 --- a/app/controllers/projects/git_http_client_controller.rb +++ b/app/controllers/projects/git_http_client_controller.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -# This file should be identical in GitLab Community Edition and Enterprise Edition - class Projects::GitHttpClientController < Projects::ApplicationController include ActionController::HttpAuthentication::Basic include KerberosSpnegoHelper diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index 0c8c03cb16a..93f7ce73a51 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -6,10 +6,10 @@ class Projects::GitHttpController < Projects::GitHttpClientController before_action :access_check prepend_before_action :deny_head_requests, only: [:info_refs] - rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403 - rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404 - rescue_from Gitlab::GitAccess::ProjectCreationError, with: :render_422 - rescue_from Gitlab::GitAccess::TimeoutError, with: :render_503 + rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403_with_exception + rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404_with_exception + rescue_from Gitlab::GitAccess::ProjectCreationError, with: :render_422_with_exception + rescue_from Gitlab::GitAccess::TimeoutError, with: :render_503_with_exception # GET /foo/bar.git/info/refs?service=git-upload-pack (git pull) # GET /foo/bar.git/info/refs?service=git-receive-pack (git push) @@ -58,19 +58,19 @@ class Projects::GitHttpController < Projects::GitHttpClientController render json: Gitlab::Workhorse.git_http_ok(repository, repo_type, user, action_name) end - def render_403(exception) + def render_403_with_exception(exception) render plain: exception.message, status: :forbidden end - def render_404(exception) + def render_404_with_exception(exception) render plain: exception.message, status: :not_found end - def render_422(exception) + def render_422_with_exception(exception) render plain: exception.message, status: :unprocessable_entity end - def render_503(exception) + def render_503_with_exception(exception) render plain: exception.message, status: :service_unavailable end diff --git a/app/controllers/projects/grafana_api_controller.rb b/app/controllers/projects/grafana_api_controller.rb new file mode 100644 index 00000000000..4bdf4c12cac --- /dev/null +++ b/app/controllers/projects/grafana_api_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Projects::GrafanaApiController < Projects::ApplicationController + include RenderServiceResults + + def proxy + result = ::Grafana::ProxyService.new( + project, + params[:datasource_id], + params[:proxy_path], + query_params.to_h + ).execute + + return continue_polling_response if result.nil? + return error_response(result) if result[:status] == :error + + success_response(result) + end + + private + + def query_params + params.permit(:query, :start, :end, :step) + end +end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 7a192a9ec2d..96cb400950b 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -42,6 +42,10 @@ class Projects::IssuesController < Projects::ApplicationController before_action :authorize_import_issues!, only: [:import_csv] before_action :authorize_download_code!, only: [:related_branches] + before_action do + push_frontend_feature_flag(:vue_issuable_sidebar, project.group) + end + respond_to :html alias_method :designs, :show diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 0fdd4d4f33d..1d914ab6011 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -11,8 +11,8 @@ class Projects::JobsController < Projects::ApplicationController before_action :authorize_erase_build!, only: [:erase] before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_websocket_authorize] before_action :verify_api_request!, only: :terminal_websocket_authorize - before_action only: [:trace] do - push_frontend_feature_flag(:job_log_json) + before_action only: [:show] do + push_frontend_feature_flag(:job_log_json, project) end layout 'project' @@ -67,38 +67,27 @@ class Projects::JobsController < Projects::ApplicationController # rubocop: enable CodeReuse/ActiveRecord def trace - if Feature.enabled?(:job_log_json, @project) - json_trace - else - html_trace - end - end - - def html_trace build.trace.read do |stream| respond_to do |format| format.json do - result = { - id: @build.id, status: @build.status, complete: @build.complete? - } - - if stream.valid? - stream.limit - state = params[:state].presence - trace = stream.html_with_state(state) - result.merge!(trace.to_h) - end - - render json: result + # TODO: when the feature flag is removed we should not pass + # content_format to serialize method. + content_format = Feature.enabled?(:job_log_json, @project) ? :json : :html + + build_trace = Ci::BuildTrace.new( + build: @build, + stream: stream, + state: params[:state], + content_format: content_format) + + render json: BuildTraceSerializer + .new(project: @project, current_user: @current_user) + .represent(build_trace) end end end end - def json_trace - # will be implemented with https://gitlab.com/gitlab-org/gitlab-foss/issues/66454 - end - def retry return respond_422 unless @build.retryable? diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb index 739f7a2437e..a1983bc5462 100644 --- a/app/controllers/projects/lfs_api_controller.rb +++ b/app/controllers/projects/lfs_api_controller.rb @@ -2,6 +2,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController include LfsRequest + include Gitlab::Utils::StrongMemoize LFS_TRANSFER_CONTENT_TYPE = 'application/octet-stream' @@ -81,7 +82,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController download: { href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}", header: { - Authorization: request.headers['Authorization'] + Authorization: authorization_header }.compact } } @@ -92,7 +93,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController upload: { href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}/#{object[:size]}", header: { - Authorization: request.headers['Authorization'], + Authorization: authorization_header, # git-lfs v2.5.0 sets the Content-Type based on the uploaded file. This # ensures that Workhorse can intercept the request. 'Content-Type': LFS_TRANSFER_CONTENT_TYPE @@ -122,6 +123,18 @@ class Projects::LfsApiController < Projects::GitHttpClientController def lfs_read_only_message _('You cannot write to this read-only GitLab instance.') end + + def authorization_header + strong_memoize(:authorization_header) do + lfs_auth_header || request.headers['Authorization'] + end + end + + def lfs_auth_header + return unless user.is_a?(User) + + Gitlab::LfsToken.new(user).basic_encoding + end end Projects::LfsApiController.prepend_if_ee('EE::Projects::LfsApiController') diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index edffeb32203..b7e99cb7ed0 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -14,7 +14,11 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont end def merge_request_includes(association) - association.includes(:metrics, :assignees, author: :status) # rubocop:disable CodeReuse/ActiveRecord + association.includes(preloadable_mr_relations) # rubocop:disable CodeReuse/ActiveRecord + end + + def preloadable_mr_relations + [:metrics, :assignees, { author: :status }] end def merge_request_params diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 9c5caf7719e..4a37dfe5c19 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -5,9 +5,9 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic include RendersNotes before_action :apply_diff_view_cookie! - before_action :commit - before_action :define_diff_vars - before_action :define_diff_comment_vars + before_action :commit, except: :diffs_batch + before_action :define_diff_vars, except: :diffs_batch + before_action :define_diff_comment_vars, except: [:diffs_batch, :diffs_metadata] def show render_diffs @@ -17,14 +17,41 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic render_diffs end + def diffs_batch + return render_404 unless Feature.enabled?(:diffs_batch_load, @merge_request.project) + + diffable = @merge_request.merge_request_diff + + return render_404 unless diffable + + diffs = diffable.diffs_in_batch(params[:page], params[:per_page], diff_options: diff_options) + positions = @merge_request.note_positions_for_paths(diffs.diff_file_paths, current_user) + + diffs.unfold_diff_files(positions.unfoldable) + + options = { + merge_request: @merge_request, + pagination_data: diffs.pagination_data + } + + render json: PaginatedDiffSerializer.new(current_user: current_user).represent(diffs, options) + end + + def diffs_metadata + render json: DiffsMetadataSerializer.new(project: @merge_request.project) + .represent(@diffs, additional_attributes) + end + private + def preloadable_mr_relations + [{ source_project: :namespace }, { target_project: :namespace }] + end + def render_diffs @environment = @merge_request.environments_for(current_user).last - note_positions = renderable_notes.map(&:position).compact - @diffs.unfold_diff_files(note_positions) - + @diffs.unfold_diff_files(note_positions.unfoldable) @diffs.write_cache request = { @@ -111,6 +138,10 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes), @merge_request) end + def note_positions + @note_positions ||= Gitlab::Diff::PositionCollection.new(renderable_notes.map(&:position)) + end + def renderable_notes define_diff_comment_vars unless @notes diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index e51ce752233..ff199e05e99 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -5,6 +5,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo include IssuableActions include RendersNotes include RendersCommits + include RendersAssignees include ToggleAwardEmoji include IssuableCollections include RecordUserLastActivity @@ -16,6 +17,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action :set_issuables_index, only: [:index] before_action :authenticate_user!, only: [:assign_related_issues] before_action :check_user_can_push_to_source_branch!, only: [:rebase] + before_action only: [:show] do + push_frontend_feature_flag(:diffs_batch_load, @project) + end + + before_action do + push_frontend_feature_flag(:vue_issuable_sidebar, @project.group) + end around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions] @@ -41,6 +49,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo # use next to appease Rubocop next render('invalid') if target_branch_missing? + preload_assignees_for_render(@merge_request) + # Build a note object for comment form @note = @project.notes.new(noteable: @merge_request) @@ -79,7 +89,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo # Get commits from repository # or from cache if already merged @commits = - set_commits_for_rendering(@merge_request.commits.with_pipeline_status) + set_commits_for_rendering(@merge_request.commits.with_latest_pipeline) render json: { html: view_to_html_string('projects/merge_requests/_commits') } end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index cfa46705483..106ef1b72c1 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Projects::PipelinesController < Projects::ApplicationController + include ::Gitlab::Utils::StrongMemoize + before_action :whitelist_query_limiting, only: [:create, :retry] before_action :pipeline, except: [:index, :new, :create, :charts] before_action :set_pipeline_path, only: [:show] @@ -151,6 +153,19 @@ class Projects::PipelinesController < Projects::ApplicationController @counts[:failed] = @project.all_pipelines.failed.count(:all) end + def test_report + return unless Feature.enabled?(:junit_pipeline_view, project) + + if pipeline_test_report == :error + render json: { status: :error_parsing_report } + return + end + + render json: TestReportSerializer + .new(current_user: @current_user) + .represent(pipeline_test_report) + end + private def serialize_pipelines @@ -169,7 +184,7 @@ class Projects::PipelinesController < Projects::ApplicationController end def show_represent_params - { grouped: true } + { grouped: true, expanded: params[:expanded].to_a.map(&:to_i) } end def create_params @@ -217,6 +232,14 @@ class Projects::PipelinesController < Projects::ApplicationController view_context.limited_counter_with_delimiter(finder.execute) end + + def pipeline_test_report + strong_memoize(:pipeline_test_report) do + @pipeline.test_reports + rescue Gitlab::Ci::Parsers::ParserError + :error + end + end end Projects::PipelinesController.prepend_if_ee('EE::Projects::PipelinesController') diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb index c5454883060..d4f7d0bc521 100644 --- a/app/controllers/projects/protected_branches_controller.rb +++ b/app/controllers/projects/protected_branches_controller.rb @@ -19,9 +19,13 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController [:merge_access_levels, :push_access_levels] end - def protected_ref_params - params.require(:protected_branch).permit(:name, - merge_access_levels_attributes: access_level_attributes, - push_access_levels_attributes: access_level_attributes) + def protected_ref_params(*attrs) + attrs = ([:name, + merge_access_levels_attributes: access_level_attributes, + push_access_levels_attributes: access_level_attributes] + attrs).uniq + + params.require(:protected_branch).permit(attrs) end end + +Projects::ProtectedBranchesController.prepend_if_ee('EE::Projects::ProtectedBranchesController') diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb index e205e2fd4f8..9405fd526ae 100644 --- a/app/controllers/projects/registry/repositories_controller.rb +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -8,6 +8,7 @@ module Projects def index @images = project.container_repositories + track_event(:list_repositories) respond_to do |format| format.html @@ -21,6 +22,7 @@ module Projects def destroy DeleteContainerRepositoryWorker.perform_async(current_user.id, image.id) + track_event(:delete_repository) respond_to do |format| format.json { head :no_content } diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb index 54e2faa2dd7..e572c56adf5 100644 --- a/app/controllers/projects/registry/tags_controller.rb +++ b/app/controllers/projects/registry/tags_controller.rb @@ -8,6 +8,7 @@ module Projects LIMIT = 15 def index + track_event(:list_tags) respond_to do |format| format.json do render json: ContainerTagsSerializer @@ -19,14 +20,13 @@ module Projects end def destroy - if tag.delete - respond_to do |format| - format.json { head :no_content } - end - else - respond_to do |format| - format.json { head :bad_request } - end + result = Projects::ContainerRepository::DeleteTagsService + .new(image.project, current_user, tags: [params[:id]]) + .execute(image) + track_event(:delete_tag) + + respond_to do |format| + format.json { head(result[:status] == :success ? :ok : bad_request) } end end @@ -42,21 +42,13 @@ module Projects return end - @tags = tag_names.map { |tag_name| image.tag(tag_name) } - unless @tags.all? { |tag| tag.valid_name? } - head :bad_request - return - end - - success_count = 0 - @tags.each do |tag| - if tag.delete - success_count += 1 - end - end + result = Projects::ContainerRepository::DeleteTagsService + .new(image.project, current_user, tags: tag_names) + .execute(image) + track_event(:delete_tag_bulk) respond_to do |format| - format.json { head(success_count == @tags.size ? :no_content : :bad_request) } + format.json { head(result[:status] == :success ? :no_content : :bad_request) } end end @@ -70,10 +62,6 @@ module Projects @image ||= project.container_repositories .find(params[:repository_id]) end - - def tag - @tag ||= image.tag(params[:id]) - end end end end diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index 4c39ee4045f..717df9f09e0 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -4,6 +4,9 @@ class Projects::ReleasesController < Projects::ApplicationController # Authorize before_action :require_non_empty_project before_action :authorize_read_release! + before_action do + push_frontend_feature_flag(:release_edit_page, project) + end def index end diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index 0d61c3cc031..cfed8727450 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -46,13 +46,19 @@ module Projects private def update_params - params.require(:project).permit( + params.require(:project).permit(*permitted_project_params) + end + + def permitted_project_params + [ :runners_token, :builds_enabled, :build_allow_git_fetch, :build_timeout_human_readable, :build_coverage_regex, :public_builds, :auto_cancel_pending_pipelines, :ci_config_path, auto_devops_attributes: [:id, :domain, :enabled, :deploy_strategy], ci_cd_settings_attributes: [:default_git_depth] - ) + ].tap do |list| + list << :max_artifacts_size if can?(current_user, :update_max_artifacts_size, project) + end end def run_autodevops_pipeline(service) diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb index 7c71486a765..5bf3618b389 100644 --- a/app/controllers/projects/settings/operations_controller.rb +++ b/app/controllers/projects/settings/operations_controller.rb @@ -13,9 +13,14 @@ module Projects def update result = ::Projects::Operations::UpdateService.new(project, current_user, update_params).execute + track_events(result) render_update_response(result) end + # overridden in EE + def track_events(result) + end + private # overridden in EE @@ -63,7 +68,9 @@ module Projects :api_host, :token, project: [:slug, :name, :organization_slug, :organization_name] - ] + ], + + grafana_integration_attributes: [:token, :grafana_url] } end end diff --git a/app/controllers/projects/templates_controller.rb b/app/controllers/projects/templates_controller.rb index f987033a26c..95739f96d39 100644 --- a/app/controllers/projects/templates_controller.rb +++ b/app/controllers/projects/templates_controller.rb @@ -13,6 +13,14 @@ class Projects::TemplatesController < Projects::ApplicationController end end + def names + templates = @template_type.dropdown_names(project) + + respond_to do |format| + format.json { render json: templates } + end + end + private # User must have: diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb index 4ffcc2ac805..3e5a1cfc74d 100644 --- a/app/controllers/projects/uploads_controller.rb +++ b/app/controllers/projects/uploads_controller.rb @@ -6,7 +6,7 @@ class Projects::UploadsController < Projects::ApplicationController # These will kick you out if you don't have access. skip_before_action :project, :repository, - if: -> { action_name == 'show' && image_or_video? } + if: -> { action_name == 'show' && embeddable? } before_action :authorize_upload_file!, only: [:create, :authorize] before_action :verify_workhorse_api!, only: [:authorize] diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index b8beecf823c..abd19df9a3d 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -376,6 +376,7 @@ class ProjectsController < Projects::ApplicationController :tag_list, :visibility_level, :template_name, + :template_project_id, :merge_method, :initialize_with_readme, diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 2e4c6a801b0..4a746fc915d 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -6,13 +6,20 @@ class RegistrationsController < Devise::RegistrationsController include RecaptchaExperimentHelper include InvisibleCaptcha + layout :choose_layout + + skip_before_action :require_role, only: [:welcome, :update_role] prepend_before_action :check_captcha, only: :create before_action :whitelist_query_limiting, only: [:destroy] before_action :ensure_terms_accepted, if: -> { action_name == 'create' && Gitlab::CurrentSettings.current_application_settings.enforce_terms? } def new - redirect_to(new_user_session_path) + if experiment_enabled?(:signup_flow) + @resource = build_resource + else + redirect_to new_user_session_path(anchor: 'register-pane') + end end def create @@ -20,8 +27,13 @@ class RegistrationsController < Devise::RegistrationsController super do |new_user| persist_accepted_terms_if_required(new_user) + set_role_required(new_user) yield new_user if block_given? end + + # Do not show the signed_up notice message when the signup_flow experiment is enabled. + # Instead, show it after succesfully updating the role. + flash[:notice] = nil if experiment_enabled?(:signup_flow) rescue Gitlab::Access::AccessDeniedError redirect_to(new_user_session_path) end @@ -36,6 +48,26 @@ class RegistrationsController < Devise::RegistrationsController end end + def welcome + return redirect_to new_user_registration_path unless current_user + return redirect_to stored_location_or_dashboard_or_almost_there_path(current_user) if current_user.role.present? + + current_user.name = nil + render layout: 'devise_experimental_separate_sign_up_flow' + end + + def update_role + user_params = params.require(:user).permit(:name, :role) + result = ::Users::UpdateService.new(current_user, user_params.merge(user: current_user)).execute + + if result[:status] == :success + set_flash_message! :notice, :signed_up + redirect_to stored_location_or_dashboard_or_almost_there_path(current_user) + else + redirect_to users_sign_up_welcome_path, alert: result[:message] + end + end + protected def persist_accepted_terms_if_required(new_user) @@ -48,6 +80,10 @@ class RegistrationsController < Devise::RegistrationsController end end + def set_role_required(new_user) + new_user.set_role_required! if new_user.persisted? && experiment_enabled?(:signup_flow) + end + def destroy_confirmation_valid? if current_user.confirm_deletion_with_password? current_user.valid_password?(params[:password]) @@ -70,7 +106,10 @@ class RegistrationsController < Devise::RegistrationsController def after_sign_up_path_for(user) Gitlab::AppLogger.info(user_created_message(confirmed: user.confirmed?)) - confirmed_or_unconfirmed_access_allowed(user) ? stored_location_or_dashboard(user) : users_almost_there_path + + return users_sign_up_welcome_path if experiment_enabled?(:signup_flow) + + stored_location_or_dashboard_or_almost_there_path(user) end def after_inactive_sign_up_path_for(resource) @@ -97,6 +136,7 @@ class RegistrationsController < Devise::RegistrationsController ensure_correct_params! return unless Feature.enabled?(:registrations_recaptcha, default_enabled: true) # reCAPTCHA on the UI will still display however + return if experiment_enabled?(:signup_flow) # when the experimental signup flow is enabled for the current user, disable the reCAPTCHA check return unless show_recaptcha_sign_up? return unless Gitlab::Recaptcha.load_configurations! @@ -108,7 +148,13 @@ class RegistrationsController < Devise::RegistrationsController end def sign_up_params - params.require(:user).permit(:username, :email, :email_confirmation, :name, :password) + clean_params = params.require(:user).permit(:username, :email, :email_confirmation, :name, :password) + + if experiment_enabled?(:signup_flow) + clean_params[:name] = clean_params[:username] + end + + clean_params end def resource_name @@ -138,12 +184,26 @@ class RegistrationsController < Devise::RegistrationsController end def confirmed_or_unconfirmed_access_allowed(user) - user.confirmed? || Feature.enabled?(:soft_email_confirmation) + user.confirmed? || Feature.enabled?(:soft_email_confirmation) || experiment_enabled?(:signup_flow) end def stored_location_or_dashboard(user) stored_location_for(user) || dashboard_projects_path end + + def stored_location_or_dashboard_or_almost_there_path(user) + confirmed_or_unconfirmed_access_allowed(user) ? stored_location_or_dashboard(user) : users_almost_there_path + end + + # Part of an experiment to build a new sign up flow. Will be resolved + # with https://gitlab.com/gitlab-org/growth/engineering/issues/64 + def choose_layout + if experiment_enabled?(:signup_flow) + 'devise_experimental_separate_sign_up_flow' + else + 'devise' + end + end end RegistrationsController.prepend_if_ee('EE::RegistrationsController') diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index f8da152e3d2..1c506065b56 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -57,8 +57,14 @@ class SessionsController < Devise::SessionsController reset_password_sent_at: nil) end - # hide the signed-in notification - flash[:notice] = nil + if resource.deactivated? + resource.activate + flash[:notice] = _('Welcome back! Your account had been deactivated due to inactivity but is now reactivated.') + else + # hide the default signed-in notification + flash[:notice] = nil + end + log_audit_event(current_user, resource, with: authentication_method) log_user_activity(current_user) end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 2adfeab182e..635db386792 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -81,8 +81,13 @@ class UploadsController < ApplicationController end end - def cache_publicly? - User === model || Appearance === model + def cache_settings + case model + when User, Appearance + [5.minutes, { public: true, must_revalidate: false }] + when Project, Group + [5.minutes, { private: true, must_revalidate: true }] + end end def secret? diff --git a/app/finders/artifacts_finder.rb b/app/finders/artifacts_finder.rb new file mode 100644 index 00000000000..81c5168d782 --- /dev/null +++ b/app/finders/artifacts_finder.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class ArtifactsFinder + def initialize(project, params = {}) + @project = project + @params = params + end + + def execute + artifacts = @project.job_artifacts + + sort(artifacts) + end + + private + + def sort_key + @params[:sort] || 'created_desc' + end + + def sort(artifacts) + artifacts.order_by(sort_key) + end +end diff --git a/app/finders/clusters/kubernetes_namespace_finder.rb b/app/finders/clusters/kubernetes_namespace_finder.rb index e947796c1e7..82df96ed79e 100644 --- a/app/finders/clusters/kubernetes_namespace_finder.rb +++ b/app/finders/clusters/kubernetes_namespace_finder.rb @@ -2,12 +2,12 @@ module Clusters class KubernetesNamespaceFinder - attr_reader :cluster, :project, :environment_slug + attr_reader :cluster, :project, :environment_name - def initialize(cluster, project:, environment_slug:, allow_blank_token: false) + def initialize(cluster, project:, environment_name:, allow_blank_token: false) @cluster = cluster @project = project - @environment_slug = environment_slug + @environment_name = environment_name @allow_blank_token = allow_blank_token end @@ -20,7 +20,11 @@ module Clusters attr_reader :allow_blank_token def find_namespace(with_environment:) - relation = with_environment ? namespaces.with_environment_slug(environment_slug) : namespaces + relation = if with_environment + namespaces.with_environment_name(environment_name) + else + namespaces + end relation.find_by_project_id(project.id) end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 2364777cdc5..477093ddadf 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -161,7 +161,7 @@ class IssuableFinder labels_count = label_names.any? ? label_names.count : 1 labels_count = 1 if use_cte_for_search? - finder.execute.reorder(nil).group(:state).count.each do |key, value| + finder.execute.reorder(nil).group(:state_id).count.each do |key, value| counts[count_key(key)] += value / labels_count end @@ -385,7 +385,8 @@ class IssuableFinder end def count_key(value) - Array(value).last.to_sym + value = Array(value).last + klass.available_states.key(value) end # Negates all params found in `negatable_params` @@ -444,7 +445,6 @@ class IssuableFinder items end - # rubocop: disable CodeReuse/ActiveRecord def by_state(items) case params[:state].to_s when 'closed' @@ -454,12 +454,11 @@ class IssuableFinder when 'opened' items.opened when 'locked' - items.where(state: 'locked') + items.with_state(:locked) else items end end - # rubocop: enable CodeReuse/ActiveRecord def by_group(items) # Selection by group is already covered by `by_project` and `projects` diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index bf29f15642d..bd6b6190fb5 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -41,13 +41,14 @@ class SnippetsFinder < UnionFinder include FinderMethods - attr_accessor :current_user, :project, :author, :scope + attr_accessor :current_user, :project, :author, :scope, :explore def initialize(current_user = nil, params = {}) @current_user = current_user @project = params[:project] @author = params[:author] @scope = params[:scope].to_s + @explore = params[:explore] if project && author raise( @@ -59,18 +60,30 @@ class SnippetsFinder < UnionFinder end def execute - base = - if project - snippets_for_a_single_project - else - snippets_for_multiple_projects - end - + base = init_collection base.with_optional_visibility(visibility_from_scope).fresh end private + def init_collection + if explore + snippets_for_explore + elsif project + snippets_for_a_single_project + else + snippets_for_multiple_projects + end + end + + # Produces a query that retrieves snippets for the Explore page + # + # We only show personal snippets here because this page is meant for + # discovery, and project snippets are of limited interest here. + def snippets_for_explore + Snippet.public_to_user(current_user).only_personal_snippets + end + # Produces a query that retrieves snippets from multiple projects. # # The resulting query will, depending on the user's permissions, include the @@ -84,7 +97,7 @@ class SnippetsFinder < UnionFinder # Each collection is constructed in isolation, allowing for greater control # over the resulting SQL query. def snippets_for_multiple_projects - queries = [global_snippets] + queries = [personal_snippets] if Ability.allowed?(current_user, :read_cross_project) queries << snippets_of_visible_projects @@ -98,8 +111,8 @@ class SnippetsFinder < UnionFinder Snippet.for_project_with_user(project, current_user) end - def global_snippets - snippets_for_author_or_visible_to_user.only_global_snippets + def personal_snippets + snippets_for_author_or_visible_to_user.only_personal_snippets end # Returns the snippets that the current user (logged in or not) can view. @@ -115,7 +128,7 @@ class SnippetsFinder < UnionFinder # This method requires that `current_user` returns a `User` instead of `nil`, # and is optimised for this specific scenario. def snippets_of_authorized_projects - base = author ? snippets_for_author : Snippet.all + base = author ? author.snippets : Snippet.all base .only_include_projects_with_snippets_enabled(include_private: true) @@ -157,3 +170,5 @@ class SnippetsFinder < UnionFinder end end end + +SnippetsFinder.prepend_if_ee('EE::SnippetsFinder') diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index 427fd3e7d85..2b46e51290f 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -33,6 +33,8 @@ class TodosFinder end def execute + return Todo.none if current_user.nil? + items = current_user.todos items = by_action_id(items) items = by_action(items) @@ -65,8 +67,20 @@ class TodosFinder params[:action_id] end + def action_array_provided? + params[:action].is_a?(Array) + end + + def map_actions_to_ids + params[:action].map { |item| Todo::ACTION_NAMES.key(item.to_sym) } + end + def to_action_id - Todo::ACTION_NAMES.key(action.to_sym) + if action_array_provided? + map_actions_to_ids + else + Todo::ACTION_NAMES.key(action.to_sym) + end end def action? @@ -133,9 +147,19 @@ class TodosFinder end end + def action_id_array_provided? + params[:action_id].is_a?(Array) && params[:action_id].any? + end + + def by_action_ids(items) + items.for_action(action_id) + end + def by_action_id(items) + return by_action_ids(items) if action_id_array_provided? + if action_id? - items.for_action(action_id) + by_action_ids(items) else items end @@ -158,11 +182,9 @@ class TodosFinder end def by_group(items) - if group? - items.for_group_and_descendants(group) - else - items - end + return items unless group? + + items.for_group_ids_and_descendants(params[:group_id]) end def by_state(items) diff --git a/app/finders/user_finder.rb b/app/finders/user_finder.rb index 556be4c4338..1dd1a27437e 100644 --- a/app/finders/user_finder.rb +++ b/app/finders/user_finder.rb @@ -52,6 +52,12 @@ class UserFinder end end + def find_by_ssh_key_id + return unless input_is_id? + + User.find_by_ssh_key_id(@username_or_id) + end + def input_is_id? @username_or_id.is_a?(Numeric) || @username_or_id =~ /^\d+$/ end diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb index 7273a74cb86..623f7c27584 100644 --- a/app/graphql/mutations/base_mutation.rb +++ b/app/graphql/mutations/base_mutation.rb @@ -5,6 +5,8 @@ module Mutations prepend Gitlab::Graphql::Authorize::AuthorizeResource prepend Gitlab::Graphql::CopyFieldDescription + ERROR_MESSAGE = 'You cannot perform write operations on a read-only instance' + field :errors, [GraphQL::STRING_TYPE], null: false, description: "Reasons why the mutation failed." @@ -17,5 +19,13 @@ module Mutations def errors_on_object(record) record.errors.full_messages end + + def ready?(**args) + if Gitlab::Database.read_only? + raise Gitlab::Graphql::Errors::ResourceNotAvailable, ERROR_MESSAGE + else + true + end + end end end diff --git a/app/graphql/mutations/concerns/mutations/resolves_group.rb b/app/graphql/mutations/concerns/mutations/resolves_group.rb new file mode 100644 index 00000000000..4306ce512f1 --- /dev/null +++ b/app/graphql/mutations/concerns/mutations/resolves_group.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Mutations + module ResolvesGroup + extend ActiveSupport::Concern + + def resolve_group(full_path:) + resolver.resolve(full_path: full_path) + end + + def resolver + Resolvers::GroupResolver.new(object: nil, context: context) + end + end +end diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index 850df2885aa..1fbc61cd950 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -35,7 +35,7 @@ module Resolvers description: 'Issues closed after this date' argument :search, GraphQL::STRING_TYPE, # rubocop:disable Graphql/Descriptions required: false - argument :sort, Types::SortEnum, + argument :sort, Types::IssueSortEnum, description: 'Sort issues by this criteria', required: false, default_value: 'created_desc' diff --git a/app/graphql/resolvers/last_commit_resolver.rb b/app/graphql/resolvers/last_commit_resolver.rb new file mode 100644 index 00000000000..7a433d6556f --- /dev/null +++ b/app/graphql/resolvers/last_commit_resolver.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Resolvers + class LastCommitResolver < BaseResolver + type Types::CommitType, null: true + + alias_method :tree, :object + + def resolve(**args) + # Ensure merge commits can be returned by sending nil to Gitaly instead of '/' + path = tree.path == '/' ? nil : tree.path + commit = Gitlab::Git::Commit.last_for_path(tree.repository, tree.sha, path) + + ::Commit.new(commit, tree.repository.project) if commit + end + end +end diff --git a/app/graphql/resolvers/todo_resolver.rb b/app/graphql/resolvers/todo_resolver.rb new file mode 100644 index 00000000000..38a4539f34a --- /dev/null +++ b/app/graphql/resolvers/todo_resolver.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Resolvers + class TodoResolver < BaseResolver + type Types::TodoType, null: true + + alias_method :user, :object + + argument :action, [Types::TodoActionEnum], + required: false, + description: 'The action to be filtered' + + argument :author_id, [GraphQL::ID_TYPE], + required: false, + description: 'The ID of an author' + + argument :project_id, [GraphQL::ID_TYPE], + required: false, + description: 'The ID of a project' + + argument :group_id, [GraphQL::ID_TYPE], + required: false, + description: 'The ID of a group' + + argument :state, [Types::TodoStateEnum], + required: false, + description: 'The state of the todo' + + argument :type, [Types::TodoTargetEnum], + required: false, + description: 'The type of the todo' + + def resolve(**args) + return Todo.none if user != context[:current_user] + + TodosFinder.new(user, todo_finder_params(args)).execute + end + + private + + # TODO: Support multiple queries for e.g. state and type on TodosFinder: + # + # https://gitlab.com/gitlab-org/gitlab/merge_requests/18487 + # https://gitlab.com/gitlab-org/gitlab/merge_requests/18518 + # + # As soon as these MR's are merged, we can refactor this to query by + # multiple contents. + # + def todo_finder_params(args) + { + state: first_state(args), + type: first_type(args), + group_id: first_group_id(args), + author_id: first_author_id(args), + action_id: first_action(args), + project_id: first_project(args) + } + end + + def first_project(args) + first_query_field(args, :project_id) + end + + def first_action(args) + first_query_field(args, :action) + end + + def first_author_id(args) + first_query_field(args, :author_id) + end + + def first_group_id(args) + first_query_field(args, :group_id) + end + + def first_state(args) + first_query_field(args, :state) + end + + def first_type(args) + first_query_field(args, :type) + end + + def first_query_field(query, field) + return unless query.key?(field) + + query[field].first if query[field].respond_to?(:first) + end + end +end diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb index dd2d81adb8b..fe71791f413 100644 --- a/app/graphql/types/commit_type.rb +++ b/app/graphql/types/commit_type.rb @@ -15,6 +15,8 @@ module Types field :message, type: GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions field :authored_date, type: Types::TimeType, null: true # rubocop:disable Graphql/Descriptions field :web_url, type: GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :signature_html, type: GraphQL::STRING_TYPE, + null: true, calls_gitaly: true, description: 'Rendered html for the commit signature' # models/commit lazy loads the author by email field :author, type: Types::UserType, null: true # rubocop:disable Graphql/Descriptions diff --git a/app/graphql/types/extended_issue_type.rb b/app/graphql/types/extended_issue_type.rb new file mode 100644 index 00000000000..e007c1109a3 --- /dev/null +++ b/app/graphql/types/extended_issue_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + class ExtendedIssueType < IssueType + graphql_name 'ExtendedIssue' + + authorize :read_issue + expose_permissions Types::PermissionTypes::Issue + present_using IssuePresenter + + field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false, complexity: 5, + description: 'Boolean flag for whether the currently logged in user is subscribed to this issue' + end +end diff --git a/app/graphql/types/issuable_sort_enum.rb b/app/graphql/types/issuable_sort_enum.rb new file mode 100644 index 00000000000..932e90c2d22 --- /dev/null +++ b/app/graphql/types/issuable_sort_enum.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + class IssuableSortEnum < SortEnum + graphql_name 'IssuableSort' + description 'Values for sorting issuables' + end + # rubocop: enable Graphql/AuthorizeTypes +end diff --git a/app/graphql/types/issue_sort_enum.rb b/app/graphql/types/issue_sort_enum.rb new file mode 100644 index 00000000000..ad919b55481 --- /dev/null +++ b/app/graphql/types/issue_sort_enum.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + class IssueSortEnum < IssuableSortEnum + graphql_name 'IssueSort' + description 'Values for sorting issues' + end + # rubocop: enable Graphql/AuthorizeTypes +end diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 09e51ae4bc0..4965601fe65 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -49,6 +49,10 @@ module Types field :web_url, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions field :relative_position, GraphQL::INT_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :participants, Types::UserType.connection_type, null: true, complexity: 5, description: 'List of participants for the issue' + field :time_estimate, GraphQL::INT_TYPE, null: false, description: 'The time estimate on the issue' + field :total_time_spent, GraphQL::INT_TYPE, null: false, description: 'Total time reported as spent on the issue' + field :closed_at, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions field :created_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 1baaa33c819..71a65dc6713 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -55,12 +55,27 @@ module Types field :web_url, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions field :upvotes, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions field :downvotes, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false # rubocop:disable Graphql/Descriptions field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline # rubocop:disable Graphql/Descriptions field :pipelines, Types::Ci::PipelineType.connection_type, # rubocop:disable Graphql/Descriptions resolver: Resolvers::MergeRequestPipelinesResolver + field :milestone, Types::MilestoneType, description: 'The milestone this merge request is linked to', + null: true, + resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find } + field :assignees, Types::UserType.connection_type, null: true, complexity: 5, description: 'The list of assignees for the merge request' + field :participants, Types::UserType.connection_type, null: true, complexity: 5, description: 'The list of participants on the merge request' + field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false, complexity: 5, + description: 'Boolean flag for whether the currently logged in user is subscribed to this MR' + field :labels, Types::LabelType.connection_type, null: true, complexity: 5, description: 'The list of labels on the merge request' + field :discussion_locked, GraphQL::BOOLEAN_TYPE, description: 'Boolean flag determining if comments on the merge request are locked to members only', + null: false, + resolve: -> (obj, _args, _ctx) { !!obj.discussion_locked } + field :time_estimate, GraphQL::INT_TYPE, null: false, description: 'The time estimate for the merge request' + field :total_time_spent, GraphQL::INT_TYPE, null: false, description: 'Total time reported as spent on the merge request' + field :reference, GraphQL::STRING_TYPE, null: false, method: :to_reference, description: 'Internal merge request reference. Returned in shortened format by default' do + argument :full, GraphQL::BOOLEAN_TYPE, required: false, default_value: false, description: 'Boolean option specifying whether the reference should be returned in full' + end field :task_completion_status, Types::TaskCompletionStatus, null: false # rubocop:disable Graphql/Descriptions end end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 7184cf42284..5663f833b7a 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -92,7 +92,7 @@ module Types resolver: Resolvers::IssuesResolver field :issue, # rubocop:disable Graphql/Descriptions - Types::IssueType, + Types::ExtendedIssueType, null: true, resolver: Resolvers::IssuesResolver.single diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index bbf94fb92df..996bf225976 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -14,6 +14,11 @@ module Types resolver: Resolvers::GroupResolver, description: "Find a group" + field :current_user, Types::UserType, + null: true, + resolve: -> (_obj, _args, context) { context[:current_user] }, + description: "Get information about current user" + field :namespace, Types::NamespaceType, null: true, resolver: Resolvers::NamespaceResolver, diff --git a/app/graphql/types/todo_action_enum.rb b/app/graphql/types/todo_action_enum.rb new file mode 100644 index 00000000000..0e538838474 --- /dev/null +++ b/app/graphql/types/todo_action_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + class TodoActionEnum < BaseEnum + value 'assigned', value: 1 + value 'mentioned', value: 2 + value 'build_failed', value: 3 + value 'marked', value: 4 + value 'approval_required', value: 5 + value 'unmergeable', value: 6 + value 'directly_addressed', value: 7 + end +end diff --git a/app/graphql/types/todo_state_enum.rb b/app/graphql/types/todo_state_enum.rb new file mode 100644 index 00000000000..29a28b5208d --- /dev/null +++ b/app/graphql/types/todo_state_enum.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Types + class TodoStateEnum < BaseEnum + value 'pending' + value 'done' + end +end diff --git a/app/graphql/types/todo_target_enum.rb b/app/graphql/types/todo_target_enum.rb new file mode 100644 index 00000000000..9a7391dcd99 --- /dev/null +++ b/app/graphql/types/todo_target_enum.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Types + class TodoTargetEnum < BaseEnum + value 'Issue' + value 'MergeRequest' + value 'Epic' + end +end diff --git a/app/graphql/types/todo_type.rb b/app/graphql/types/todo_type.rb new file mode 100644 index 00000000000..d36daaf7dec --- /dev/null +++ b/app/graphql/types/todo_type.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Types + class TodoType < BaseObject + graphql_name 'Todo' + description 'Representing a todo entry' + + present_using TodoPresenter + + authorize :read_todo + + field :id, GraphQL::ID_TYPE, + description: 'Id of the todo', + null: false + + field :project, Types::ProjectType, + description: 'The project this todo is associated with', + null: true, + authorize: :read_project, + resolve: -> (todo, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, todo.project_id).find } + + field :group, Types::GroupType, + description: 'Group this todo is associated with', + null: true, + authorize: :read_group, + resolve: -> (todo, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, todo.group_id).find } + + field :author, Types::UserType, + description: 'The owner of this todo', + null: false, + resolve: -> (todo, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, todo.author_id).find } + + field :action, Types::TodoActionEnum, + description: 'Action of the todo', + null: false + + field :target_type, Types::TodoTargetEnum, + description: 'Target type of the todo', + null: false + + field :body, GraphQL::STRING_TYPE, + description: 'Body of the todo', + null: false + + field :state, Types::TodoStateEnum, + description: 'State of the todo', + null: false + + field :created_at, Types::TimeType, + description: 'Timestamp this todo was created', + null: false + end +end diff --git a/app/graphql/types/tree/tree_type.rb b/app/graphql/types/tree/tree_type.rb index b967cf3a247..56d544b5fd1 100644 --- a/app/graphql/types/tree/tree_type.rb +++ b/app/graphql/types/tree/tree_type.rb @@ -7,9 +7,9 @@ module Types graphql_name 'Tree' # Complexity 10 as it triggers a Gitaly call on each render - field :last_commit, Types::CommitType, null: true, complexity: 10, calls_gitaly: true, resolve: -> (tree, args, ctx) do # rubocop:disable Graphql/Descriptions - tree.repository.last_commit_for_path(tree.sha, tree.path) - end + field :last_commit, Types::CommitType, + null: true, complexity: 10, calls_gitaly: true, resolver: Resolvers::LastCommitResolver, + description: 'Last commit for the tree' field :trees, Types::Tree::TreeEntryType.connection_type, null: false, resolve: -> (obj, args, ctx) do # rubocop:disable Graphql/Descriptions Gitlab::Graphql::Representation::TreeEntry.decorate(obj.trees, obj.repository) diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index 9f7d2a171d6..1ba37927b40 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -12,5 +12,8 @@ module Types field :username, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions field :avatar_url, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions field :web_url, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :todos, Types::TodoType.connection_type, null: false, + resolver: Resolvers::TodoResolver, + description: 'Todos of this user' end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 5c2420e80f2..ecaeb7060c8 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -108,6 +108,11 @@ module ApplicationHelper Gitlab.config.extra end + # shortcut for gitlab registry config + def registry_config + Gitlab.config.registry + end + # Render a `time` element with Javascript-based relative date and tooltip # # time - Time object diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 8c5be1c315d..df17b82412f 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -265,6 +265,10 @@ module ApplicationSettingsHelper :throttle_unauthenticated_enabled, :throttle_unauthenticated_period_in_seconds, :throttle_unauthenticated_requests_per_period, + :throttle_protected_paths_enabled, + :throttle_protected_paths_period_in_seconds, + :throttle_protected_paths_requests_per_period, + :protected_paths_raw, :time_tracking_limit_to_hours, :two_factor_grace_period, :unique_ips_limit_enabled, @@ -285,7 +289,10 @@ module ApplicationSettingsHelper :snowplow_collector_hostname, :snowplow_cookie_domain, :snowplow_enabled, - :snowplow_site_id + :snowplow_site_id, + :push_event_hooks_limit, + :push_event_activities_limit, + :custom_http_clone_url_root ] end @@ -308,6 +315,10 @@ module ApplicationSettingsHelper def instance_clusters_enabled? can?(current_user, :read_cluster, Clusters::Instance.new) end + + def omnibus_protected_paths_throttle? + Rack::Attack.throttles.key?('protected paths') + end end ApplicationSettingsHelper.prepend_if_ee('EE::ApplicationSettingsHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index b7f7e617825..733d21daec1 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -56,16 +56,6 @@ module AvatarsHelper })) end - def user_avatar_url_for(only_path: true, **options) - if options[:url] - options[:url] - elsif options[:user] - avatar_icon_for_user(options[:user], options[:size], only_path: only_path) - else - avatar_icon_for_email(options[:user_email], options[:size], only_path: only_path) - end - end - def user_avatar_without_link(options = {}) avatar_size = options[:size] || 16 user_name = options[:user].try(:name) || options[:user_name] @@ -111,6 +101,19 @@ module AvatarsHelper private + def user_avatar_url_for(only_path: true, **options) + return options[:url] if options[:url] + + email = options[:user_email] + user = options.key?(:user) ? options[:user] : User.find_by_any_email(email) + + if user + avatar_icon_for_user(user, options[:size], only_path: only_path) + else + gravatar_icon(email, options[:size]) + end + end + def source_icon(source, options = {}) avatar_url = source.try(:avatar_url) diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 4b0713001a1..5c24b0e1704 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -32,6 +32,14 @@ module BlobHelper File.join(segments) end + def ide_fork_and_edit_path(project = @project, ref = @ref, path = @path, options = {}) + if current_user + project_forks_path(project, + namespace_key: current_user&.namespace&.id, + continue: edit_blob_fork_params(ide_edit_path(project, ref, path))) + end + end + def encode_ide_path(path) url_encode(path).gsub('%2F', '/') end @@ -197,13 +205,13 @@ module BlobHelper end def copy_file_path_button(file_path) - clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent', title: 'Copy file path to clipboard') + clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent', title: _('Copy file path')) end def copy_blob_source_button(blob) return unless blob.rendered_as_text?(ignore_errors: false) - clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm js-copy-blob-source-btn", title: "Copy source to clipboard") + clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm js-copy-blob-source-btn", title: _("Copy file contents")) end def open_raw_blob_button(blob) diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index 12cd5403f71..610d823dd3c 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -21,7 +21,7 @@ module ButtonHelper # See http://clipboardjs.com/#usage def clipboard_button(data = {}) css_class = data[:class] || 'btn-clipboard btn-transparent' - title = data[:title] || _('Copy to clipboard') + title = data[:title] || _('Copy') button_text = data[:button_text] || '' hide_tooltip = data[:hide_tooltip] || false hide_button_icon = data[:hide_button_icon] || false diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index 144df676304..4471d5b64b2 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -64,7 +64,7 @@ module CiStatusHelper def ci_icon_for_status(status, size: 16) if detailed_status?(status) - return sprite_icon(status.icon) + return sprite_icon(status.icon, size: size) end icon_name = @@ -77,6 +77,8 @@ module CiStatusHelper 'status_failed' when 'pending' 'status_pending' + when 'preparing' + 'status_preparing' when 'running' 'status_running' when 'play' @@ -96,23 +98,29 @@ module CiStatusHelper sprite_icon(icon_name, size: size) end + def ci_icon_class_for_status(status) + group = detailed_status?(status) ? status.group : status.dasherize + + "ci-status-icon-#{group}" + end + def pipeline_status_cache_key(pipeline_status) "pipeline-status/#{pipeline_status.sha}-#{pipeline_status.status}" end - def render_commit_status(commit, ref: nil, tooltip_placement: 'left') + def render_commit_status(commit, status, ref: nil, tooltip_placement: 'left') project = commit.project path = pipelines_project_commit_path(project, commit, ref: ref) render_status_with_link( - commit.status(ref), + status, path, tooltip_placement: tooltip_placement, icon_size: 24) end def render_status_with_link(status, path = nil, type: _('pipeline'), tooltip_placement: 'left', cssclass: '', container: 'body', icon_size: 16) - klass = "ci-status-link ci-status-icon-#{status.dasherize} d-inline-flex #{cssclass}" + klass = "ci-status-link #{ci_icon_class_for_status(status)} d-inline-flex #{cssclass}" title = "#{type.titleize}: #{ci_label_for_status(status)}" data = { toggle: 'tooltip', placement: tooltip_placement, container: container } @@ -127,6 +135,7 @@ module CiStatusHelper def detailed_status?(status) status.respond_to?(:text) && + status.respond_to?(:group) && status.respond_to?(:label) && status.respond_to?(:icon) end diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index 7f3e78f3a81..52ec2eadf5e 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -60,9 +60,14 @@ module DiffHelper if line.blank? " ".html_safe else - # We can't use `sub` because the HTML-safeness of `line` will not survive. - line[0] = '' if line.start_with?('+', '-', ' ') - line + # `sub` and substring-ing would destroy HTML-safeness of `line` + if line.start_with?('+', '-', ' ') + line.dup.tap do |line| + line[0] = '' + end + else + line + end end end @@ -198,8 +203,8 @@ module DiffHelper link_to "#{hide_whitespace? ? 'Show' : 'Hide'} whitespace changes", url, class: options[:class] end - def render_overflow_warning?(diff_files) - diffs = @merge_request_diff.presence || diff_files + def render_overflow_warning?(diffs_collection) + diffs = @merge_request_diff.presence || diffs_collection.diff_files diffs.overflow? end diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb index 2b7320817ed..52f189b122f 100644 --- a/app/helpers/environment_helper.rb +++ b/app/helpers/environment_helper.rb @@ -18,12 +18,16 @@ module EnvironmentHelper end end + def deployment_path(deployment) + [deployment.project.namespace.becomes(Namespace), deployment.project, deployment.deployable] + end + def deployment_link(deployment, text: nil) return unless deployment link_label = text ? text : "##{deployment.iid}" - link_to link_label, [deployment.project.namespace.becomes(Namespace), deployment.project, deployment.deployable] + link_to link_label, deployment_path(deployment) end def last_deployment_link_for_environment_build(project, build) @@ -32,4 +36,31 @@ module EnvironmentHelper deployment_link(environment.last_deployment) end + + def render_deployment_status(deployment) + status = deployment.status + + status_text = + case status + when 'created' + s_('Deployment|created') + when 'running' + s_('Deployment|running') + when 'success' + s_('Deployment|success') + when 'failed' + s_('Deployment|failed') + when 'canceled' + s_('Deployment|canceled') + end + + klass = "ci-status ci-#{status.dasherize}" + text = "#{ci_icon_for_status(status)} #{status_text}".html_safe + + if deployment.deployable + link_to(text, deployment_path(deployment), class: klass) + else + content_tag(:span, text, class: klass) + end + end end diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb new file mode 100644 index 00000000000..d03fa6eadb2 --- /dev/null +++ b/app/helpers/export_helper.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ExportHelper + # An EE-overwriteable list of descriptions + def project_export_descriptions + [ + _('Project and wiki repositories'), + _('Project uploads'), + _('Project configuration, including services'), + _('Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities'), + _('LFS objects'), + _('Issue Boards') + ] + end +end + +ExportHelper.prepend_if_ee('EE::ExportHelper') diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index f524696cc2f..4f31cc67ccc 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -58,7 +58,7 @@ module GitlabRoutingHelper end def commits_url(entity, *args) - project_commits_url(entity.project, entity.ref, *args) + project_commits_url(entity.project, entity.source_ref, *args) end def commit_url(entity, *args) @@ -76,10 +76,10 @@ module GitlabRoutingHelper end def edit_milestone_path(entity, *args) - if entity.parent.is_a?(Group) - edit_group_milestone_path(entity.parent, entity, *args) + if entity.resource_parent.is_a?(Group) + edit_group_milestone_path(entity.resource_parent, entity, *args) else - edit_project_milestone_path(entity.parent, entity, *args) + edit_project_milestone_path(entity.resource_parent, entity, *args) end end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 601560cca92..6ddcbf61090 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -15,6 +15,18 @@ module GroupsHelper %w[groups#projects groups#edit badges#index ci_cd#show ldap_group_links#index hooks#index audit_events#index pipeline_quota#index] end + def group_packages_nav_link_paths + %w[ + groups/container_registries#index + ] + end + + def group_container_registry_nav? + Gitlab.config.registry.enabled && + can?(current_user, :read_container_image, @group) && + Feature.enabled?(:group_container_registry_browser, @group) + end + def group_sidebar_links @group_sidebar_links ||= get_group_sidebar_links end @@ -32,8 +44,7 @@ module GroupsHelper end def can_disable_group_emails?(group) - Feature.enabled?(:emails_disabled, group, default_enabled: true) && - can?(current_user, :set_emails_disabled, group) && !group.parent&.emails_disabled? + can?(current_user, :set_emails_disabled, group) && !group.parent&.emails_disabled? end def group_issues_count(state:) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 014523b54cb..df9d1933271 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -272,7 +272,7 @@ module IssuablesHelper markdownPreviewPath: preview_markdown_path(parent), markdownDocsPath: help_page_path('user/markdown'), lockVersion: issuable.lock_version, - issuableTemplates: issuable_templates(issuable), + issuableTemplateNamesPath: template_names_path(parent, issuable), initialTitleHtml: markdown_field(issuable, :title), initialTitleText: issuable.title, initialDescriptionHtml: markdown_field(issuable, :description), @@ -372,6 +372,12 @@ module IssuablesHelper finder.class.scalar_params.any? { |p| params[p].present? } end + def assignee_sidebar_data(assignee, merge_request: nil) + { avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username }.tap do |data| + data[:can_merge] = merge_request.can_be_merged_by?(assignee) if merge_request + end + end + private def sidebar_gutter_collapsed? @@ -429,6 +435,12 @@ module IssuablesHelper end end + def template_names_path(parent, issuable) + return '' unless parent.is_a?(Project) + + project_template_names_path(parent, template_type: issuable.class.name.underscore) + end + def issuable_sidebar_options(issuable) { endpoint: "#{issuable[:issuable_json_path]}?serializer=sidebar_extras", diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 6aa910e6c3f..2ce45cec878 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -20,7 +20,6 @@ module NavHelper def page_gutter_class if page_has_markdown? - if cookies[:collapsed_gutter] == 'true' %w[page-gutter right-sidebar-collapsed] else @@ -87,6 +86,12 @@ module NavHelper links << :admin_impersonation end + if Feature.enabled?(:user_mode_in_session) + if current_user&.admin? && current_user_mode&.admin_mode? + links << :admin_mode + end + end + links end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index bf6abdb8c4b..16360c7139a 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -76,7 +76,7 @@ module ProjectsHelper link_to(author_html, user_path(author), class: "author-link js-user-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}", data: data_attrs).html_safe else title = opts[:title].sub(":name", sanitize(author.name)) - link_to(author_html, user_path(author), class: "author-link has-tooltip", title: title, data: { container: 'body' }).html_safe + link_to(author_html, user_path(author), class: "author-link has-tooltip", title: title, data: { container: 'body', qa_selector: 'assignee_link' }).html_safe end end @@ -160,7 +160,7 @@ module ProjectsHelper def can_disable_emails?(project, current_user) return false if project.group&.emails_disabled? - can?(current_user, :set_emails_disabled, project) && Feature.enabled?(:emails_disabled, project, default_enabled: true) + can?(current_user, :set_emails_disabled, project) end def last_push_event @@ -168,7 +168,7 @@ module ProjectsHelper end def link_to_autodeploy_doc - link_to _('About auto deploy'), help_page_path('ci/autodeploy/index'), target: '_blank' + link_to _('About auto deploy'), help_page_path('autodevops/index.md#auto-deploy'), target: '_blank' end def autodeploy_flash_notice(branch_name) @@ -354,6 +354,14 @@ module ProjectsHelper @project.metrics_setting_external_dashboard_url end + def grafana_integration_url + @project.grafana_integration&.grafana_url + end + + def grafana_integration_token + @project.grafana_integration&.token + end + private def get_project_nav_tabs(project, current_user) @@ -565,7 +573,7 @@ module ProjectsHelper lfsHelpPath: help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs'), pagesAvailable: Gitlab.config.pages.enabled, pagesAccessControlEnabled: Gitlab.config.pages.access_control, - pagesHelpPath: help_page_path('user/project/pages/introduction', anchor: 'gitlab-pages-access-control-core-only') + pagesHelpPath: help_page_path('user/project/pages/introduction', anchor: 'gitlab-pages-access-control-core') } end diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb index 4d9fe345edf..68a19152d8f 100644 --- a/app/helpers/releases_helper.rb +++ b/app/helpers/releases_helper.rb @@ -12,27 +12,21 @@ module ReleasesHelper help_page_path(DOCUMENTATION_PATH) end - def url_for_merge_requests - project_merge_requests_url(@project, params_for_issue_and_mr_paths) - end - - def url_for_issues - project_issues_url(@project, params_for_issue_and_mr_paths) - end - def data_for_releases_page { project_id: @project.id, illustration_path: illustration, - documentation_path: help_page, - merge_requests_url: url_for_merge_requests, - issues_url: url_for_issues + documentation_path: help_page } end - private - - def params_for_issue_and_mr_paths - { scope: 'all', state: 'opened' } + def data_for_edit_release_page + { + project_id: @project.id, + tag_name: @release.tag, + markdown_preview_path: preview_markdown_path(@project), + markdown_docs_path: help_page_path('user/markdown'), + releases_page_path: project_releases_path(@project, anchor: @release.tag) + } end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 0f4e5adca6c..9a19758b4e8 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -34,15 +34,15 @@ module SearchHelper from: from, to: to, count: count, - scope: search_entries_info_label(scope, count), + scope: search_entries_scope_label(scope, count), term: term } end - def search_entries_info_label(scope, count) + def search_entries_scope_label(scope, count) case scope - when 'blobs', 'snippet_blobs', 'wiki_blobs' - ns_('SearchResults|result', 'SearchResults|results', count) + when 'blobs' + ns_('SearchResults|code result', 'SearchResults|code results', count) when 'commits' ns_('SearchResults|commit', 'SearchResults|commits', count) when 'issues' @@ -55,10 +55,14 @@ module SearchHelper ns_('SearchResults|comment', 'SearchResults|comments', count) when 'projects' ns_('SearchResults|project', 'SearchResults|projects', count) + when 'snippet_blobs' + ns_('SearchResults|snippet result', 'SearchResults|snippet results', count) when 'snippet_titles' ns_('SearchResults|snippet', 'SearchResults|snippets', count) when 'users' ns_('SearchResults|user', 'SearchResults|users', count) + when 'wiki_blobs' + ns_('SearchResults|wiki result', 'SearchResults|wiki results', count) else raise "Unrecognized search scope '#{scope}'" end @@ -72,6 +76,13 @@ module SearchHelper end end + def search_entries_empty_message(scope, term) + (s_("SearchResults|We couldn't find any %{scope} matching %{term}") % { + scope: search_entries_scope_label(scope, 0), + term: "<code>#{h(term)}</code>" + }).html_safe + end + def find_project_for_result_blob(projects, result) @project end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index d680e10525d..33f3bb0b749 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -28,7 +28,9 @@ module SortingHelper sort_value_priority => sort_title_priority, sort_value_upvotes => sort_title_upvotes, sort_value_contacted_date => sort_title_contacted_date, - sort_value_relative_position => sort_title_relative_position + sort_value_relative_position => sort_title_relative_position, + sort_value_size => sort_title_size, + sort_value_expire_date => sort_title_expire_date } end @@ -406,6 +408,14 @@ module SortingHelper s_('SortOptions|Manual') end + def sort_title_size + s_('SortOptions|Size') + end + + def sort_title_expire_date + s_('SortOptions|Expired date') + end + # Values. def sort_value_access_level_asc 'access_level_asc' @@ -558,4 +568,12 @@ module SortingHelper def sort_value_relative_position 'relative_position' end + + def sort_value_size + 'size_desc' + end + + def sort_value_expire_date + 'expired_asc' + end end diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb index e683e2959d1..4b83988e8bb 100644 --- a/app/helpers/submodule_helper.rb +++ b/app/helpers/submodule_helper.rb @@ -81,7 +81,7 @@ module SubmoduleHelper end def relative_self_links(relative_path, commit, project) - relative_path.rstrip! + relative_path = relative_path.rstrip absolute_project_path = "/" + project.full_path # Resolve `relative_path` to target path diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb index de0b92b6fd7..4984b51555d 100644 --- a/app/helpers/tags_helper.rb +++ b/app/helpers/tags_helper.rb @@ -28,4 +28,14 @@ module TagsHelper def protected_tag?(project, tag) ProtectedTag.protected?(project, tag.name) end + + def tag_description_help_text + text = s_('TagsPage|Optionally, add a message to the tag. Leaving this blank creates '\ + 'a %{link_start}lightweight tag.%{link_end}') % { + link_start: '<a href="https://git-scm.com/book/en/v2/Git-Basics-Tagging\" target="_blank" rel="noopener noreferrer">', + link_end: '</a>' + } + + text.html_safe + end end diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index a919c068c42..dce0842060d 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -45,8 +45,8 @@ module TodosHelper end def todo_parent_path(todo) - if todo.parent.is_a?(Group) - link_to todo.parent.name, group_path(todo.parent) + if todo.resource_parent.is_a?(Group) + link_to todo.resource_parent.name, group_path(todo.resource_parent) else link_to_project(todo.project) end @@ -64,7 +64,7 @@ module TodosHelper if todo.for_commit? project_commit_path(todo.project, todo.target, path_options) else - path = [todo.parent, todo.target] + path = [todo.resource_parent, todo.target] path.unshift(:pipelines) if todo.build_failed? diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 47d15836da0..3fd865003c1 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -85,7 +85,7 @@ module Emails @project = Project.find(project_id) @results = results - mail(to: recipient(@user.id, @project.group), subject: subject('Imported issues')) do |format| + mail(to: @user.notification_email_for(@project.group), subject: subject('Imported issues')) do |format| format.html { render layout: 'mailer' } format.text { render layout: 'mailer' } end @@ -105,7 +105,7 @@ module Emails def issue_thread_options(sender_id, recipient_id, reason) { from: sender(sender_id), - to: recipient(recipient_id, @project.group), + to: User.find(recipient_id).notification_email_for(@project.group), subject: subject("#{@issue.title} (##{@issue.iid})"), 'X-GitLab-NotificationReason' => reason } diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 76fa7236ab1..ea8032324aa 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -13,7 +13,9 @@ module Emails @member_source_type = member_source_type @member_id = member_id - mail(to: recipient(recipient_id, notification_group), + user = User.find(recipient_id) + + mail(to: user.notification_email_for(notification_group), subject: subject("Request to join the #{member_source.human_name} #{member_source.model_name.singular}")) end @@ -21,7 +23,7 @@ module Emails @member_source_type = member_source_type @member_id = member_id - mail(to: recipient(member.user, notification_group), + mail(to: member.user.notification_email_for(notification_group), subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was granted")) end @@ -29,7 +31,9 @@ module Emails @member_source_type = member_source_type @member_source = member_source_class.find(source_id) - mail(to: recipient(user_id, notification_group), + user = User.find(user_id) + + mail(to: user.notification_email_for(notification_group), subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was denied")) end @@ -47,7 +51,7 @@ module Emails @member_id = member_id return unless member.created_by - mail(to: recipient(member.created_by, notification_group), + mail(to: member.created_by.notification_email_for(notification_group), subject: subject('Invitation accepted')) end @@ -58,7 +62,9 @@ module Emails @member_source = member_source_class.find(source_id) @invite_email = invite_email - mail(to: recipient(created_by_id, notification_group), + user = User.find(created_by_id) + + mail(to: user.notification_email_for(notification_group), subject: subject('Invitation declined')) end diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index d972d0dea28..76b1c2d234c 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -110,7 +110,7 @@ module Emails def merge_request_thread_options(sender_id, recipient_id, reason = nil) { from: sender(sender_id), - to: recipient(recipient_id, @project.group), + to: User.find(recipient_id).notification_email_for(@project.group), subject: subject("#{@merge_request.title} (#{@merge_request.to_reference})"), 'X-GitLab-NotificationReason' => reason } diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb index 51b6368a307..a1c8c3455b5 100644 --- a/app/mailers/emails/notes.rb +++ b/app/mailers/emails/notes.rb @@ -55,7 +55,7 @@ module Emails def note_thread_options(recipient_id, reason) { from: sender(@note.author_id), - to: recipient(recipient_id, @project&.group || @group), + to: User.find(recipient_id).notification_email_for(@project&.group || @group), subject: subject("#{@note.noteable.title} (#{@note.noteable.reference_link_text})"), 'X-GitLab-NotificationReason' => reason } diff --git a/app/mailers/emails/pages_domains.rb b/app/mailers/emails/pages_domains.rb index 2d390666f65..1caca6b3e44 100644 --- a/app/mailers/emails/pages_domains.rb +++ b/app/mailers/emails/pages_domains.rb @@ -7,7 +7,7 @@ module Emails @project = domain.project mail( - to: recipient(recipient.id, @project.group), + to: recipient.notification_email_for(@project.group), subject: subject("GitLab Pages domain '#{domain.domain}' has been enabled") ) end @@ -17,7 +17,7 @@ module Emails @project = domain.project mail( - to: recipient(recipient.id, @project.group), + to: recipient.notification_email_for(@project.group), subject: subject("GitLab Pages domain '#{domain.domain}' has been disabled") ) end @@ -27,7 +27,7 @@ module Emails @project = domain.project mail( - to: recipient(recipient.id, @project.group), + to: recipient.notification_email_for(@project.group), subject: subject("Verification succeeded for GitLab Pages domain '#{domain.domain}'") ) end @@ -37,7 +37,7 @@ module Emails @project = domain.project mail( - to: recipient(recipient.id, @project.group), + to: recipient.notification_email_for(@project.group), subject: subject("ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'") ) end diff --git a/app/mailers/emails/pipelines.rb b/app/mailers/emails/pipelines.rb index fb57c0da34d..34e12a5fa6d 100644 --- a/app/mailers/emails/pipelines.rb +++ b/app/mailers/emails/pipelines.rb @@ -15,7 +15,7 @@ module Emails def pipeline_mail(pipeline, recipients, status) @project = pipeline.project @pipeline = pipeline - @merge_request = pipeline.merge_requests_as_head_pipeline.first + @merge_request = pipeline.all_merge_requests.first add_headers # We use bcc here because we don't want to generate this emails for a @@ -44,7 +44,7 @@ module Emails commit = [@pipeline.short_sha] commit << "in #{@merge_request.to_reference}" if @merge_request - subject("Pipeline ##{@pipeline.id} has #{status} for #{@pipeline.ref}", commit.join(' ')) + subject("Pipeline ##{@pipeline.id} has #{status} for #{@pipeline.source_ref}", commit.join(' ')) end end end diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 4acf4a1dc4f..6274879ee99 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -7,20 +7,20 @@ module Emails @project = Project.find project_id @target_url = project_url(@project) @old_path_with_namespace = old_path_with_namespace - mail(to: recipient(user_id, @project.group), + mail(to: @user.notification_email_for(@project.group), subject: subject("Project was moved")) end def project_was_exported_email(current_user, project) @project = project - mail(to: recipient(current_user.id, project.group), + mail(to: current_user.notification_email_for(project.group), subject: subject("Project was exported")) end def project_was_not_exported_email(current_user, project, errors) @project = project @errors = errors - mail(to: recipient(current_user.id, @project.group), + mail(to: current_user.notification_email_for(@project.group), subject: subject("Project export error")) end @@ -28,7 +28,7 @@ module Emails @project = project @user = user - mail(to: recipient(user.id, project.group), subject: subject("Project cleanup has completed")) + mail(to: user.notification_email_for(project.group), subject: subject("Project cleanup has completed")) end def repository_cleanup_failure_email(project, user, error) @@ -36,7 +36,7 @@ module Emails @user = user @error = error - mail(to: recipient(user.id, project.group), subject: subject("Project cleanup failure")) + mail(to: user.notification_email_for(project.group), subject: subject("Project cleanup failure")) end def repository_push_email(project_id, opts = {}) diff --git a/app/mailers/emails/releases.rb b/app/mailers/emails/releases.rb new file mode 100644 index 00000000000..137858d31e8 --- /dev/null +++ b/app/mailers/emails/releases.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Emails + module Releases + def new_release_email(user_id, release, reason = nil) + @release = release + @project = @release.project + @target_url = namespace_project_releases_url( + namespace_id: @project.namespace, + project_id: @project + ) + + user = User.find(user_id) + + mail( + to: user.notification_email_for(@project.group), + subject: subject(release_email_subject) + ) + end + + private + + def release_email_subject + release_info = [@release.name, @release.tag].select(&:presence).join(' - ') + "New release: #{release_info}" + end + end +end diff --git a/app/mailers/emails/remote_mirrors.rb b/app/mailers/emails/remote_mirrors.rb index f3938a052b0..9cde53918b9 100644 --- a/app/mailers/emails/remote_mirrors.rb +++ b/app/mailers/emails/remote_mirrors.rb @@ -5,8 +5,9 @@ module Emails def remote_mirror_update_failed_email(remote_mirror_id, recipient_id) @remote_mirror = RemoteMirror.find_by_id(remote_mirror_id) @project = @remote_mirror.project + user = User.find(recipient_id) - mail(to: recipient(recipient_id, @project.group), subject: subject('Remote mirror update failed')) + mail(to: user.notification_email_for(@project.group), subject: subject('Remote mirror update failed')) end end end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 6fa1c701cd8..c7cfefeec9b 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -16,6 +16,7 @@ class Notify < BaseMailer include Emails::Members include Emails::AutoDevops include Emails::RemoteMirrors + include Emails::Releases helper MilestonesHelper helper MergeRequestsHelper @@ -71,20 +72,6 @@ class Notify < BaseMailer address.format end - # Look up a User's notification email for a particular context. - # Can look up by their ID or can accept a User object. - # - # recipient - User object OR a User ID - # notification_group - The parent group of the notification - # - # Returns a String containing the User's email address. - def recipient(recipient, notification_group = nil) - user = recipient if recipient.is_a?(User) - user ||= User.find(recipient) - - user.notification_email_for(notification_group) - end - # Formats arguments into a String suitable for use as an email subject # # extra - Extra Strings to be inserted into the subject diff --git a/app/models/analytics/cycle_analytics/project_stage.rb b/app/models/analytics/cycle_analytics/project_stage.rb index a312bd24e78..23f0db0829b 100644 --- a/app/models/analytics/cycle_analytics/project_stage.rb +++ b/app/models/analytics/cycle_analytics/project_stage.rb @@ -9,6 +9,7 @@ module Analytics belongs_to :project alias_attribute :parent, :project + alias_attribute :parent_id, :project_id end end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 92526def144..a07933d4975 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -210,6 +210,16 @@ class ApplicationSetting < ApplicationRecord presence: true, if: :static_objects_external_storage_url? + validates :protected_paths, + length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') }, + allow_nil: false + + validates :push_event_hooks_limit, + numericality: { greater_than_or_equal_to: 0 } + + validates :push_event_activities_limit, + numericality: { greater_than_or_equal_to: 0 } + SUPPORTED_KEY_TYPES.each do |type| validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } end diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 8d9597aa5a4..0c0ffb67c9a 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -4,7 +4,7 @@ module ApplicationSettingImplementation extend ActiveSupport::Concern include Gitlab::Utils::StrongMemoize - DOMAIN_LIST_SEPARATOR = %r{\s*[,;]\s* # comma or semicolon, optionally surrounded by whitespace + STRING_LIST_SEPARATOR = %r{\s*[,;]\s* # comma or semicolon, optionally surrounded by whitespace | # or \s # any whitespace character | # or @@ -16,6 +16,19 @@ module ApplicationSettingImplementation FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN SUPPORTED_KEY_TYPES = %i[rsa dsa ecdsa ed25519].freeze + DEFAULT_PROTECTED_PATHS = [ + '/users/password', + '/users/sign_in', + '/api/v3/session.json', + '/api/v3/session', + '/api/v4/session.json', + '/api/v4/session', + '/users', + '/users/confirmation', + '/unsubscribes/', + '/import/github/personal_access_token' + ].freeze + class_methods do def defaults { @@ -69,6 +82,8 @@ module ApplicationSettingImplementation polling_interval_multiplier: 1, project_export_enabled: true, protected_ci_variables: false, + push_event_hooks_limit: 3, + push_event_activities_limit: 3, raw_blob_request_limit: 300, recaptcha_enabled: false, login_recaptcha_protection_enabled: false, @@ -92,6 +107,13 @@ module ApplicationSettingImplementation throttle_unauthenticated_enabled: false, throttle_unauthenticated_period_in_seconds: 3600, throttle_unauthenticated_requests_per_period: 3600, + throttle_protected_paths_enabled: false, + throttle_protected_paths_in_seconds: 10, + throttle_protected_paths_per_period: 60, + protected_paths: DEFAULT_PROTECTED_PATHS, + throttle_incident_management_notification_enabled: false, + throttle_incident_management_notification_period_in_seconds: 3600, + throttle_incident_management_notification_per_period: 3600, time_tracking_limit_to_hours: false, two_factor_grace_period: 48, unique_ips_limit_enabled: false, @@ -106,7 +128,8 @@ module ApplicationSettingImplementation snowplow_collector_hostname: nil, snowplow_cookie_domain: nil, snowplow_enabled: false, - snowplow_site_id: nil + snowplow_site_id: nil, + custom_http_clone_url_root: nil } end @@ -149,11 +172,11 @@ module ApplicationSettingImplementation end def domain_whitelist_raw=(values) - self.domain_whitelist = domain_strings_to_array(values) + self.domain_whitelist = strings_to_array(values) end def domain_blacklist_raw=(values) - self.domain_blacklist = domain_strings_to_array(values) + self.domain_blacklist = strings_to_array(values) end def domain_blacklist_file=(file) @@ -167,7 +190,7 @@ module ApplicationSettingImplementation def outbound_local_requests_whitelist_raw=(values) clear_memoization(:outbound_local_requests_whitelist_arrays) - self.outbound_local_requests_whitelist = domain_strings_to_array(values) + self.outbound_local_requests_whitelist = strings_to_array(values) end def add_to_outbound_local_requests_whitelist(values_array) @@ -200,8 +223,16 @@ module ApplicationSettingImplementation end end + def protected_paths_raw + array_to_string(self.protected_paths) + end + + def protected_paths_raw=(values) + self.protected_paths = strings_to_array(values) + end + def asset_proxy_whitelist=(values) - values = domain_strings_to_array(values) if values.is_a?(String) + values = strings_to_array(values) if values.is_a?(String) # make sure we always whitelist the running host values << Gitlab.config.gitlab.host unless values.include?(Gitlab.config.gitlab.host) @@ -316,11 +347,11 @@ module ApplicationSettingImplementation arr&.join("\n") end - def domain_strings_to_array(values) + def strings_to_array(values) return [] unless values values - .split(DOMAIN_LIST_SEPARATOR) + .split(STRING_LIST_SEPARATOR) .map(&:strip) .reject(&:empty?) .uniq diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index c2eef500fb0..06a607b75a4 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class AuditEvent < ApplicationRecord + include CreatedAtFilterable + serialize :details, Hash # rubocop:disable Cop/ActiveRecordSerialize belongs_to :user, foreign_key: :author_id @@ -9,6 +11,9 @@ class AuditEvent < ApplicationRecord validates :entity_id, presence: true validates :entity_type, presence: true + scope :by_entity_type, -> (entity_type) { where(entity_type: entity_type) } + scope :by_entity_id, -> (entity_id) { where(entity_id: entity_id) } + after_initialize :initialize_details def initialize_details @@ -18,6 +23,10 @@ class AuditEvent < ApplicationRecord def author_name self.user.name end + + def formatted_details + details.merge(details.slice(:from, :to).transform_values(&:to_s)) + end end AuditEvent.prepend_if_ee('EE::AuditEvent') diff --git a/app/models/aws/role.rb b/app/models/aws/role.rb new file mode 100644 index 00000000000..836107435ad --- /dev/null +++ b/app/models/aws/role.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Aws + class Role < ApplicationRecord + self.table_name = 'aws_roles' + + belongs_to :user, inverse_of: :aws_role + + validates :role_external_id, uniqueness: true, length: { in: 1..64 } + validates :role_arn, + length: 1..2048, + format: { + with: Gitlab::Regex.aws_arn_regex, + message: Gitlab::Regex.aws_arn_regex_message + } + end +end diff --git a/app/models/blob.rb b/app/models/blob.rb index a590536d5fe..cc089715b06 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -32,6 +32,7 @@ class Blob < SimpleDelegator BlobViewer::Balsamiq, BlobViewer::Video, + BlobViewer::Audio, BlobViewer::PDF, @@ -176,7 +177,11 @@ class Blob < SimpleDelegator end def video? - UploaderHelper::VIDEO_EXT.include?(extension) + UploaderHelper::SAFE_VIDEO_EXT.include?(extension) + end + + def audio? + UploaderHelper::SAFE_AUDIO_EXT.include?(extension) end def readable_text? diff --git a/app/models/blob_viewer/audio.rb b/app/models/blob_viewer/audio.rb new file mode 100644 index 00000000000..cc7fe3b0d90 --- /dev/null +++ b/app/models/blob_viewer/audio.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module BlobViewer + class Audio < Base + include Rich + include ClientSide + + self.partial_name = 'audio' + self.extensions = UploaderHelper::SAFE_AUDIO_EXT + self.binary = true + end +end diff --git a/app/models/blob_viewer/image.rb b/app/models/blob_viewer/image.rb index 56e27839fca..cbebef46c60 100644 --- a/app/models/blob_viewer/image.rb +++ b/app/models/blob_viewer/image.rb @@ -6,7 +6,7 @@ module BlobViewer include ClientSide self.partial_name = 'image' - self.extensions = UploaderHelper::IMAGE_EXT + self.extensions = UploaderHelper::SAFE_IMAGE_EXT self.binary = true self.switcher_icon = 'picture-o' self.switcher_title = 'image' diff --git a/app/models/blob_viewer/video.rb b/app/models/blob_viewer/video.rb index 48bb2a13518..3ec4e90b24e 100644 --- a/app/models/blob_viewer/video.rb +++ b/app/models/blob_viewer/video.rb @@ -6,9 +6,7 @@ module BlobViewer include ClientSide self.partial_name = 'video' - self.extensions = UploaderHelper::VIDEO_EXT + self.extensions = UploaderHelper::SAFE_VIDEO_EXT self.binary = true - self.switcher_icon = 'film' - self.switcher_title = 'video' end end diff --git a/app/models/board.rb b/app/models/board.rb index 31011dc4742..f3f938224a4 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -16,10 +16,9 @@ class Board < ApplicationRecord !group end - def parent - @parent ||= group || project + def resource_parent + @resource_parent ||= group || project end - alias_method :resource_parent, :parent def group_board? group_id.present? diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb index ef00ad75683..76d4b9d6206 100644 --- a/app/models/ci/artifact_blob.rb +++ b/app/models/ci/artifact_blob.rb @@ -53,7 +53,7 @@ module Ci pages_config.enabled && pages_config.artifacts_server && EXTENSIONS_SERVED_BY_PAGES.include?(File.extname(name)) && - job.project.public? + (pages_config.access_control || job.project.public?) end private diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 1f8a0373450..c48ab28ce73 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -12,7 +12,6 @@ module Ci include Presentable include Importable include Gitlab::Utils::StrongMemoize - include Deployable include HasRef BuildArchivedError = Class.new(StandardError) @@ -43,6 +42,7 @@ module Ci has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent has_many :job_variables, class_name: 'Ci::JobVariable', foreign_key: :job_id + has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_job_id Ci::JobArtifact.file_types.each do |key, value| has_one :"job_artifacts_#{key}", -> { where(file_type: value) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id @@ -118,8 +118,6 @@ module Ci scope :eager_load_job_artifacts, -> { includes(:job_artifacts) } - scope :with_artifacts_stored_locally, -> { with_existing_job_artifacts(Ci::JobArtifact.archive.with_files_stored_locally) } - scope :with_archived_trace_stored_locally, -> { with_existing_job_artifacts(Ci::JobArtifact.trace.with_files_stored_locally) } scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } @@ -130,6 +128,12 @@ module Ci scope :with_stale_live_trace, -> { with_live_trace.finished_before(12.hours.ago) } scope :finished_before, -> (date) { finished.where('finished_at < ?', date) } + scope :with_secure_reports_from_options, -> (job_type) { where('options like :job_type', job_type: "%:artifacts:%:reports:%:#{job_type}:%") } + + scope :with_secure_reports_from_config_options, -> (job_types) do + joins(:metadata).where("ci_builds_metadata.config_options -> 'artifacts' -> 'reports' ?| array[:job_types]", job_types: job_types) + end + scope :matches_tag_ids, -> (tag_ids) do matcher = ::ActsAsTaggableOn::Tagging .where(taggable_type: CommitStatus.name) @@ -236,6 +240,7 @@ module Ci end after_transition pending: :running do |build| + build.pipeline.persistent_ref.create build.deployment&.run build.run_after_commit do @@ -412,10 +417,6 @@ module Ci self.options.fetch(:environment, {}).fetch(:action, 'start') if self.options end - def has_deployment? - !!self.deployment - end - def outdated_deployment? success? && !deployment.try(:last?) end @@ -753,6 +754,10 @@ module Ci true end + def invalid_dependencies + dependencies.reject(&:valid_dependency?) + end + def runner_required_feature_names strong_memoize(:runner_required_feature_names) do RUNNER_FEATURES.select do |feature, method| diff --git a/app/models/ci/build_trace.rb b/app/models/ci/build_trace.rb new file mode 100644 index 00000000000..b9db1559836 --- /dev/null +++ b/app/models/ci/build_trace.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Ci + class BuildTrace + CONVERTERS = { + html: Gitlab::Ci::Ansi2html, + json: Gitlab::Ci::Ansi2json + }.freeze + + attr_reader :trace, :build + + delegate :state, :append, :truncated, :offset, :size, :total, to: :trace, allow_nil: true + delegate :id, :status, :complete?, to: :build, prefix: true + + def initialize(build:, stream:, state:, content_format:) + @build = build + @content_format = content_format + + if stream.valid? + stream.limit + @trace = CONVERTERS.fetch(content_format).convert(stream.stream, state) + end + end + + def json? + @content_format == :json + end + + def html? + @content_format == :html + end + + def json_lines + @trace&.lines if json? + end + + def html_lines + @trace&.html if html? + end + end +end diff --git a/app/models/ci/build_trace_section.rb b/app/models/ci/build_trace_section.rb index 8be42eb48d6..7fe6b753da1 100644 --- a/app/models/ci/build_trace_section.rb +++ b/app/models/ci/build_trace_section.rb @@ -4,6 +4,9 @@ module Ci class BuildTraceSection < ApplicationRecord extend Gitlab::Ci::Model + # Only remove > 2019-11-22 and > 12.5 + self.ignored_columns += %i[id] + belongs_to :build, class_name: 'Ci::Build' belongs_to :project belongs_to :section_name, class_name: 'Ci::BuildTraceSectionName' diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb index 9b2c3c807ac..0e05318b253 100644 --- a/app/models/ci/group.rb +++ b/app/models/ci/group.rb @@ -9,6 +9,7 @@ module Ci # class Group include StaticModel + include Gitlab::Utils::StrongMemoize attr_reader :stage, :name, :jobs @@ -21,7 +22,17 @@ module Ci end def status - @status ||= commit_statuses.status + strong_memoize(:status) do + if Feature.enabled?(:ci_composite_status, default_enabled: false) + Gitlab::Ci::Status::Composite + .new(@jobs) + .status + else + CommitStatus + .where(id: @jobs) + .legacy_status + end + end end def detailed_status(current_user) @@ -40,11 +51,5 @@ module Ci self.new(stage, name: group_name, jobs: grouped_statuses) end end - - private - - def commit_statuses - @commit_statuses ||= CommitStatus.where(id: jobs.map(&:id)) - end end end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index da2758507ce..62bf2c3ac9c 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -5,6 +5,7 @@ module Ci include AfterCommitQueue include ObjectStorage::BackgroundMove include UpdateProjectStatistics + include Sortable extend Gitlab::Ci::Model NotSupportedAdapterError = Class.new(StandardError) @@ -64,6 +65,7 @@ module Ci after_save :update_file_store, if: :saved_change_to_file? scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) } + scope :with_files_stored_remotely, -> { where(file_store: ::JobArtifactUploader::Store::REMOTE) } scope :with_file_types, -> (file_types) do types = self.file_types.select { |file_type| file_types.include?(file_type) }.values @@ -143,6 +145,10 @@ module Ci self.update_column(:file_store, file.object_store) end + def self.total_size + self.sum(:size) + end + def self.artifacts_size_for(project) self.where(project: project).sum(:size) end diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb index 930c8a71453..2fd369c9aff 100644 --- a/app/models/ci/legacy_stage.rb +++ b/app/models/ci/legacy_stage.rb @@ -14,7 +14,8 @@ module Ci @pipeline = pipeline @name = name @status = status - @warnings = warnings + # support ints and booleans + @has_warnings = ActiveRecord::Type::Boolean.new.cast(warnings) end def groups @@ -30,7 +31,7 @@ module Ci end def status - @status ||= statuses.latest.status + @status ||= statuses.latest.slow_composite_status end def detailed_status(current_user) @@ -52,11 +53,12 @@ module Ci end def has_warnings? - if @warnings.is_a?(Integer) - @warnings > 0 - else - statuses.latest.failed_but_allowed.any? + # lazilly calculate the warnings + if @has_warnings.nil? + @has_warnings = statuses.latest.failed_but_allowed.any? end + + @has_warnings end def manual_playable? diff --git a/app/models/ci/persistent_ref.rb b/app/models/ci/persistent_ref.rb new file mode 100644 index 00000000000..be3d4aa3203 --- /dev/null +++ b/app/models/ci/persistent_ref.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Ci + ## + # The persistent pipeline ref to ensure runners can safely fetch source code + # even if force-push/source-branch-deletion happens. + class PersistentRef + include ActiveModel::Model + + attr_accessor :pipeline + + delegate :project, :sha, to: :pipeline + delegate :repository, to: :project + delegate :ref_exists?, :create_ref, :delete_refs, to: :repository + + def exist? + ref_exists?(path) + rescue + false + end + + def create + return if exist? + + create_ref(sha, path) + rescue => e + Gitlab::Sentry + .track_acceptable_exception(e, extra: { pipeline_id: pipeline.id }) + end + + def delete + delete_refs(path) + rescue Gitlab::Git::Repository::NoRepository + # no-op + rescue => e + Gitlab::Sentry + .track_acceptable_exception(e, extra: { pipeline_id: pipeline.id }) + end + + def path + "refs/#{Repository::REF_PIPELINES}/#{pipeline.id}" + end + end +end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 20b8be4017e..3bf19399cec 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -25,7 +25,7 @@ module Ci belongs_to :merge_request, class_name: 'MergeRequest' belongs_to :external_pull_request - has_internal_id :iid, scope: :project, presence: false, init: ->(s) do + has_internal_id :iid, scope: :project, presence: false, ensure_if: -> { !importing? }, init: ->(s) do s&.project&.all_pipelines&.maximum(:iid) || s&.project&.all_pipelines&.count end @@ -52,9 +52,15 @@ 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' + has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_pipeline_id + has_one :source_pipeline, class_name: 'Ci::Sources::Pipeline', inverse_of: :pipeline has_one :chat_data, class_name: 'Ci::PipelineChatData' + has_many :triggered_pipelines, through: :sourced_pipelines, source: :pipeline + has_one :triggered_by_pipeline, through: :source_pipeline, source: :source_pipeline + has_one :source_job, through: :source_pipeline, source: :source_job + accepts_nested_attributes_for :variables, reject_if: :persisted? delegate :id, to: :project, prefix: true @@ -174,6 +180,8 @@ module Ci after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline| pipeline.run_after_commit do + pipeline.persistent_ref.delete + pipeline.all_merge_requests.each do |merge_request| next unless merge_request.auto_merge_enabled? @@ -209,6 +217,8 @@ module Ci scope :for_sha, -> (sha) { where(sha: sha) } scope :for_source_sha, -> (source_sha) { where(source_sha: source_sha) } scope :for_sha_or_source_sha, -> (sha) { for_sha(sha).or(for_source_sha(sha)) } + scope :for_ref, -> (ref) { where(ref: ref) } + scope :for_id, -> (id) { where(id: id) } scope :created_after, -> (time) { where('ci_pipelines.created_at > ?', time) } scope :triggered_by_merge_request, -> (merge_request) do @@ -279,16 +289,16 @@ module Ci end end - # Returns a Hash containing the latest pipeline status for every given + # Returns a Hash containing the latest pipeline for every given # commit. # - # The keys of this Hash are the commit SHAs, the values the statuses. + # The keys of this Hash are the commit SHAs, the values the pipelines. # - # commits - The list of commit SHAs to get the status for. + # commits - The list of commit SHAs to get the pipelines for. # ref - The ref to scope the data to (e.g. "master"). If the ref is not - # given we simply get the latest status for the commits, regardless - # of what refs their pipelines belong to. - def self.latest_status_per_commit(commits, ref = nil) + # given we simply get the latest pipelines for the commits, regardless + # of what refs the pipelines belong to. + def self.latest_pipeline_per_commit(commits, ref = nil) p1 = arel_table p2 = arel_table.alias @@ -302,15 +312,14 @@ module Ci cond = cond.and(p1[:ref].eq(p2[:ref])) if ref join = p1.join(p2, Arel::Nodes::OuterJoin).on(cond) - relation = select(:sha, :status) - .where(sha: commits) + relation = where(sha: commits) .where(p2[:id].eq(nil)) .joins(join.join_sources) relation = relation.where(ref: ref) if ref - relation.each_with_object({}) do |row, hash| - hash[row[:sha]] = row[:status] + relation.each_with_object({}) do |pipeline, hash| + hash[pipeline.sha] = pipeline end end @@ -385,13 +394,12 @@ module Ci end end - def legacy_stages + def legacy_stages_using_sql # TODO, this needs refactoring, see gitlab-foss#26481. - stages_query = statuses .group('stage').select(:stage).order('max(stage_idx)') - status_sql = statuses.latest.where('stage=sg.stage').status_sql + status_sql = statuses.latest.where('stage=sg.stage').legacy_status_sql warnings_sql = statuses.latest.select('COUNT(*)') .where('stage=sg.stage').failed_but_allowed.to_sql @@ -404,6 +412,30 @@ module Ci end end + def legacy_stages_using_composite_status + stages = statuses.latest + .order(:stage_idx, :stage) + .group_by(&:stage) + + stages.map do |stage_name, jobs| + composite_status = Gitlab::Ci::Status::Composite + .new(jobs) + + Ci::LegacyStage.new(self, + name: stage_name, + status: composite_status.status, + warnings: composite_status.warnings?) + end + end + + def legacy_stages + if Feature.enabled?(:ci_composite_status, default_enabled: false) + legacy_stages_using_composite_status + else + legacy_stages_using_sql + end + end + def valid_commit_sha if self.sha == Gitlab::Git::BLANK_SHA self.errors.add(:sha, " cant be 00000000 (branch removal)") @@ -584,11 +616,7 @@ module Ci def ci_yaml_file_path return unless repository_source? || unknown_source? - if project.ci_config_path.blank? - '.gitlab-ci.yml' - else - project.ci_config_path - end + project.ci_config_path.presence || '.gitlab-ci.yml' end def ci_yaml_file @@ -638,7 +666,8 @@ module Ci def update_status retry_optimistic_lock(self) do - case latest_builds_status.to_s + new_status = latest_builds_status.to_s + case new_status when 'created' then nil when 'preparing' then prepare when 'pending' then enqueue @@ -651,7 +680,7 @@ module Ci when 'scheduled' then delay else raise HasStatus::UnknownStatusError, - "Unknown status `#{latest_builds_status}`" + "Unknown status `#{new_status}`" end end end @@ -725,6 +754,10 @@ module Ci end end + def all_merge_requests_by_recency + all_merge_requests.order(id: :desc) + end + def detailed_status(current_user) Gitlab::Ci::Status::Pipeline::Factory .new(self, current_user) @@ -771,6 +804,18 @@ module Ci end end + def all_worktree_paths + strong_memoize(:all_worktree_paths) do + project.repository.ls_files(sha) + end + end + + def top_level_worktree_paths + strong_memoize(:top_level_worktree_paths) do + project.repository.tree(sha).blobs.map(&:path) + end + end + def default_branch? ref == project.default_branch end @@ -845,6 +890,10 @@ module Ci end end + def persistent_ref + @persistent_ref ||= PersistentRef.new(pipeline: self) + end + private def ci_yaml_from_repo @@ -894,7 +943,7 @@ module Ci def latest_builds_status return 'failed' unless yaml_errors.blank? - statuses.latest.status || 'skipped' + statuses.latest.slow_composite_status || 'skipped' end def keep_around_commits diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb index cb92aef4bda..859abc4a0d5 100644 --- a/app/models/ci/pipeline_enums.rb +++ b/app/models/ci/pipeline_enums.rb @@ -22,6 +22,7 @@ module Ci schedule: 4, api: 5, external: 6, + pipeline: 7, chat: 8, merge_request_event: 10, external_pull_request_event: 11 diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index 42d4e86fe8d..946241b7d4c 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -86,3 +86,5 @@ module Ci end end end + +Ci::PipelineSchedule.prepend_if_ee('EE::Ci::PipelineSchedule') diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb new file mode 100644 index 00000000000..feaec27281c --- /dev/null +++ b/app/models/ci/sources/pipeline.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Ci + module Sources + class Pipeline < ApplicationRecord + self.table_name = "ci_sources_pipelines" + + belongs_to :project, class_name: "Project" + belongs_to :pipeline, class_name: "Ci::Pipeline", inverse_of: :source_pipeline + + belongs_to :source_project, class_name: "Project", foreign_key: :source_project_id + belongs_to :source_job, class_name: "CommitStatus", foreign_key: :source_job_id + belongs_to :source_pipeline, class_name: "Ci::Pipeline", foreign_key: :source_pipeline_id + + validates :project, presence: true + validates :pipeline, presence: true + + validates :source_project, presence: true + validates :source_job, presence: true + validates :source_pipeline, presence: true + end + end +end + +::Ci::Sources::Pipeline.prepend_if_ee('::EE::Ci::Sources::Pipeline') diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index d90339d90dc..77ac8bfe875 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -78,7 +78,8 @@ module Ci def update_status retry_optimistic_lock(self) do - case statuses.latest.status + new_status = latest_stage_status.to_s + case new_status when 'created' then nil when 'preparing' then prepare when 'pending' then enqueue @@ -91,7 +92,7 @@ module Ci when 'skipped', nil then skip else raise HasStatus::UnknownStatusError, - "Unknown status `#{statuses.latest.status}`" + "Unknown status `#{new_status}`" end end end @@ -124,5 +125,9 @@ module Ci def manual_playable? blocked? || skipped? end + + def latest_stage_status + statuses.latest.slow_composite_status || 'skipped' + end end end diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 8792c5cf98b..68548bd2fdc 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -45,3 +45,5 @@ module Ci end end end + +Ci::Trigger.prepend_if_ee('EE::Ci::Trigger') diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb index 27d4180e5b9..18cbf827a67 100644 --- a/app/models/clusters/applications/cert_manager.rb +++ b/app/models/clusters/applications/cert_manager.rb @@ -65,7 +65,7 @@ module Clusters end def retry_command(command) - "for i in $(seq 1 30); do #{command} && break; sleep 1s; echo \"Retrying ($i)...\"; done" + "for i in $(seq 1 30); do #{command} && s=0 && break || s=$?; sleep 1s; echo \"Retrying ($i)...\"; done; (exit $s)" end def post_delete_script diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb index 455cf200fbc..4a1bcac4bb7 100644 --- a/app/models/clusters/applications/helm.rb +++ b/app/models/clusters/applications/helm.rb @@ -27,7 +27,7 @@ module Clusters def set_initial_status return unless not_installable? - self.status = 'installable' if cluster&.platform_kubernetes_active? + self.status = status_states[:installable] if cluster&.platform_kubernetes_active? end # It can only be uninstalled if there are no other applications installed @@ -68,6 +68,13 @@ module Clusters ca_key.present? && ca_cert.present? end + def post_uninstall + cluster.kubeclient.delete_namespace(Gitlab::Kubernetes::Helm::NAMESPACE) + rescue Kubeclient::ResourceNotFoundError + # we actually don't care if the namespace is not present + # since we want to delete it anyway. + end + private def files diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index 44c66f06059..885e4ff7197 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Ingress < ApplicationRecord - VERSION = '1.1.2' + VERSION = '1.22.1' self.table_name = 'clusters_applications_ingress' diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb index ec65482a846..ca93bc15be0 100644 --- a/app/models/clusters/applications/jupyter.rb +++ b/app/models/clusters/applications/jupyter.rb @@ -23,7 +23,7 @@ module Clusters return unless cluster&.application_ingress_available? ingress = cluster.application_ingress - self.status = 'installable' if ingress.external_ip_or_hostname? + self.status = status_states[:installable] if ingress.external_ip_or_hostname? end def chart diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index a9b9374622d..1093efee85a 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Knative < ApplicationRecord - VERSION = '0.6.0' + VERSION = '0.7.0' REPOSITORY = 'https://storage.googleapis.com/triggermesh-charts' METRICS_CONFIG = 'https://storage.googleapis.com/triggermesh-charts/istio-metrics.yaml' FETCH_IP_ADDRESS_DELAY = 30.seconds @@ -21,7 +21,7 @@ module Clusters return unless not_installable? return unless verify_cluster? - self.status = 'installable' + self.status = status_states[:installable] end state_machine :status do @@ -47,6 +47,10 @@ module Clusters { "domain" => hostname }.to_yaml end + def allowed_to_uninstall? + !pre_installed? + end + def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 7a414d1a5bb..5e7fdd55cb6 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -112,7 +112,12 @@ module Clusters def delete_knative_istio_metrics return [] unless cluster.application_knative_available? - [Gitlab::Kubernetes::KubectlCmd.delete("-f", Clusters::Applications::Knative::METRICS_CONFIG)] + [ + Gitlab::Kubernetes::KubectlCmd.delete( + "-f", Clusters::Applications::Knative::METRICS_CONFIG, + "--ignore-not-found" + ) + ] end end end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 2d6af8f4f0b..954046c143b 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.8.0' + VERSION = '0.9.0' self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 6a5b98a4676..d6f5d7c3f93 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -24,6 +24,7 @@ module Clusters KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN' belongs_to :user + belongs_to :management_project, class_name: '::Project', optional: true has_many :cluster_projects, class_name: 'Clusters::Project' has_many :projects, through: :cluster_projects, class_name: '::Project' @@ -34,12 +35,13 @@ module Clusters # we force autosave to happen when we save `Cluster` model has_one :provider_gcp, class_name: 'Clusters::Providers::Gcp', autosave: true + has_one :provider_aws, class_name: 'Clusters::Providers::Aws', autosave: true has_one :platform_kubernetes, class_name: 'Clusters::Platforms::Kubernetes', inverse_of: :cluster, autosave: true def self.has_one_cluster_application(name) # rubocop:disable Naming/PredicateName application = APPLICATIONS[name.to_s] - has_one application.association_name, class_name: application.to_s # rubocop:disable Rails/ReflectionClassName + has_one application.association_name, class_name: application.to_s, inverse_of: :cluster # rubocop:disable Rails/ReflectionClassName end has_one_cluster_application :helm @@ -63,6 +65,7 @@ module Clusters validate :restrict_modification, on: :update validate :no_groups, unless: :group_type? validate :no_projects, unless: :project_type? + validate :unique_management_project_environment_scope after_save :clear_reactive_cache! @@ -94,14 +97,20 @@ module Clusters enum provider_type: { user: 0, - gcp: 1 + gcp: 1, + aws: 2 } scope :enabled, -> { where(enabled: true) } scope :disabled, -> { where(enabled: false) } - scope :user_provided, -> { where(provider_type: ::Clusters::Cluster.provider_types[:user]) } - scope :gcp_provided, -> { where(provider_type: ::Clusters::Cluster.provider_types[:gcp]) } - scope :gcp_installed, -> { gcp_provided.includes(:provider_gcp).where(cluster_providers_gcp: { status: ::Clusters::Providers::Gcp.state_machines[:status].states[:created].value }) } + + scope :user_provided, -> { where(provider_type: :user) } + scope :gcp_provided, -> { where(provider_type: :gcp) } + scope :aws_provided, -> { where(provider_type: :aws) } + + scope :gcp_installed, -> { gcp_provided.joins(:provider_gcp).merge(Clusters::Providers::Gcp.with_status(:created)) } + scope :aws_installed, -> { aws_provided.joins(:provider_aws).merge(Clusters::Providers::Aws.with_status(:created)) } + scope :managed, -> { where(managed: true) } scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) } @@ -138,7 +147,11 @@ module Clusters end def provider - return provider_gcp if gcp? + if gcp? + provider_gcp + elsif aws? + provider_aws + end end def platform @@ -172,7 +185,7 @@ module Clusters persisted_namespace = Clusters::KubernetesNamespaceFinder.new( self, project: project, - environment_slug: environment.slug + environment_name: environment.name ).execute persisted_namespace&.namespace || Gitlab::Kubernetes::DefaultNamespace.new(self, project: project).from_environment_slug(environment.slug) @@ -194,8 +207,24 @@ module Clusters end end + def knative_pre_installed? + provider&.knative_pre_installed? + end + private + def unique_management_project_environment_scope + return unless management_project + + duplicate_management_clusters = management_project.management_clusters + .where(environment_scope: environment_scope) + .where.not(id: id) + + if duplicate_management_clusters.any? + errors.add(:environment_scope, "cannot add duplicated environment scope") + end + end + def instance_domain @instance_domain ||= Gitlab::CurrentSettings.auto_devops_domain end diff --git a/app/models/clusters/clusters_hierarchy.rb b/app/models/clusters/clusters_hierarchy.rb index 5556fc8d3f0..a906eb2888b 100644 --- a/app/models/clusters/clusters_hierarchy.rb +++ b/app/models/clusters/clusters_hierarchy.rb @@ -4,8 +4,9 @@ module Clusters class ClustersHierarchy DEPTH_COLUMN = :depth - def initialize(clusterable) + def initialize(clusterable, include_management_project: true) @clusterable = clusterable + @include_management_project = include_management_project end # Returns clusters in order from deepest to highest group @@ -24,7 +25,7 @@ module Clusters private - attr_reader :clusterable + attr_reader :clusterable, :include_management_project def recursive_cte cte = Gitlab::SQL::RecursiveCTE.new(:clusters_cte) @@ -38,12 +39,25 @@ module Clusters raise ArgumentError, "unknown type for #{clusterable}" end + if clusterable.is_a?(::Project) && include_management_project + cte << management_clusters_query + end + cte << base_query cte << parent_query(cte) cte end + # Management clusters should be first in the hierarchy so we use 0 for the + # depth column. + # + # group_parent_id is un-used but we still need to match the same number of + # columns as other queries in the CTE. + def management_clusters_query + clusterable.management_clusters.select([clusters_star, 'NULL AS group_parent_id', "0 AS #{DEPTH_COLUMN}"]) + end + def group_clusters_base_query group_parent_id_alias = alias_as_column(groups[:parent_id], 'group_parent_id') join_sources = ::Group.left_joins(:clusters).arel.join_sources diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb index d1b57a21a7d..979cf0645f5 100644 --- a/app/models/clusters/concerns/application_core.rb +++ b/app/models/clusters/concerns/application_core.rb @@ -15,7 +15,7 @@ module Clusters def set_initial_status return unless not_installable? - self.status = 'installable' if cluster&.application_helm_available? + self.status = status_states[:installable] if cluster&.application_helm_available? end def can_uninstall? @@ -64,3 +64,5 @@ module Clusters end end end + +Clusters::Concerns::ApplicationCore.prepend_if_ee('EE::Clusters::Concerns::ApplicationCore') diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb index 342d766f723..b63a596dfee 100644 --- a/app/models/clusters/concerns/application_status.rb +++ b/app/models/clusters/concerns/application_status.rb @@ -28,6 +28,13 @@ module Clusters state :uninstalling, value: 7 state :uninstall_errored, value: 8 + # Used for applications that are pre-installed by the cluster, + # e.g. Knative in GCP Cloud Run enabled clusters + # Because we cannot upgrade or uninstall Knative in these clusters, + # we define only one simple state transition to enter the `pre_installed` state, + # and no exit transitions. + state :pre_installed, value: 9 + event :make_scheduled do transition [:installable, :errored, :installed, :updated, :update_errored, :uninstall_errored] => :scheduled end @@ -41,6 +48,10 @@ module Clusters transition [:updating] => :updated end + event :make_pre_installed do + transition any => :pre_installed + end + event :make_errored do transition any - [:updating, :uninstalling] => :errored transition [:updating] => :update_errored @@ -90,12 +101,18 @@ module Clusters end end + def status_states + self.class.state_machines[:status].states.each_with_object({}) do |state, states| + states[state.name] = state.value + end + end + def updateable? installed? || updated? || update_errored? end def available? - installed? || updated? + pre_installed? || installed? || updated? end def update_in_progress? diff --git a/app/models/clusters/concerns/application_version.rb b/app/models/clusters/concerns/application_version.rb index db94e8e08c9..6c0b014662c 100644 --- a/app/models/clusters/concerns/application_version.rb +++ b/app/models/clusters/concerns/application_version.rb @@ -8,13 +8,13 @@ module Clusters included do state_machine :status do before_transition any => [:installed, :updated] do |application| - application.version = application.class.const_get(:VERSION) + application.version = application.class.const_get(:VERSION, false) end end end def update_available? - version != self.class.const_get(:VERSION) + version != self.class.const_get(:VERSION, false) end end end diff --git a/app/models/clusters/concerns/provider_status.rb b/app/models/clusters/concerns/provider_status.rb new file mode 100644 index 00000000000..2da1ee7aabb --- /dev/null +++ b/app/models/clusters/concerns/provider_status.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Clusters + module Concerns + module ProviderStatus + extend ActiveSupport::Concern + + included do + state_machine :status, initial: :scheduled do + state :scheduled, value: 1 + state :creating, value: 2 + state :created, value: 3 + state :errored, value: 4 + + event :make_creating do + transition any - [:creating] => :creating + end + + event :make_created do + transition any - [:created] => :created + end + + event :make_errored do + transition any - [:errored] => :errored + end + + before_transition any => [:errored, :created] do |provider| + provider.nullify_credentials + end + + before_transition any => [:creating] do |provider, transition| + operation_id = transition.args.first + provider.assign_operation_id(operation_id) if operation_id + end + + before_transition any => [:errored] do |provider, transition| + status_reason = transition.args.first + provider.status_reason = status_reason if status_reason + end + end + + def on_creation? + scheduled? || creating? + end + + def assign_operation_id(_) + # Implemented by individual providers if operation ID is supported. + end + end + end + end +end diff --git a/app/models/clusters/kubernetes_namespace.rb b/app/models/clusters/kubernetes_namespace.rb index 69a2b99fcb6..42332bdc193 100644 --- a/app/models/clusters/kubernetes_namespace.rb +++ b/app/models/clusters/kubernetes_namespace.rb @@ -27,7 +27,7 @@ module Clusters algorithm: 'aes-256-cbc' scope :has_service_account_token, -> { where.not(encrypted_service_account_token: nil) } - scope :with_environment_slug, -> (slug) { joins(:environment).where(environments: { slug: slug }) } + scope :with_environment_name, -> (name) { joins(:environment).where(environments: { name: name }) } def token_name "#{namespace}-token" diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 89b50d8e8ff..314ef78757d 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -6,6 +6,7 @@ module Clusters include Gitlab::Kubernetes include EnumWithNil include AfterCommitQueue + include ReactiveCaching RESERVED_NAMESPACES = %w(gitlab-managed-apps).freeze @@ -23,11 +24,12 @@ module Clusters key: Settings.attr_encrypted_db_key_base_truncated, algorithm: 'aes-256-cbc' + before_validation :nullify_blank_namespace before_validation :enforce_namespace_to_lower_case before_validation :enforce_ca_whitespace_trimming validates :namespace, - allow_blank: true, + allow_nil: true, length: 1..63, format: { with: Gitlab::Regex.kubernetes_namespace_regex, @@ -71,7 +73,7 @@ module Clusters .append(key: 'KUBE_CA_PEM_FILE', value: ca_pem, file: true) end - if !cluster.managed? + if !cluster.managed? || cluster.management_project == project namespace = Gitlab::Kubernetes::DefaultNamespace.new(cluster, project: project).from_environment_name(environment_name) variables @@ -105,19 +107,11 @@ module Clusters private - ## - # Environment slug can be predicted given an environment - # name, so even if the environment isn't persisted yet we - # still know what to look for. - def environment_slug(name) - Gitlab::Slug::Environment.new(name).generate - end - def find_persisted_namespace(project, environment_name:) Clusters::KubernetesNamespaceFinder.new( cluster, project: project, - environment_slug: environment_slug(environment_name) + environment_name: environment_name ).execute end @@ -198,6 +192,10 @@ module Clusters true end + + def nullify_blank_namespace + self.namespace = nil if namespace.blank? + end end end end diff --git a/app/models/clusters/providers/aws.rb b/app/models/clusters/providers/aws.rb new file mode 100644 index 00000000000..ae4156896bc --- /dev/null +++ b/app/models/clusters/providers/aws.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Clusters + module Providers + class Aws < ApplicationRecord + include Clusters::Concerns::ProviderStatus + + self.table_name = 'cluster_providers_aws' + + belongs_to :cluster, inverse_of: :provider_aws, class_name: 'Clusters::Cluster' + belongs_to :created_by_user, class_name: 'User' + + default_value_for :region, 'us-east-1' + default_value_for :num_nodes, 3 + default_value_for :instance_type, 'm5.large' + + attr_encrypted :secret_access_key, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm' + + validates :role_arn, + length: 1..2048, + format: { + with: Gitlab::Regex.aws_arn_regex, + message: Gitlab::Regex.aws_arn_regex_message + } + + validates :num_nodes, + numericality: { + only_integer: true, + greater_than: 0 + } + + validates :key_name, :region, :instance_type, :security_group_id, length: { in: 1..255 } + validates :subnet_ids, presence: true + + def nullify_credentials + assign_attributes( + access_key_id: nil, + secret_access_key: nil, + session_token: nil + ) + end + end + end +end diff --git a/app/models/clusters/providers/gcp.rb b/app/models/clusters/providers/gcp.rb index 390748bf252..f871674676f 100644 --- a/app/models/clusters/providers/gcp.rb +++ b/app/models/clusters/providers/gcp.rb @@ -3,6 +3,8 @@ module Clusters module Providers class Gcp < ApplicationRecord + include Clusters::Concerns::ProviderStatus + self.table_name = 'cluster_providers_gcp' belongs_to :cluster, inverse_of: :provider_gcp, class_name: 'Clusters::Cluster' @@ -10,6 +12,9 @@ module Clusters default_value_for :zone, 'us-central1-a' default_value_for :num_nodes, 3 default_value_for :machine_type, 'n1-standard-2' + default_value_for :cloud_run, false + + scope :cloud_run, -> { where(cloud_run: true) } attr_encrypted :access_token, mode: :per_attribute_iv, @@ -32,50 +37,25 @@ module Clusters greater_than: 0 } - state_machine :status, initial: :scheduled do - state :scheduled, value: 1 - state :creating, value: 2 - state :created, value: 3 - state :errored, value: 4 - - event :make_creating do - transition any - [:creating] => :creating - end - - event :make_created do - transition any - [:created] => :created - end - - event :make_errored do - transition any - [:errored] => :errored - end - - before_transition any => [:errored, :created] do |provider| - provider.access_token = nil - provider.operation_id = nil - end - - before_transition any => [:creating] do |provider, transition| - operation_id = transition.args.first - raise ArgumentError.new('operation_id is required') unless operation_id.present? - - provider.operation_id = operation_id - end + def api_client + return unless access_token - before_transition any => [:errored] do |provider, transition| - status_reason = transition.args.first - provider.status_reason = status_reason if status_reason - end + @api_client ||= GoogleApi::CloudPlatform::Client.new(access_token, nil) end - def on_creation? - scheduled? || creating? + def nullify_credentials + assign_attributes( + access_token: nil, + operation_id: nil + ) end - def api_client - return unless access_token + def assign_operation_id(operation_id) + assign_attributes(operation_id: operation_id) + end - @api_client ||= GoogleApi::CloudPlatform::Client.new(access_token, nil) + def knative_pre_installed? + cloud_run? end end end diff --git a/app/models/commit.rb b/app/models/commit.rb index a442f607fbf..aae49c36899 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -119,10 +119,22 @@ class Commit @raw = raw_commit @project = project - @statuses = {} @gpg_commit = Gitlab::Gpg::Commit.new(self) if project end + delegate \ + :pipelines, + :last_pipeline, + :latest_pipeline, + :latest_pipeline_for_project, + :set_latest_pipeline_for_ref, + :status, + to: :with_pipeline + + def with_pipeline + @with_pipeline ||= CommitWithPipeline.new(self) + end + def id raw.id end @@ -245,10 +257,9 @@ class Commit end def author - # We use __sync so that we get the actual objects back (including an actual - # nil), instead of a wrapper, as returning a wrapped nil breaks a lot of - # code. - lazy_author.__sync + strong_memoize(:author) do + lazy_author&.itself + end end request_cache(:author) { author_email.downcase } @@ -301,30 +312,6 @@ class Commit ) end - def pipelines - project.ci_pipelines.where(sha: sha) - end - - def last_pipeline - strong_memoize(:last_pipeline) do - pipelines.last - end - end - - def status(ref = nil) - return @statuses[ref] if @statuses.key?(ref) - - @statuses[ref] = status_for_project(ref, project) - end - - def status_for_project(ref, pipeline_project) - pipeline_project.ci_pipelines.latest_status_per_commit(id, ref)[id] - end - - def set_status_for_ref(ref, status) - @statuses[ref] = status - end - def signature return @signature if defined?(@signature) @@ -427,7 +414,7 @@ class Commit if entry[:type] == :blob blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name]), @project) - blob.image? || blob.video? ? :raw : :blob + blob.image? || blob.video? || blob.audio? ? :raw : :blob else entry[:type] end diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb index e8df46e1cc3..d4c29aa295b 100644 --- a/app/models/commit_collection.rb +++ b/app/models/commit_collection.rb @@ -34,6 +34,20 @@ class CommitCollection end end + # Returns the collection with the latest pipeline for every commit pre-set. + # + # Setting the pipeline for each commit ahead of time removes the need for running + # a query for every commit we're displaying. + def with_latest_pipeline(ref = nil) + pipelines = project.ci_pipelines.latest_pipeline_per_commit(map(&:id), ref) + + each do |commit| + commit.set_latest_pipeline_for_ref(ref, pipelines[commit.id]) + end + + self + end + def unenriched commits.reject(&:gitaly_commit?) end @@ -58,22 +72,15 @@ class CommitCollection end.compact] # Replace the commits, keeping the same order - @commits = @commits.map do |c| - replacements.fetch(c.id, c) - end - - self - end - - # Sets the pipeline status for every commit. - # - # Setting this status ahead of time removes the need for running a query for - # every commit we're displaying. - def with_pipeline_status - statuses = project.ci_pipelines.latest_status_per_commit(map(&:id), ref) - - each do |commit| - commit.set_status_for_ref(ref, statuses[commit.id]) + @commits = @commits.map do |original_commit| + # Return the original instance: if it didn't need to be batchloaded, it was + # already enriched. + batch_loaded_commit = replacements.fetch(original_commit.id, original_commit) + + # If batch loading the commit failed, fall back to the original commit. + # We need to explicitly check `.nil?` since otherwise a `BatchLoader` instance + # that looks like `nil` is returned. + batch_loaded_commit.nil? ? original_commit : batch_loaded_commit end self diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 5d9d3179f9d..39a6247b3b2 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -48,6 +48,10 @@ class CommitStatus < ApplicationRecord scope :processables, -> { where(type: %w[Ci::Build Ci::Bridge]) } scope :for_ids, -> (ids) { where(id: ids) } + scope :with_preloads, -> do + preload(:project, :user) + end + scope :with_needs, -> (names = nil) do needs = Ci::BuildNeed.scoped_build.select(1) needs = needs.where(name: names) if names @@ -161,11 +165,11 @@ class CommitStatus < ApplicationRecord end def self.status_for_prior_stages(index) - before_stage(index).latest.status || 'success' + before_stage(index).latest.slow_composite_status || 'success' end def self.status_for_names(names) - where(name: names).latest.status || 'success' + where(name: names).latest.slow_composite_status || 'success' end def locking_enabled? diff --git a/app/models/commit_with_pipeline.rb b/app/models/commit_with_pipeline.rb new file mode 100644 index 00000000000..f382ae8f55a --- /dev/null +++ b/app/models/commit_with_pipeline.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class CommitWithPipeline < SimpleDelegator + include Presentable + + def initialize(commit) + @latest_pipelines = {} + super(commit) + end + + def pipelines + project.ci_pipelines.where(sha: sha) + end + + def last_pipeline + strong_memoize(:last_pipeline) do + pipelines.last + end + end + + def latest_pipeline(ref = nil) + @latest_pipelines.fetch(ref) do |ref| + @latest_pipelines[ref] = latest_pipeline_for_project(ref, project) + end + end + + def latest_pipeline_for_project(ref, pipeline_project) + pipeline_project.ci_pipelines.latest_pipeline_per_commit(id, ref)[id] + end + + def set_latest_pipeline_for_ref(ref, pipeline) + @latest_pipelines[ref] = pipeline + end + + def status(ref = nil) + latest_pipeline(ref)&.status + end +end diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb index 0c603c2d5e6..54e9a13d1ea 100644 --- a/app/models/concerns/analytics/cycle_analytics/stage.rb +++ b/app/models/concerns/analytics/cycle_analytics/stage.rb @@ -7,6 +7,7 @@ module Analytics included do validates :name, presence: true + validates :name, exclusion: { in: Gitlab::Analytics::CycleAnalytics::DefaultStages.names }, if: :custom? validates :start_event_identifier, presence: true validates :end_event_identifier, presence: true validate :validate_stage_event_pairs @@ -15,6 +16,7 @@ module Analytics enum end_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum, _prefix: :end_event_identifier alias_attribute :custom_stage?, :custom + scope :default_stages, -> { where(custom: false) } end def parent=(_) @@ -45,11 +47,17 @@ module Analytics !custom end - # The model that is going to be queried, Issue or MergeRequest - def subject_model + # The model class that is going to be queried, Issue or MergeRequest + def subject_class start_event.object_type end + def matches_with_stage_params?(stage_params) + default_stage? && + start_event_identifier.to_s.eql?(stage_params[:start_event_identifier].to_s) && + end_event_identifier.to_s.eql?(stage_params[:end_event_identifier].to_s) + end + private def validate_stage_event_pairs diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index dc1735a7e48..64df265dc25 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -27,40 +27,73 @@ module AtomicInternalId extend ActiveSupport::Concern class_methods do - def has_internal_id(column, scope:, init:, presence: true) # rubocop:disable Naming/PredicateName + def has_internal_id(column, scope:, init:, ensure_if: nil, presence: true) # rubocop:disable Naming/PredicateName # We require init here to retain the ability to recalculate in the absence of a # InternaLId record (we may delete records in `internal_ids` for example). raise "has_internal_id requires a init block, none given." unless init + raise "has_internal_id needs to be defined on association." unless self.reflect_on_association(scope) - before_validation :"ensure_#{scope}_#{column}!", on: :create + before_validation :"track_#{scope}_#{column}!", on: :create + before_validation :"ensure_#{scope}_#{column}!", on: :create, if: ensure_if validates column, presence: presence define_method("ensure_#{scope}_#{column}!") do - scope_value = association(scope).reader + scope_value = internal_id_read_scope(scope) value = read_attribute(column) - return value unless scope_value - scope_attrs = { scope_value.class.table_name.singularize.to_sym => scope_value } - usage = self.class.table_name.to_sym - - if value.present? - InternalId.track_greatest(self, scope_attrs, usage, value, init) - else - value = InternalId.generate_next(self, scope_attrs, usage, init) + if value.nil? + # We don't have a value yet and use a InternalId record to generate + # the next value. + value = InternalId.generate_next( + self, + internal_id_scope_attrs(scope), + internal_id_scope_usage, + init) write_attribute(column, value) end value end + define_method("track_#{scope}_#{column}!") do + return unless @internal_id_needs_tracking + + scope_value = internal_id_read_scope(scope) + return unless scope_value + + value = read_attribute(column) + + if value.present? + # The value was set externally, e.g. by the user + # We update the InternalId record to keep track of the greatest value. + InternalId.track_greatest( + self, + internal_id_scope_attrs(scope), + internal_id_scope_usage, + value, + init) + + @internal_id_needs_tracking = false + end + end + + define_method("#{column}=") do |value| + super(value).tap do |v| + # Indicate the iid was set from externally + @internal_id_needs_tracking = true + end + end + define_method("reset_#{scope}_#{column}") do if value = read_attribute(column) - scope_value = association(scope).reader - scope_attrs = { scope_value.class.table_name.singularize.to_sym => scope_value } - usage = self.class.table_name.to_sym + did_reset = InternalId.reset( + self, + internal_id_scope_attrs(scope), + internal_id_scope_usage, + value) - if InternalId.reset(self, scope_attrs, usage, value) + if did_reset write_attribute(column, nil) end end @@ -69,4 +102,18 @@ module AtomicInternalId end end end + + def internal_id_scope_attrs(scope) + scope_value = internal_id_read_scope(scope) + + { scope_value.class.table_name.singularize.to_sym => scope_value } if scope_value + end + + def internal_id_scope_usage + self.class.table_name.to_sym + end + + def internal_id_read_scope(scope) + association(scope).reader + end end diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index 269145309fc..a98baeb0e3d 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -38,7 +38,7 @@ module Avatarable def avatar_type unless self.avatar.image? - errors.add :avatar, "file format is not supported. Please try one of the following supported formats: #{AvatarUploader::IMAGE_EXT.join(', ')}" + errors.add :avatar, "file format is not supported. Please try one of the following supported formats: #{AvatarUploader::SAFE_IMAGE_EXT.join(', ')}" end end diff --git a/app/models/concerns/checksummable.rb b/app/models/concerns/checksummable.rb new file mode 100644 index 00000000000..1f76eb87aa5 --- /dev/null +++ b/app/models/concerns/checksummable.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Checksummable + extend ActiveSupport::Concern + + class_methods do + def hexdigest(path) + Digest::SHA256.file(path).hexdigest + end + end +end diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb index 91dda803031..49d6f3d399c 100644 --- a/app/models/concerns/ci/contextable.rb +++ b/app/models/concerns/ci/contextable.rb @@ -78,6 +78,7 @@ module Ci variables.append(key: "CI_JOB_MANUAL", value: 'true') if action? variables.append(key: "CI_NODE_INDEX", value: self.options[:instance].to_s) if self.options&.include?(:instance) variables.append(key: "CI_NODE_TOTAL", value: (self.options&.dig(:parallel) || 1).to_s) + variables.append(key: "CI_DEFAULT_BRANCH", value: project.default_branch) variables.concat(legacy_variables) end end diff --git a/app/models/concerns/ci/pipeline_delegator.rb b/app/models/concerns/ci/pipeline_delegator.rb index dbc5ed1bc9a..76e0cbc7dff 100644 --- a/app/models/concerns/ci/pipeline_delegator.rb +++ b/app/models/concerns/ci/pipeline_delegator.rb @@ -15,7 +15,8 @@ module Ci :merge_request_ref?, :source_ref, :source_ref_slug, - :legacy_detached_merge_request_pipeline?, to: :pipeline + :legacy_detached_merge_request_pipeline?, + :merge_train_pipeline?, to: :pipeline end end end diff --git a/app/models/concerns/deployable.rb b/app/models/concerns/deployable.rb deleted file mode 100644 index 957b72f3721..00000000000 --- a/app/models/concerns/deployable.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -module Deployable - extend ActiveSupport::Concern - - included do - after_create :create_deployment - - def create_deployment - return unless starts_environment? && !has_deployment? - - environment = project.environments.find_or_create_by( - name: expanded_environment_name - ) - - # If we failed to persist envirionment record by validation error, such as name with invalid character, - # the job will fall back to a non-environment job. - return unless environment.persisted? - - create_deployment!( - cluster_id: environment.deployment_platform&.cluster_id, - project_id: environment.project_id, - environment: environment, - ref: ref, - tag: tag, - sha: sha, - user: user, - on_stop: on_stop) - end - end -end diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb index e1a8725e728..fe8e9609820 100644 --- a/app/models/concerns/deployment_platform.rb +++ b/app/models/concerns/deployment_platform.rb @@ -11,6 +11,10 @@ module DeploymentPlatform private + def cluster_management_project_enabled? + Feature.enabled?(:cluster_management_project, default_enabled: true) + end + def find_deployment_platform(environment) find_platform_kubernetes_with_cte(environment) || find_instance_cluster_platform_kubernetes(environment: environment) @@ -18,7 +22,7 @@ module DeploymentPlatform # EE would override this and utilize environment argument def find_platform_kubernetes_with_cte(_environment) - Clusters::ClustersHierarchy.new(self).base_and_ancestors + Clusters::ClustersHierarchy.new(self, include_management_project: cluster_management_project_enabled?).base_and_ancestors .enabled.default_environment .first&.platform_kubernetes end diff --git a/app/models/concerns/group_api_compatibility.rb b/app/models/concerns/group_api_compatibility.rb new file mode 100644 index 00000000000..f02aa2035e5 --- /dev/null +++ b/app/models/concerns/group_api_compatibility.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# Add methods used by the groups API +module GroupAPICompatibility + extend ActiveSupport::Concern + + def project_creation_level_str + ::Gitlab::Access.project_creation_string_options.key(project_creation_level) + end + + def project_creation_level_str=(value) + write_attribute(:project_creation_level, ::Gitlab::Access.project_creation_string_options.fetch(value)) + end + + def subgroup_creation_level_str + ::Gitlab::Access.subgroup_creation_string_options.key(subgroup_creation_level) + end + + def subgroup_creation_level_str=(value) + write_attribute(:subgroup_creation_level, ::Gitlab::Access.subgroup_creation_string_options.fetch(value)) + end +end diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb index bcbbb27a9a8..c01fb4740e5 100644 --- a/app/models/concerns/has_status.rb +++ b/app/models/concerns/has_status.rb @@ -10,6 +10,8 @@ module HasStatus ACTIVE_STATUSES = %w[preparing pending running].freeze COMPLETED_STATUSES = %w[success failed canceled skipped].freeze ORDERED_STATUSES = %w[failed preparing pending running manual scheduled canceled success skipped created].freeze + PASSED_WITH_WARNINGS_STATUSES = %w[failed canceled].to_set.freeze + EXCLUDE_IGNORED_STATUSES = %w[manual failed canceled].to_set.freeze STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3, failed: 4, canceled: 5, skipped: 6, manual: 7, scheduled: 8, preparing: 9 }.freeze @@ -17,7 +19,7 @@ module HasStatus UnknownStatusError = Class.new(StandardError) class_methods do - def status_sql + def legacy_status_sql scope_relevant = respond_to?(:exclude_ignored) ? exclude_ignored : all scope_warnings = respond_to?(:failed_but_allowed) ? failed_but_allowed : none @@ -53,8 +55,22 @@ module HasStatus ) end - def status - all.pluck(status_sql).first + def legacy_status + all.pluck(legacy_status_sql).first + end + + # This method should not be used. + # This method performs expensive calculation of status: + # 1. By plucking all related objects, + # 2. Or executes expensive SQL query + def slow_composite_status + if Feature.enabled?(:ci_composite_status, default_enabled: false) + Gitlab::Ci::Status::Composite + .new(all, with_allow_failure: columns_hash.key?('allow_failure')) + .status + else + legacy_status + end end def started_at diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index d02f3731cc2..852576dbbc2 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -4,7 +4,7 @@ # # Contains common functionality shared between Issues and MergeRequests # -# Used by Issue, MergeRequest +# Used by Issue, MergeRequest, Epic # module Issuable extend ActiveSupport::Concern @@ -25,6 +25,19 @@ module Issuable include UpdatedAtFilterable include IssuableStates include ClosedAtFilterable + include VersionedDescription + + TITLE_LENGTH_MAX = 255 + TITLE_HTML_LENGTH_MAX = 800 + DESCRIPTION_LENGTH_MAX = 1.megabyte + DESCRIPTION_HTML_LENGTH_MAX = 5.megabytes + + STATE_ID_MAP = { + opened: 1, + closed: 2, + merged: 3, + locked: 4 + }.with_indifferent_access.freeze # This object is used to gather issuable meta data for displaying # upvotes, downvotes, notes and closing merge requests count for issues and merge requests @@ -72,10 +85,15 @@ module Issuable prefix: true validates :author, presence: true - validates :title, presence: true, length: { maximum: 255 } - validates :description, length: { maximum: Gitlab::Database::MAX_TEXT_SIZE_LIMIT }, allow_blank: true + validates :title, presence: true, length: { maximum: TITLE_LENGTH_MAX } + # we validate the description against DESCRIPTION_LENGTH_MAX only for Issuables being created + # to avoid breaking the existing Issuables which may have their descriptions longer + validates :description, length: { maximum: DESCRIPTION_LENGTH_MAX }, allow_blank: true, on: :create + validate :description_max_length_for_new_records_is_valid, on: :update validate :milestone_is_valid + before_validation :truncate_description_on_import! + scope :authored, ->(user) { where(author_id: user) } scope :recent, -> { reorder(id: :desc) } scope :of_projects, ->(ids) { where(project_id: ids) } @@ -138,6 +156,16 @@ module Issuable def milestone_is_valid errors.add(:milestone_id, message: "is invalid") if milestone_id.present? && !milestone_available? end + + def description_max_length_for_new_records_is_valid + if new_record? && description.length > Issuable::DESCRIPTION_LENGTH_MAX + errors.add(:description, :too_long, count: Issuable::DESCRIPTION_LENGTH_MAX) + end + end + + def truncate_description_on_import! + self.description = description&.slice(0, Issuable::DESCRIPTION_LENGTH_MAX) if importing? + end end class_methods do @@ -152,13 +180,17 @@ module Issuable fuzzy_search(query, [:title]) end - # Available state values persisted in state_id column using state machine + def available_states + @available_states ||= STATE_ID_MAP.slice(*available_state_names) + end + + # Available state names used to persist state_id column using state machine # # Override this on subclasses if different states are needed # - # Check MergeRequest.available_states for example - def available_states - @available_states ||= { opened: 1, closed: 2 }.with_indifferent_access + # Check MergeRequest.available_states_names for example + def available_state_names + [:opened, :closed] end # Searches for records with a matching title or description. @@ -277,6 +309,14 @@ module Issuable end end + def state + self.class.available_states.key(state_id) + end + + def state=(value) + self.state_id = self.class.available_states[value] + end + def resource_parent project end diff --git a/app/models/concerns/issuable_states.rb b/app/models/concerns/issuable_states.rb index 33bc41d7f44..f0b9f0d1f3a 100644 --- a/app/models/concerns/issuable_states.rb +++ b/app/models/concerns/issuable_states.rb @@ -4,22 +4,20 @@ module IssuableStates extend ActiveSupport::Concern # The state:string column is being migrated to state_id:integer column - # This is a temporary hook to populate state_id column with new values - # and should be removed after the state column is removed. - # Check https://gitlab.com/gitlab-org/gitlab-foss/issues/51789 for more information + # This is a temporary hook to keep state column in sync until it is removed. + # Check https: https://gitlab.com/gitlab-org/gitlab/issues/33814 for more information + # The state column can be safely removed after 2019-10-27 included do - before_save :set_state_id + before_save :sync_issuable_deprecated_state end - def set_state_id - return if state.nil? || state.empty? + def sync_issuable_deprecated_state + return if self.is_a?(Epic) + return unless respond_to?(:state) + return if state_id.nil? - # Needed to prevent breaking some migration specs that - # rollback database to a point where state_id does not exist. - # We can use this guard clause for now since this file will - # be removed in the next release. - return unless self.has_attribute?(:state_id) + deprecated_state = self.class.available_states.key(state_id) - self.state_id = self.class.available_states[state] + self.write_attribute(:state, deprecated_state) end end diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 377600ef6e5..9b6c57261d8 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -150,7 +150,7 @@ module Mentionable # # Returns a Hash. def detect_mentionable_changes - source = (changes.present? ? changes : previous_changes).dup + source = (changes.presence || previous_changes).dup mentionable = self.class.mentionable_attrs.map { |attr, options| attr } diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index 3deb86da6cf..42b370990ac 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -6,7 +6,9 @@ module Milestoneish end def closed_issues_count(user) - count_issues_by_state(user)['closed'].to_i + closed_state_id = Issue.available_states[:closed] + + count_issues_by_state(user)[closed_state_id].to_i end def complete?(user) @@ -117,7 +119,7 @@ module Milestoneish def count_issues_by_state(user) memoize_per_user(user, :count_issues_by_state) do - issues_visible_to_user(user).reorder(nil).group(:state).count + issues_visible_to_user(user).reorder(nil).group(:state_id).count end end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index 6caa23ef9b7..3065e0ba6c5 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -7,6 +7,8 @@ module Noteable # avoiding n+1 queries and improving performance. NoteableMeta = Struct.new(:user_notes_count) + MAX_NOTES_LIMIT = 5_000 + class_methods do # `Noteable` class names that support replying to individual notes. def replyable_types diff --git a/app/models/concerns/notification_branch_selection.rb b/app/models/concerns/notification_branch_selection.rb index d8e18de7551..7f00b652530 100644 --- a/app/models/concerns/notification_branch_selection.rb +++ b/app/models/concerns/notification_branch_selection.rb @@ -21,7 +21,7 @@ module NotificationBranchSelection end is_default_branch = ref == project.default_branch - is_protected_branch = project.protected_branches.exists?(name: ref) + is_protected_branch = ProtectedBranch.protected?(project, ref) case branches_to_be_notified when "all" diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb index aab0589f7ca..9df77b565da 100644 --- a/app/models/concerns/prometheus_adapter.rb +++ b/app/models/concerns/prometheus_adapter.rb @@ -44,7 +44,7 @@ module PrometheusAdapter end def query_klass_for(query_name) - Gitlab::Prometheus::Queries.const_get("#{query_name.to_s.classify}Query") + Gitlab::Prometheus::Queries.const_get("#{query_name.to_s.classify}Query", false) end def build_query_args(*args) diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb index dfe3c391880..b645cf71443 100644 --- a/app/models/concerns/relative_positioning.rb +++ b/app/models/concerns/relative_positioning.rb @@ -127,6 +127,7 @@ module RelativePositioning if pos_after && (pos_after - pos_before) < 2 before.move_sequence_after + pos_after = before.next_relative_position end self.relative_position = self.class.position_between(pos_before, pos_after) @@ -138,6 +139,7 @@ module RelativePositioning if pos_before && (pos_after - pos_before) < 2 after.move_sequence_before + pos_before = after.prev_relative_position end self.relative_position = self.class.position_between(pos_before, pos_after) diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index bdd87437e2a..129d0fbb2c0 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -51,14 +51,21 @@ module Routable # Klass.where_full_path_in(%w{gitlab-org/gitlab-foss gitlab-org/gitlab}) # # Returns an ActiveRecord::Relation. - def where_full_path_in(paths) + def where_full_path_in(paths, use_includes: true) return none if paths.empty? wheres = paths.map do |path| "(LOWER(routes.path) = LOWER(#{connection.quote(path)}))" end - includes(:route).where(wheres.join(' OR ')).references(:routes) + route = + if use_includes + includes(:route).references(:routes) + else + joins(:route) + end + + route.where(wheres.join(' OR ')) end end diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index 3ff4b4046d3..10bbeecc2f7 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -80,4 +80,9 @@ module Spammable def check_for_spam? true end + + # Override in Spammable if differs + def allow_possible_spam? + Feature.enabled?(:allow_possible_spam, project) + end end diff --git a/app/models/concerns/stepable.rb b/app/models/concerns/stepable.rb index d00a049a004..dea241c5dbe 100644 --- a/app/models/concerns/stepable.rb +++ b/app/models/concerns/stepable.rb @@ -11,15 +11,15 @@ module Stepable initial_result = {} steps.inject(initial_result) do |previous_result, callback| - result = method(callback).call + result = method(callback).call(previous_result) - if result[:status] == :error - result[:failed_step] = callback + if result[:status] != :success + result[:last_step] = callback break result end - previous_result.merge(result) + result end end diff --git a/app/models/concerns/versioned_description.rb b/app/models/concerns/versioned_description.rb new file mode 100644 index 00000000000..63a24aadc8a --- /dev/null +++ b/app/models/concerns/versioned_description.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module VersionedDescription + extend ActiveSupport::Concern + + included do + attr_accessor :saved_description_version + + has_many :description_versions + + after_update :save_description_version + end + + private + + def save_description_version + self.saved_description_version = nil + + return unless Feature.enabled?(:save_description_versions, issuing_parent) + return unless saved_change_to_description? + + unless description_versions.exists? + description_versions.create!( + description: description_before_last_save, + created_at: created_at + ) + end + + self.saved_description_version = description_versions.create!(description: description) + end +end diff --git a/app/models/concerns/worker_attributes.rb b/app/models/concerns/worker_attributes.rb new file mode 100644 index 00000000000..af40e9e3b19 --- /dev/null +++ b/app/models/concerns/worker_attributes.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module WorkerAttributes + extend ActiveSupport::Concern + + class_methods do + def feature_category(value) + raise "Invalid category. Use `feature_category_not_owned!` to mark a worker as not owned" if value == :not_owned + + worker_attributes[:feature_category] = value + end + + # Special case: mark this work as not associated with a feature category + # this should be used for cross-cutting concerns, such as mailer workers. + def feature_category_not_owned! + worker_attributes[:feature_category] = :not_owned + end + + def get_feature_category + get_worker_attribute(:feature_category) + end + + def feature_category_not_owned? + get_worker_attribute(:feature_category) == :not_owned + end + + protected + + # Returns a worker attribute declared on this class or its parent class. + # This approach allows declared attributes to be inherited by + # child classes. + def get_worker_attribute(name) + worker_attributes[name] || superclass_worker_attributes(name) + end + + private + + def worker_attributes + @attributes ||= {} + end + + def superclass_worker_attributes(name) + return unless superclass.include? WorkerAttributes + + superclass.get_worker_attribute(name) + end + end +end diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 583e23d1274..27bb76835c7 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -11,6 +11,7 @@ class ContainerRepository < ApplicationRecord delegate :client, to: :registry scope :ordered, -> { order(:name) } + scope :with_api_entity_associations, -> { preload(:project) } # rubocop: disable CodeReuse/ServiceClass def registry @@ -67,11 +68,9 @@ class ContainerRepository < ApplicationRecord def delete_tags! return unless has_tags? - digests = tags.map { |tag| tag.digest }.to_set + digests = tags.map { |tag| tag.digest }.compact.to_set - digests.all? do |digest| - delete_tag_by_digest(digest) - end + digests.map(&method(:delete_tag_by_digest)).all? end def delete_tag_by_digest(digest) diff --git a/app/models/cycle_analytics/project_level.rb b/app/models/cycle_analytics/project_level.rb index 4aa426c58a1..591435baf34 100644 --- a/app/models/cycle_analytics/project_level.rb +++ b/app/models/cycle_analytics/project_level.rb @@ -13,6 +13,7 @@ module CycleAnalytics def summary @summary ||= ::Gitlab::CycleAnalytics::StageSummary.new(project, from: options[:from], + to: options[:to], current_user: options[:current_user]).data end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index db7f9e06362..7ccd5e98360 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -9,7 +9,7 @@ class Deployment < ApplicationRecord belongs_to :environment, required: true belongs_to :cluster, class_name: 'Clusters::Cluster', optional: true belongs_to :user - belongs_to :deployable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations + belongs_to :deployable, polymorphic: true, optional: true # rubocop:disable Cop/PolymorphicAssociations has_internal_id :iid, scope: :project, init: ->(s) do Deployment.where(project: s.project).maximum(:iid) if s&.project @@ -22,6 +22,8 @@ class Deployment < ApplicationRecord scope :for_environment, -> (environment) { where(environment_id: environment) } + scope :visible, -> { where(status: %i[running success failed canceled]) } + state_machine :status, initial: :created do event :run do transition created: :running @@ -73,6 +75,10 @@ class Deployment < ApplicationRecord find(ids) end + def self.find_successful_deployment!(iid) + success.find_by!(iid: iid) + end + def commit project.commit(sha) end @@ -180,3 +186,5 @@ class Deployment < ApplicationRecord self.created_at if success? && !read_attribute(:finished_at) end end + +Deployment.prepend_if_ee('EE::Deployment') diff --git a/app/models/description_version.rb b/app/models/description_version.rb new file mode 100644 index 00000000000..abab7f94212 --- /dev/null +++ b/app/models/description_version.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class DescriptionVersion < ApplicationRecord + belongs_to :issue + belongs_to :merge_request + + validate :exactly_one_issuable + + def self.issuable_attrs + %i(issue merge_request).freeze + end + + private + + def exactly_one_issuable + issuable_count = self.class.issuable_attrs.count { |attr| self["#{attr}_id"] } + + errors.add(:base, "Exactly one of #{self.class.issuable_attrs.join(', ')} is required") if issuable_count != 1 + end +end + +DescriptionVersion.prepend_if_ee('EE::DescriptionVersion') diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index aa7286a9971..65e87bb08a7 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -75,6 +75,10 @@ class DiffNote < Note self.original_position.diff_refs == diff_refs end + # Checks if the current `position` line in the diff + # exists and is suggestible (not a deletion). + # + # Avoid using in iterations as it requests Gitaly. def supports_suggestion? return false unless noteable&.supports_suggestion? && on_text? # We don't want to trigger side-effects of `diff_file` call. diff --git a/app/models/diff_viewer/image.rb b/app/models/diff_viewer/image.rb index 350bef1d42a..cfda0058d81 100644 --- a/app/models/diff_viewer/image.rb +++ b/app/models/diff_viewer/image.rb @@ -6,7 +6,7 @@ module DiffViewer include ClientSide self.partial_name = 'image' - self.extensions = UploaderHelper::IMAGE_EXT + self.extensions = UploaderHelper::SAFE_IMAGE_EXT self.binary = true self.switcher_icon = 'picture-o' self.switcher_title = _('image diff') diff --git a/app/models/environment.rb b/app/models/environment.rb index fe438b142b2..af0c219d9a0 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -6,7 +6,8 @@ class Environment < ApplicationRecord belongs_to :project, required: true - has_many :deployments, -> { success }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :deployments, -> { visible }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :successful_deployments, -> { success }, class_name: 'Deployment' has_one :last_deployment, -> { success.order('deployments.id DESC') }, class_name: 'Deployment' @@ -81,6 +82,10 @@ class Environment < ApplicationRecord pluck(:name) end + def self.find_or_create_by_name(name) + find_or_create_by(name: name) + end + def predefined_variables Gitlab::Ci::Variables::Collection.new .append(key: 'CI_ENVIRONMENT_NAME', value: name) diff --git a/app/models/event.rb b/app/models/event.rb index 205e1f71c74..9611019adb8 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -77,15 +77,6 @@ class Event < ApplicationRecord scope :recent, -> { reorder(id: :desc) } scope :code_push, -> { where(action: PUSHED) } - scope :in_projects, -> (projects) do - sub_query = projects - .except(:order) - .select(1) - .where('projects.id = events.project_id') - - where('EXISTS (?)', sub_query).recent - end - scope :with_associations, -> do # We're using preload for "push_event_payload" as otherwise the association # is not always available (depending on the query being built). diff --git a/app/models/event_collection.rb b/app/models/event_collection.rb index a4c69b11781..4778f74568e 100644 --- a/app/models/event_collection.rb +++ b/app/models/event_collection.rb @@ -6,6 +6,8 @@ # in a controller), it's not suitable for building queries that are used for # building other queries. class EventCollection + include Gitlab::Utils::StrongMemoize + # To prevent users from putting too much pressure on the database by cycling # through thousands of events we put a limit on the number of pages. MAX_PAGE = 10 @@ -13,57 +15,52 @@ class EventCollection # projects - An ActiveRecord::Relation object that returns the projects for # which to retrieve events. # filter - An EventFilter instance to use for filtering events. - def initialize(projects, limit: 20, offset: 0, filter: nil) + def initialize(projects, limit: 20, offset: 0, filter: nil, groups: nil) @projects = projects @limit = limit @offset = offset @filter = filter + @groups = groups end # Returns an Array containing the events. def to_a return [] if current_page > MAX_PAGE - relation = if Gitlab::Database.join_lateral_supported? - relation_with_join_lateral + relation = if groups + project_and_group_events else - relation_without_join_lateral + relation_with_join_lateral('project_id', projects) end + relation = paginate_events(relation) relation.with_associations.to_a end private - # Returns the events relation to use when JOIN LATERAL is not supported. - # - # This relation simply gets all the events for all authorized projects, then - # limits that set. - def relation_without_join_lateral - events = filtered_events.in_projects(projects) + def project_and_group_events + project_events = relation_with_join_lateral('project_id', projects) + group_events = relation_with_join_lateral('group_id', groups) - paginate_events(events) + Event.from_union([project_events, group_events]).recent end - # Returns the events relation to use when JOIN LATERAL is supported. - # # This relation is built using JOIN LATERAL, producing faster queries than a # regular LIMIT + OFFSET approach. - def relation_with_join_lateral - projects_for_lateral = projects.select(:id).to_sql + def relation_with_join_lateral(parent_column, parents) + parents_for_lateral = parents.select(:id).to_sql lateral = filtered_events .limit(limit_for_join_lateral) - .where('events.project_id = projects_for_lateral.id') + .where("events.#{parent_column} = parents_for_lateral.id") # rubocop:disable GitlabSecurity/SqlInjection .to_sql # The outer query does not need to re-apply the filters since the JOIN # LATERAL body already takes care of this. - outer = base_relation - .from("(#{projects_for_lateral}) projects_for_lateral") + base_relation + .from("(#{parents_for_lateral}) parents_for_lateral") .joins("JOIN LATERAL (#{lateral}) AS #{Event.table_name} ON true") - - paginate_events(outer) end def filtered_events @@ -97,4 +94,10 @@ class EventCollection def projects @projects.except(:order) end + + def groups + strong_memoize(:groups) do + groups.except(:order) if @groups + end + end end diff --git a/app/models/evidence.rb b/app/models/evidence.rb new file mode 100644 index 00000000000..69a00f1cb3f --- /dev/null +++ b/app/models/evidence.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Evidence < ApplicationRecord + include ShaAttribute + + belongs_to :release + + before_validation :generate_summary_and_sha + + default_scope { order(created_at: :asc) } + + sha_attribute :summary_sha + + def milestones + @milestones ||= release.milestones.includes(:issues) + end + + private + + def generate_summary_and_sha + summary = Evidences::EvidenceSerializer.new.represent(self) # rubocop: disable CodeReuse/Serializer + return unless summary + + self.summary = summary + self.summary_sha = Gitlab::CryptoHelper.sha256(summary) + end +end diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index 1d553fc8312..7d766e1f25c 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -11,7 +11,7 @@ class GlobalMilestone delegate :title, :state, :due_date, :start_date, :participants, :project, :group, :expires_at, :closed?, :iid, :group_milestone?, :safe_title, - :milestoneish_id, :parent, to: :milestone + :milestoneish_id, :resource_parent, to: :milestone def to_hash { diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb index 46cac1d41bb..0c36e51120f 100644 --- a/app/models/gpg_signature.rb +++ b/app/models/gpg_signature.rb @@ -23,6 +23,8 @@ class GpgSignature < ApplicationRecord validates :project_id, presence: true validates :gpg_key_primary_keyid, presence: true + scope :by_commit_sha, ->(shas) { where(commit_sha: shas) } + def self.with_key_and_subkeys(gpg_key) subkey_ids = gpg_key.subkeys.pluck(:id) diff --git a/app/models/grafana_integration.rb b/app/models/grafana_integration.rb new file mode 100644 index 00000000000..51cc398394d --- /dev/null +++ b/app/models/grafana_integration.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class GrafanaIntegration < ApplicationRecord + belongs_to :project + + attr_encrypted :token, + mode: :per_attribute_iv, + algorithm: 'aes-256-gcm', + key: Settings.attr_encrypted_db_key_base_32 + + validates :grafana_url, + length: { maximum: 1024 }, + addressable_url: { enforce_sanitization: true, ascii_only: true } + + validates :token, :project, presence: true + + def client + @client ||= ::Grafana::Client.new(api_url: grafana_url.chomp('/'), token: token) + end +end diff --git a/app/models/group.rb b/app/models/group.rb index 1b62db04ab7..042201ffa14 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -14,6 +14,7 @@ class Group < Namespace include TokenAuthenticatable include WithUploads include Gitlab::Utils::StrongMemoize + include GroupAPICompatibility ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10 @@ -258,6 +259,10 @@ class Group < Namespace members_with_parents.maintainers.exists?(user_id: user) end + def has_container_repositories? + container_repositories.exists? + end + # @deprecated alias_method :has_master?, :has_maintainer? @@ -435,6 +440,10 @@ class Group < Namespace members.owners.order_recent_sign_in.limit(ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) end + def supports_events? + false + end + private def update_two_factor_requirement @@ -464,6 +473,12 @@ class Group < Namespace errors.add(:visibility_level, "#{visibility} is not allowed since there are sub-groups with higher visibility.") end + + def self.groups_including_descendants_by(group_ids) + Gitlab::ObjectHierarchy + .new(Group.where(id: group_ids)) + .base_and_descendants + end end Group.prepend_if_ee('EE::Group') diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 16fc7fdbd48..e51b1c41059 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -13,7 +13,7 @@ class WebHook < ApplicationRecord algorithm: 'aes-256-gcm', key: Settings.attr_encrypted_db_key_base_32 - has_many :web_hook_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :web_hook_logs validates :url, presence: true validates :url, public_url: true, unless: ->(hook) { hook.is_a?(SystemHook) } diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb index 237401899db..8d3eeaf2461 100644 --- a/app/models/internal_id.rb +++ b/app/models/internal_id.rb @@ -16,6 +16,8 @@ # * Add `usage` value to enum # * (Optionally) add columns to `internal_ids` if needed for scope. class InternalId < ApplicationRecord + include Gitlab::Utils::StrongMemoize + belongs_to :project belongs_to :namespace @@ -47,10 +49,18 @@ class InternalId < ApplicationRecord def update_and_save(&block) lock! yield + update_and_save_counter.increment(usage: usage, changed: last_value_changed?) save! last_value end + # Instrumentation to track for-update locks + def update_and_save_counter + strong_memoize(:update_and_save_counter) do + Gitlab::Metrics.counter(:gitlab_internal_id_for_update_lock, 'Number of ROW SHARE (FOR UPDATE) locks on individual records from internal_ids') + end + end + class << self def track_greatest(subject, scope, usage, new_value, init) return new_value unless available? diff --git a/app/models/issue.rb b/app/models/issue.rb index d0b2165fcc7..b9b481ac29b 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -71,7 +71,7 @@ class Issue < ApplicationRecord attr_spammable :title, spam_title: true attr_spammable :description, spam_description: true - state_machine :state, initial: :opened do + state_machine :state_id, initial: :opened do event :close do transition [:opened] => :closed end @@ -80,8 +80,8 @@ class Issue < ApplicationRecord transition closed: :opened end - state :opened - state :closed + state :opened, value: Issue.available_states[:opened] + state :closed, value: Issue.available_states[:closed] before_transition any => :closed do |issue| issue.closed_at = issue.system_note_timestamp @@ -93,6 +93,13 @@ class Issue < ApplicationRecord end end + # Alias to state machine .with_state_id method + # This needs to be defined after the state machine block to avoid errors + class << self + alias_method :with_state, :with_state_id + alias_method :with_states, :with_state_ids + end + def self.relative_positioning_query_base(issue) in_projects(issue.parent_ids) end diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index 60b11ad9356..535c3cf2ba1 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -2,6 +2,7 @@ class LfsObject < ApplicationRecord include AfterCommitQueue + include Checksummable include EachBatch include ObjectStorage::BackgroundMove @@ -9,6 +10,7 @@ class LfsObject < ApplicationRecord has_many :projects, -> { distinct }, through: :lfs_objects_projects scope :with_files_stored_locally, -> { where(file_store: LfsObjectUploader::Store::LOCAL) } + scope :with_files_stored_remotely, -> { where(file_store: LfsObjectUploader::Store::REMOTE) } validates :oid, presence: true, uniqueness: true @@ -23,7 +25,13 @@ class LfsObject < ApplicationRecord end def project_allowed_access?(project) - projects.exists?(project.lfs_storage_project.id) + if project.fork_network_member + lfs_objects_projects + .where("EXISTS(?)", project.fork_network.fork_network_members.select(1).where("fork_network_members.project_id = lfs_objects_projects.project_id")) + .exists? + else + lfs_objects_projects.where(project_id: project.id).exists? + end end def local_store? @@ -39,7 +47,7 @@ class LfsObject < ApplicationRecord # rubocop: enable DestroyAll def self.calculate_oid(path) - Digest::SHA256.file(path).hexdigest + self.hexdigest(path) end end diff --git a/app/models/list.rb b/app/models/list.rb index b4a4631b397..13c42b55bf7 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -21,20 +21,10 @@ class List < ApplicationRecord scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) } scope :movable, -> { where(list_type: list_types.slice(*movable_types).values) } - scope :preload_associations, -> (user) do - preload(:board, label: :priorities) - end + scope :preload_associations, -> { preload(:board, label: :priorities) } scope :ordered, -> { order(:list_type, :position) } - # Loads list with preferences for given user - # if preferences exists for user or not - scope :with_preferences_for, -> (user) do - return unless user - - includes(:list_user_preferences).where(list_user_preferences: { user_id: [user.id, nil] }) - end - alias_method :preferences, :list_user_preferences class << self @@ -45,25 +35,25 @@ class List < ApplicationRecord def movable_types [:label] end + + def preload_preferences_for_user(lists, user) + return unless user + + lists.each { |list| list.preferences_for(user) } + end end def preferences_for(user) return preferences.build unless user - if preferences.loaded? - preloaded_preferences_for(user) - else - preferences.find_or_initialize_by(user: user) - end - end + BatchLoader.for(list_id: id, user_id: user.id).batch(default_value: preferences.build(user: user)) do |items, loader| + list_ids = items.map { |i| i[:list_id] } + user_ids = items.map { |i| i[:user_id] } - def preloaded_preferences_for(user) - user_preferences = - preferences.find do |preference| - preference.user_id == user.id + ListUserPreference.where(list_id: list_ids.uniq, user_id: user_ids.uniq).find_each do |preference| + loader.call({ list_id: preference.list_id, user_id: preference.user_id }, preference) end - - user_preferences || preferences.build(user: user) + end end def update_preferences_for(user, preferences = {}) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 63133ca285b..7cdaa3e3ca7 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -85,7 +85,13 @@ class MergeRequest < ApplicationRecord # when creating new merge request attr_accessor :can_be_created, :compare_commits, :diff_options, :compare - state_machine :state, initial: :opened do + # Keep states definition to be evaluated before the state_machine block to avoid spec failures. + # If this gets evaluated after, the `merged` and `locked` states which are overrided can be nil. + def self.available_state_names + super + [:merged, :locked] + end + + state_machine :state_id, initial: :opened do event :close do transition [:opened] => :closed end @@ -116,10 +122,17 @@ class MergeRequest < ApplicationRecord end end - state :opened - state :closed - state :merged - state :locked + state :opened, value: MergeRequest.available_states[:opened] + state :closed, value: MergeRequest.available_states[:closed] + state :merged, value: MergeRequest.available_states[:merged] + state :locked, value: MergeRequest.available_states[:locked] + end + + # Alias to state machine .with_state_id method + # This needs to be defined after the state machine block to avoid errors + class << self + alias_method :with_state, :with_state_id + alias_method :with_states, :with_state_ids end state_machine :merge_status, initial: :unchecked do @@ -196,6 +209,10 @@ class MergeRequest < ApplicationRecord scope :by_target_branch, ->(branch_name) { where(target_branch: branch_name) } scope :preload_source_project, -> { preload(:source_project) } + scope :with_open_merge_when_pipeline_succeeds, -> do + with_state(:opened).where(merge_when_pipeline_succeeds: true) + end + after_save :keep_around_commit alias_attribute :project, :target_project @@ -207,10 +224,6 @@ class MergeRequest < ApplicationRecord '!' end - def self.available_states - @available_states ||= super.merge(merged: 3, locked: 4) - end - # Returns the top 100 target branches # # The returned value is a Array containing branch names @@ -450,6 +463,15 @@ class MergeRequest < ApplicationRecord merge_request_diffs.where.not(id: merge_request_diff.id) end + # Overwritten in EE + def note_positions_for_paths(paths, _user = nil) + positions = notes.new_diff_notes.joins(:note_diff_file) + .where('note_diff_files.old_path IN (?) OR note_diff_files.new_path IN (?)', paths, paths) + .positions + + Gitlab::Diff::PositionCollection.new(positions, diff_head_sha) + end + def preloads_discussion_diff_highlighting? true end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 8b5f10ce159..735ad046f22 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -83,7 +83,7 @@ class MergeRequestDiff < ApplicationRecord metrics_join = mr_diffs.join(mr_metrics).on(metrics_join_condition) - condition = MergeRequest.arel_table[:state].eq(:merged) + condition = MergeRequest.arel_table[:state_id].eq(MergeRequest.available_states[:merged]) .and(MergeRequest::Metrics.arel_table[:merged_at].lteq(before)) .and(MergeRequest::Metrics.arel_table[:merged_at].not_eq(nil)) @@ -91,7 +91,7 @@ class MergeRequestDiff < ApplicationRecord end scope :old_closed_diffs, -> (before) do - condition = MergeRequest.arel_table[:state].eq(:closed) + condition = MergeRequest.arel_table[:state_id].eq(MergeRequest.available_states[:closed]) .and(MergeRequest::Metrics.arel_table[:latest_closed_at].lteq(before)) joins(merge_request: :metrics).where(condition) @@ -136,6 +136,7 @@ class MergeRequestDiff < ApplicationRecord # All diff information is collected from repository after object is created. # It allows you to override variables like head_commit_sha before getting diff. after_create :save_git_content, unless: :importing? + after_create_commit :set_as_latest_diff after_save :update_external_diff_store, if: -> { !importing? && saved_change_to_external_diff? } @@ -150,10 +151,6 @@ class MergeRequestDiff < ApplicationRecord # Collect information about commits and diff from repository # and save it to the database as serialized data def save_git_content - MergeRequest - .where('id = ? AND COALESCE(latest_merge_request_diff_id, 0) < ?', self.merge_request_id, self.id) - .update_all(latest_merge_request_diff_id: self.id) - ensure_commit_shas save_commits save_diffs @@ -168,6 +165,12 @@ class MergeRequestDiff < ApplicationRecord keep_around_commits end + def set_as_latest_diff + MergeRequest + .where('id = ? AND COALESCE(latest_merge_request_diff_id, 0) < ?', self.merge_request_id, self.id) + .update_all(latest_merge_request_diff_id: self.id) + end + def ensure_commit_shas self.start_commit_sha ||= merge_request.target_branch_sha self.head_commit_sha ||= merge_request.source_branch_sha @@ -297,6 +300,13 @@ class MergeRequestDiff < ApplicationRecord base_commit_sha? && head_commit_sha? && start_commit_sha? end + def diffs_in_batch(batch_page, batch_size, diff_options:) + Gitlab::Diff::FileCollection::MergeRequestDiffBatch.new(self, + batch_page, + batch_size, + diff_options: diff_options) + end + def diffs(diff_options = nil) if without_files? && comparison = diff_refs&.compare_in(project) # It should fetch the repository when diffs are cleaned by the system. @@ -495,11 +505,6 @@ class MergeRequestDiff < ApplicationRecord merge_request.closed? && merge_request.metrics.latest_closed_at < EXTERNAL_DIFF_CUTOFF.ago end - # We can't rely on `merge_request.latest_merge_request_diff_id` because that - # may have been changed in `save_git_content` without being reflected in - # the association's instance. This query is always subject to races, but - # the worst case is that we *don't* make a diff external when we could. The - # background worker will make it external at a later date. def old_version? latest_id = MergeRequest .where(id: merge_request_id) @@ -507,7 +512,7 @@ class MergeRequestDiff < ApplicationRecord .pluck(:latest_merge_request_diff_id) .first - self.id != latest_id + latest_id && self.id < latest_id end def load_diffs(options) @@ -584,3 +589,5 @@ class MergeRequestDiff < ApplicationRecord end end end + +MergeRequestDiff.prepend_if_ee('EE::MergeRequestDiff') diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb index a532c1e6356..14c86ec69da 100644 --- a/app/models/merge_request_diff_file.rb +++ b/app/models/merge_request_diff_file.rb @@ -5,6 +5,7 @@ class MergeRequestDiffFile < ApplicationRecord include DiffFile belongs_to :merge_request_diff, inverse_of: :merge_request_diff_files + alias_attribute :index, :relative_order def utf8_diff return '' if diff.blank? diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 916c11a8d03..2fa0cfc9b93 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -257,10 +257,9 @@ class Milestone < ApplicationRecord title.to_slug.normalize.to_s end - def parent + def resource_parent group || project end - alias_method :resource_parent, :parent def group_milestone? group_id.present? diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 9a7c3dc03c3..5663ebf8ba1 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -120,6 +120,13 @@ class Namespace < ApplicationRecord uniquify = Uniquify.new uniquify.string(path) { |s| Namespace.find_by_path_or_name(s) } end + + def find_by_pages_host(host) + gitlab_host = "." + Settings.pages.host.downcase + name = host.downcase.delete_suffix(gitlab_host) + + Namespace.find_by_full_path(name) + end end def visibility_level_field @@ -175,7 +182,7 @@ class Namespace < ApplicationRecord # any ancestor can disable emails for all descendants def emails_disabled? strong_memoize(:emails_disabled) do - Feature.enabled?(:emails_disabled, self, default_enabled: true) && self_and_ancestors.where(emails_disabled: true).exists? + self_and_ancestors.where(emails_disabled: true).exists? end end @@ -249,7 +256,7 @@ class Namespace < ApplicationRecord end def has_parent? - parent.present? + parent_id.present? || parent.present? end def root_ancestor @@ -305,8 +312,28 @@ class Namespace < ApplicationRecord aggregation_schedule.present? end + def pages_virtual_domain + Pages::VirtualDomain.new(all_projects_with_pages, trim_prefix: full_path) + end + + def closest_setting(name) + self_and_ancestors(hierarchy_order: :asc) + .find { |n| !n.read_attribute(name).nil? } + .try(name) + end + private + def all_projects_with_pages + if all_projects.pages_metadata_not_migrated.exists? + Gitlab::BackgroundMigration::MigratePagesMetadata.new.perform_on_relation( + all_projects.pages_metadata_not_migrated + ) + end + + all_projects.with_pages_deployed + end + def parent_changed? parent_id_changed? end diff --git a/app/models/note.rb b/app/models/note.rb index b1829e71017..43f349c6fa2 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -24,7 +24,7 @@ class Note < ApplicationRecord class << self def values - constants.map {|const| self.const_get(const)} + constants.map {|const| self.const_get(const, false)} end def value?(val) @@ -104,6 +104,8 @@ class Note < ApplicationRecord end end + validate :does_not_exceed_notes_limit?, on: :create, unless: [:system?, :importing?] + # @deprecated attachments are handler by the MarkdownUploader mount_uploader :attachment, AttachmentUploader @@ -143,6 +145,9 @@ class Note < ApplicationRecord end scope :with_metadata, -> { includes(:system_note_metadata) } + scope :for_note_or_capitalized_note, ->(text) { where(note: [text, text.capitalize]) } + scope :like_note_or_capitalized_note, ->(text) { where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) } + after_initialize :ensure_discussion_id before_validation :nullify_blank_type, :nullify_blank_line_code before_validation :set_discussion_id, on: :create @@ -193,6 +198,12 @@ class Note < ApplicationRecord groups end + def positions + where.not(position: nil) + .select(:id, :type, :position) # ActiveRecord needs id and type for typecasting. + .map(&:position) + end + def count_for_collection(ids, type) user.select('noteable_id', 'COUNT(*) as count') .group(:noteable_id) @@ -215,7 +226,7 @@ class Note < ApplicationRecord if force_cross_reference_regex_check? matches_cross_reference_regex? else - SystemNoteService.cross_reference?(note) + ::SystemNotes::IssuablesService.cross_reference?(note) end end # rubocop: enable CodeReuse/ServiceClass @@ -472,10 +483,9 @@ class Note < ApplicationRecord Upload.find_by(model: self, path: paths) end - def parent + def resource_parent project end - alias_method :resource_parent, :parent private @@ -519,6 +529,12 @@ class Note < ApplicationRecord system_note_metadata&.cross_reference_types&.include?(system_note_metadata&.action) end + + def does_not_exceed_notes_limit? + return unless noteable + + errors.add(:base, _('Maximum number of comments exceeded')) if noteable.notes.count >= Noteable::MAX_NOTES_LIMIT + end end Note.prepend_if_ee('EE::Note') diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 981590b688f..2b3443f24d7 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -25,6 +25,7 @@ class NotificationSetting < ApplicationRecord end EMAIL_EVENTS = [ + :new_release, :new_note, :new_issue, :reopen_issue, @@ -46,6 +47,10 @@ class NotificationSetting < ApplicationRecord EMAIL_EVENTS end + def self.allowed_fields(source = nil) + NotificationSetting.email_events(source).dup + %i(level notification_email) + end + def email_events self.class.email_events(source) end diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb index 1b3183a2a43..51c496c77d3 100644 --- a/app/models/pages/lookup_path.rb +++ b/app/models/pages/lookup_path.rb @@ -2,9 +2,10 @@ module Pages class LookupPath - def initialize(project, domain: nil) + def initialize(project, trim_prefix: nil, domain: nil) @project = project @domain = domain + @trim_prefix = trim_prefix || project.full_path end def project_id @@ -28,11 +29,15 @@ module Pages end def prefix - '/' + if project.pages_group_root? + '/' + else + project.full_path.delete_prefix(trim_prefix) + '/' + end end private - attr_reader :project, :domain + attr_reader :project, :trim_prefix, :domain end end diff --git a/app/models/pages/virtual_domain.rb b/app/models/pages/virtual_domain.rb index 3a876dc06a2..7e42b8e6ae2 100644 --- a/app/models/pages/virtual_domain.rb +++ b/app/models/pages/virtual_domain.rb @@ -2,8 +2,9 @@ module Pages class VirtualDomain - def initialize(projects, domain: nil) + def initialize(projects, trim_prefix: nil, domain: nil) @projects = projects + @trim_prefix = trim_prefix @domain = domain end @@ -17,12 +18,12 @@ module Pages def lookup_paths projects.map do |project| - project.pages_lookup_path(domain: domain) + project.pages_lookup_path(trim_prefix: trim_prefix, domain: domain) end.sort_by(&:prefix).reverse end private - attr_reader :projects, :domain + attr_reader :projects, :trim_prefix, :domain end end diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 22a6bae7cf7..7903a2182dd 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -186,11 +186,27 @@ class PagesDomain < ApplicationRecord end def pages_virtual_domain + return unless pages_deployed? + Pages::VirtualDomain.new([project], domain: self) end private + def pages_deployed? + # TODO: remove once `pages_metadatum` is migrated + # https://gitlab.com/gitlab-org/gitlab/issues/33106 + unless project.pages_metadatum + Gitlab::BackgroundMigration::MigratePagesMetadata + .new + .perform_on_relation(Project.where(id: project_id)) + + project.reset + end + + project.pages_metadatum&.deployed? + end + def set_verification_code return if self.verification_code.present? diff --git a/app/models/project.rb b/app/models/project.rb index 5c3bf4a3b5d..3525f37f8d5 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -68,7 +68,7 @@ class Project < ApplicationRecord :snippets_access_level, :builds_access_level, :repository_access_level, to: :project_feature, allow_nil: true - delegate :base_dir, :disk_path, :ensure_storage_path_exists, to: :storage + delegate :base_dir, :disk_path, to: :storage delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?, @@ -104,6 +104,9 @@ class Project < ApplicationRecord unless: :ci_cd_settings, if: proc { ProjectCiCdSetting.available? } + after_create :create_pages_metadatum, + unless: :pages_metadatum + after_create :set_timestamps_for_create after_update :update_forks_visibility_level @@ -119,8 +122,6 @@ class Project < ApplicationRecord # Storage specific hooks after_initialize :use_hashed_storage after_create :check_repository_absence! - after_create :ensure_storage_path_exists - after_save :ensure_storage_path_exists, if: :saved_change_to_namespace_id? acts_as_ordered_taggable @@ -192,6 +193,7 @@ class Project < ApplicationRecord has_one :project_repository, inverse_of: :project has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting' has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting' + has_one :grafana_integration, inverse_of: :project # Merge Requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project @@ -242,8 +244,8 @@ class Project < ApplicationRecord 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' has_many :kubernetes_namespaces, class_name: 'Clusters::KubernetesNamespace' + has_many :management_clusters, class_name: 'Clusters::Cluster', foreign_key: :management_project_id, inverse_of: :management_project has_many :prometheus_metrics @@ -273,12 +275,13 @@ class Project < ApplicationRecord 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 :job_artifacts, class_name: 'Ci::JobArtifact' has_many :runner_projects, class_name: 'Ci::RunnerProject', inverse_of: :project has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' has_many :variables, class_name: 'Ci::Variable' has_many :triggers, class_name: 'Ci::Trigger' has_many :environments - has_many :deployments, -> { success } + has_many :deployments has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule' has_many :project_deploy_tokens has_many :deploy_tokens, through: :project_deploy_tokens @@ -294,6 +297,11 @@ class Project < ApplicationRecord has_many :external_pull_requests, inverse_of: :project + has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_project_id + has_many :source_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :project_id + + has_one :pages_metadatum, class_name: 'ProjectPagesMetadatum', 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 @@ -306,6 +314,7 @@ class Project < ApplicationRecord accepts_nested_attributes_for :error_tracking_setting, update_only: true accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true + accepts_nested_attributes_for :grafana_integration, update_only: true, allow_destroy: true delegate :name, to: :owner, allow_nil: true, prefix: true delegate :members, to: :team, prefix: true @@ -424,6 +433,15 @@ class Project < ApplicationRecord .where(project_ci_cd_settings: { group_runners_enabled: true }) end + scope :with_pages_deployed, -> do + joins(:pages_metadatum).merge(ProjectPagesMetadatum.deployed) + end + + scope :pages_metadata_not_migrated, -> do + left_outer_joins(:pages_metadatum) + .where(project_pages_metadata: { project_id: nil }) + end + enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } chronic_duration_attr :build_timeout_human_readable, :build_timeout, @@ -650,7 +668,7 @@ class Project < ApplicationRecord def emails_disabled? strong_memoize(:emails_disabled) do # disabling in the namespace overrides the project setting - Feature.enabled?(:emails_disabled, self, default_enabled: true) && (super || namespace.emails_disabled?) + super || namespace.emails_disabled? end end @@ -1018,8 +1036,8 @@ class Project < ApplicationRecord end end - def web_url - Gitlab::Routing.url_helpers.project_url(self) + def web_url(only_path: nil) + Gitlab::Routing.url_helpers.project_url(self, only_path: only_path) end def readme_url @@ -1298,7 +1316,18 @@ class Project < ApplicationRecord end def http_url_to_repo - "#{web_url}.git" + custom_root = Gitlab::CurrentSettings.custom_http_clone_url_root + + project_url = if custom_root.present? + Gitlab::Utils.append_path( + custom_root, + web_url(only_path: true) + ) + else + web_url + end + + "#{project_url}.git" end # Is overridden in EE @@ -1647,6 +1676,10 @@ class Project < ApplicationRecord "#{url}/#{url_path}" end + def pages_group_root? + pages_group_url == pages_url + end + def pages_subdomain full_path.partition('/').first end @@ -1685,6 +1718,7 @@ class Project < ApplicationRecord # Projects with a missing namespace cannot have their pages removed return unless namespace + mark_pages_as_not_deployed unless destroyed? ::Projects::UpdatePagesConfigurationService.new(self).execute # 1. We rename pages to temporary directory @@ -1698,6 +1732,14 @@ class Project < ApplicationRecord end # rubocop: enable CodeReuse/ServiceClass + def mark_pages_as_deployed + ensure_pages_metadatum.update!(deployed: true) + end + + def mark_pages_as_not_deployed + ensure_pages_metadatum.update!(deployed: false) + end + # rubocop:disable Gitlab/RailsLogger def write_repository_config(gl_full_path: full_path) # We'd need to keep track of project full path otherwise directory tree @@ -1821,6 +1863,7 @@ class Project < ApplicationRecord Gitlab::Ci::Variables::Collection.new .append(key: 'CI_PROJECT_ID', value: id.to_s) .append(key: 'CI_PROJECT_NAME', value: path) + .append(key: 'CI_PROJECT_TITLE', value: title) .append(key: 'CI_PROJECT_PATH', value: full_path) .append(key: 'CI_PROJECT_PATH_SLUG', value: full_path_slug) .append(key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path) @@ -2217,12 +2260,37 @@ class Project < ApplicationRecord members.maintainers.order_recent_sign_in.limit(ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) end - def pages_lookup_path(domain: nil) - Pages::LookupPath.new(self, domain: domain) + def pages_lookup_path(trim_prefix: nil, domain: nil) + Pages::LookupPath.new(self, trim_prefix: trim_prefix, domain: domain) + end + + def closest_setting(name) + setting = read_attribute(name) + setting = closest_namespace_setting(name) if setting.nil? + setting = app_settings_for(name) if setting.nil? + setting + end + + def drop_visibility_level! + if group && group.visibility_level < visibility_level + self.visibility_level = group.visibility_level + end + + if Gitlab::CurrentSettings.restricted_visibility_levels.include?(visibility_level) + self.visibility_level = Gitlab::VisibilityLevel::PRIVATE + end end private + def closest_namespace_setting(name) + namespace.closest_setting(name) + end + + def app_settings_for(name) + Gitlab::CurrentSettings.send(name) # rubocop:disable GitlabSecurity/PublicSend + end + def merge_requests_allowing_collaboration(source_branch = nil) relation = source_of_merge_requests.opened.where(allow_collaboration: true) relation = relation.where(source_branch: source_branch) if source_branch @@ -2268,7 +2336,7 @@ class Project < ApplicationRecord end def repository_with_same_path_already_exists? - gitlab_shell.exists?(repository_storage, "#{disk_path}.git") + gitlab_shell.repository_exists?(repository_storage, "#{disk_path}.git") end def set_timestamps_for_create @@ -2346,6 +2414,13 @@ class Project < ApplicationRecord def services_templates @services_templates ||= Service.where(template: true) end + + def ensure_pages_metadatum + pages_metadatum || create_pages_metadatum! + rescue ActiveRecord::RecordNotUnique + reset + retry + end end Project.prepend_if_ee('EE::Project') diff --git a/app/models/project_pages_metadatum.rb b/app/models/project_pages_metadatum.rb new file mode 100644 index 00000000000..1fda388b1ae --- /dev/null +++ b/app/models/project_pages_metadatum.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ProjectPagesMetadatum < ApplicationRecord + self.primary_key = :project_id + + belongs_to :project, inverse_of: :pages_metadatum + + scope :deployed, -> { where(deployed: true) } +end diff --git a/app/models/project_services/data_fields.rb b/app/models/project_services/data_fields.rb index 46136556ade..cffb493d569 100644 --- a/app/models/project_services/data_fields.rb +++ b/app/models/project_services/data_fields.rb @@ -5,7 +5,7 @@ module DataFields class_methods do # Provide convenient accessor methods for data fields. - # TODO: Simplify as part of https://gitlab.com/gitlab-org/gitlab-foss/issues/63084 + # TODO: Simplify as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 def data_field(*args) args.each do |arg| self.class_eval <<-RUBY, __FILE__, __LINE__ + 1 diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb index 3320405e9e9..019bd54f48c 100644 --- a/app/models/project_services/hipchat_service.rb +++ b/app/models/project_services/hipchat_service.rb @@ -73,7 +73,7 @@ class HipchatService < Service private def gate - options = { api_version: api_version.present? ? api_version : 'v2' } + options = { api_version: api_version.presence || 'v2' } options[:server_url] = server unless server.blank? @gate ||= HipChat::Client.new(token, options) end @@ -161,7 +161,7 @@ class HipchatService < Service obj_attr = data[:object_attributes] obj_attr = HashWithIndifferentAccess.new(obj_attr) title = render_line(obj_attr[:title]) - state = obj_attr[:state] + state = Issue.available_states.key(obj_attr[:state_id]) issue_iid = obj_attr[:iid] issue_url = obj_attr[:url] description = obj_attr[:description] diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb index fb76bc89c98..4a6c8339625 100644 --- a/app/models/project_services/irker_service.rb +++ b/app/models/project_services/irker_service.rb @@ -36,8 +36,8 @@ class IrkerService < Service def settings { - server_host: server_host.present? ? server_host : 'localhost', - server_port: server_port.present? ? server_port : 6659 + server_host: server_host.presence || 'localhost', + server_port: server_port.presence || 6659 } end diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb index 3ecd5390d79..9e1393196ff 100644 --- a/app/models/project_services/issue_tracker_service.rb +++ b/app/models/project_services/issue_tracker_service.rb @@ -4,7 +4,7 @@ class IssueTrackerService < Service validate :one_issue_tracker, if: :activated?, on: :manual_change # TODO: we can probably just delegate as part of - # https://gitlab.com/gitlab-org/gitlab-foss/issues/63084 + # https://gitlab.com/gitlab-org/gitlab/issues/29404 data_field :project_url, :issues_url, :new_issue_url default_value_for :category, 'issue_tracker' @@ -25,7 +25,7 @@ class IssueTrackerService < Service end end - # this will be removed as part of https://gitlab.com/gitlab-org/gitlab-foss/issues/63084 + # this will be removed as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 def title if title_attribute = read_attribute(:title) title_attribute @@ -36,7 +36,7 @@ class IssueTrackerService < Service end end - # this will be removed as part of https://gitlab.com/gitlab-org/gitlab-foss/issues/63084 + # this will be removed as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 def description if description_attribute = read_attribute(:description) description_attribute @@ -49,7 +49,7 @@ class IssueTrackerService < Service def handle_properties # this has been moved from initialize_properties and should be improved - # as part of https://gitlab.com/gitlab-org/gitlab-foss/issues/63084 + # as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 return unless properties @legacy_properties_data = properties.dup @@ -62,6 +62,7 @@ class IssueTrackerService < Service end data_values.reject! { |key| data_fields.changed.include?(key) } + data_values.slice!(*data_fields.attributes.keys) data_fields.assign_attributes(data_values) if data_values.present? self.properties = {} @@ -71,6 +72,10 @@ class IssueTrackerService < Service @legacy_properties_data ||= {} end + def supports_data_fields? + true + end + def data_fields issue_tracker_data || self.build_issue_tracker_data end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index a76970bfa2a..ba61810e26f 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -19,7 +19,7 @@ class JiraService < IssueTrackerService # for more information check: https://gitlab.com/gitlab-org/gitlab-foss/issues/49936. # TODO: we can probably just delegate as part of - # https://gitlab.com/gitlab-org/gitlab-foss/issues/63084 + # https://gitlab.com/gitlab-org/gitlab/issues/29404 data_field :username, :password, :url, :api_url, :jira_issue_transition_id before_update :reset_password @@ -64,7 +64,7 @@ class JiraService < IssueTrackerService url = URI.parse(client_url) { - username: username, + username: username&.strip, password: password, site: URI.join(url, '/').to_s, # Intended to find the root context_path: url.path, @@ -122,9 +122,13 @@ class JiraService < IssueTrackerService end alias_method :original_url, :url - def url - original_url&.chomp('/') + original_url&.delete_suffix('/') + end + + alias_method :original_api_url, :api_url + def api_url + original_api_url&.delete_suffix('/') end def execute(push) @@ -137,10 +141,9 @@ class JiraService < IssueTrackerService return if issue.nil? || has_resolution?(issue) || !jira_issue_transition_id.present? - commit_id = if entity.is_a?(Commit) - entity.id - elsif entity.is_a?(MergeRequest) - entity.diff_head_sha + commit_id = case entity + when Commit then entity.id + when MergeRequest then entity.diff_head_sha end commit_url = build_entity_url(:commit, commit_id) @@ -298,7 +301,7 @@ class JiraService < IssueTrackerService title: title, status: status, icon: { - title: 'GitLab', url16x16: asset_url(Gitlab::Favicon.main, host: gitlab_config.url) + title: 'GitLab', url16x16: asset_url(Gitlab::Favicon.main, host: gitlab_config.base_url) } } } @@ -331,7 +334,6 @@ class JiraService < IssueTrackerService # Handle errors when doing Jira API calls def jira_request yield - rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => e @error = e.message log_error("Error sending message", client_url: client_url, error: @error) @@ -339,7 +341,7 @@ class JiraService < IssueTrackerService end def client_url - api_url.present? ? api_url : url + api_url.presence || url end def reset_password? diff --git a/app/models/project_services/packagist_service.rb b/app/models/project_services/packagist_service.rb index 003884bb7ac..35dbedd1341 100644 --- a/app/models/project_services/packagist_service.rb +++ b/app/models/project_services/packagist_service.rb @@ -59,7 +59,7 @@ class PackagistService < Service end def hook_url - base_url = server.present? ? server : 'https://packagist.org' + base_url = server.presence || 'https://packagist.org' "#{base_url}/api/update-package?username=#{username}&apiToken=#{token}" end end diff --git a/app/models/project_services/slash_commands_service.rb b/app/models/project_services/slash_commands_service.rb index 5bfd06476f0..d436176a52c 100644 --- a/app/models/project_services/slash_commands_service.rb +++ b/app/models/project_services/slash_commands_service.rb @@ -33,9 +33,12 @@ class SlashCommandsService < Service return unless valid_token?(params[:token]) chat_user = find_chat_user(params) + user = chat_user&.user + + if user + unless user.can?(:use_slash_commands) + return Gitlab::SlashCommands::Presenters::Access.new.deactivated if user.deactivated? - if chat_user&.user - unless chat_user.user.can?(:use_slash_commands) return Gitlab::SlashCommands::Presenters::Access.new.access_denied(project) end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 218be974218..bb222ac7629 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -54,7 +54,7 @@ class ProjectWiki end def http_url_to_repo - "#{Gitlab.config.gitlab.url}/#{full_path}.git" + @project.http_url_to_repo.sub(%r{git\z}, 'wiki.git') end def wiki_base_path diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 8769d3eb916..1857a59e01c 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -40,6 +40,11 @@ class ProtectedBranch < ApplicationRecord def self.protected_refs(project) project.protected_branches.select(:name) end + + def self.branch_requires_code_owner_approval?(project, branch_name) + # NOOP + # + end end ProtectedBranch.prepend_if_ee('EE::ProtectedBranch') diff --git a/app/models/push_event.rb b/app/models/push_event.rb index 4698df39730..5cab686f20b 100644 --- a/app/models/push_event.rb +++ b/app/models/push_event.rb @@ -26,6 +26,8 @@ class PushEvent < Event delegate :commit_count, to: :push_event_payload alias_method :commits_count, :commit_count + delegate :ref_count, to: :push_event_payload + # Returns events of pushes that either pushed to an existing ref or created a # new one. def self.created_or_pushed @@ -52,7 +54,7 @@ class PushEvent < Event .select(1) .where('merge_requests.source_project_id = events.project_id') .where('merge_requests.source_branch = push_event_payloads.ref') - .where(state: :opened) + .with_state(:opened) # For reasons unknown the use of #eager_load will result in the # "push_event_payload" association not being set. Because of this we're diff --git a/app/models/release.rb b/app/models/release.rb index cd63b4d5fef..5a7bfe2d495 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -14,6 +14,7 @@ class Release < ApplicationRecord has_many :milestone_releases has_many :milestones, through: :milestone_releases + has_one :evidence default_value_for :released_at, allows_nil: false do Time.zone.now @@ -22,13 +23,16 @@ class Release < ApplicationRecord accepts_nested_attributes_for :links, allow_destroy: true validates :description, :project, :tag, presence: true - validates :name, presence: true, on: :create validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") } scope :sorted, -> { order(released_at: :desc) } + scope :with_project_and_namespace, -> { includes(project: :namespace) } delegate :repository, to: :project + after_commit :create_evidence!, on: :create + after_commit :notify_new_release, on: :create + def commit strong_memoize(:commit) do repository.commit(actual_sha) @@ -67,4 +71,14 @@ class Release < ApplicationRecord repository.find_tag(tag) end end + + def create_evidence! + CreateEvidenceWorker.perform_async(self.id) + end + + def notify_new_release + NewReleaseWorker.perform_async(id) + end end + +Release.prepend_if_ee('EE::Release') diff --git a/app/models/repository.rb b/app/models/repository.rb index f084a314392..b9f57169ea5 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -6,6 +6,7 @@ class Repository REF_MERGE_REQUEST = 'merge-requests' REF_KEEP_AROUND = 'keep-around' REF_ENVIRONMENTS = 'environments' + REF_PIPELINES = 'pipelines' ARCHIVE_CACHE_TIME = 60 # Cache archives referred to by a (mutable) ref for 1 minute ARCHIVE_CACHE_TIME_IMMUTABLE = 3600 # Cache archives referred to by an immutable reference for 1 hour @@ -16,7 +17,7 @@ class Repository replace #{REF_ENVIRONMENTS} #{REF_KEEP_AROUND} - #{REF_ENVIRONMENTS} + #{REF_PIPELINES} ].freeze include Gitlab::RepositoryCacheAdapter @@ -133,18 +134,28 @@ class Repository end end - def commits(ref = nil, path: nil, limit: nil, offset: nil, skip_merges: false, after: nil, before: nil, all: nil) + # the opts are: + # - :path + # - :limit + # - :offset + # - :skip_merges + # - :after + # - :before + # - :all + # - :first_parent + def commits(ref = nil, opts = {}) options = { repo: raw_repository, ref: ref, - path: path, - limit: limit, - offset: offset, - after: after, - before: before, - follow: Array(path).length == 1, - skip_merges: skip_merges, - all: all + path: opts[:path], + follow: Array(opts[:path]).length == 1, + limit: opts[:limit], + offset: opts[:offset], + skip_merges: !!opts[:skip_merges], + after: opts[:after], + before: opts[:before], + all: !!opts[:all], + first_parent: !!opts[:first_parent] } commits = Gitlab::Git::Commit.where(options) @@ -239,13 +250,13 @@ class Repository def branch_exists?(branch_name) return false unless raw_repository - branch_names.include?(branch_name) + branch_names_include?(branch_name) end def tag_exists?(tag_name) return false unless raw_repository - tag_names.include?(tag_name) + tag_names_include?(tag_name) end def ref_exists?(ref) @@ -549,15 +560,15 @@ class Repository end delegate :branch_names, to: :raw_repository - cache_method :branch_names, fallback: [] + cache_method_as_redis_set :branch_names, fallback: [] delegate :tag_names, to: :raw_repository - cache_method :tag_names, fallback: [] + cache_method_as_redis_set :tag_names, fallback: [] delegate :branch_count, :tag_count, :has_visible_content?, to: :raw_repository cache_method :branch_count, fallback: 0 cache_method :tag_count, fallback: 0 - cache_method :has_visible_content?, fallback: false + cache_method_asymmetrically :has_visible_content? def avatar # n+1: https://gitlab.com/gitlab-org/gitlab-foss/issues/38327 @@ -1088,6 +1099,8 @@ class Repository raw.create_repository after_create + + true end def blobs_metadata(paths, ref = 'HEAD') diff --git a/app/models/repository_language.rb b/app/models/repository_language.rb index e6867f905e2..e468d716239 100644 --- a/app/models/repository_language.rb +++ b/app/models/repository_language.rb @@ -7,7 +7,7 @@ class RepositoryLanguage < ApplicationRecord default_scope { includes(:programming_language) } validates :project, presence: true - validates :share, inclusion: { in: 0..100, message: "The share of a lanuage is between 0 and 100" } + validates :share, inclusion: { in: 0..100, message: "The share of a language is between 0 and 100" } validates :programming_language, uniqueness: { scope: :project_id } delegate :name, :color, to: :programming_language diff --git a/app/models/service.rb b/app/models/service.rb index 43ed0c7dfaa..305cf7b78a2 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -291,6 +291,12 @@ class Service < ApplicationRecord def self.build_from_template(project_id, template) service = template.dup + + if template.supports_data_fields? + data_fields = template.data_fields.dup + data_fields.service = service + end + service.template = false service.project_id = project_id service.active = false if service.active? && !service.valid? @@ -309,6 +315,11 @@ class Service < ApplicationRecord find_by(template: true) end + # override if needed + def supports_data_fields? + false + end + private def cache_project_has_external_issue_tracker diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 1e84b9fa12e..4010a3e2167 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -14,6 +14,7 @@ class Snippet < ApplicationRecord include Editable include Gitlab::SQL::Pattern include FromUnion + extend ::Gitlab::Utils::Override cache_markdown_field :title, pipeline: :single_line cache_markdown_field :description @@ -32,8 +33,6 @@ class Snippet < ApplicationRecord default_content_html_invalidator || file_name_changed? end - default_value_for(:visibility_level) { Gitlab::CurrentSettings.default_snippet_visibility } - belongs_to :author, class_name: 'User' belongs_to :project @@ -72,7 +71,7 @@ class Snippet < ApplicationRecord end end - def self.only_global_snippets + def self.only_personal_snippets where(project_id: nil) end @@ -138,6 +137,24 @@ class Snippet < ApplicationRecord @link_reference_pattern ||= super("snippets", /(?<snippet>\d+)/) end + def initialize(attributes = {}) + # We can't use default_value_for because the database has a default + # value of 0 for visibility_level. If someone attempts to create a + # private snippet, default_value_for will assume that the + # visibility_level hasn't changed and will use the application + # setting default, which could be internal or public. + # + # To fix the problem, we assign the actual snippet default if no + # explicit visibility has been initialized. + attributes ||= {} + + unless visibility_attribute_present?(attributes) + attributes[:visibility_level] = Gitlab::CurrentSettings.default_snippet_visibility + end + + super + end + def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{id}" @@ -191,6 +208,12 @@ class Snippet < ApplicationRecord (public? && (title_changed? || content_changed?)) end + # snippers are the biggest sources of spam + override :allow_possible_spam? + def allow_possible_spam? + false + end + def spammable_entity_type 'snippet' end diff --git a/app/models/storage/hashed_project.rb b/app/models/storage/hashed_project.rb index f5d0d6fab3b..9a38b06b2f9 100644 --- a/app/models/storage/hashed_project.rb +++ b/app/models/storage/hashed_project.rb @@ -27,10 +27,6 @@ module Storage "#{base_dir}/#{disk_hash}" if disk_hash end - def ensure_storage_path_exists - gitlab_shell.add_namespace(repository_storage, base_dir) - end - def rename_repo(old_full_path: nil, new_full_path: nil) true end diff --git a/app/models/storage/legacy_project.rb b/app/models/storage/legacy_project.rb index 928c773c307..345172cca76 100644 --- a/app/models/storage/legacy_project.rb +++ b/app/models/storage/legacy_project.rb @@ -23,12 +23,6 @@ module Storage project.full_path end - def ensure_storage_path_exists - return unless namespace - - gitlab_shell.add_namespace(repository_storage, base_dir) - end - def rename_repo(old_full_path: nil, new_full_path: nil) old_full_path ||= project.full_path_before_last_save new_full_path ||= project.build_full_path diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb index 22e2f11230d..96ffec90c00 100644 --- a/app/models/suggestion.rb +++ b/app/models/suggestion.rb @@ -41,7 +41,6 @@ class Suggestion < ApplicationRecord !applied? && noteable.opened? && !outdated?(cached: cached) && - note.supports_suggestion? && different_content? && note.active? end diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 8ec90ca25d3..11cbeb60bba 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -23,6 +23,7 @@ class SystemNoteMetadata < ApplicationRecord validates :action, inclusion: { in: :icon_types }, allow_nil: true belongs_to :note + belongs_to :description_version def icon_types ICON_TYPES diff --git a/app/models/todo.rb b/app/models/todo.rb index 6b71845856a..1927b54510e 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -75,13 +75,13 @@ class Todo < ApplicationRecord after_save :keep_around_commit, if: :commit_id class << self - # Returns all todos for the given group and its descendants. + # Returns all todos for the given group ids and their descendants. # - # group - A `Group` to retrieve todos for. + # group_ids - Group Ids to retrieve todos for. # # Returns an `ActiveRecord::Relation`. - def for_group_and_descendants(group) - groups = group.self_and_descendants + def for_group_ids_and_descendants(group_ids) + groups = Group.groups_including_descendants_by(group_ids) from_union([ for_project(Project.for_group(groups)), @@ -144,10 +144,9 @@ class Todo < ApplicationRecord end end - def parent + def resource_parent project end - alias_method :resource_parent, :parent def unmergeable? action == UNMERGEABLE diff --git a/app/models/upload.rb b/app/models/upload.rb index 7560002ada8..8c409641452 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Upload < ApplicationRecord + include Checksummable # Upper limit for foreground checksum processing CHECKSUM_THRESHOLD = 100.megabytes @@ -15,16 +16,12 @@ class Upload < ApplicationRecord scope :with_files_stored_remotely, -> { where(store: ObjectStorage::Store::REMOTE) } before_save :calculate_checksum!, if: :foreground_checksummable? - after_commit :schedule_checksum, if: :checksummable? + after_commit :schedule_checksum, if: :needs_checksum? # as the FileUploader is not mounted, the default CarrierWave ActiveRecord # hooks are not executed and the file will not be deleted after_destroy :delete_file!, if: -> { uploader_class <= FileUploader } - def self.hexdigest(path) - Digest::SHA256.file(path).hexdigest - end - class << self ## # FastDestroyAll concerns @@ -53,20 +50,41 @@ class Upload < ApplicationRecord def calculate_checksum! self.checksum = nil - return unless checksummable? + return unless needs_checksum? - self.checksum = Digest::SHA256.file(absolute_path).hexdigest + self.checksum = self.class.hexdigest(absolute_path) end + # Initialize the associated Uploader class with current model + # + # @param [String] mounted_as + # @return [GitlabUploader] one of the subclasses, defined at the model's uploader attribute def build_uploader(mounted_as = nil) uploader_class.new(model, mounted_as || mount_point).tap do |uploader| uploader.upload = self + end + end + + # Initialize the associated Uploader class with current model and + # retrieve existing file from the store to a local cache + # + # @param [String] mounted_as + # @return [GitlabUploader] one of the subclasses, defined at the model's uploader attribute + def retrieve_uploader(mounted_as = nil) + build_uploader(mounted_as).tap do |uploader| uploader.retrieve_from_store!(identifier) end end + # This checks for existence of the upload on storage + # + # @return [Boolean] whether upload exists on storage def exist? - exist = File.exist?(absolute_path) + exist = if local? + File.exist?(absolute_path) + else + retrieve_uploader.exists? + end # Help sysadmins find missing upload files if persisted? && !exist @@ -91,18 +109,24 @@ class Upload < ApplicationRecord store == ObjectStorage::Store::LOCAL end + # Returns whether generating checksum is needed + # + # This takes into account whether file exists, if any checksum exists + # or if the storage has checksum generation code implemented + # + # @return [Boolean] whether generating a checksum is needed + def needs_checksum? + checksum.nil? && local? && exist? + end + private def delete_file! - build_uploader.remove! - end - - def checksummable? - checksum.nil? && local? && exist? + retrieve_uploader.remove! end def foreground_checksummable? - checksummable? && size <= CHECKSUM_THRESHOLD + needs_checksum? && size <= CHECKSUM_THRESHOLD end def schedule_checksum @@ -114,7 +138,7 @@ class Upload < ApplicationRecord end def uploader_class - Object.const_get(uploader) + Object.const_get(uploader, false) end def identifier diff --git a/app/models/user.rb b/app/models/user.rb index a69db121a0b..321a4080484 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -59,6 +59,8 @@ class User < ApplicationRecord # Removed in GitLab 12.3. Keep until after 2019-09-22. self.ignored_columns += %i[support_bot] + MINIMUM_INACTIVE_DAYS = 14 + # Override Devise::Models::Trackable#update_tracked_fields! # to limit database writes to at most once every hour # rubocop: disable CodeReuse/ServiceClass @@ -97,6 +99,7 @@ class User < ApplicationRecord has_many :u2f_registrations, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_one :user_synced_attributes_metadata, autosave: true + has_one :aws_role, class_name: 'Aws::Role' # Groups has_many :members @@ -228,6 +231,10 @@ class User < ApplicationRecord # Note: When adding an option, it MUST go on the end of the array. enum project_view: [:readme, :activity, :files] + # User's role + # Note: When adding an option, it MUST go on the end of the array. + enum role: [:software_developer, :development_team_lead, :devops_engineer, :systems_administrator, :security_analyst, :data_analyst, :product_manager, :product_designer, :other], _suffix: true + delegate :path, to: :namespace, allow_nil: true, prefix: true delegate :notes_filter_for, to: :user_preference delegate :set_notes_filter, to: :user_preference @@ -242,18 +249,25 @@ class User < ApplicationRecord state_machine :state, initial: :active do event :block do transition active: :blocked + transition deactivated: :blocked transition ldap_blocked: :blocked end event :ldap_block do transition active: :ldap_blocked + transition deactivated: :ldap_blocked end event :activate do + transition deactivated: :active transition blocked: :active transition ldap_blocked: :active end + event :deactivate do + transition active: :deactivated + end + state :blocked, :ldap_blocked do def blocked? true @@ -284,6 +298,7 @@ class User < ApplicationRecord scope :blocked, -> { with_states(:blocked, :ldap_blocked) } scope :external, -> { where(external: true) } scope :active, -> { with_state(:active).non_internal } + scope :deactivated, -> { with_state(:deactivated).non_internal } scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) } 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')) } @@ -431,6 +446,8 @@ class User < ApplicationRecord without_projects when 'external' external + when 'deactivated' + deactivated else active end @@ -444,6 +461,7 @@ class User < ApplicationRecord # # Returns an ActiveRecord::Relation. def search(query) + query = query&.delete_prefix('@') return none if query.blank? query = query.downcase @@ -520,7 +538,7 @@ class User < ApplicationRecord # Returns a user for the given SSH key. def find_by_ssh_key_id(key_id) - Key.find_by(id: key_id)&.user + find_by('EXISTS (?)', Key.select(1).where('keys.user_id = users.id').where(id: key_id)) end def find_by_full_path(path, follow_redirects: false) @@ -1303,14 +1321,27 @@ class User < ApplicationRecord notification_group&.notification_email_for(self) || notification_email end - def notification_settings_for(source) + def notification_settings_for(source, inherit: false) if notification_settings.loaded? notification_settings.find do |notification| notification.source_type == source.class.base_class.name && notification.source_id == source.id end else - notification_settings.find_or_initialize_by(source: source) + notification_settings.find_or_initialize_by(source: source) do |ns| + next unless source.is_a?(Group) && inherit + + # If we're here it means we're trying to create a NotificationSetting for a group that doesn't have one. + # Find the closest parent with a notification_setting that's not Global level, or that has an email set. + ancestor_ns = source + .notification_settings(hierarchy_order: :asc) + .where(user: self) + .find_by('level != ? OR notification_email IS NOT NULL', NotificationSetting.levels[:global]) + # Use it to seed the settings + ns.assign_attributes(ancestor_ns&.slice(*NotificationSetting.allowed_fields)) + ns.source = source + ns.user = self + end end end @@ -1529,6 +1560,35 @@ class User < ApplicationRecord todos.find_by(target: target, state: :pending) end + def password_expired? + !!(password_expires_at && password_expires_at < Time.now) + end + + def can_be_deactivated? + active? && no_recent_activity? + end + + def last_active_at + last_activity = last_activity_on&.to_time&.in_time_zone + last_sign_in = current_sign_in_at + + [last_activity, last_sign_in].compact.max + end + + # Below is used for the signup_flow experiment. Should be removed + # when experiment finishes. + # See https://gitlab.com/gitlab-org/growth/engineering/issues/64 + REQUIRES_ROLE_VALUE = 99 + + def role_required? + role_before_type_cast == REQUIRES_ROLE_VALUE + end + + def set_role_required! + update_column(:role, REQUIRES_ROLE_VALUE) + end + # End of signup_flow experiment methods + # @deprecated alias_method :owned_or_masters_groups, :owned_or_maintainers_groups @@ -1678,6 +1738,10 @@ class User < ApplicationRecord ::Group.where(id: developer_groups_hierarchy.select(:id), project_creation_level: project_creation_levels) end + + def no_recent_activity? + last_active_at.to_i <= MINIMUM_INACTIVE_DAYS.days.ago.to_i + end end User.prepend_if_ee('EE::User') diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index cd4c7895587..1fa29e5b933 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -77,11 +77,7 @@ class WikiPage # The escaped URL path of this page. def slug - if @attributes[:slug].present? - @attributes[:slug] - else - wiki.wiki.preview_slug(title, format) - end + @attributes[:slug].presence || wiki.wiki.preview_slug(title, format) end alias_method :to_param, :slug diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 78379516062..18c23cbd13a 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -5,12 +5,22 @@ require_dependency 'declarative_policy' class BasePolicy < DeclarativePolicy::Base desc "User is an instance admin" with_options scope: :user, score: 0 - condition(:admin) { @user&.admin? } + condition(:admin) do + if Feature.enabled?(:user_mode_in_session) + Gitlab::Auth::CurrentUserMode.new(@user).admin_mode? + else + @user&.admin? + end + end desc "User is blocked" with_options scope: :user, score: 0 condition(:blocked) { @user&.blocked? } + desc "User is deactivated" + with_options scope: :user, score: 0 + condition(:deactivated) { @user&.deactivated? } + desc "User has access to all private groups & projects" with_options scope: :user, score: 0 condition(:full_private_access) { @user&.full_private_access? } diff --git a/app/policies/board_policy.rb b/app/policies/board_policy.rb index b8435dad3f1..e2b16249c85 100644 --- a/app/policies/board_policy.rb +++ b/app/policies/board_policy.rb @@ -3,7 +3,7 @@ class BoardPolicy < BasePolicy include FindGroupProjects - delegate { @subject.parent } + delegate { @subject.resource_parent } condition(:is_group_board) { @subject.group_board? } condition(:is_project_board) { @subject.project_board? } @@ -19,7 +19,7 @@ class BoardPolicy < BasePolicy condition(:reporter_of_group_projects) do next unless @user - group_projects_for(user: @user, group: @subject.parent) + group_projects_for(user: @user, group: @subject.resource_parent) .visible_to_user_and_access_level(@user, ::Gitlab::Access::REPORTER) .exists? end diff --git a/app/policies/clusters/instance_policy.rb b/app/policies/clusters/instance_policy.rb index c8e6c973bf5..d8e8f9ff2c1 100644 --- a/app/policies/clusters/instance_policy.rb +++ b/app/policies/clusters/instance_policy.rb @@ -12,3 +12,5 @@ module Clusters end end end + +Clusters::InstancePolicy.prepend_if_ee('EE::Clusters::InstancePolicy') diff --git a/app/policies/deploy_keys_project_policy.rb b/app/policies/deploy_keys_project_policy.rb new file mode 100644 index 00000000000..368377048a4 --- /dev/null +++ b/app/policies/deploy_keys_project_policy.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class DeployKeysProjectPolicy < BasePolicy + delegate { @subject.project } + + with_options scope: :subject, score: 0 + condition(:public_deploy_key) { @subject.deploy_key.public? } + + rule { public_deploy_key & can?(:admin_project) }.enable :update_deploy_keys_project +end diff --git a/app/policies/deployment_policy.rb b/app/policies/deployment_policy.rb index d4f2f3c52b1..1a92b735e36 100644 --- a/app/policies/deployment_policy.rb +++ b/app/policies/deployment_policy.rb @@ -7,8 +7,20 @@ class DeploymentPolicy < BasePolicy can?(:update_build, @subject.deployable) end - rule { ~can_retry_deployable }.policy do + condition(:has_deployable) do + @subject.deployable.present? + end + + condition(:can_update_deployment) do + can?(:update_deployment, @subject.environment) + end + + rule { has_deployable & ~can_retry_deployable }.policy do prevent :create_deployment prevent :update_deployment end + + rule { ~can_update_deployment }.policy do + prevent :update_deployment + end end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 659fde574fc..eca73f0a241 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -44,6 +44,13 @@ class GlobalPolicy < BasePolicy prevent :use_slash_commands end + rule { deactivated }.policy do + prevent :access_git + prevent :access_api + prevent :receive_notifications + prevent :use_slash_commands + end + rule { required_terms_not_accepted }.policy do prevent :access_api prevent :access_git diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index f56ac0a5279..9e8ee3acf00 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -44,25 +44,28 @@ class GroupPolicy < BasePolicy rule { public_group }.policy do enable :read_group - enable :read_list - enable :read_label end rule { logged_in_viewable }.enable :read_group rule { guest }.policy do enable :read_group - enable :read_list enable :upload_file - enable :read_label end - rule { admin }.enable :read_group + rule { admin }.policy do + enable :read_group + enable :update_max_artifacts_size + end rule { has_projects }.policy do + enable :read_group + end + + rule { can?(:read_group) }.policy do + enable :read_milestone enable :read_list enable :read_label - enable :read_group end rule { has_access }.enable :read_namespace diff --git a/app/policies/milestone_policy.rb b/app/policies/milestone_policy.rb index ac4f5b08504..9cea8ddd7b3 100644 --- a/app/policies/milestone_policy.rb +++ b/app/policies/milestone_policy.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class MilestonePolicy < BasePolicy - delegate { @subject.project } + delegate { @subject.resource_parent } end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index e6f8d1052ed..ea2be37d7e6 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -137,6 +137,8 @@ class ProjectPolicy < BasePolicy # not. rule { guest | admin }.enable :read_project_for_iids + rule { admin }.enable :update_max_artifacts_size + rule { guest }.enable :guest_access rule { reporter }.enable :reporter_access rule { developer }.enable :developer_access @@ -260,6 +262,7 @@ class ProjectPolicy < BasePolicy enable :destroy_container_image enable :create_environment enable :create_deployment + enable :update_deployment enable :create_release enable :update_release end diff --git a/app/policies/todo_policy.rb b/app/policies/todo_policy.rb new file mode 100644 index 00000000000..f8644217f04 --- /dev/null +++ b/app/policies/todo_policy.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class TodoPolicy < BasePolicy + desc 'User can only read own todos' + condition(:own_todo) do + @user && @subject.user_id == @user.id + end + + rule { own_todo }.enable :read_todo +end diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index 5231a8efa55..8e469795581 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -34,7 +34,8 @@ module Ci def refspecs specs = [] - specs << refspec_for_merge_request_ref if merge_request_ref? + specs << refspec_for_pipeline_ref if merge_request_ref? + specs << refspec_for_persistent_ref if persistent_ref_exist? if git_depth > 0 specs << refspec_for_branch(ref) if branch? || legacy_detached_merge_request_pipeline? @@ -86,10 +87,22 @@ module Ci "+#{Gitlab::Git::TAG_REF_PREFIX}#{ref}:#{RUNNER_REMOTE_TAG_PREFIX}#{ref}" end - def refspec_for_merge_request_ref + def refspec_for_pipeline_ref "+#{ref}:#{ref}" end + def refspec_for_persistent_ref + "+#{persistent_ref_path}:#{persistent_ref_path}" + end + + def persistent_ref_exist? + pipeline.persistent_ref.exist? + end + + def persistent_ref_path + pipeline.persistent_ref.path + end + def git_depth_variable strong_memoize(:git_depth_variable) do variables&.find { |variable| variable[:key] == 'GIT_DEPTH' } diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index 73a048dfa56..d81b1e6c522 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -48,15 +48,35 @@ module Ci def ref_text if pipeline.detached_merge_request_pipeline? - _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch}").html_safe % { link_to_merge_request: link_to_merge_request, link_to_merge_request_source_branch: link_to_merge_request_source_branch } + _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch}") + .html_safe % { + link_to_merge_request: link_to_merge_request, + link_to_merge_request_source_branch: link_to_merge_request_source_branch + } elsif pipeline.merge_request_pipeline? - _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch} into %{link_to_merge_request_target_branch}").html_safe % { link_to_merge_request: link_to_merge_request, link_to_merge_request_source_branch: link_to_merge_request_source_branch, link_to_merge_request_target_branch: link_to_merge_request_target_branch } + _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch} into %{link_to_merge_request_target_branch}") + .html_safe % { + link_to_merge_request: link_to_merge_request, + link_to_merge_request_source_branch: link_to_merge_request_source_branch, + link_to_merge_request_target_branch: link_to_merge_request_target_branch + } + elsif pipeline.ref && pipeline.ref_exists? + _("for %{link_to_pipeline_ref}") + .html_safe % { link_to_pipeline_ref: link_to_pipeline_ref } elsif pipeline.ref - if pipeline.ref_exists? - _("for %{link_to_pipeline_ref}").html_safe % { link_to_pipeline_ref: link_to_pipeline_ref } - else - _("for %{ref}").html_safe % { ref: content_tag(:span, pipeline.ref, class: 'ref-name') } - end + _("for %{ref}").html_safe % { ref: plain_ref_name } + end + end + + def all_related_merge_request_text + if all_related_merge_requests.none? + 'No related merge requests found.' + else + _("%{count} related %{pluralized_subject}: %{links}" % { + count: all_related_merge_requests.count, + pluralized_subject: 'merge request'.pluralize(all_related_merge_requests.count), + links: all_related_merge_request_links.join(', ') + }).html_safe end end @@ -84,10 +104,30 @@ module Ci private + def plain_ref_name + content_tag(:span, pipeline.ref, class: 'ref-name') + end + def merge_request_presenter - return unless pipeline.triggered_by_merge_request? + strong_memoize(:merge_request_presenter) do + if pipeline.triggered_by_merge_request? + pipeline.merge_request.present(current_user: current_user) + end + end + end - @merge_request_presenter ||= pipeline.merge_request.present(current_user: current_user) + def all_related_merge_request_links + all_related_merge_requests.map do |merge_request| + mr_path = project_merge_request_path(merge_request.project, merge_request) + + link_to "#{merge_request.to_reference} #{merge_request.title}", mr_path, class: 'mr-iid' + end + end + + def all_related_merge_requests + strong_memoize(:all_related_merge_requests) do + pipeline.ref ? pipeline.all_merge_requests_by_recency.to_a : [] + end end end end diff --git a/app/presenters/commit_presenter.rb b/app/presenters/commit_presenter.rb index fc9853733c1..94fc8ac8e39 100644 --- a/app/presenters/commit_presenter.rb +++ b/app/presenters/commit_presenter.rb @@ -6,14 +6,29 @@ class CommitPresenter < Gitlab::View::Presenter::Delegated presents :commit def status_for(ref) - can?(current_user, :read_commit_status, commit.project) && commit.status(ref) + return unless can?(current_user, :read_commit_status, commit.project) + + commit.latest_pipeline(ref)&.detailed_status(current_user) end def any_pipelines? - can?(current_user, :read_pipeline, commit.project) && commit.pipelines.any? + return false unless can?(current_user, :read_pipeline, commit.project) + + commit.pipelines.any? end def web_url Gitlab::UrlBuilder.new(commit).url end + + def signature_html + return unless commit.has_signature? + + ApplicationController.renderer.render( + 'projects/commit/_signature', + locals: { signature: commit.signature }, + layout: false, + formats: [:html] + ) + end end diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb index c9dc0dbf443..3d55b00ac3b 100644 --- a/app/presenters/issue_presenter.rb +++ b/app/presenters/issue_presenter.rb @@ -11,6 +11,10 @@ class IssuePresenter < Gitlab::View::Presenter::Delegated url_builder.issue_path(issue) end + def subscribed? + issue.subscribed?(current_user, issue.project) + end + private def url_builder diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 6c300cd8be1..6d370f6241c 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -51,7 +51,8 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated new_file_anchor_data, readme_anchor_data, changelog_anchor_data, - contribution_guide_anchor_data + contribution_guide_anchor_data, + gitlab_ci_anchor_data ].compact.reject { |item| item.is_link } end diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb index 6f8c4e1f902..9bb7fe13593 100644 --- a/app/presenters/projects/settings/deploy_keys_presenter.rb +++ b/app/presenters/projects/settings/deploy_keys_presenter.rb @@ -40,7 +40,7 @@ module Projects def as_json serializer = DeployKeySerializer.new # rubocop: disable CodeReuse/Serializer - opts = { user: current_user } + opts = { user: current_user, project: project } { enabled_keys: serializer.represent(enabled_keys.with_projects, opts), diff --git a/app/presenters/todo_presenter.rb b/app/presenters/todo_presenter.rb new file mode 100644 index 00000000000..b57fc712c5a --- /dev/null +++ b/app/presenters/todo_presenter.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class TodoPresenter < Gitlab::View::Presenter::Delegated + include GlobalID::Identification + + presents :todo +end diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index 0c754157267..480a8cab6ff 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -121,4 +121,28 @@ class BuildDetailsEntity < JobEntity def can_admin_build? can?(request.current_user, :admin_build, project) end + + def callout_message + return super unless build.failure_reason.to_sym == :missing_dependency_failure + + docs_url = "https://docs.gitlab.com/ce/ci/yaml/README.html#dependencies" + + [ + failure_message.html_safe, + help_message(docs_url).html_safe + ].join("<br />") + end + + def invalid_dependencies + build.invalid_dependencies.map(&:name).join(', ') + end + + def failure_message + _("This job depends on other jobs with expired/erased artifacts: %{invalid_dependencies}") % + { invalid_dependencies: invalid_dependencies } + end + + def help_message(docs_url) + _("Please refer to <a href=\"%{docs_url}\">%{docs_url}</a>") % { docs_url: docs_url } + end end diff --git a/app/serializers/build_trace_entity.rb b/app/serializers/build_trace_entity.rb new file mode 100644 index 00000000000..b5bac8a5d64 --- /dev/null +++ b/app/serializers/build_trace_entity.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class BuildTraceEntity < Grape::Entity + expose :build_id, as: :id + expose :build_status, as: :status + expose :build_complete?, as: :complete + + expose :state + expose :append + expose :truncated + expose :offset + expose :size + expose :total + + expose :json_lines, as: :lines, if: ->(*) { object.json? } + expose :html_lines, as: :html, if: ->(*) { object.html? } +end diff --git a/app/serializers/build_trace_serializer.rb b/app/serializers/build_trace_serializer.rb new file mode 100644 index 00000000000..c95158f10a4 --- /dev/null +++ b/app/serializers/build_trace_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class BuildTraceSerializer < BaseSerializer + entity BuildTraceEntity +end diff --git a/app/serializers/commit_entity.rb b/app/serializers/commit_entity.rb index a94e32478ce..ae3f1c6bbf5 100644 --- a/app/serializers/commit_entity.rb +++ b/app/serializers/commit_entity.rb @@ -35,8 +35,8 @@ class CommitEntity < API::Entities::Commit pipeline_project = options[:pipeline_project] || commit.project next unless pipeline_ref && pipeline_project - status = commit.status_for_project(pipeline_ref, pipeline_project) - next unless status + pipeline = commit.latest_pipeline_for_project(pipeline_ref, pipeline_project) + next unless pipeline&.status pipelines_project_commit_path(pipeline_project, commit.id, ref: pipeline_ref) end diff --git a/app/serializers/container_repository_entity.rb b/app/serializers/container_repository_entity.rb index cc746698a05..db9cf1c7835 100644 --- a/app/serializers/container_repository_entity.rb +++ b/app/serializers/container_repository_entity.rb @@ -18,7 +18,7 @@ class ContainerRepositoryEntity < Grape::Entity alias_method :repository, :object def project - request.project + request.respond_to?(:project) ? request.project : object.project end def can_destroy? diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb index e47d6454780..9a558d12bec 100644 --- a/app/serializers/deploy_key_entity.rb +++ b/app/serializers/deploy_key_entity.rb @@ -20,6 +20,7 @@ class DeployKeyEntity < Grape::Entity private def can_edit - Ability.allowed?(options[:user], :update_deploy_key, object) + Ability.allowed?(options[:user], :update_deploy_key, object) || + Ability.allowed?(options[:user], :update_deploy_keys_project, object.deploy_keys_project_for(options[:project])) end end diff --git a/app/serializers/diff_file_metadata_entity.rb b/app/serializers/diff_file_metadata_entity.rb new file mode 100644 index 00000000000..500a844b170 --- /dev/null +++ b/app/serializers/diff_file_metadata_entity.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class DiffFileMetadataEntity < Grape::Entity + expose :added_lines + expose :removed_lines + expose :new_path + expose :old_path + expose :new_file?, as: :new_file + expose :deleted_file?, as: :deleted_file +end diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb index 1763fe5b6ab..19875a1287c 100644 --- a/app/serializers/diffs_entity.rb +++ b/app/serializers/diffs_entity.rb @@ -53,7 +53,7 @@ class DiffsEntity < Grape::Entity # rubocop: enable CodeReuse/ActiveRecord expose :render_overflow_warning do |diffs| - render_overflow_warning?(diffs.diff_files) + render_overflow_warning?(diffs) end expose :email_patch_path, if: -> (*) { merge_request } do |diffs| diff --git a/app/serializers/diffs_metadata_entity.rb b/app/serializers/diffs_metadata_entity.rb new file mode 100644 index 00000000000..c82c686e8ef --- /dev/null +++ b/app/serializers/diffs_metadata_entity.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class DiffsMetadataEntity < DiffsEntity + unexpose :diff_files + expose :diff_files, using: DiffFileMetadataEntity +end diff --git a/app/serializers/diffs_metadata_serializer.rb b/app/serializers/diffs_metadata_serializer.rb new file mode 100644 index 00000000000..9f3ba7b778d --- /dev/null +++ b/app/serializers/diffs_metadata_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class DiffsMetadataSerializer < BaseSerializer + entity DiffsMetadataEntity +end diff --git a/app/serializers/evidences/evidence_entity.rb b/app/serializers/evidences/evidence_entity.rb new file mode 100644 index 00000000000..9689ae10895 --- /dev/null +++ b/app/serializers/evidences/evidence_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Evidences + class EvidenceEntity < Grape::Entity + expose :release, using: Evidences::ReleaseEntity + end +end diff --git a/app/serializers/evidences/evidence_serializer.rb b/app/serializers/evidences/evidence_serializer.rb new file mode 100644 index 00000000000..d03032bc65c --- /dev/null +++ b/app/serializers/evidences/evidence_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Evidences + class EvidenceSerializer < BaseSerializer + entity EvidenceEntity + end +end diff --git a/app/serializers/evidences/issue_entity.rb b/app/serializers/evidences/issue_entity.rb new file mode 100644 index 00000000000..2f1f5dc3d18 --- /dev/null +++ b/app/serializers/evidences/issue_entity.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Evidences + class IssueEntity < Grape::Entity + expose :id + expose :title + expose :description + expose :state + expose :iid + expose :confidential + expose :created_at + expose :due_date + end +end diff --git a/app/serializers/evidences/milestone_entity.rb b/app/serializers/evidences/milestone_entity.rb new file mode 100644 index 00000000000..eeb3d58d4c7 --- /dev/null +++ b/app/serializers/evidences/milestone_entity.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Evidences + class MilestoneEntity < Grape::Entity + expose :id + expose :title + expose :description + expose :state + expose :iid + expose :created_at + expose :due_date + expose :issues, using: Evidences::IssueEntity + end +end diff --git a/app/serializers/evidences/project_entity.rb b/app/serializers/evidences/project_entity.rb new file mode 100644 index 00000000000..2a859c2afdc --- /dev/null +++ b/app/serializers/evidences/project_entity.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Evidences + class ProjectEntity < Grape::Entity + expose :id + expose :name + expose :description + expose :created_at + end +end diff --git a/app/serializers/evidences/release_entity.rb b/app/serializers/evidences/release_entity.rb new file mode 100644 index 00000000000..59e379a3c08 --- /dev/null +++ b/app/serializers/evidences/release_entity.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Evidences + class ReleaseEntity < Grape::Entity + expose :id + expose :tag, as: :tag_name + expose :name + expose :description + expose :created_at + expose :project, using: Evidences::ProjectEntity + expose :milestones, using: Evidences::MilestoneEntity + end +end diff --git a/app/serializers/evidences/release_serializer.rb b/app/serializers/evidences/release_serializer.rb new file mode 100644 index 00000000000..35a3bbc2275 --- /dev/null +++ b/app/serializers/evidences/release_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Evidences + class ReleaseSerializer < BaseSerializer + entity ReleaseEntity + end +end diff --git a/app/serializers/paginated_diff_entity.rb b/app/serializers/paginated_diff_entity.rb new file mode 100644 index 00000000000..622da926c69 --- /dev/null +++ b/app/serializers/paginated_diff_entity.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# Serializes diffs with pagination data. +# +# Avoid adding more keys to this serializer as processing the +# diff file serialization is not cheap. +# +class PaginatedDiffEntity < Grape::Entity + include RequestAwareEntity + + expose :diff_files do |diffs, options| + submodule_links = Gitlab::SubmoduleLinks.new(merge_request.project.repository) + DiffFileEntity.represent(diffs.diff_files, options.merge(submodule_links: submodule_links)) + end + + expose :pagination do + expose :current_page + expose :next_page + expose :total_pages + expose :next_page_href do |diffs| + next unless next_page + + project = merge_request.target_project + + diffs_batch_namespace_project_json_merge_request_path( + namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: merge_request.iid, + page: next_page, + format: :json + ) + end + end + + private + + %i[current_page next_page total_pages].each do |method| + define_method method do + pagination_data[method] + end + end + + def pagination_data + options.fetch(:pagination_data, {}) + end + + def merge_request + options[:merge_request] + end +end diff --git a/app/serializers/paginated_diff_serializer.rb b/app/serializers/paginated_diff_serializer.rb new file mode 100644 index 00000000000..9b40fbf7843 --- /dev/null +++ b/app/serializers/paginated_diff_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class PaginatedDiffSerializer < BaseSerializer + entity PaginatedDiffEntity +end diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb index 808e87c3fcf..71589ac8315 100644 --- a/app/serializers/pipeline_details_entity.rb +++ b/app/serializers/pipeline_details_entity.rb @@ -10,6 +10,7 @@ class PipelineDetailsEntity < PipelineEntity expose :manual_actions, using: BuildActionEntity expose :scheduled_actions, using: BuildActionEntity end -end -PipelineDetailsEntity.prepend_if_ee('EE::PipelineDetailsEntity') + expose :triggered_by_pipeline, as: :triggered_by, with: TriggeredPipelineEntity + expose :triggered_pipelines, as: :triggered, using: TriggeredPipelineEntity +end diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index eaaeaf040a2..fc3160e3c69 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -54,9 +54,9 @@ class PipelineSerializer < BaseSerializer artifacts: { project: [:route, { namespace: :route }] } - } + }, + { triggered_by_pipeline: [:project, :user] }, + { triggered_pipelines: [:project, :user] } ] end end - -PipelineSerializer.prepend_if_ee('EE::PipelineSerializer') diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb index a46f8af1466..a1e0bf02d11 100644 --- a/app/serializers/projects/serverless/service_entity.rb +++ b/app/serializers/projects/serverless/service_entity.rb @@ -44,7 +44,7 @@ module Projects end expose :url do |service| - "http://#{service.dig('status', 'domain')}" + service.dig('status', 'url') || "http://#{service.dig('status', 'domain')}" end expose :description do |service| diff --git a/app/serializers/test_report_entity.rb b/app/serializers/test_report_entity.rb new file mode 100644 index 00000000000..9eb487da60a --- /dev/null +++ b/app/serializers/test_report_entity.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class TestReportEntity < Grape::Entity + expose :total_time + expose :total_count + + expose :success_count + expose :failed_count + expose :skipped_count + expose :error_count + + expose :test_suites, using: TestSuiteEntity do |report| + report.test_suites.values + end +end diff --git a/app/serializers/test_report_serializer.rb b/app/serializers/test_report_serializer.rb new file mode 100644 index 00000000000..8906b09bc6a --- /dev/null +++ b/app/serializers/test_report_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class TestReportSerializer < BaseSerializer + entity TestReportEntity +end diff --git a/app/serializers/test_suite_entity.rb b/app/serializers/test_suite_entity.rb new file mode 100644 index 00000000000..0f88a496c77 --- /dev/null +++ b/app/serializers/test_suite_entity.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class TestSuiteEntity < Grape::Entity + expose :name + expose :total_time + expose :total_count + + expose :success_count + expose :failed_count + expose :skipped_count + expose :error_count + + expose :test_cases, using: TestCaseEntity do |test_suite| + test_suite.test_cases.values.flat_map(&:values) + end +end diff --git a/app/serializers/triggered_pipeline_entity.rb b/app/serializers/triggered_pipeline_entity.rb new file mode 100644 index 00000000000..fd7e4454abf --- /dev/null +++ b/app/serializers/triggered_pipeline_entity.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class TriggeredPipelineEntity < Grape::Entity + include RequestAwareEntity + + MAX_EXPAND_DEPTH = 3 + + expose :id + expose :user, using: UserEntity + expose :active?, as: :active + expose :coverage + expose :source + + expose :path do |pipeline| + project_pipeline_path(pipeline.project, pipeline) + end + + expose :details do + expose :detailed_status, as: :status, with: DetailedStatusEntity + + expose :ordered_stages, + as: :stages, using: StageEntity, + if: -> (_, opts) { can_read_details? && expand?(opts) } + end + + expose :triggered_by_pipeline, + as: :triggered_by, with: TriggeredPipelineEntity, + if: -> (_, opts) { can_read_details? && expand_for_path?(opts) } + + expose :triggered_pipelines, + as: :triggered, using: TriggeredPipelineEntity, + if: -> (_, opts) { can_read_details? && expand_for_path?(opts) } + + expose :project, using: ProjectEntity + + private + + alias_method :pipeline, :object + + def can_read_details? + can?(request.current_user, :read_pipeline, pipeline) + end + + def detailed_status + pipeline.detailed_status(request.current_user) + end + + def expand?(opts) + opts[:expanded].to_a.include?(pipeline.id) + end + + def expand_for_path?(opts) + # The `opts[:attr_path]` holds a list of all `exposes` in path + # The check ensures that we always expand only `triggered_by`, `triggered_by`, ... + # but not the `triggered_by`, `triggered` which would result in dead loop + attr_path = opts[:attr_path] + current_expose = attr_path.last + + # We expand at most to depth of MAX_DEPTH + # We ensure that we expand in one direction: triggered_by,... or triggered, ... + attr_path.length < MAX_EXPAND_DEPTH && + attr_path.all?(current_expose) && + expand?(opts) + end +end diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb index 6400b182715..df9217bea32 100644 --- a/app/services/application_settings/update_service.rb +++ b/app/services/application_settings/update_service.rb @@ -20,7 +20,11 @@ module ApplicationSettings add_to_outbound_local_requests_whitelist(@params.delete(:add_to_outbound_local_requests_whitelist)) if params.key?(:performance_bar_allowed_group_path) - params[:performance_bar_allowed_group_id] = performance_bar_allowed_group_id + group_id = process_performance_bar_allowed_group_id + + return false if application_setting.errors.any? + + params[:performance_bar_allowed_group_id] = group_id end if usage_stats_updated? && !params.delete(:skip_usage_stats_user) @@ -65,12 +69,27 @@ module ApplicationSettings @application_setting.reset_memoized_terms end - def performance_bar_allowed_group_id - performance_bar_enabled = !params.key?(:performance_bar_enabled) || params.delete(:performance_bar_enabled) + def process_performance_bar_allowed_group_id group_full_path = params.delete(:performance_bar_allowed_group_path) - return unless Gitlab::Utils.to_boolean(performance_bar_enabled) + enable_param_on = Gitlab::Utils.to_boolean(params.delete(:performance_bar_enabled)) + performance_bar_enabled = enable_param_on.nil? || enable_param_on # Default to true + + return if group_full_path.blank? + return if enable_param_on == false # Explicitly disabling + + unless performance_bar_enabled + application_setting.errors.add(:performance_bar_allowed_group_id, 'not allowed when performance bar is disabled') + return + end + + group = Group.find_by_full_path(group_full_path.chomp('/')) + + unless group + application_setting.errors.add(:performance_bar_allowed_group_id, 'not found') + return + end - Group.find_by_full_path(group_full_path)&.id if group_full_path.present? + group.id end def bypass_external_auth? diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb index 10eb1141f59..37a74cd1b00 100644 --- a/app/services/boards/issues/list_service.rb +++ b/app/services/boards/issues/list_service.rb @@ -11,9 +11,11 @@ module Boards # rubocop: disable CodeReuse/ActiveRecord def metadata + issues = Issue.arel_table keys = metadata_fields.keys - columns = metadata_fields.values_at(*keys).join(', ') - results = Issue.where(id: fetch_issues.select('issues.id')).pluck(columns) + # TODO: eliminate need for SQL literal fragment + columns = Arel.sql(metadata_fields.values_at(*keys).join(', ')) + results = Issue.where(id: fetch_issues.select(issues[:id])).pluck(columns) Hash[keys.zip(results.flatten)] end diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb index eb417ac4f5f..6f9a063cb16 100644 --- a/app/services/boards/lists/create_service.rb +++ b/app/services/boards/lists/create_service.rb @@ -43,7 +43,11 @@ module Boards end def create_list(board, type, target, position) - board.lists.create(type => target, list_type: type, position: position) + board.lists.create(create_list_attributes(type, target, position)) + end + + def create_list_attributes(type, target, position) + { type => target, list_type: type, position: position } end end end diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb index 3609d9c6283..82cba1b68c4 100644 --- a/app/services/boards/lists/list_service.rb +++ b/app/services/boards/lists/list_service.rb @@ -6,7 +6,7 @@ module Boards def execute(board) board.lists.create(list_type: :backlog) unless board.lists.backlog.exists? - board.lists.preload_associations(current_user) + board.lists.preload_associations end end end diff --git a/app/services/boards/lists/update_service.rb b/app/services/boards/lists/update_service.rb index ad96e42f756..4a463372c82 100644 --- a/app/services/boards/lists/update_service.rb +++ b/app/services/boards/lists/update_service.rb @@ -4,16 +4,22 @@ module Boards module Lists class UpdateService < Boards::BaseService def execute(list) - update_preferences_result = update_preferences(list) if can_read?(list) - update_position_result = update_position(list) if can_admin?(list) - - if update_preferences_result || update_position_result + if execute_by_params(list) success(list: list) else error(list.errors.messages, 422) end end + private + + def execute_by_params(list) + update_preferences_result = update_preferences(list) if can_read?(list) + update_position_result = update_position(list) if can_admin?(list) + + update_preferences_result || update_position_result + end + def update_preferences(list) return unless preferences? @@ -50,3 +56,5 @@ module Boards end end end + +Boards::Lists::UpdateService.prepend_if_ee('EE::Boards::Lists::UpdateService') diff --git a/app/services/bulk_push_event_payload_service.rb b/app/services/bulk_push_event_payload_service.rb new file mode 100644 index 00000000000..54157bc23f9 --- /dev/null +++ b/app/services/bulk_push_event_payload_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class BulkPushEventPayloadService + def initialize(event, push_data) + @event = event + @push_data = push_data + end + + def execute + @event.build_push_event_payload( + action: @push_data[:action], + commit_count: 0, + ref_count: @push_data[:ref_count], + ref_type: @push_data[:ref_type] + ) + + @event.push_event_payload.tap(&:save!) + end +end diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb index 0e99f142492..37b9b4c362c 100644 --- a/app/services/ci/pipeline_trigger_service.rb +++ b/app/services/ci/pipeline_trigger_service.rb @@ -38,11 +38,34 @@ module Ci end def create_pipeline_from_job(job) - # overridden in EE + # this check is to not leak the presence of the project if user cannot read it + return unless can?(job.user, :read_project, project) + + return error("400 Job has to be running", 400) unless job.running? + + pipeline = Ci::CreatePipelineService.new(project, job.user, ref: params[:ref]) + .execute(:pipeline, ignore_skip_ci: true) do |pipeline| + source = job.sourced_pipelines.build( + source_pipeline: job.pipeline, + source_project: job.project, + pipeline: pipeline, + project: project) + + pipeline.source_pipeline = source + pipeline.variables.build(variables) + end + + if pipeline.persisted? + success(pipeline: pipeline) + else + error(pipeline.errors.messages, 400) + end end def job_from_token - # overridden in EE + strong_memoize(:job) do + Ci::Build.find_by_token(params[:token].to_s) + end end def variables @@ -52,5 +75,3 @@ module Ci end end end - -Ci::PipelineTriggerService.prepend_if_ee('EE::Ci::PipelineTriggerService') diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index 3b145a65d79..039670f58c8 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -2,6 +2,8 @@ module Ci class ProcessPipelineService < BaseService + include Gitlab::Utils::StrongMemoize + attr_reader :pipeline def execute(pipeline, trigger_build_ids = nil) @@ -33,9 +35,9 @@ module Ci return unless HasStatus::COMPLETED_STATUSES.include?(current_status) - created_processables_in_stage_without_needs(index).select do |build| + created_processables_in_stage_without_needs(index).find_each.select do |build| process_build(build, current_status) - end + end.any? end def process_builds_with_needs(trigger_build_ids) @@ -92,6 +94,7 @@ module Ci def created_processables_in_stage_without_needs(index) created_processables_without_needs + .with_preloads .for_stage(index) end diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index 338495ba030..7a5e33c61ba 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -39,9 +39,18 @@ module Ci .where(name: build.name) .update_all(retried: true) - project.builds.create!(Hash[attributes]) + create_build!(attributes) end end # rubocop: enable CodeReuse/ActiveRecord + + private + + def create_build!(attributes) + build = project.builds.new(Hash[attributes]) + build.deployment = ::Gitlab::Ci::Pipeline::Seed::Deployment.new(build).to_resource + build.save! + build + end end end diff --git a/app/services/clusters/gcp/finalize_creation_service.rb b/app/services/clusters/gcp/finalize_creation_service.rb index c5cde831964..0aff1bcc8b9 100644 --- a/app/services/clusters/gcp/finalize_creation_service.rb +++ b/app/services/clusters/gcp/finalize_creation_service.rb @@ -11,6 +11,7 @@ module Clusters configure_provider create_gitlab_service_account! configure_kubernetes + configure_pre_installed_knative if provider.knative_pre_installed? cluster.save! rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e log_service_error(e.class.name, provider.id, e.message) @@ -48,6 +49,13 @@ module Clusters token: request_kubernetes_token) end + def configure_pre_installed_knative + knative = cluster.build_application_knative( + hostname: 'example.com' + ) + knative.make_pre_installed! + end + def request_kubernetes_token Clusters::Kubernetes::FetchKubernetesTokenService.new( kube_client, diff --git a/app/services/clusters/gcp/provision_service.rb b/app/services/clusters/gcp/provision_service.rb index 80040511ec2..7dc2d3c32f1 100644 --- a/app/services/clusters/gcp/provision_service.rb +++ b/app/services/clusters/gcp/provision_service.rb @@ -3,6 +3,8 @@ module Clusters module Gcp class ProvisionService + CLOUD_RUN_ADDONS = %i[http_load_balancing istio_config cloud_run_config].freeze + attr_reader :provider def execute(provider) @@ -22,13 +24,16 @@ module Clusters private def get_operation_id + enable_addons = provider.cloud_run? ? CLOUD_RUN_ADDONS : [] + operation = provider.api_client.projects_zones_clusters_create( provider.gcp_project_id, provider.zone, provider.cluster.name, provider.num_nodes, machine_type: provider.machine_type, - legacy_abac: provider.legacy_abac + legacy_abac: provider.legacy_abac, + enable_addons: enable_addons ) unless operation.status == 'PENDING' || operation.status == 'RUNNING' diff --git a/app/services/concerns/git/change_params.rb b/app/services/concerns/git/change_params.rb new file mode 100644 index 00000000000..32faf805b5e --- /dev/null +++ b/app/services/concerns/git/change_params.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Git + module ChangeParams + private + + %i[oldrev newrev ref].each do |method| + define_method method do + change[method] + end + end + + def change + @change ||= params.fetch(:change, {}) + end + end +end diff --git a/app/services/deployments/after_create_service.rb b/app/services/deployments/after_create_service.rb new file mode 100644 index 00000000000..2572802e6a1 --- /dev/null +++ b/app/services/deployments/after_create_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Deployments + class AfterCreateService + attr_reader :deployment + attr_reader :deployable + + delegate :environment, to: :deployment + delegate :variables, to: :deployable + delegate :options, to: :deployable, allow_nil: true + + def initialize(deployment) + @deployment = deployment + @deployable = deployment.deployable + end + + def execute + deployment.create_ref + deployment.invalidate_cache + + update_environment(deployment) + + deployment + end + + def update_environment(deployment) + ActiveRecord::Base.transaction do + if (url = expanded_environment_url) + environment.external_url = url + end + + environment.fire_state_event(action) + + if environment.save && !environment.stopped? + deployment.update_merge_request_metrics! + end + end + end + + private + + def environment_options + options&.dig(:environment) || {} + end + + def expanded_environment_url + ExpandVariables.expand(environment_url, -> { variables }) if environment_url + end + + def environment_url + environment_options[:url] + end + + def action + environment_options[:action] || 'start' + end + end +end + +Deployments::AfterCreateService.prepend_if_ee('EE::Deployments::AfterCreateService') diff --git a/app/services/deployments/create_service.rb b/app/services/deployments/create_service.rb new file mode 100644 index 00000000000..89e3f7c8b83 --- /dev/null +++ b/app/services/deployments/create_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Deployments + class CreateService + attr_reader :environment, :current_user, :params + + def initialize(environment, current_user, params) + @environment = environment + @current_user = current_user + @params = params + end + + def execute + create_deployment.tap do |deployment| + AfterCreateService.new(deployment).execute if deployment.persisted? + end + end + + def create_deployment + environment.deployments.create(deployment_attributes) + end + + def deployment_attributes + # We use explicit parameters here so we never by accident allow parameters + # to be set that one should not be able to set (e.g. the row ID). + { + cluster_id: environment.deployment_platform&.cluster_id, + project_id: environment.project_id, + environment_id: environment.id, + ref: params[:ref], + tag: params[:tag], + sha: params[:sha], + user: current_user, + on_stop: params[:on_stop], + status: params[:status] + } + end + end +end diff --git a/app/services/deployments/update_service.rb b/app/services/deployments/update_service.rb new file mode 100644 index 00000000000..7c8215d28f2 --- /dev/null +++ b/app/services/deployments/update_service.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Deployments + class UpdateService + attr_reader :deployment, :params + + def initialize(deployment, params) + @deployment = deployment + @params = params + end + + def execute + deployment.update(status: params[:status]) + end + end +end diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb index 395c5fe09ac..f7282c22a52 100644 --- a/app/services/event_create_service.rb +++ b/app/services/event_create_service.rb @@ -73,15 +73,27 @@ class EventCreateService end def push(project, current_user, push_data) + create_push_event(PushEventPayloadService, project, current_user, push_data) + end + + def bulk_push(project, current_user, push_data) + create_push_event(BulkPushEventPayloadService, project, current_user, push_data) + end + + private + + def create_record_event(record, current_user, status) + create_event(record.resource_parent, current_user, status, target_id: record.id, target_type: record.class.name) + end + + def create_push_event(service_class, project, current_user, push_data) # We're using an explicit transaction here so that any errors that may occur # when creating push payload data will result in the event creation being # rolled back as well. event = Event.transaction do new_event = create_event(project, current_user, Event::PUSHED) - PushEventPayloadService - .new(new_event, push_data) - .execute + service_class.new(new_event, push_data).execute new_event end @@ -92,12 +104,6 @@ class EventCreateService Users::ActivityService.new(current_user, 'push').execute end - private - - def create_record_event(record, current_user, status) - create_event(record.resource_parent, current_user, status, target_id: record.id, target_type: record.class.name) - end - def create_event(resource_parent, current_user, status, attributes = {}) attributes.reverse_merge!( action: status, diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb index 35a4d2015fa..0801fd4d03f 100644 --- a/app/services/git/base_hooks_service.rb +++ b/app/services/git/base_hooks_service.rb @@ -3,6 +3,7 @@ module Git class BaseHooksService < ::BaseService include Gitlab::Utils::StrongMemoize + include ChangeParams # The N most recent commits to process in a single push payload. PROCESS_COMMIT_LIMIT = 100 @@ -15,8 +16,6 @@ module Git # Not a hook, but it needs access to the list of changed commits enqueue_invalidate_cache - update_remote_mirrors - success end @@ -49,6 +48,8 @@ module Git # Push events in the activity feed only show information for the # last commit. def create_events + return unless params.fetch(:create_push_event, true) + EventCreateService.new.push(project, current_user, event_push_data) end @@ -63,6 +64,8 @@ module Git end def execute_project_hooks + return unless params.fetch(:execute_project_hooks, true) + # Creating push_data invokes one CommitDelta RPC per commit. Only # build this data if we actually need it. project.execute_hooks(push_data, hook_name) if project.has_active_hooks?(hook_name) @@ -79,20 +82,20 @@ module Git def pipeline_params { - before: params[:oldrev], - after: params[:newrev], - ref: params[:ref], + before: oldrev, + after: newrev, + ref: ref, push_options: params[:push_options] || {}, checkout_sha: Gitlab::DataBuilder::Push.checkout_sha( - project.repository, params[:newrev], params[:ref]) + project.repository, newrev, ref) } end def push_data_params(commits:, with_changed_files: true) { - oldrev: params[:oldrev], - newrev: params[:newrev], - ref: params[:ref], + oldrev: oldrev, + newrev: newrev, + ref: ref, project: project, user: current_user, commits: commits, @@ -121,13 +124,6 @@ module Git {} end - def update_remote_mirrors - return unless project.has_remote_mirror? - - project.mark_stuck_remote_mirrors_as_failed! - project.update_remote_mirrors - end - def log_pipeline_errors(exception) data = { class: self.class.name, diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb index c633cff2822..69f1f9eb31f 100644 --- a/app/services/git/branch_hooks_service.rb +++ b/app/services/git/branch_hooks_service.rb @@ -20,15 +20,15 @@ module Git strong_memoize(:commits) do if creating_default_branch? # The most recent PROCESS_COMMIT_LIMIT commits in the default branch - project.repository.commits(params[:newrev], limit: PROCESS_COMMIT_LIMIT) + project.repository.commits(newrev, limit: PROCESS_COMMIT_LIMIT) elsif creating_branch? # Use the pushed commits that aren't reachable by the default branch # as a heuristic. This may include more commits than are actually # pushed, but that shouldn't matter because we check for existing # cross-references later. - project.repository.commits_between(project.default_branch, params[:newrev]) + project.repository.commits_between(project.default_branch, newrev) elsif updating_branch? - project.repository.commits_between(params[:oldrev], params[:newrev]) + project.repository.commits_between(oldrev, newrev) else # removing branch [] end @@ -70,7 +70,7 @@ module Git def branch_update_hooks # Update the bare repositories info/attributes file using the contents of # the default branch's .gitattributes file - project.repository.copy_gitattributes(params[:ref]) if default_branch? + project.repository.copy_gitattributes(ref) if default_branch? end def branch_change_hooks @@ -118,7 +118,7 @@ module Git # https://gitlab.com/gitlab-org/gitlab-foss/issues/59257 def creating_branch? strong_memoize(:creating_branch) do - Gitlab::Git.blank_ref?(params[:oldrev]) || + Gitlab::Git.blank_ref?(oldrev) || !project.repository.branch_exists?(branch_name) end end @@ -128,7 +128,7 @@ module Git end def removing_branch? - Gitlab::Git.blank_ref?(params[:newrev]) + Gitlab::Git.blank_ref?(newrev) end def creating_default_branch? @@ -137,7 +137,7 @@ module Git def count_commits_in_branch strong_memoize(:count_commits_in_branch) do - project.repository.commit_count_for_ref(params[:ref]) + project.repository.commit_count_for_ref(ref) end end @@ -148,7 +148,7 @@ module Git end def branch_name - strong_memoize(:branch_name) { Gitlab::Git.ref_name(params[:ref]) } + strong_memoize(:branch_name) { Gitlab::Git.ref_name(ref) } end def upstream_commit_ids(commits) diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb index 49c54e42b7c..da45bcc7eaa 100644 --- a/app/services/git/branch_push_service.rb +++ b/app/services/git/branch_push_service.rb @@ -4,6 +4,7 @@ module Git class BranchPushService < ::BaseService include Gitlab::Access include Gitlab::Utils::StrongMemoize + include ChangeParams # This method will be called after each git update # and only if the provided user and project are present in GitLab. @@ -19,7 +20,7 @@ module Git # 6. Checks if the project's main language has changed # def execute - return unless Gitlab::Git.branch_ref?(params[:ref]) + return unless Gitlab::Git.branch_ref?(ref) enqueue_update_mrs enqueue_detect_repository_languages @@ -38,9 +39,9 @@ module Git UpdateMergeRequestsWorker.perform_async( project.id, current_user.id, - params[:oldrev], - params[:newrev], - params[:ref] + oldrev, + newrev, + ref ) end @@ -57,13 +58,6 @@ module Git Ci::StopEnvironmentsService.new(project, current_user).execute(branch_name) end - 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 BranchHooksService.new(project, current_user, params).execute end @@ -76,11 +70,11 @@ module Git end def removing_branch? - Gitlab::Git.blank_ref?(params[:newrev]) + Gitlab::Git.blank_ref?(newrev) end def branch_name - strong_memoize(:branch_name) { Gitlab::Git.ref_name(params[:ref]) } + strong_memoize(:branch_name) { Gitlab::Git.ref_name(ref) } end def default_branch? diff --git a/app/services/git/process_ref_changes_service.rb b/app/services/git/process_ref_changes_service.rb new file mode 100644 index 00000000000..3052bed51bc --- /dev/null +++ b/app/services/git/process_ref_changes_service.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Git + class ProcessRefChangesService < BaseService + PIPELINE_PROCESS_LIMIT = 4 + + def execute + changes = params[:changes] + + process_changes_by_action(:branch, changes.branch_changes) + process_changes_by_action(:tag, changes.tag_changes) + end + + private + + def process_changes_by_action(ref_type, changes) + changes_by_action = group_changes_by_action(changes) + + changes_by_action.each do |action, changes| + process_changes(ref_type, action, changes, execute_project_hooks: execute_project_hooks?(changes)) if changes.any? + end + end + + def group_changes_by_action(changes) + changes.group_by do |change| + change_action(change) + end + end + + def change_action(change) + return :created if Gitlab::Git.blank_ref?(change[:oldrev]) + return :removed if Gitlab::Git.blank_ref?(change[:newrev]) + + :pushed + end + + def execute_project_hooks?(changes) + (changes.size <= Gitlab::CurrentSettings.push_event_hooks_limit) || Feature.enabled?(:git_push_execute_all_project_hooks, project) + end + + def process_changes(ref_type, action, changes, execute_project_hooks:) + push_service_class = push_service_class_for(ref_type) + + create_bulk_push_event = changes.size > Gitlab::CurrentSettings.push_event_activities_limit + + changes.each do |change| + push_service_class.new( + project, + current_user, + change: change, + push_options: params[:push_options], + create_pipelines: change[:index] < PIPELINE_PROCESS_LIMIT || Feature.enabled?(:git_push_create_all_pipelines, project), + execute_project_hooks: execute_project_hooks, + create_push_event: !create_bulk_push_event + ).execute + end + + create_bulk_push_event(ref_type, action, changes) if create_bulk_push_event + end + + def create_bulk_push_event(ref_type, action, changes) + EventCreateService.new.bulk_push( + project, + current_user, + Gitlab::DataBuilder::Push.build_bulk(action: action, ref_type: ref_type, changes: changes) + ) + end + + def push_service_class_for(ref_type) + return Git::TagPushService if ref_type == :tag + + Git::BranchPushService + end + end +end diff --git a/app/services/git/tag_hooks_service.rb b/app/services/git/tag_hooks_service.rb index e5b109c79d6..0e5e1bbc992 100644 --- a/app/services/git/tag_hooks_service.rb +++ b/app/services/git/tag_hooks_service.rb @@ -18,12 +18,12 @@ module Git def tag strong_memoize(:tag) do - next if Gitlab::Git.blank_ref?(params[:newrev]) + next if Gitlab::Git.blank_ref?(newrev) - tag_name = Gitlab::Git.ref_name(params[:ref]) + tag_name = Gitlab::Git.ref_name(ref) tag = project.repository.find_tag(tag_name) - tag if tag && tag.target == params[:newrev] + tag if tag && tag.target == newrev end end diff --git a/app/services/git/tag_push_service.rb b/app/services/git/tag_push_service.rb index ee4166dccd0..9a266f7d74c 100644 --- a/app/services/git/tag_push_service.rb +++ b/app/services/git/tag_push_service.rb @@ -2,8 +2,10 @@ module Git class TagPushService < ::BaseService + include ChangeParams + def execute - return unless Gitlab::Git.tag_ref?(params[:ref]) + return unless Gitlab::Git.tag_ref?(ref) project.repository.before_push_tag TagHooksService.new(project, current_user, params).execute diff --git a/app/services/grafana/proxy_service.rb b/app/services/grafana/proxy_service.rb new file mode 100644 index 00000000000..74fcdc750b0 --- /dev/null +++ b/app/services/grafana/proxy_service.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# Proxies calls to a Grafana-integrated Prometheus instance +# through the Grafana proxy API + +# This allows us to fetch and render metrics in GitLab from a Prometheus +# instance for which dashboards are configured in Grafana +module Grafana + class ProxyService < BaseService + include ReactiveCaching + + self.reactive_cache_key = ->(service) { service.cache_key } + self.reactive_cache_lease_timeout = 30.seconds + self.reactive_cache_refresh_interval = 30.seconds + self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } + + attr_accessor :project, :datasource_id, :proxy_path, :query_params + + # @param project_id [Integer] Project id for which grafana is configured. + # + # See #initialize for other parameters. + def self.from_cache(project_id, datasource_id, proxy_path, query_params) + project = Project.find(project_id) + + new(project, datasource_id, proxy_path, query_params) + end + + # @param project [Project] Project for which grafana is configured. + # @param datasource_id [String] Grafana datasource id for Prometheus instance + # @param proxy_path [String] Path to Prometheus endpoint; EX) 'api/v1/query_range' + # @param query_params [Hash<String, String>] Supported params: [query, start, end, step] + def initialize(project, datasource_id, proxy_path, query_params) + @project = project + @datasource_id = datasource_id + @proxy_path = proxy_path + @query_params = query_params + end + + def execute + return cannot_proxy_response unless client + + with_reactive_cache(*cache_key) { |result| result } + end + + def calculate_reactive_cache(*) + return cannot_proxy_response unless client + + response = client.proxy_datasource( + datasource_id: datasource_id, + proxy_path: proxy_path, + query: query_params + ) + + success(http_status: response.code, body: response.body) + rescue ::Grafana::Client::Error => error + service_unavailable_response(error) + end + + # Required for ReactiveCaching; Usage overridden by + # self.reactive_cache_worker_finder + def id + nil + end + + def cache_key + [project.id, datasource_id, proxy_path, query_params] + end + + private + + def client + project.grafana_integration&.client + end + + def service_unavailable_response(exception) + error(exception.message, :service_unavailable) + end + + def cannot_proxy_response + error('Proxy support for this API is not available currently') + end + end +end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index 61bd50616b8..8cc31200689 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -9,6 +9,7 @@ module Groups def execute remove_unallowed_params + set_visibility_level @group = Group.new(params) @@ -68,6 +69,12 @@ module Groups true end + + def set_visibility_level + return if visibility_level.present? + + params[:visibility_level] = Gitlab::CurrentSettings.current_application_settings.default_group_visibility + end end end diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index fe7e07ef9f0..6902b7bd529 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -7,7 +7,8 @@ module Groups namespace_with_same_path: s_('TransferGroup|The parent group already has a subgroup with the same path.'), group_is_already_root: s_('TransferGroup|Group is already a root group.'), same_parent_as_current: s_('TransferGroup|Group is already associated to the parent group.'), - invalid_policies: s_("TransferGroup|You don't have enough permissions.") + invalid_policies: s_("TransferGroup|You don't have enough permissions."), + group_contains_images: s_('TransferGroup|Cannot update the path because there are projects under this group that contain Docker images in their Container Registry. Please remove the images from your projects first and try again.') }.freeze TransferError = Class.new(StandardError) @@ -46,6 +47,7 @@ module Groups raise_transfer_error(:same_parent_as_current) if same_parent? raise_transfer_error(:invalid_policies) unless valid_policies? raise_transfer_error(:namespace_with_same_path) if namespace_with_same_path? + raise_transfer_error(:group_contains_images) if group_projects_contain_registry_images? end def group_is_already_root? @@ -72,6 +74,10 @@ module Groups end # rubocop: enable CodeReuse/ActiveRecord + def group_projects_contain_registry_images? + @group.has_container_repositories? + end + def update_group_attributes if @new_parent_group && @new_parent_group.visibility_level < @group.visibility_level update_children_and_projects_visibility diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index 534de601e20..be7502a193e 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -8,6 +8,11 @@ module Groups reject_parent_id! remove_unallowed_params + if renaming_group_with_container_registry_images? + group.errors.add(:base, container_images_error) + return false + end + return false unless valid_visibility_level_change?(group, params[:visibility_level]) return false unless valid_share_with_group_lock_change? @@ -35,6 +40,17 @@ module Groups # overridden in EE end + def renaming_group_with_container_registry_images? + new_path = params[:path] + + new_path && new_path != group.path && + group.has_container_repositories? + end + + def container_images_error + s_("GroupSettings|Cannot update the path because there are projects under this group that contain Docker images in their Container Registry. Please remove the images from your projects first and try again.") + end + def after_update if group.previous_changes.include?(:visibility_level) && group.private? # don't enqueue immediately to prevent todos removal in case of a mistake diff --git a/app/services/import_export_clean_up_service.rb b/app/services/import_export_clean_up_service.rb index 3ecb51b60d0..66ac7dac4ca 100644 --- a/app/services/import_export_clean_up_service.rb +++ b/app/services/import_export_clean_up_service.rb @@ -12,16 +12,20 @@ class ImportExportCleanUpService def execute Gitlab::Metrics.measure(:import_export_clean_up) do - clean_up_export_object_files - - break unless File.directory?(path) - - clean_up_export_files + execute_cleanup end end private + def execute_cleanup + clean_up_export_object_files + ensure + # We don't want a failure in cleaning up object storage from + # blocking us from cleaning up temporary storage. + clean_up_export_files if File.directory?(path) + end + def clean_up_export_files Gitlab::Popen.popen(%W(find #{path} -not -path #{path} -mmin +#{mmin} -delete)) end diff --git a/app/services/issuable/clone/content_rewriter.rb b/app/services/issuable/clone/content_rewriter.rb index f75b51c4be3..67d2f9fd3fe 100644 --- a/app/services/issuable/clone/content_rewriter.rb +++ b/app/services/issuable/clone/content_rewriter.rb @@ -39,6 +39,10 @@ module Issuable if note.system_note_metadata new_params[:system_note_metadata] = note.system_note_metadata.dup + + # TODO: Implement copying of description versions when an issue is moved + # https://gitlab.com/gitlab-org/gitlab/issues/32300 + new_params[:system_note_metadata].description_version = nil end new_note.update(new_params) diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index 805721212ba..14585c2850b 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -4,7 +4,7 @@ module Issues class CloseService < Issues::BaseService # Closes the supplied issue if the current user is able to do so. def execute(issue, commit: nil, notifications: true, system_note: true) - return issue unless can?(current_user, :update_issue, issue) + return issue unless can?(current_user, :update_issue, issue) || issue.is_a?(ExternalIssue) close_issue(issue, closed_via: commit, @@ -18,7 +18,7 @@ module Issues # The code calling this method is responsible for ensuring that a user is # allowed to close the given issue. def close_issue(issue, closed_via: nil, notifications: true, system_note: true) - if project.jira_tracker? && project.jira_service.active && issue.is_a?(ExternalIssue) + if project.jira_tracker_active? && issue.is_a?(ExternalIssue) project.jira_service.close_issue(closed_via, issue) todo_service.close_issue(issue, current_user) return issue diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index dc3c363f650..528b1ea61b3 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -56,7 +56,7 @@ module Issues handle_milestone_change(issue) - added_mentions = issue.mentioned_users - old_mentioned_users + added_mentions = issue.mentioned_users(current_user) - old_mentioned_users if added_mentions.present? notification_service.async.new_mentions_in_issue(issue, added_mentions, current_user) diff --git a/app/services/issues/zoom_link_service.rb b/app/services/issues/zoom_link_service.rb index a061ab22875..561c86475e5 100644 --- a/app/services/issues/zoom_link_service.rb +++ b/app/services/issues/zoom_link_service.rb @@ -10,6 +10,7 @@ module Issues def add_link(link) if can_add_link? && (link = parse_link(link)) + track_meeting_added_event success(_('Zoom meeting added'), append_to_description(link)) else error(_('Failed to add a Zoom meeting')) @@ -17,11 +18,12 @@ module Issues end def can_add_link? - available? && !link_in_issue_description? + can? && !link_in_issue_description? end def remove_link if can_remove_link? + track_meeting_removed_event success(_('Zoom meeting removed'), remove_from_description) else error(_('Failed to remove a Zoom meeting')) @@ -29,7 +31,7 @@ module Issues end def can_remove_link? - available? && link_in_issue_description? + can? && link_in_issue_description? end def parse_link(link) @@ -44,6 +46,14 @@ module Issues issue.description || '' end + def track_meeting_added_event + ::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'add_zoom_meeting', label: 'Issue ID', value: issue.id) + end + + def track_meeting_removed_event + ::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'remove_zoom_meeting', label: 'Issue ID', value: issue.id) + end + def success(message, description) ServiceResponse .success(message: message, payload: { description: description }) @@ -75,14 +85,6 @@ module Issues issue_description[/(\S+)\z/, 1] end - def available? - feature_enabled? && can? - end - - def feature_enabled? - Feature.enabled?(:issue_zoom_integration, project) - end - def can? current_user.can?(:update_issue, project) end diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index fbe6c48ac28..0364c0dd479 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -29,9 +29,7 @@ module MergeRequests closed_issues = merge_request.visible_closing_issues_for(current_user) closed_issues.each do |issue| - if can?(current_user, :update_issue, issue) - Issues::CloseService.new(project, current_user, {}).execute(issue, commit: merge_request) - end + Issues::CloseService.new(project, current_user).execute(issue, commit: merge_request) end end diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index edcfc3bf33f..b32499629ff 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -25,6 +25,7 @@ module MergeRequests outdate_suggestions refresh_pipelines_on_merge_requests abort_auto_merges + abort_ff_merge_requests_with_when_pipeline_succeeds mark_pending_todos_done cache_merge_requests_closing_issues @@ -148,6 +149,31 @@ module MergeRequests end end + def abort_ff_merge_requests_with_when_pipeline_succeeds + return unless @project.ff_merge_must_be_possible? + + requests_with_auto_merge_enabled_to(@push.branch_name).each do |merge_request| + next unless merge_request.should_be_rebased? + + abort_auto_merge_with_todo(merge_request, 'target branch was updated') + end + end + + def abort_auto_merge_with_todo(merge_request, reason) + response = abort_auto_merge(merge_request, reason) + response = ServiceResponse.new(response) + return unless response.success? + + todo_service.merge_request_became_unmergeable(merge_request) + end + + def requests_with_auto_merge_enabled_to(target_branch) + @project + .merge_requests + .by_target_branch(target_branch) + .with_open_merge_when_pipeline_succeeds + end + def mark_pending_todos_done merge_requests_for_source_branch.each do |merge_request| todo_service.merge_request_push(merge_request, @current_user) diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 4acc3f1981a..ae678d4c036 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -69,7 +69,8 @@ module MergeRequests ) end - added_mentions = merge_request.mentioned_users - old_mentioned_users + added_mentions = merge_request.mentioned_users(current_user) - old_mentioned_users + if added_mentions.present? notification_service.async.new_mentions_in_merge_request( merge_request, diff --git a/app/services/note_summary.rb b/app/services/note_summary.rb index 60a68568833..6fe14939aaa 100644 --- a/app/services/note_summary.rb +++ b/app/services/note_summary.rb @@ -10,6 +10,10 @@ class NoteSummary project: project, author: author, note: body } @metadata = { action: action, commit_count: commit_count }.compact + if action == 'description' && noteable.saved_description_version + @metadata[:description_version] = noteable.saved_description_version + end + set_commit_params if note[:noteable].is_a?(Commit) end diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb index 076df10bf6f..7e6568b5b25 100644 --- a/app/services/notes/quick_actions_service.rb +++ b/app/services/notes/quick_actions_service.rb @@ -50,7 +50,7 @@ module Notes return if update_params.empty? return unless supported?(note) - self.class.noteable_update_service(note).new(note.parent, current_user, update_params).execute(note.noteable) + self.class.noteable_update_service(note).new(note.resource_parent, current_user, update_params).execute(note.noteable) end end end diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb index 853faed9d85..573be8fbe8b 100644 --- a/app/services/notes/update_service.rb +++ b/app/services/notes/update_service.rb @@ -5,7 +5,7 @@ module Notes def execute(note) return note unless note.editable? - old_mentioned_users = note.mentioned_users.to_a + old_mentioned_users = note.mentioned_users(current_user).to_a note.update(params.merge(updated_by: current_user)) diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index fca64270cae..9afbb678f5d 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -28,6 +28,10 @@ module NotificationRecipientService Builder::ProjectMaintainers.new(*args).notification_recipients end + def self.build_new_release_recipients(*args) + Builder::NewRelease.new(*args).notification_recipients + end + module Builder class Base def initialize(*) @@ -359,6 +363,26 @@ module NotificationRecipientService end end + class NewRelease < Base + attr_reader :target + + def initialize(target) + @target = target + end + + def build! + add_recipients(target.project.authorized_users, :custom, nil) + end + + def custom_action + :new_release + end + + def acting_user + target.author + end + end + class MergeRequestUnmergeable < Base attr_reader :target def initialize(merge_request) diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index ed357aa0392..b56b2cf14e3 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -289,6 +289,15 @@ class NotificationService end end + # Notify users when a new release is created + def send_new_release_notifications(release) + recipients = NotificationRecipientService.build_new_release_recipients(release) + + recipients.each do |recipient| + mailer.new_release_email(recipient.user.id, release, recipient.reason).deliver_later + end + end + # Members def new_access_request(member) return true unless member.notifiable?(:subscription) diff --git a/app/services/projects/after_import_service.rb b/app/services/projects/after_import_service.rb index e30da0f26df..6fc15db9b4c 100644 --- a/app/services/projects/after_import_service.rb +++ b/app/services/projects/after_import_service.rb @@ -9,9 +9,16 @@ module Projects end def execute - Projects::HousekeepingService.new(@project).execute do + service = Projects::HousekeepingService.new(@project) + + service.execute do repository.delete_all_refs_except(RESERVED_REF_PREFIXES) end + + # Right now we don't actually have a way to know if a project + # import actually changed, so we increment the counter to avoid + # causing GC to run every time. + service.increment! rescue Projects::HousekeepingService::LeaseTaken => e Rails.logger.info( # rubocop:disable Gitlab/RailsLogger "Could not perform housekeeping for project #{@project.full_path} (#{@project.id}): #{e}") diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb index d1d9b9f22e8..1b880a7aab1 100644 --- a/app/services/projects/container_repository/cleanup_tags_service.rb +++ b/app/services/projects/container_repository/cleanup_tags_service.rb @@ -40,7 +40,7 @@ module Projects return unless tags.count == other_tags.count # delete all tags - tags.map(&:delete) + tags.map(&:unsafe_delete) end def group_by_digest(tags) diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb new file mode 100644 index 00000000000..5129e2269a8 --- /dev/null +++ b/app/services/projects/container_repository/delete_tags_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Projects + module ContainerRepository + class DeleteTagsService < BaseService + def execute(container_repository) + return error('access denied') unless can?(current_user, :destroy_container_image, project) + + tag_names = params[:tags] + return error('not tags specified') if tag_names.blank? + + if can_use? + smart_delete(container_repository, tag_names) + else + unsafe_delete(container_repository, tag_names) + end + end + + private + + def unsafe_delete(container_repository, tag_names) + deleted_tags = tag_names.select do |tag_name| + container_repository.tag(tag_name).unsafe_delete + end + + return error('could not delete tags') if deleted_tags.empty? + + success(deleted: deleted_tags) + end + + # Replace a tag on the registry with a dummy tag. + # This is a hack as the registry doesn't support deleting individual + # tags. This code effectively pushes a dummy image and assigns the tag to it. + # This way when the tag is deleted only the dummy image is affected. + # See https://gitlab.com/gitlab-org/gitlab/issues/15737 for a discussion + def smart_delete(container_repository, tag_names) + # generates the blobs for the dummy image + dummy_manifest = container_repository.client.generate_empty_manifest(container_repository.path) + + # update the manifests of the tags with the new dummy image + tag_digests = tag_names.map do |name| + container_repository.client.put_tag(container_repository.path, name, dummy_manifest) + end + + # make sure the digests are the same (it should always be) + tag_digests.uniq! + + # rubocop: disable CodeReuse/ActiveRecord + Gitlab::Sentry.track_exception(ArgumentError.new('multiple tag digests')) if tag_digests.many? + + # Deletes the dummy image + # All created tag digests are the same since they all have the same dummy image. + # a single delete is sufficient to remove all tags with it + if container_repository.delete_tag_by_digest(tag_digests.first) + success(deleted: tag_names) + else + error('could not delete tags') + end + end + + def can_use? + Feature.enabled?(:container_registry_smart_delete, project, default_enabled: true) + end + end + end +end diff --git a/app/services/projects/create_from_template_service.rb b/app/services/projects/create_from_template_service.rb index 91ece024e13..a207fd2c574 100644 --- a/app/services/projects/create_from_template_service.rb +++ b/app/services/projects/create_from_template_service.rb @@ -4,8 +4,11 @@ module Projects class CreateFromTemplateService < BaseService include Gitlab::Utils::StrongMemoize + attr_reader :template_name + def initialize(user, params) @current_user, @params = user, params.to_h.dup + @template_name = @params.delete(:template_name).presence end def execute @@ -21,12 +24,6 @@ module Projects file&.close end - def template_name - strong_memoize(:template_name) do - params.delete(:template_name).presence - end - end - private def validate_template! diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 728eb039b54..ef06545b27d 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -13,7 +13,7 @@ module Projects end def execute - if @params[:template_name].present? + if create_from_template? return ::Projects::CreateFromTemplateService.new(current_user, params).execute end @@ -184,6 +184,10 @@ module Projects private + def create_from_template? + @params[:template_name].present? || @params[:template_project_id].present? + end + def import_schedule if @project.errors.empty? @project.import_state.schedule if @project.import? && !@project.bare_repository_import? diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 5fdf98c3c5e..90e703e7050 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -123,11 +123,9 @@ module Projects mv_repository(old_path, new_path) end - # rubocop: disable CodeReuse/ActiveRecord def repo_exists?(path) - gitlab_shell.exists?(project.repository_storage, path + '.git') + gitlab_shell.repository_exists?(project.repository_storage, path + '.git') end - # rubocop: enable CodeReuse/ActiveRecord def mv_repository(from_path, to_path) return true unless repo_exists?(from_path) diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index 17686b45900..47ab7f9a8a0 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -43,6 +43,7 @@ module Projects shared_runners_enabled: @project.shared_runners_enabled, namespace_id: target_namespace.id, fork_network: fork_network, + ci_config_path: @project.ci_config_path, # We need to set ci_default_git_depth to 0 for the forked project when # @project.ci_default_git_depth is nil in order to keep the same behaviour # and not get ProjectCiCdSetting::DEFAULT_GIT_DEPTH set on create diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb index f97a28b8c3b..b7e9d3e8791 100644 --- a/app/services/projects/hashed_storage/base_repository_service.rb +++ b/app/services/projects/hashed_storage/base_repository_service.rb @@ -20,16 +20,13 @@ module Projects protected - # rubocop: disable CodeReuse/ActiveRecord def has_wiki? - gitlab_shell.exists?(project.repository_storage, "#{old_wiki_disk_path}.git") + gitlab_shell.repository_exists?(project.repository_storage, "#{old_wiki_disk_path}.git") end - # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord def move_repository(from_name, to_name) - from_exists = gitlab_shell.exists?(project.repository_storage, "#{from_name}.git") - to_exists = gitlab_shell.exists?(project.repository_storage, "#{to_name}.git") + from_exists = gitlab_shell.repository_exists?(project.repository_storage, "#{from_name}.git") + to_exists = gitlab_shell.repository_exists?(project.repository_storage, "#{to_name}.git") # If we don't find the repository on either original or target we should log that as it could be an issue if the # project was not originally empty. @@ -46,7 +43,6 @@ module Projects gitlab_shell.mv_repository(project.repository_storage, from_name, to_name) end - # rubocop: enable CodeReuse/ActiveRecord def rollback_folder_move move_repository(new_disk_path, old_disk_path) diff --git a/app/services/projects/hashed_storage/migrate_repository_service.rb b/app/services/projects/hashed_storage/migrate_repository_service.rb index e248a13c702..0a0bd90cd20 100644 --- a/app/services/projects/hashed_storage/migrate_repository_service.rb +++ b/app/services/projects/hashed_storage/migrate_repository_service.rb @@ -8,7 +8,6 @@ module Projects @old_storage_version = project.storage_version project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository] - project.ensure_storage_path_exists @new_disk_path = project.disk_path diff --git a/app/services/projects/hashed_storage/rollback_repository_service.rb b/app/services/projects/hashed_storage/rollback_repository_service.rb index 67733f4770b..a705112ebe3 100644 --- a/app/services/projects/hashed_storage/rollback_repository_service.rb +++ b/app/services/projects/hashed_storage/rollback_repository_service.rb @@ -8,7 +8,6 @@ module Projects @old_storage_version = project.storage_version project.storage_version = nil - project.ensure_storage_path_exists @new_disk_path = project.disk_path diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb index 9c6d7ef41f6..d3638c57552 100644 --- a/app/services/projects/import_export/export_service.rb +++ b/app/services/projects/import_export/export_service.rb @@ -12,6 +12,8 @@ module Projects private + attr_accessor :shared + def execute_after_export_action(after_export_strategy) return unless after_export_strategy @@ -21,50 +23,54 @@ module Projects end def save_all! - if save_services - Gitlab::ImportExport::Saver.save(project: project, shared: @shared) + if save_exporters + Gitlab::ImportExport::Saver.save(project: project, shared: shared) notify_success else cleanup_and_notify_error! end end - def save_services - [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver, lfs_saver].all?(&:save) + def save_exporters + exporters.all?(&:save) + end + + def exporters + [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver, lfs_saver] end def version_saver - Gitlab::ImportExport::VersionSaver.new(shared: @shared) + Gitlab::ImportExport::VersionSaver.new(shared: shared) end def avatar_saver - Gitlab::ImportExport::AvatarSaver.new(project: project, shared: @shared) + Gitlab::ImportExport::AvatarSaver.new(project: project, shared: shared) end def project_tree_saver - Gitlab::ImportExport::ProjectTreeSaver.new(project: project, current_user: @current_user, shared: @shared, params: @params) + Gitlab::ImportExport::ProjectTreeSaver.new(project: project, current_user: current_user, shared: shared, params: params) end def uploads_saver - Gitlab::ImportExport::UploadsSaver.new(project: project, shared: @shared) + Gitlab::ImportExport::UploadsSaver.new(project: project, shared: shared) end def repo_saver - Gitlab::ImportExport::RepoSaver.new(project: project, shared: @shared) + Gitlab::ImportExport::RepoSaver.new(project: project, shared: shared) end def wiki_repo_saver - Gitlab::ImportExport::WikiRepoSaver.new(project: project, shared: @shared) + Gitlab::ImportExport::WikiRepoSaver.new(project: project, shared: shared) end def lfs_saver - Gitlab::ImportExport::LfsSaver.new(project: project, shared: @shared) + Gitlab::ImportExport::LfsSaver.new(project: project, shared: shared) end def cleanup_and_notify_error - Rails.logger.error("Import/Export - Project #{project.name} with ID: #{project.id} export error - #{@shared.errors.join(', ')}") # rubocop:disable Gitlab/RailsLogger + Rails.logger.error("Import/Export - Project #{project.name} with ID: #{project.id} export error - #{shared.errors.join(', ')}") # rubocop:disable Gitlab/RailsLogger - FileUtils.rm_rf(@shared.export_path) + FileUtils.rm_rf(shared.export_path) notify_error end @@ -72,7 +78,7 @@ module Projects def cleanup_and_notify_error! cleanup_and_notify_error - raise Gitlab::ImportExport::Error.new(@shared.errors.join(', ')) + raise Gitlab::ImportExport::Error.new(shared.errors.to_sentence) end def notify_success @@ -80,8 +86,10 @@ module Projects end def notify_error - notification_service.project_not_exported(@project, @current_user, @shared.errors) + notification_service.project_not_exported(project, current_user, shared.errors) end end end end + +Projects::ImportExport::ExportService.prepend_if_ee('EE::Projects::ImportExport::ExportService') diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb index dd72c2844c2..64519501ff4 100644 --- a/app/services/projects/operations/update_service.rb +++ b/app/services/projects/operations/update_service.rb @@ -12,7 +12,9 @@ module Projects private def project_update_params - error_tracking_params.merge(metrics_setting_params) + error_tracking_params + .merge(metrics_setting_params) + .merge(grafana_integration_params) end def metrics_setting_params @@ -44,6 +46,14 @@ module Projects } } end + + def grafana_integration_params + return {} unless attrs = params[:grafana_integration_attributes] + + destroy = attrs[:grafana_url].blank? && attrs[:token].blank? + + { grafana_integration_attributes: attrs.merge(_destroy: destroy) } + end end end end diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index fa7a4f0ed82..e8a87fc4320 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -53,6 +53,7 @@ module Projects def success @status.success + @project.mark_pages_as_deployed super end diff --git a/app/services/search/snippet_service.rb b/app/services/search/snippet_service.rb index 7c6c6878400..e686d3bf7c2 100644 --- a/app/services/search/snippet_service.rb +++ b/app/services/search/snippet_service.rb @@ -1,17 +1,9 @@ # frozen_string_literal: true module Search - class SnippetService - attr_accessor :current_user, :params - - def initialize(user, params) - @current_user, @params = user, params.dup - end - + class SnippetService < Search::GlobalService def execute - snippets = SnippetsFinder.new(current_user).execute - - Gitlab::SnippetSearchResults.new(snippets, params[:search]) + Gitlab::SnippetSearchResults.new(current_user, params[:search]) end def scope diff --git a/app/services/spam_service.rb b/app/services/spam_service.rb index f2f133dae28..babe69cfdc8 100644 --- a/app/services/spam_service.rb +++ b/app/services/spam_service.rb @@ -37,7 +37,8 @@ class SpamService else # Otherwise, it goes to Akismet and check if it's a spam. If that's the # case, it assigns spammable record as "spam" and create a SpamLog record. - spammable.spam = check(api) + possible_spam = check(api) + spammable.spam = possible_spam unless spammable.allow_possible_spam? spammable.spam_log = spam_log end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index c01094bd689..b3eee01ea7a 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -16,20 +16,9 @@ module SystemNoteService # existing_commits - Array of Commits added in a previous push # oldrev - Optional String SHA of a previous Commit # - # See new_commit_summary and existing_commit_summary. - # # Returns the created Note object def add_commits(noteable, project, author, new_commits, existing_commits = [], oldrev = nil) - total_count = new_commits.length + existing_commits.length - commits_text = "#{total_count} commit".pluralize(total_count) - - text_parts = ["added #{commits_text}"] - text_parts << commits_list(noteable, new_commits, existing_commits, oldrev) - text_parts << "[Compare with previous version](#{diff_comparison_path(noteable, project, oldrev)})" - - body = text_parts.join("\n\n") - - create_note(NoteSummary.new(noteable, project, author, body, action: 'commit', commit_count: total_count)) + ::SystemNotes::CommitService.new(noteable: noteable, project: project, author: author).add_commits(new_commits, existing_commits, oldrev) end # Called when a commit was tagged @@ -41,84 +30,19 @@ module SystemNoteService # # Returns the created Note object def tag_commit(noteable, project, author, tag_name) - link = url_helpers.project_tag_path(project, id: tag_name) - body = "tagged commit #{noteable.sha} to [`#{tag_name}`](#{link})" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'tag')) + ::SystemNotes::CommitService.new(noteable: noteable, project: project, author: author).tag_commit(tag_name) end - # Called when the assignee of a Noteable is changed or removed - # - # noteable - Noteable object - # project - Project owning noteable - # author - User performing the change - # assignee - User being assigned, or nil - # - # Example Note text: - # - # "removed assignee" - # - # "assigned to @rspeicher" - # - # Returns the created Note object def change_assignee(noteable, project, author, assignee) - body = assignee.nil? ? 'removed assignee' : "assigned to #{assignee.to_reference}" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee')) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_assignee(assignee) end - # Called when the assignees of an issuable is changed or removed - # - # issuable - Issuable object (responds to assignees) - # project - Project owning noteable - # author - User performing the change - # assignees - Users being assigned, or nil - # - # Example Note text: - # - # "removed all assignees" - # - # "assigned to @user1 additionally to @user2" - # - # "assigned to @user1, @user2 and @user3 and unassigned from @user4 and @user5" - # - # "assigned to @user1 and @user2" - # - # Returns the created Note object def change_issuable_assignees(issuable, project, author, old_assignees) - unassigned_users = old_assignees - issuable.assignees - added_users = issuable.assignees.to_a - old_assignees - text_parts = [] - - Gitlab::I18n.with_default_locale do - text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any? - text_parts << "unassigned #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any? - end - - body = text_parts.join(' and ') - - create_note(NoteSummary.new(issuable, project, author, body, action: 'assignee')) + ::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).change_issuable_assignees(old_assignees) end - # Called when the milestone of a Noteable is changed - # - # noteable - Noteable object - # project - Project owning noteable - # author - User performing the change - # milestone - Milestone being assigned, or nil - # - # Example Note text: - # - # "removed milestone" - # - # "changed milestone to 7.11" - # - # Returns the created Note object def change_milestone(noteable, project, author, milestone) - format = milestone&.group_milestone? ? :name : :iid - body = milestone.nil? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project, format: format)}" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'milestone')) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_milestone(milestone) end # Called when the due_date of a Noteable is changed @@ -198,28 +122,8 @@ module SystemNoteService create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking')) end - # Called when the status of a Noteable is changed - # - # noteable - Noteable object - # project - Project owning noteable - # author - User performing the change - # status - String status - # source - Mentionable performing the change, or nil - # - # Example Note text: - # - # "merged" - # - # "closed via bc17db76" - # - # Returns the created Note object def change_status(noteable, project, author, status, source = nil) - body = status.dup - body << " via #{source.gfm_reference(project)}" if source - - action = status == 'reopened' ? 'opened' : status - - create_note(NoteSummary.new(noteable, project, author, body, action: action)) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_status(status, source) end # Called when 'merge when pipeline succeeds' is executed @@ -302,69 +206,16 @@ module SystemNoteService note end - # Called when the title of a Noteable is changed - # - # noteable - Noteable object that responds to `title` - # project - Project owning noteable - # author - User performing the change - # old_title - Previous String title - # - # Example Note text: - # - # "changed title from **Old** to **New**" - # - # Returns the created Note object def change_title(noteable, project, author, old_title) - new_title = noteable.title.dup - - old_diffs, new_diffs = Gitlab::Diff::InlineDiff.new(old_title, new_title).inline_diffs - - marked_old_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(old_title).mark(old_diffs, mode: :deletion) - marked_new_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(new_title).mark(new_diffs, mode: :addition) - - body = "changed title from **#{marked_old_title}** to **#{marked_new_title}**" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_title(old_title) end - # Called when the description of a Noteable is changed - # - # noteable - Noteable object that responds to `description` - # project - Project owning noteable - # author - User performing the change - # - # Example Note text: - # - # "changed the description" - # - # Returns the created Note object def change_description(noteable, project, author) - body = 'changed the description' - - create_note(NoteSummary.new(noteable, project, author, body, action: 'description')) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_description end - # Called when the confidentiality changes - # - # issue - Issue object - # project - Project owning the issue - # author - User performing the change - # - # Example Note text: - # - # "made the issue confidential" - # - # Returns the created Note object def change_issue_confidentiality(issue, project, author) - if issue.confidential - body = 'made the issue confidential' - action = 'confidential' - else - body = 'made the issue visible to everyone' - action = 'visible' - end - - create_note(NoteSummary.new(issue, project, author, body, action: action)) + ::SystemNotes::IssuablesService.new(noteable: issue, project: project, author: author).change_issue_confidentiality end # Called when a branch in Noteable is changed @@ -433,195 +284,48 @@ module SystemNoteService create_note(NoteSummary.new(issue, project, author, body, action: 'merge')) end - # Called when a Mentionable references a Noteable - # - # noteable - Noteable object being referenced - # mentioner - Mentionable object - # author - User performing the reference - # - # Example Note text: - # - # "mentioned in #1" - # - # "mentioned in !2" - # - # "mentioned in 54f7727c" - # - # See cross_reference_note_content. - # - # Returns the created Note object def cross_reference(noteable, mentioner, author) - return if cross_reference_disallowed?(noteable, mentioner) - - gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group) - body = cross_reference_note_content(gfm_reference) - - if noteable.is_a?(ExternalIssue) - noteable.project.issues_tracker.create_cross_reference_note(noteable, mentioner, author) - else - create_note(NoteSummary.new(noteable, noteable.project, author, body, action: 'cross_reference')) - end + ::SystemNotes::IssuablesService.new(noteable: noteable, author: author).cross_reference(mentioner) end - # Check if a cross-reference is disallowed - # - # This method prevents adding a "mentioned in !1" note on every single commit - # in a merge request. Additionally, it prevents the creation of references to - # external issues (which would fail). - # - # noteable - Noteable object being referenced - # mentioner - Mentionable object - # - # Returns Boolean - def cross_reference_disallowed?(noteable, mentioner) - return true if noteable.is_a?(ExternalIssue) && !noteable.project.jira_tracker_active? - return false unless mentioner.is_a?(MergeRequest) - return false unless noteable.is_a?(Commit) - - mentioner.commits.include?(noteable) - end - - # Check if a cross reference to a noteable from a mentioner already exists - # - # This method is used to prevent multiple notes being created for a mention - # when a issue is updated, for example. The method also calls notes_for_mentioner - # to check if the mentioner is a commit, and return matches only on commit hash - # instead of project + commit, to avoid repeated mentions from forks. - # - # noteable - Noteable object being referenced - # mentioner - Mentionable object - # - # Returns Boolean def cross_reference_exists?(noteable, mentioner) - notes = noteable.notes.system - notes_for_mentioner(mentioner, noteable, notes).exists? - end - - # Build an Array of lines detailing each commit added in a merge request - # - # new_commits - Array of new Commit objects - # - # Returns an Array of Strings - def new_commit_summary(new_commits) - new_commits.collect do |commit| - content_tag('li', "#{commit.short_id} - #{commit.title}") - end + ::SystemNotes::IssuablesService.new(noteable: noteable).cross_reference_exists?(mentioner) end - # Called when the status of a Task has changed - # - # noteable - Noteable object. - # project - Project owning noteable - # author - User performing the change - # new_task - TaskList::Item object. - # - # Example Note text: - # - # "marked the task Whatever as completed." - # - # Returns the created Note object def change_task_status(noteable, project, author, new_task) - status_label = new_task.complete? ? Taskable::COMPLETED : Taskable::INCOMPLETE - body = "marked the task **#{new_task.source}** as #{status_label}" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'task')) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_task_status(new_task) end - # Called when noteable has been moved to another project - # - # direction - symbol, :to or :from - # noteable - Noteable object - # noteable_ref - Referenced noteable - # author - User performing the move - # - # Example Note text: - # - # "moved to some_namespace/project_new#11" - # - # Returns the created Note object def noteable_moved(noteable, project, noteable_ref, author, direction:) - unless [:to, :from].include?(direction) - raise ArgumentError, "Invalid direction `#{direction}`" - end - - cross_reference = noteable_ref.to_reference(project) - body = "moved #{direction} #{cross_reference}" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'moved')) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_moved(noteable_ref, direction) end - # Called when a Noteable has been marked as a duplicate of another Issue - # - # noteable - Noteable object - # project - Project owning noteable - # author - User performing the change - # canonical_issue - Issue that this is a duplicate of - # - # Example Note text: - # - # "marked this issue as a duplicate of #1234" - # - # "marked this issue as a duplicate of other_project#5678" - # - # Returns the created Note object def mark_duplicate_issue(noteable, project, author, canonical_issue) - body = "marked this issue as a duplicate of #{canonical_issue.to_reference(project)}" - create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate')) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).mark_duplicate_issue(canonical_issue) end - # Called when a Noteable has been marked as the canonical Issue of a duplicate - # - # noteable - Noteable object - # project - Project owning noteable - # author - User performing the change - # duplicate_issue - Issue that was a duplicate of this - # - # Example Note text: - # - # "marked #1234 as a duplicate of this issue" - # - # "marked other_project#5678 as a duplicate of this issue" - # - # Returns the created Note object def mark_canonical_issue_of_duplicate(noteable, project, author, duplicate_issue) - body = "marked #{duplicate_issue.to_reference(project)} as a duplicate of this issue" - create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate')) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).mark_canonical_issue_of_duplicate(duplicate_issue) end def discussion_lock(issuable, author) - action = issuable.discussion_locked? ? 'locked' : 'unlocked' - body = "#{action} this #{issuable.class.to_s.titleize.downcase}" - - create_note(NoteSummary.new(issuable, issuable.project, author, body, action: action)) + ::SystemNotes::IssuablesService.new(noteable: issuable, project: issuable.project, author: author).discussion_lock end - def cross_reference?(note_text) - note_text =~ /\A#{cross_reference_note_prefix}/i + def cross_reference_disallowed?(noteable, mentioner) + ::SystemNotes::IssuablesService.new(noteable: noteable).cross_reference_disallowed?(mentioner) end def zoom_link_added(issue, project, author) - create_note(NoteSummary.new(issue, project, author, _('added a Zoom call to this issue'), action: 'pinned_embed')) + ::SystemNotes::ZoomService.new(noteable: issue, project: project, author: author).zoom_link_added end def zoom_link_removed(issue, project, author) - create_note(NoteSummary.new(issue, project, author, _('removed a Zoom call from this issue'), action: 'pinned_embed')) + ::SystemNotes::ZoomService.new(noteable: issue, project: project, author: author).zoom_link_removed end private - # rubocop: disable CodeReuse/ActiveRecord - def notes_for_mentioner(mentioner, noteable, notes) - if mentioner.is_a?(Commit) - text = "#{cross_reference_note_prefix}%#{mentioner.to_reference(nil)}" - notes.where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) - else - gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group) - text = cross_reference_note_content(gfm_reference) - notes.where(note: [text, text.capitalize]) - end - end - # rubocop: enable CodeReuse/ActiveRecord - def create_note(note_summary) note = Note.create(note_summary.note.merge(system: true)) note.system_note_metadata = SystemNoteMetadata.new(note_summary.metadata) if note_summary.metadata? @@ -629,79 +333,10 @@ module SystemNoteService note end - def cross_reference_note_prefix - 'mentioned in ' - end - - def cross_reference_note_content(gfm_reference) - "#{cross_reference_note_prefix}#{gfm_reference}" - end - - # Builds a list of existing and new commits according to existing_commits and - # new_commits methods. - # Returns a String wrapped in `ul` and `li` tags. - def commits_list(noteable, new_commits, existing_commits, oldrev) - existing_commit_summary = existing_commit_summary(noteable, existing_commits, oldrev) - new_commit_summary = new_commit_summary(new_commits).join - - content_tag('ul', "#{existing_commit_summary}#{new_commit_summary}".html_safe) - end - - # Build a single line summarizing existing commits being added in a merge - # request - # - # noteable - MergeRequest object - # existing_commits - Array of existing Commit objects - # oldrev - Optional String SHA of a previous Commit - # - # Examples: - # - # "* ea0f8418...2f4426b7 - 24 commits from branch `master`" - # - # "* ea0f8418..4188f0ea - 15 commits from branch `fork:master`" - # - # "* ea0f8418 - 1 commit from branch `feature`" - # - # Returns a newline-terminated String - def existing_commit_summary(noteable, existing_commits, oldrev = nil) - return '' if existing_commits.empty? - - count = existing_commits.size - - commit_ids = if count == 1 - existing_commits.first.short_id - else - if oldrev && !Gitlab::Git.blank_ref?(oldrev) - "#{Commit.truncate_sha(oldrev)}...#{existing_commits.last.short_id}" - else - "#{existing_commits.first.short_id}..#{existing_commits.last.short_id}" - end - end - - commits_text = "#{count} commit".pluralize(count) - - branch = noteable.target_branch - branch = "#{noteable.target_project_namespace}:#{branch}" if noteable.for_fork? - - branch_name = content_tag('code', branch) - content_tag('li', "#{commit_ids} - #{commits_text} from branch #{branch_name}".html_safe) - end - def url_helpers @url_helpers ||= Gitlab::Routing.url_helpers end - def diff_comparison_path(merge_request, project, oldrev) - diff_id = merge_request.merge_request_diff.id - - url_helpers.diffs_project_merge_request_path( - project, - merge_request, - diff_id: diff_id, - start_sha: oldrev - ) - end - def content_tag(*args) ActionController::Base.helpers.content_tag(*args) end diff --git a/app/services/system_notes/base_service.rb b/app/services/system_notes/base_service.rb new file mode 100644 index 00000000000..7341a25b133 --- /dev/null +++ b/app/services/system_notes/base_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module SystemNotes + class BaseService + attr_reader :noteable, :project, :author + + def initialize(noteable: nil, author: nil, project: nil) + @noteable = noteable + @project = project + @author = author + end + + protected + + def create_note(note_summary) + note = Note.create(note_summary.note.merge(system: true)) + note.system_note_metadata = SystemNoteMetadata.new(note_summary.metadata) if note_summary.metadata? + + note + end + + def content_tag(*args) + ActionController::Base.helpers.content_tag(*args) + end + + def url_helpers + @url_helpers ||= Gitlab::Routing.url_helpers + end + end +end diff --git a/app/services/system_notes/commit_service.rb b/app/services/system_notes/commit_service.rb new file mode 100644 index 00000000000..11119956e0f --- /dev/null +++ b/app/services/system_notes/commit_service.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module SystemNotes + class CommitService < ::SystemNotes::BaseService + # Called when commits are added to a Merge Request + # + # new_commits - Array of Commits added since last push + # existing_commits - Array of Commits added in a previous push + # oldrev - Optional String SHA of a previous Commit + # + # See new_commit_summary and existing_commit_summary. + # + # Returns the created Note object + def add_commits(new_commits, existing_commits = [], oldrev = nil) + total_count = new_commits.length + existing_commits.length + commits_text = "#{total_count} commit".pluralize(total_count) + + text_parts = ["added #{commits_text}"] + text_parts << commits_list(noteable, new_commits, existing_commits, oldrev) + text_parts << "[Compare with previous version](#{diff_comparison_path(noteable, project, oldrev)})" + + body = text_parts.join("\n\n") + + create_note(NoteSummary.new(noteable, project, author, body, action: 'commit', commit_count: total_count)) + end + + # Called when a commit was tagged + # + # tag_name - The created tag name + # + # Returns the created Note object + def tag_commit(tag_name) + link = url_helpers.project_tag_path(project, id: tag_name) + body = "tagged commit #{noteable.sha} to [`#{tag_name}`](#{link})" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'tag')) + end + + # Build an Array of lines detailing each commit added in a merge request + # + # new_commits - Array of new Commit objects + # + # Returns an Array of Strings + def new_commit_summary(new_commits) + new_commits.collect do |commit| + content_tag('li', "#{commit.short_id} - #{commit.title}") + end + end + + private + + # Builds a list of existing and new commits according to existing_commits and + # new_commits methods. + # Returns a String wrapped in `ul` and `li` tags. + def commits_list(noteable, new_commits, existing_commits, oldrev) + existing_commit_summary = existing_commit_summary(noteable, existing_commits, oldrev) + new_commit_summary = new_commit_summary(new_commits).join + + content_tag('ul', "#{existing_commit_summary}#{new_commit_summary}".html_safe) + end + + # Build a single line summarizing existing commits being added in a merge + # request + # + # existing_commits - Array of existing Commit objects + # oldrev - Optional String SHA of a previous Commit + # + # Examples: + # + # "* ea0f8418...2f4426b7 - 24 commits from branch `master`" + # + # "* ea0f8418..4188f0ea - 15 commits from branch `fork:master`" + # + # "* ea0f8418 - 1 commit from branch `feature`" + # + # Returns a newline-terminated String + def existing_commit_summary(noteable, existing_commits, oldrev = nil) + return '' if existing_commits.empty? + + count = existing_commits.size + + commit_ids = if count == 1 + existing_commits.first.short_id + else + if oldrev && !Gitlab::Git.blank_ref?(oldrev) + "#{Commit.truncate_sha(oldrev)}...#{existing_commits.last.short_id}" + else + "#{existing_commits.first.short_id}..#{existing_commits.last.short_id}" + end + end + + commits_text = "#{count} commit".pluralize(count) + + branch = noteable.target_branch + branch = "#{noteable.target_project_namespace}:#{branch}" if noteable.for_fork? + + branch_name = content_tag('code', branch) + content_tag('li', "#{commit_ids} - #{commits_text} from branch #{branch_name}".html_safe) + end + + def diff_comparison_path(merge_request, project, oldrev) + diff_id = merge_request.merge_request_diff.id + + url_helpers.diffs_project_merge_request_path( + project, + merge_request, + diff_id: diff_id, + start_sha: oldrev + ) + end + end +end diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb new file mode 100644 index 00000000000..6fffd2ed4bf --- /dev/null +++ b/app/services/system_notes/issuables_service.rb @@ -0,0 +1,312 @@ +# frozen_string_literal: true + +module SystemNotes + class IssuablesService < ::SystemNotes::BaseService + # Called when the assignee of a Noteable is changed or removed + # + # assignee - User being assigned, or nil + # + # Example Note text: + # + # "removed assignee" + # + # "assigned to @rspeicher" + # + # Returns the created Note object + def change_assignee(assignee) + body = assignee.nil? ? 'removed assignee' : "assigned to #{assignee.to_reference}" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee')) + end + + # Called when the assignees of an issuable is changed or removed + # + # assignees - Users being assigned, or nil + # + # Example Note text: + # + # "removed all assignees" + # + # "assigned to @user1 additionally to @user2" + # + # "assigned to @user1, @user2 and @user3 and unassigned from @user4 and @user5" + # + # "assigned to @user1 and @user2" + # + # Returns the created Note object + def change_issuable_assignees(old_assignees) + unassigned_users = old_assignees - noteable.assignees + added_users = noteable.assignees.to_a - old_assignees + text_parts = [] + + Gitlab::I18n.with_default_locale do + text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any? + text_parts << "unassigned #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any? + end + + body = text_parts.join(' and ') + + create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee')) + end + + # Called when the milestone of a Noteable is changed + # + # milestone - Milestone being assigned, or nil + # + # Example Note text: + # + # "removed milestone" + # + # "changed milestone to 7.11" + # + # Returns the created Note object + def change_milestone(milestone) + format = milestone&.group_milestone? ? :name : :iid + body = milestone.nil? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project, format: format)}" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'milestone')) + end + + # Called when the title of a Noteable is changed + # + # old_title - Previous String title + # + # Example Note text: + # + # "changed title from **Old** to **New**" + # + # Returns the created Note object + def change_title(old_title) + new_title = noteable.title.dup + + old_diffs, new_diffs = Gitlab::Diff::InlineDiff.new(old_title, new_title).inline_diffs + + marked_old_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(old_title).mark(old_diffs, mode: :deletion) + marked_new_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(new_title).mark(new_diffs, mode: :addition) + + body = "changed title from **#{marked_old_title}** to **#{marked_new_title}**" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) + end + + # Called when the description of a Noteable is changed + # + # noteable - Noteable object that responds to `description` + # project - Project owning noteable + # author - User performing the change + # + # Example Note text: + # + # "changed the description" + # + # Returns the created Note object + def change_description + body = 'changed the description' + + create_note(NoteSummary.new(noteable, project, author, body, action: 'description')) + end + + # Called when a Mentionable references a Noteable + # + # mentioner - Mentionable object + # + # Example Note text: + # + # "mentioned in #1" + # + # "mentioned in !2" + # + # "mentioned in 54f7727c" + # + # See cross_reference_note_content. + # + # Returns the created Note object + def cross_reference(mentioner) + return if cross_reference_disallowed?(mentioner) + + gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group) + body = cross_reference_note_content(gfm_reference) + + if noteable.is_a?(ExternalIssue) + noteable.project.issues_tracker.create_cross_reference_note(noteable, mentioner, author) + else + create_note(NoteSummary.new(noteable, noteable.project, author, body, action: 'cross_reference')) + end + end + + # Check if a cross-reference is disallowed + # + # This method prevents adding a "mentioned in !1" note on every single commit + # in a merge request. Additionally, it prevents the creation of references to + # external issues (which would fail). + # + # mentioner - Mentionable object + # + # Returns Boolean + def cross_reference_disallowed?(mentioner) + return true if noteable.is_a?(ExternalIssue) && !noteable.project.jira_tracker_active? + return false unless mentioner.is_a?(MergeRequest) + return false unless noteable.is_a?(Commit) + + mentioner.commits.include?(noteable) + end + + # Called when the status of a Task has changed + # + # new_task - TaskList::Item object. + # + # Example Note text: + # + # "marked the task Whatever as completed." + # + # Returns the created Note object + def change_task_status(new_task) + status_label = new_task.complete? ? Taskable::COMPLETED : Taskable::INCOMPLETE + body = "marked the task **#{new_task.source}** as #{status_label}" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'task')) + end + + # Called when noteable has been moved to another project + # + # noteable_ref - Referenced noteable + # direction - symbol, :to or :from + # + # Example Note text: + # + # "moved to some_namespace/project_new#11" + # + # Returns the created Note object + def noteable_moved(noteable_ref, direction) + unless [:to, :from].include?(direction) + raise ArgumentError, "Invalid direction `#{direction}`" + end + + cross_reference = noteable_ref.to_reference(project) + body = "moved #{direction} #{cross_reference}" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'moved')) + end + + # Called when the confidentiality changes + # + # Example Note text: + # + # "made the issue confidential" + # + # Returns the created Note object + def change_issue_confidentiality + if noteable.confidential + body = 'made the issue confidential' + action = 'confidential' + else + body = 'made the issue visible to everyone' + action = 'visible' + end + + create_note(NoteSummary.new(noteable, project, author, body, action: action)) + end + + # Called when the status of a Noteable is changed + # + # status - String status + # source - Mentionable performing the change, or nil + # + # Example Note text: + # + # "merged" + # + # "closed via bc17db76" + # + # Returns the created Note object + def change_status(status, source = nil) + body = status.dup + body << " via #{source.gfm_reference(project)}" if source + + action = status == 'reopened' ? 'opened' : status + + create_note(NoteSummary.new(noteable, project, author, body, action: action)) + end + + # Check if a cross reference to a noteable from a mentioner already exists + # + # This method is used to prevent multiple notes being created for a mention + # when a issue is updated, for example. The method also calls notes_for_mentioner + # to check if the mentioner is a commit, and return matches only on commit hash + # instead of project + commit, to avoid repeated mentions from forks. + # + # mentioner - Mentionable object + # + # Returns Boolean + def cross_reference_exists?(mentioner) + notes = noteable.notes.system + notes_for_mentioner(mentioner, noteable, notes).exists? + end + + # Called when a Noteable has been marked as a duplicate of another Issue + # + # canonical_issue - Issue that this is a duplicate of + # + # Example Note text: + # + # "marked this issue as a duplicate of #1234" + # + # "marked this issue as a duplicate of other_project#5678" + # + # Returns the created Note object + def mark_duplicate_issue(canonical_issue) + body = "marked this issue as a duplicate of #{canonical_issue.to_reference(project)}" + create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate')) + end + + # Called when a Noteable has been marked as the canonical Issue of a duplicate + # + # duplicate_issue - Issue that was a duplicate of this + # + # Example Note text: + # + # "marked #1234 as a duplicate of this issue" + # + # "marked other_project#5678 as a duplicate of this issue" + # + # Returns the created Note object + def mark_canonical_issue_of_duplicate(duplicate_issue) + body = "marked #{duplicate_issue.to_reference(project)} as a duplicate of this issue" + create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate')) + end + + def discussion_lock + action = noteable.discussion_locked? ? 'locked' : 'unlocked' + body = "#{action} this #{noteable.class.to_s.titleize.downcase}" + + create_note(NoteSummary.new(noteable, project, author, body, action: action)) + end + + private + + def cross_reference_note_content(gfm_reference) + "#{self.class.cross_reference_note_prefix}#{gfm_reference}" + end + + def notes_for_mentioner(mentioner, noteable, notes) + if mentioner.is_a?(Commit) + text = "#{self.class.cross_reference_note_prefix}%#{mentioner.to_reference(nil)}" + notes.like_note_or_capitalized_note(text) + else + gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group) + text = cross_reference_note_content(gfm_reference) + notes.for_note_or_capitalized_note(text) + end + end + + def self.cross_reference_note_prefix + 'mentioned in ' + end + + def self.cross_reference?(note_text) + note_text =~ /\A#{cross_reference_note_prefix}/i + end + end +end + +SystemNotes::IssuablesService.prepend_if_ee('::EE::SystemNotes::IssuablesService') diff --git a/app/services/system_notes/zoom_service.rb b/app/services/system_notes/zoom_service.rb new file mode 100644 index 00000000000..6cd166d6cb9 --- /dev/null +++ b/app/services/system_notes/zoom_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module SystemNotes + class ZoomService < ::SystemNotes::BaseService + def zoom_link_added + create_note(NoteSummary.new(noteable, project, author, _('added a Zoom call to this issue'), action: 'pinned_embed')) + end + + def zoom_link_removed + create_note(NoteSummary.new(noteable, project, author, _('removed a Zoom call from this issue'), action: 'pinned_embed')) + end + end +end diff --git a/app/services/update_deployment_service.rb b/app/services/update_deployment_service.rb deleted file mode 100644 index 730210c611a..00000000000 --- a/app/services/update_deployment_service.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -class UpdateDeploymentService - attr_reader :deployment - attr_reader :deployable - - delegate :environment, to: :deployment - delegate :variables, to: :deployable - - def initialize(deployment) - @deployment = deployment - @deployable = deployment.deployable - end - - def execute - deployment.create_ref - deployment.invalidate_cache - - ActiveRecord::Base.transaction do - environment.external_url = expanded_environment_url if - expanded_environment_url - - environment.fire_state_event(action) - - break unless environment.save - break if environment.stopped? - - deployment.tap(&:update_merge_request_metrics!) - end - - deployment - end - - private - - def environment_options - @environment_options ||= deployable.options&.dig(:environment) || {} - end - - def expanded_environment_url - return @expanded_environment_url if defined?(@expanded_environment_url) - return unless environment_url - - @expanded_environment_url = - ExpandVariables.expand(environment_url, -> { variables }) - end - - def environment_url - environment_options[:url] - end - - def action - environment_options[:action] || 'start' - end -end - -UpdateDeploymentService.prepend_if_ee('EE::UpdateDeploymentService') diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index 9af59b0aceb..d42c9dbedf4 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -19,7 +19,7 @@ class AvatarUploader < GitlabUploader end def absolute_path - self.class.absolute_path(model.avatar.upload) + self.class.absolute_path(upload) end private diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb index cefcd3d3f5a..7dc211b14e4 100644 --- a/app/uploaders/gitlab_uploader.rb +++ b/app/uploaders/gitlab_uploader.rb @@ -99,6 +99,17 @@ class GitlabUploader < CarrierWave::Uploader::Base end end + # Used to replace an existing upload with another +file+ without modifying stored metadata + # Use this method only to repair/replace an existing upload, or to upload to a Geo secondary node + # + # @param [CarrierWave::SanitizedFile] file that will replace existing upload + # @return CarrierWave::SanitizedFile + def replace_file_without_saving!(file) + raise ArgumentError, 'should be a CarrierWave::SanitizedFile' unless file.is_a? CarrierWave::SanitizedFile + + storage.store!(file) + end + private # Designed to be overridden by child uploaders that have a dynamic path diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb index f99ad987156..36bde629f9c 100644 --- a/app/uploaders/object_storage.rb +++ b/app/uploaders/object_storage.rb @@ -180,10 +180,11 @@ module ObjectStorage end def workhorse_authorize(has_length:, maximum_size: nil) - { - RemoteObject: workhorse_remote_upload_options(has_length: has_length, maximum_size: maximum_size), - TempPath: workhorse_local_upload_path - }.compact + if self.object_store_enabled? && self.direct_upload_enabled? + { RemoteObject: workhorse_remote_upload_options(has_length: has_length, maximum_size: maximum_size) } + else + { TempPath: workhorse_local_upload_path } + end end def workhorse_local_upload_path diff --git a/app/validators/named_ecdsa_key_validator.rb b/app/validators/named_ecdsa_key_validator.rb index 42ee02b6ad4..9053f375100 100644 --- a/app/validators/named_ecdsa_key_validator.rb +++ b/app/validators/named_ecdsa_key_validator.rb @@ -19,15 +19,13 @@ class NamedEcdsaKeyValidator < ActiveModel::EachValidator private - UNNAMED_CURVE = "UNDEF" - def explicit_ec?(value) return false unless value pkey = OpenSSL::PKey.read(value) return false unless pkey.is_a?(OpenSSL::PKey::EC) - pkey.group.curve_name == UNNAMED_CURVE + pkey.group.asn1_flag != OpenSSL::PKey::EC::NAMED_CURVE rescue OpenSSL::PKey::PKeyError false end diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index d1de4286ee7..1f5bce19bc6 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -34,7 +34,7 @@ = f.number_field :max_artifacts_size, class: 'form-control' .form-text.text-muted = _("Set the maximum file size for each job's artifacts") - = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size') + = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size-core-only') .form-group = f.label :default_artifacts_expire_in, _('Default artifacts expiration'), class: 'label-bold' = f.text_field :default_artifacts_expire_in, class: 'form-control' diff --git a/app/views/admin/application_settings/_influx.html.haml b/app/views/admin/application_settings/_influx.html.haml index 98c7a9659c3..300b01c6777 100644 --- a/app/views/admin/application_settings/_influx.html.haml +++ b/app/views/admin/application_settings/_influx.html.haml @@ -7,7 +7,7 @@ in running SQL queries. These settings require a = link_to 'restart', help_page_path('administration/restart_gitlab') to take effect. - = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/introduction') + = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/index') .form-group .form-check = f.check_box :metrics_enabled, class: 'form-check-input' diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml index 1282a032f52..b15afb3b806 100644 --- a/app/views/admin/application_settings/_pages.html.haml +++ b/app/views/admin/application_settings/_pages.html.haml @@ -30,6 +30,6 @@ = f.check_box :lets_encrypt_terms_of_service_accepted, class: 'form-check-input' = f.label :lets_encrypt_terms_of_service_accepted, class: 'form-check-label' do - terms_of_service_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: lets_encrypt_terms_of_service_admin_application_settings_path } - = _("I have read and agree to the Let's Encrypt %{link_start}Terms of Service%{link_end}").html_safe % { link_start: terms_of_service_link_start, link_end: '</a>'.html_safe } + = _("I have read and agree to the Let's Encrypt %{link_start}Terms of Service%{link_end} (PDF)").html_safe % { link_start: terms_of_service_link_start, link_end: '</a>'.html_safe } = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml index b52171afc69..6b02521a0f0 100644 --- a/app/views/admin/application_settings/_performance.html.haml +++ b/app/views/admin/application_settings/_performance.html.haml @@ -20,5 +20,15 @@ = f.number_field :raw_blob_request_limit, class: 'form-control' .form-text.text-muted = _('Highest number of requests per minute for each raw path, default to 300. To disable throttling set to 0.') + .form-group + = f.label :push_event_hooks_limit, class: 'label-bold' + = f.number_field :push_event_hooks_limit, class: 'form-control' + .form-text.text-muted + = _("Number of changes (branches or tags) in a single push to determine whether webhooks and services will be fired or not. Webhooks and services won't be submitted if it surpasses that value.") + .form-group + = f.label :push_event_activities_limit, class: 'label-bold' + = f.number_field :push_event_activities_limit, class: 'form-control' + .form-text.text-muted + = _('Number of changes (branches or tags) in a single push to determine whether individual push events or bulk push event will be created. Bulk push event will be created if it surpasses that value.') = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_protected_paths.html.haml b/app/views/admin/application_settings/_protected_paths.html.haml new file mode 100644 index 00000000000..f4d40e10f36 --- /dev/null +++ b/app/views/admin/application_settings/_protected_paths.html.haml @@ -0,0 +1,31 @@ += form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-protected-paths-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + - if omnibus_protected_paths_throttle? + .bs-callout.bs-callout-danger + - relative_url_link = 'https://docs.gitlab.com/ee/user/admin_area/settings/protected_paths.html#migrate-settings-from-gitlab-123-and-earlier' + - relative_url_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: relative_url_link } + = _("Omnibus Protected Paths throttle is active. From 12.4, Omnibus throttle is deprecated and will be removed in a future release. Please read the %{relative_url_link_start}Migrating Protected Paths documentation%{relative_url_link_end}.").html_safe % { relative_url_link_start: relative_url_link_start, relative_url_link_end: '</a>'.html_safe } + + .form-group + .form-check + = f.check_box :throttle_protected_paths_enabled, class: 'form-check-input' + = f.label :throttle_protected_paths_enabled, class: 'form-check-label' do + = _('Enable protected paths rate limit') + %span.form-text.text-muted + = _('Helps reduce request volume for protected paths') + .form-group + = f.label :throttle_protected_paths_requests_per_period, 'Max requests per period per user', class: 'label-bold' + = f.number_field :throttle_protected_paths_requests_per_period, class: 'form-control' + .form-group + = f.label :throttle_protected_paths_period_in_seconds, 'Rate limit period in seconds', class: 'label-bold' + = f.number_field :throttle_protected_paths_period_in_seconds, class: 'form-control' + .form-group + = f.label :protected_paths, class: 'label-bold' do + - relative_url_link = 'https://docs.gitlab.com/omnibus/settings/configuration.html#configuring-a-relative-url-for-gitlab' + - relative_url_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: relative_url_link } + = _('All paths are relative to the GitLab URL. Do not include %{relative_url_link_start}relative URL%{relative_url_link_end}.').html_safe % { relative_url_link_start: relative_url_link_start, relative_url_link_end: '</a>'.html_safe } + = f.text_area :protected_paths_raw, placeholder: '/users/sign_in,/users/password', class: 'form-control', rows: 10 + + = f.submit 'Save changes', class: 'btn btn-success' diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml index e5bcb180445..b97e9a194f3 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -3,21 +3,24 @@ %fieldset .sub-section + %h4= _("Hashed repository storage paths") .form-group .form-check = f.check_box :hashed_storage_enabled, class: 'form-check-input qa-hashed-storage-checkbox' - = f.label :hashed_storage_enabled, class: 'form-check-label' do - Use hashed storage paths for newly created and renamed projects + = f.label :hashed_storage_enabled, _("Use hashed storage"), class: 'label-bold form-check-label' .form-text.text-muted - Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents - repositories from having to be moved or renamed when the Project URL changes and may improve disk I/O performance. + = _("Use hashed storage paths for newly created and renamed projects. Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents repositories from having to be moved or renamed when the Project URL changes and may improve disk I/O performance.") + .sub-section + %h4= _("Storage nodes for new projects") .form-group - = f.label :repository_storages, 'Storage paths for new projects', class: 'label-bold' - = f.select :repository_storages, repository_storages_options_for_select(@application_setting.repository_storages), - {include_hidden: false}, multiple: true, class: 'form-control' - .form-text.text-muted - Manage repository storage paths. Learn more in the - = succeed "." do - = link_to "repository storages documentation", help_page_path("administration/repository_storage_paths") + .form-text + %p.text-secondary + = _('Select the configured storaged available for new projects to be placed on.') + = link_to icon('question-circle'), help_page_path('administration/repository_storage_paths') + .form-check + = f.collection_check_boxes :repository_storages, Gitlab.config.repositories.storages, :first, :first, include_hidden: false do |b| + = b.check_box class: 'form-check-input' + = b.label class: 'label-bold form-check-label' + %br - = f.submit 'Save changes', class: "btn btn-success qa-save-changes-button" + = f.submit _('Save changes'), class: "btn btn-success qa-save-changes-button" diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml index b60b5d55a1b..31fd12d191e 100644 --- a/app/views/admin/application_settings/_snowplow.html.haml +++ b/app/views/admin/application_settings/_snowplow.html.haml @@ -9,7 +9,7 @@ = _('Configure the %{link} integration.').html_safe % { link: link_to('Snowplow', 'https://snowplowanalytics.com/', target: '_blank') } .settings-content - = form_for @application_setting, url: integrations_admin_application_settings_path, html: { class: 'fieldset-form' } do |f| + = form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-snowplow-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) %fieldset diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index e57ef1ea18f..be5f1f4f9a8 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -53,6 +53,11 @@ = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') %span.form-text.text-muted#clone-protocol-help = _('Allow only the selected protocols to be used for Git access.') + .form-group + = f.label :custom_http_clone_url_root, _('Custom Git clone URL for HTTP(S)'), class: 'label-bold' + = f.text_field :custom_http_clone_url_root, class: 'form-control', placeholder: 'https://git.example.com', :'aria-describedby' => 'custom_http_clone_url_root_help_block' + %span.form-text.text-muted#custom_http_clone_url_root_help_block + = _('Replaces the clone URL root.') - ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type| - field_name = :"#{type}_key_restriction" diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index 3a4d901ca1d..092834b993c 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -34,3 +34,16 @@ = _('Allow requests to the local network from hooks and services.') .settings-content = render 'outbound' + +%section.settings.as-protected-paths.no-animate#js-protected-paths-settings{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4 + = _('Protected Paths') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = _('Configure paths to be protected by Rack Attack. A web server restart is required after changing these settings.') + .settings-content + = render 'protected_paths' + += render_if_exists 'admin/application_settings/ee_network_settings' diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml index 180066723f1..aca9302aff7 100644 --- a/app/views/admin/applications/show.html.haml +++ b/app/views/admin/applications/show.html.haml @@ -13,7 +13,7 @@ .input-group %input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true } .input-group-append - = clipboard_button(target: '#application_id', title: _("Copy ID to clipboard"), class: "btn btn btn-default") + = clipboard_button(target: '#application_id', title: _("Copy ID"), class: "btn btn btn-default") %tr %td = _('Secret') @@ -22,7 +22,7 @@ .input-group %input.label.label-monospace.monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true } .input-group-append - = clipboard_button(target: '#secret', title: _("Copy secret to clipboard"), class: "btn btn btn-default") + = clipboard_button(target: '#secret', title: _("Copy secret"), class: "btn btn btn-default") %tr %td = _('Callback URL') diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 8fad42436ca..41147950c40 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -1,6 +1,7 @@ - breadcrumb_title "Dashboard" -= render_if_exists 'admin/licenses/breakdown', license: @license +- if show_license_breakdown? + = render_if_exists 'admin/licenses/breakdown', license: @license .admin-dashboard.prepend-top-default .row @@ -40,7 +41,7 @@ .info-well .well-segment.admin-well.admin-well-features %h4 Features - = feature_entry(_('Sign up'), href: admin_application_settings_path(anchor: 'js-signup-settings')) + = feature_entry(_('Sign up'), href: admin_application_settings_path(anchor: 'js-signup-settings'), enabled: allow_signup?) = feature_entry(_('LDAP'), enabled: Gitlab.config.ldap.enabled) = feature_entry(_('Gravatar'), href: admin_application_settings_path(anchor: 'js-account-settings'), enabled: gravatar_enabled?) = feature_entry(_('OmniAuth'), href: admin_application_settings_path(anchor: 'js-signin-settings'), enabled: Gitlab::Auth.omniauth_enabled?) diff --git a/app/views/admin/impersonation_tokens/index.html.haml b/app/views/admin/impersonation_tokens/index.html.haml index 8e869fb4b71..a7da14d16ff 100644 --- a/app/views/admin/impersonation_tokens/index.html.haml +++ b/app/views/admin/impersonation_tokens/index.html.haml @@ -8,7 +8,7 @@ - if @new_impersonation_token = render "shared/personal_access_tokens_created_container", new_token_value: @new_impersonation_token, container_title: 'Your New Impersonation Token', - clipboard_button_title: 'Copy impersonation token to clipboard' + clipboard_button_title: _('Copy impersonation token') = render "shared/personal_access_tokens_form", path: admin_user_impersonation_tokens_path, impersonation: true, token: @impersonation_token, scopes: @scopes diff --git a/app/views/admin/labels/destroy.js.haml b/app/views/admin/labels/destroy.js.haml index 7a0dcbdd1c6..394d3c11f31 100644 --- a/app/views/admin/labels/destroy.js.haml +++ b/app/views/admin/labels/destroy.js.haml @@ -1,2 +1,2 @@ -- if @labels.size == 0 - $('.labels').load(document.URL + ' .card.bg-light').hide().fadeIn(1000) +- if @labels.size.zero? + $('.labels').load(document.URL + ' .nothing-here-block').hide().fadeIn(1000) diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml index 5a5b3d18c5f..38137f360fd 100644 --- a/app/views/admin/labels/index.html.haml +++ b/app/views/admin/labels/index.html.haml @@ -14,6 +14,4 @@ = paginate @labels, theme: 'gitlab' - else - .card.bg-light - .nothing-here-block= _('There are no labels yet') - + .nothing-here-block= _('There are no labels yet') diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index bb0d62a70c0..08e668e8623 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -1,8 +1,20 @@ -- page_title "Projects" +- page_title _('Projects') - params[:visibility_level] ||= [] .top-area.scrolling-tabs-container.inner-page-scroll-tabs - .prepend-top-default + %ul.nav-links.nav.nav-tabs + - opts = params[:visibility_level].present? ? {} : { page: admin_projects_path } + = nav_link(opts) do + = link_to _('All'), admin_projects_path + + = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::PRIVATE.to_s) }) do + = link_to _('Private'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::INTERNAL.to_s) }) do + = link_to _('Internal'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::INTERNAL) + = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::PUBLIC.to_s) }) do + = link_to _('Public'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PUBLIC) + + .nav-controls .search-holder = render 'shared/projects/search_form', autofocus: true, admin_view: true .dropdown @@ -22,20 +34,4 @@ New Project = button_tag "Search", class: "btn btn-primary btn-search hide" - %ul.nav-links.nav.nav-tabs - - opts = params[:visibility_level].present? ? {} : { page: admin_projects_path } - = nav_link(opts) do - = link_to admin_projects_path do - All - - = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::PRIVATE.to_s) }) do - = link_to admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PRIVATE) do - Private - = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::INTERNAL.to_s) }) do - = link_to admin_projects_path(visibility_level: Gitlab::VisibilityLevel::INTERNAL) do - Internal - = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::PUBLIC.to_s) }) do - = link_to admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PUBLIC) do - Public - = render 'projects' diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index 76af4189b5b..2bf2b5fce8d 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -43,14 +43,13 @@ .row .col-sm-9 = form_tag admin_runners_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do - .filtered-search-wrapper + .filtered-search-wrapper.d-flex .filtered-search-box - = dropdown_tag(custom_icon('icon_history'), + = dropdown_tag(_('Recent searches'), options: { wrapper_class: 'filtered-search-history-dropdown-wrapper', toggle_class: 'filtered-search-history-dropdown-toggle-button', dropdown_class: 'filtered-search-history-dropdown', - content_class: 'filtered-search-history-dropdown-content', - title: _('Recent searches') }) do + content_class: 'filtered-search-history-dropdown-content' }) do .js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } } .filtered-search-box-input-container.droplab-dropdown .scroll-container diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml new file mode 100644 index 00000000000..55aea0296e7 --- /dev/null +++ b/app/views/admin/sessions/_new_base.html.haml @@ -0,0 +1,7 @@ += form_tag(admin_session_path, method: :post, html: { class: 'new_user gl-show-field-errors', 'aria-live': 'assertive'}) do + .form-group + = label_tag :password, _('Password'), class: 'label-bold' + = password_field_tag :password, nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } + + .submit-container.move-submit-down + = submit_tag _('Enter admin mode'), class: 'btn btn-success', data: { qa_selector: 'sign_in_button' } diff --git a/app/views/admin/sessions/_signin_box.html.haml b/app/views/admin/sessions/_signin_box.html.haml new file mode 100644 index 00000000000..69baa76060e --- /dev/null +++ b/app/views/admin/sessions/_signin_box.html.haml @@ -0,0 +1,11 @@ +- if form_based_providers.any? + + - if password_authentication_enabled_for_web? + .login-box.tab-pane{ id: 'login-pane', role: 'tabpanel' } + .login-body + = render 'admin/sessions/new_base' + +- elsif password_authentication_enabled_for_web? + .login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' } + .login-body + = render 'admin/sessions/new_base' diff --git a/app/views/admin/sessions/_tabs_normal.html.haml b/app/views/admin/sessions/_tabs_normal.html.haml new file mode 100644 index 00000000000..f5dedb5ad76 --- /dev/null +++ b/app/views/admin/sessions/_tabs_normal.html.haml @@ -0,0 +1,3 @@ +%ul.nav-links.new-session-tabs.nav-tabs.nav{ role: 'tablist' } + %li.nav-item{ role: 'presentation' } + %a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' }= _('Enter admin mode') diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml new file mode 100644 index 00000000000..ee06b4a1741 --- /dev/null +++ b/app/views/admin/sessions/new.html.haml @@ -0,0 +1,15 @@ +- @hide_breadcrumbs = true +- page_title _('Enter admin mode') + +.row.justify-content-center + .col-6.new-session-forms-container + .login-page + #signin-container + = render 'admin/sessions/tabs_normal' + .tab-content + - if password_authentication_enabled_for_web? + = render 'admin/sessions/signin_box' + - else + -# Show a message if none of the mechanisms above are enabled + .prepend-top-default.center + = _('No authentication methods configured.') diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml index 948a11646f7..b7648979edd 100644 --- a/app/views/admin/system_info/show.html.haml +++ b/app/views/admin/system_info/show.html.haml @@ -1,35 +1,35 @@ -- page_title "System Info" +- page_title _('System Info') .prepend-top-default .row - .col-sm-4 - .card.bg-light.light-well - %h4 CPU + .col-sm + .bg-light.light-well + %h4= _('CPU') .data - if @cpus - %h1 #{@cpus.length} cores + %h2= _('%{cores} cores') % { cores: @cpus.length } - else = icon('warning', class: 'text-warning') - Unable to collect CPU info - .col-sm-4 - .card.bg-light.light-well - %h4 Memory Usage + = _('Unable to collect CPU info') + .bg-light.light-well.prepend-top-default + %h4= _('Memory Usage') .data - if @memory - %h1 #{number_to_human_size(@memory.active_bytes)} / #{number_to_human_size(@memory.total_bytes)} + %h2 #{number_to_human_size(@memory.active_bytes)} / #{number_to_human_size(@memory.total_bytes)} - else = icon('warning', class: 'text-warning') - Unable to collect memory info - .col-sm-4 - .card.bg-light.light-well - %h4 Disk Usage + = _('Unable to collect memory info') + .bg-light.light-well.prepend-top-default + %h4= _('Uptime') .data - - @disks.each do |disk| - %h1 #{number_to_human_size(disk[:bytes_used])} / #{number_to_human_size(disk[:bytes_total])} - %p= disk[:disk_name] - %p= disk[:mount_path] - .col-sm-4 - .card.bg-light.light-well - %h4 Uptime + %h2= distance_of_time_in_words_to_now(Rails.application.config.booted_at) + .col-sm + .bg-light.light-well + %h4= _('Disk Usage') .data - %h1= distance_of_time_in_words_to_now(Rails.application.config.booted_at) + %ul + - @disks.each do |disk| + %li + %h2 #{number_to_human_size(disk[:bytes_used])} / #{number_to_human_size(disk[:bytes_total])} + %p= disk[:disk_name] + %p= disk[:mount_path] diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml index e7dde7985fd..a218885a00e 100644 --- a/app/views/admin/users/_head.html.haml +++ b/app/views/admin/users/_head.html.haml @@ -6,11 +6,13 @@ %span.cred (Internal) - if @user.admin %span.cred (Admin) + - if @user.deactivated? + %span.cred (Deactivated) = render_if_exists 'admin/users/audtior_user_badge' .float-right - if impersonation_enabled? && @user != current_user && @user.can?(:log_in) - = link_to 'Impersonate', impersonate_admin_user_path(@user), method: :post, class: "btn btn-nr btn-grouped btn-info" + = link_to 'Impersonate', impersonate_admin_user_path(@user), method: :post, class: "btn btn-nr btn-grouped btn-info", data: { qa_selector: 'impersonate_user_link' } = link_to edit_admin_user_path(@user), class: "btn btn-nr btn-grouped" do %i.fa.fa-pencil-square-o Edit diff --git a/app/views/admin/users/_modals.html.haml b/app/views/admin/users/_modals.html.haml new file mode 100644 index 00000000000..eaec6d69f5a --- /dev/null +++ b/app/views/admin/users/_modals.html.haml @@ -0,0 +1,30 @@ +#user-modal +#modal-texts.hidden{ "hidden": true, "aria-hidden": true } + %div{ data: { modal: "deactivate", + title: s_("AdminUsers|Deactivate User %{username}?"), + action: s_("AdminUsers|Deactivate") } } + = render partial: 'admin/users/user_deactivation_effects' + + %div{ data: { modal: "block", + title: s_("AdminUsers|Block user %{username}?"), + action: s_("AdminUsers|Block") } } + = render partial: 'admin/users/user_block_effects' + + %div{ data: { modal: "delete", + title: s_("AdminUsers|Delete User %{username}?"), + action: s_('AdminUsers|Delete user'), + 'secondary-action': s_('AdminUsers|Block user') } } + = s_('AdminUsers|You are about to permanently delete the user %{username}. Issues, merge requests, + and groups linked to them will be transferred to a system-wide "Ghost-user". To avoid data loss, + consider using the %{strong_start}block user%{strong_end} feature instead. Once you %{strong_start}Delete user%{strong_end}, + it cannot be undone or recovered.') + + %div{ data: { modal: "delete-with-contributions", + title: s_("AdminUsers|Delete User %{username} and contributions?"), + action: s_('AdminUsers|Delete user and contributions') , + 'secondary-action': s_('AdminUsers|Block user') } } + = s_('AdminUsers|You are about to permanently delete the user %{username}. This will delete all of the issues, + merge requests, and groups linked to them. To avoid data loss, + consider using the %{strong_start}block user%{strong_end} feature instead. Once you %{strong_start}Delete user%{strong_end}, + it cannot be undone or recovered.') + diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index be7bfa958b2..ca5109614fc 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -1,4 +1,4 @@ -.gl-responsive-table-row{ role: 'row' } +.gl-responsive-table-row{ role: 'row', data: { qa_selector: 'user_row_content' } } .table-section.section-40 .table-mobile-header{ role: 'rowheader' } = _('Name') @@ -31,7 +31,19 @@ - elsif user.blocked? = link_to _('Unblock'), unblock_admin_user_path(user), method: :put - else - = link_to _('Block'), block_admin_user_path(user), data: { confirm: "#{s_('AdminUsers|User will be blocked').upcase}! #{_('Are you sure')}?" }, method: :put + %button.btn{ data: { 'gl-modal-action': 'block', + url: block_admin_user_path(user), + username: sanitize_name(user.name) } } + = s_('AdminUsers|Block') + - if user.can_be_deactivated? + %li + %button.btn{ data: { 'gl-modal-action': 'deactivate', + url: deactivate_admin_user_path(user), + username: sanitize_name(user.name) } } + = s_('AdminUsers|Deactivate') + - elsif user.deactivated? + %li + = link_to _('Activate'), activate_admin_user_path(user), method: :put - if user.access_locked? %li = link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') } @@ -39,19 +51,14 @@ %li.divider - if user.can_be_removed? %li - %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal', - target: '#delete-user-modal', + %button.delete-user-button.btn.text-danger{ data: { 'gl-modal-action': 'delete', delete_user_url: admin_user_path(user), block_user_url: block_admin_user_path(user), - username: sanitize_name(user.name), - delete_contributions: false }, type: 'button' } + username: sanitize_name(user.name) } } = s_('AdminUsers|Delete user') - - %li - %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal', - target: '#delete-user-modal', - delete_user_url: admin_user_path(user, hard_delete: true), - block_user_url: block_admin_user_path(user), - username: sanitize_name(user.name), - delete_contributions: true }, type: 'button' } - = s_('AdminUsers|Delete user and contributions') + %li + %button.delete-user-button.btn.text-danger{ data: { 'gl-modal-action': 'delete-with-contributions', + delete_user_url: admin_user_path(user, hard_delete: true), + block_user_url: block_admin_user_path(user), + username: sanitize_name(user.name) } } + = s_('AdminUsers|Delete user and contributions') diff --git a/app/views/admin/users/_user_activation_effects.html.haml b/app/views/admin/users/_user_activation_effects.html.haml new file mode 100644 index 00000000000..244836dac11 --- /dev/null +++ b/app/views/admin/users/_user_activation_effects.html.haml @@ -0,0 +1,6 @@ +%p + = s_('AdminUsers|Reactivating a user will:') +%ul + %li + = s_('AdminUsers|Restore user access to the account, including web, Git and API.') + = render_if_exists 'admin/users/user_activation_effects_on_seats' diff --git a/app/views/admin/users/_user_block_effects.html.haml b/app/views/admin/users/_user_block_effects.html.haml new file mode 100644 index 00000000000..8ffbe145169 --- /dev/null +++ b/app/views/admin/users/_user_block_effects.html.haml @@ -0,0 +1,11 @@ +%p + = s_('AdminUsers|Blocking user has the following effects:') +%ul + %li + = s_('AdminUsers|User will not be able to login') + %li + = s_('AdminUsers|User will not be able to access git repositories') + %li + = s_('AdminUsers|Personal projects will be left') + %li + = s_('AdminUsers|Owned groups will be left') diff --git a/app/views/admin/users/_user_deactivation_effects.html.haml b/app/views/admin/users/_user_deactivation_effects.html.haml new file mode 100644 index 00000000000..dc3896e18c0 --- /dev/null +++ b/app/views/admin/users/_user_deactivation_effects.html.haml @@ -0,0 +1,18 @@ +%p + = s_('AdminUsers|Deactivating a user has the following effects:') +%ul + %li + = s_('AdminUsers|The user will be logged out') + %li + = s_('AdminUsers|The user will not be able to access git repositories') + %li + = s_('AdminUsers|The user will not be able to access the API') + %li + = s_('AdminUsers|The user will not receive any notifications') + %li + = s_('AdminUsers|The user will not be able to use slash commands') + %li + = s_('AdminUsers|When the user logs back in, their account will reactivate as a fully active account') + %li + = s_('AdminUsers|Personal projects, group and user history will be left intact') + = render_if_exists 'admin/users/user_deactivation_effects_on_seats' diff --git a/app/views/admin/users/_user_detail.html.haml b/app/views/admin/users/_user_detail.html.haml index 13d10dcd625..3cc3fc6fa92 100644 --- a/app/views/admin/users/_user_detail.html.haml +++ b/app/views/admin/users/_user_detail.html.haml @@ -4,7 +4,7 @@ .row-main-content .row-title.str-truncated-100 = image_tag avatar_icon_for_user(user), class: 'avatar s16 d-xs-flex d-md-none mr-1 prepend-top-2', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) } - = link_to user.name, admin_user_path(user), class: 'text-plain js-user-link', data: { user_id: user.id } + = link_to user.name, admin_user_path(user), class: 'text-plain js-user-link', data: { user_id: user.id, qa_selector: 'username_link' } = render_if_exists 'admin/users/user_listing_note', user: user diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index 36b62557fa6..3c6ad899d1e 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -30,6 +30,10 @@ = link_to admin_users_path(filter: "blocked") do = s_('AdminUsers|Blocked') %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked) + = nav_link(html_options: { class: active_when(params[:filter] == 'deactivated') }) do + = link_to admin_users_path(filter: "deactivated") do + = s_('AdminUsers|Deactivated') + %small.badge.badge-pill= limited_counter_with_delimiter(User.deactivated) = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do = link_to admin_users_path(filter: "wop") do = s_('AdminUsers|Without projects') @@ -44,12 +48,13 @@ = hidden_field_tag "filter", h(params[:filter]) .search-holder .search-field-holder - = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false + = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false, data: { qa_selector: 'user_search_field' } - if @sort.present? = hidden_field_tag :sort, @sort = icon("search", class: "search-icon") = button_tag s_('AdminUsers|Search users') if Rails.env.test? .dropdown.user-sort-dropdown + = label_tag 'Sort by', nil, class: 'label-bold' - toggle_text = @sort.present? ? users_sort_options_hash[@sort] : sort_title_name = dropdown_toggle(toggle_text, { toggle: 'dropdown' }) %ul.dropdown-menu.dropdown-menu-right @@ -74,4 +79,4 @@ = paginate @users, theme: "gitlab" -#delete-user-modal += render partial: 'admin/users/modals' diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index a988f746ced..706fa033c51 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -156,6 +156,27 @@ = render_if_exists 'admin/users/user_detail_note' + - if @user.deactivated? + .card.border-info + .card-header.bg-info.text-white + Reactivate this user + .card-body + = render partial: 'admin/users/user_activation_effects' + %br + = link_to 'Activate user', activate_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?' } + - elsif @user.can_be_deactivated? + .card.border-warning + .card-header.bg-warning.text-white + Deactivate this user + .card-body + = render partial: 'admin/users/user_deactivation_effects' + %br + %button.btn.btn-warning{ data: { 'gl-modal-action': 'deactivate', + content: 'You can always re-activate their account, their data will remain intact.', + url: deactivate_admin_user_path(@user), + username: sanitize_name(@user.name) } } + = s_('AdminUsers|Deactivate user') + - if @user.blocked? .card.border-info .card-header.bg-info.text-white @@ -172,14 +193,13 @@ .card-header.bg-warning.text-white Block this user .card-body - %p Blocking user has the following effects: - %ul - %li User will not be able to login - %li User will not be able to access git repositories - %li Personal projects will be left - %li Owned groups will be left + = render partial: 'admin/users/user_block_effects' %br - = link_to 'Block user', block_admin_user_path(@user), data: { confirm: 'USER WILL BE BLOCKED! Are you sure?' }, method: :put, class: "btn btn-warning" + %button.btn.btn-warning{ data: { 'gl-modal-action': 'block', + content: 'You can always unblock their account, their data will remain intact.', + url: block_admin_user_path(@user), + username: sanitize_name(@user.name) } } + = s_('AdminUsers|Block user') - if @user.access_locked? .card.border-info .card-header.bg-info.text-white @@ -197,12 +217,10 @@ %p Deleting a user has the following effects: = render 'users/deletion_guidance', user: @user %br - %button.delete-user-button.btn.btn-danger{ data: { toggle: 'modal', - target: '#delete-user-modal', + %button.delete-user-button.btn.btn-danger{ data: { 'gl-modal-action': 'delete', delete_user_url: admin_user_path(@user), block_user_url: block_admin_user_path(@user), - username: @user.name, - delete_contributions: false }, type: 'button' } + username: sanitize_name(@user.name) } } = s_('AdminUsers|Delete user') - else - if @user.solo_owned_groups.present? @@ -229,15 +247,13 @@ the user, and projects in them, will also be removed. Commits to other projects are unaffected. %br - %button.delete-user-button.btn.btn-danger{ data: { toggle: 'modal', - target: '#delete-user-modal', + %button.delete-user-button.btn.btn-danger{ data: { 'gl-modal-action': 'delete-with-contributions', delete_user_url: admin_user_path(@user, hard_delete: true), block_user_url: block_admin_user_path(@user), - username: @user.name, - delete_contributions: true }, type: 'button' } + username: @user.name } } = s_('AdminUsers|Delete user and contributions') - else %p You don't have access to delete this user. - #delete-user-modal += render partial: 'admin/users/modals' diff --git a/app/views/ci/runner/_how_to_setup_runner.html.haml b/app/views/ci/runner/_how_to_setup_runner.html.haml index 4307060d748..aca8aa5d341 100644 --- a/app/views/ci/runner/_how_to_setup_runner.html.haml +++ b/app/views/ci/runner/_how_to_setup_runner.html.haml @@ -8,11 +8,11 @@ %li = _("Specify the following URL during the Runner setup:") %code#coordinator_address= root_url(only_path: false) - = clipboard_button(target: '#coordinator_address', title: _("Copy URL to clipboard"), class: "btn-transparent btn-clipboard") + = clipboard_button(target: '#coordinator_address', title: _("Copy URL"), class: "btn-transparent btn-clipboard") %li = _("Use the following registration token during setup:") %code#registration_token= registration_token - = clipboard_button(target: '#registration_token', title: _("Copy token to clipboard"), class: "btn-transparent btn-clipboard") + = clipboard_button(target: '#registration_token', title: _("Copy token"), class: "btn-transparent btn-clipboard") .prepend-top-10.append-bottom-10 = button_to _("Reset runners registration token"), reset_token_url, method: :put, class: 'btn btn-default', diff --git a/app/views/ci/runner/_how_to_setup_runner_automatically.html.haml b/app/views/ci/runner/_how_to_setup_runner_automatically.html.haml new file mode 100644 index 00000000000..58d2ef5d5e6 --- /dev/null +++ b/app/views/ci/runner/_how_to_setup_runner_automatically.html.haml @@ -0,0 +1,22 @@ +.append-bottom-10 + %h4= _('Set up a %{type} Runner automatically') % { type: type } + +%p + - link_to_help_page = link_to(_('Learn more about Kubernetes'), + help_page_path('user/project/clusters/index'), + target: '_blank', + rel: 'noopener noreferrer') + + = _('You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}').html_safe % { link_to_help_page: link_to_help_page } + +%ol + %li + = _('Click the button below to begin the install process by navigating to the Kubernetes page') + %li + = _('Select an existing Kubernetes cluster or create a new one') + %li + = _('From the Kubernetes cluster details view, install Runner from the applications list') + += link_to _('Install Runner on Kubernetes'), + clusters_path, + class: 'btn btn-info' diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml index ed4bd5ae19e..ed9b3ab1940 100644 --- a/app/views/ci/variables/_variable_row.html.haml +++ b/app/views/ci/variables/_variable_row.html.haml @@ -30,7 +30,7 @@ value: key, placeholder: s_('CiVariables|Input variable key') } .ci-variable-body-item.gl-show-field-errors.table-section.section-15.border-top-0.p-0 - .form-control.js-secret-value-placeholder.qa-ci-variable-input-value{ class: ('hide' unless id) } + .form-control.js-secret-value-placeholder.qa-ci-variable-input-value.overflow-hidden{ class: ('hide' unless id) } = '*' * 17 %textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control{ class: ('hide' if id), rows: 1, diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml index f707c6585ec..d4999798c19 100644 --- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml +++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml @@ -3,6 +3,6 @@ - label = local_assigns.fetch(:label) = link_to clusterable.new_path(provider: provider), class: 'btn gl-button btn-outline flex-fill d-inline-flex flex-column mr-3 justify-content-center align-items-center' do - = image_tag logo_path, alt: label, class: 'gl-w-13 gl-h-13' + .svg-content= image_tag logo_path, alt: label, class: 'gl-w-13 gl-h-13' %span = label diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml index 24506205243..7a93a7604f5 100644 --- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml +++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml @@ -6,6 +6,6 @@ = create_cluster_label .d-flex = render partial: 'clusters/clusters/cloud_providers/cloud_provider_button', - locals: { provider: 'gke', label: gke_label, logo_path: '' } + locals: { provider: 'eks', label: eks_label, logo_path: 'illustrations/logos/amazon_eks.svg' } = render partial: 'clusters/clusters/cloud_providers/cloud_provider_button', - locals: { provider: 'eks', label: eks_label, logo_path: '' } + locals: { provider: 'gke', label: gke_label, logo_path: 'illustrations/logos/google_gke.svg' } diff --git a/app/views/clusters/clusters/eks/_index.html.haml b/app/views/clusters/clusters/eks/_index.html.haml index ca8e9ba527a..db64698a7f2 100644 --- a/app/views/clusters/clusters/eks/_index.html.haml +++ b/app/views/clusters/clusters/eks/_index.html.haml @@ -1 +1,2 @@ -.js-create-eks-cluster-form-container +.js-create-eks-cluster-form-container{ data: { 'gitlab-managed-cluster-help-path' => help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), +'kubernetes-integration-help-path' => help_page_path('user/project/clusters/index') } } diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml index 4d3e3359ea0..cca16ce7eda 100644 --- a/app/views/clusters/clusters/gcp/_form.html.haml +++ b/app/views/clusters/clusters/gcp/_form.html.haml @@ -3,13 +3,12 @@ - zones_link_url = 'https://cloud.google.com/compute/docs/regions-zones/regions-zones' - machine_type_link_url = 'https://cloud.google.com/compute/docs/machine-types' - pricing_link_url = 'https://cloud.google.com/compute/pricing#machinetype' +- kubernetes_integration_url = help_page_path('user/project/clusters/index') - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe - help_link_end = ' %{external_link_icon}</a>'.html_safe % { external_link_icon: external_link_icon } %p - - link_to_help_page = link_to(s_('ClusterIntegration|help page'), - help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') - = s_('ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration.').html_safe % { link_to_help_page: link_to_help_page } + = s_('ClusterIntegration|Read our %{link_start}help page%{link_end} on Kubernetes cluster integration.').html_safe % { link_start: help_link_start % { url: kubernetes_integration_url }, link_end: '</a>'.html_safe } %p= link_to('Select a different Google account', @authorize_url) @@ -65,6 +64,13 @@ %p.form-text.text-muted = s_('ClusterIntegration|Learn more about %{help_link_start_machine_type}machine types%{help_link_end} and %{help_link_start_pricing}pricing%{help_link_end}.').html_safe % { help_link_start_machine_type: help_link_start % { url: machine_type_link_url }, help_link_start_pricing: help_link_start % { url: pricing_link_url }, help_link_end: help_link_end } + .form-group + = provider_gcp_field.check_box :cloud_run, { label: s_('ClusterIntegration|Enable Cloud Run on GKE (beta)'), + label_class: 'label-bold' } + .form-text.text-muted + = s_('ClusterIntegration|Uses the Cloud Run, Istio, and HTTP Load Balancing addons for this cluster.') + = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'cloud-run-on-gke'), target: '_blank' + .form-group = field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'), label_class: 'label-bold' } diff --git a/app/views/clusters/clusters/gcp/_gcp_not_configured.html.haml b/app/views/clusters/clusters/gcp/_gcp_not_configured.html.haml new file mode 100644 index 00000000000..b57e45e9812 --- /dev/null +++ b/app/views/clusters/clusters/gcp/_gcp_not_configured.html.haml @@ -0,0 +1,3 @@ +- documentation_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path("integration/google") } +- link_end = '<a/>'.html_safe += s_('Google authentication is not %{link_start}property configured%{link_end}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_start: documentation_link_start, link_end: link_end } diff --git a/app/views/clusters/clusters/gcp/_signin_with_google_button.html.haml b/app/views/clusters/clusters/gcp/_signin_with_google_button.html.haml new file mode 100644 index 00000000000..65cfa6552b1 --- /dev/null +++ b/app/views/clusters/clusters/gcp/_signin_with_google_button.html.haml @@ -0,0 +1,4 @@ +.signin-with-google + - create_account_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://accounts.google.com/SignUpWithoutGmail?service=cloudconsole&continue=https%3A%2F%2Fconsole.cloud.google.com%2Ffreetrial%3Futm_campaign%3D2018_cpanel%26utm_source%3Dgitlab%26utm_medium%3Dreferral' } + = link_to(image_tag('auth_buttons/signin_with_google.png', width: '191px', alt: _('Sign in with Google')), @authorize_url) + = s_('or %{link_start}create a new Google account%{link_end}').html_safe % { link_start: create_account_link, link_end: '</a>'.html_safe } diff --git a/app/views/clusters/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml index fb182d99ff0..2c23426aaf9 100644 --- a/app/views/clusters/clusters/new.html.haml +++ b/app/views/clusters/clusters/new.html.haml @@ -2,7 +2,9 @@ - page_title _('Kubernetes Cluster') - create_eks_enabled = Feature.enabled?(:create_eks_clusters) - active_tab = local_assigns.fetch(:active_tab, 'create') -- link_end = '<a/>'.html_safe +- create_on_gke_tab_label = s_('ClusterIntegration|Create new Cluster on GKE') +- create_on_eks_tab_label = s_('ClusterIntegration|Create new Cluster on EKS') +- create_new_cluster_label = s_('ClusterIntegration|Create new Cluster') = javascript_include_tag 'https://apis.google.com/js/api.js' = render_gcp_signup_offer @@ -14,7 +16,16 @@ %ul.nav-links.nav-tabs.gitlab-tabs.nav{ role: 'tablist' } %li.nav-item{ role: 'presentation' } %a.nav-link{ href: '#create-cluster-pane', id: 'create-cluster-tab', class: active_when(active_tab == 'create'), data: { toggle: 'tab' }, role: 'tab' } - %span Create new Cluster on GKE + %span + - if create_eks_enabled + - if @gke_selected + = create_on_gke_tab_label + - elsif @eks_selected + = create_on_eks_tab_label + - else + = create_new_cluster_label + - else + = create_on_gke_tab_label %li.nav-item{ role: 'presentation' } %a.nav-link{ href: '#add-cluster-pane', id: 'add-cluster-tab', class: active_when(active_tab == 'add'), data: { toggle: 'tab' }, role: 'tab' } %span Add existing cluster @@ -22,9 +33,14 @@ .tab-content.gitlab-tab-content - if create_eks_enabled .tab-pane{ id: 'create-cluster-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' } - - if @gke_selected && @valid_gcp_token + - if @gke_selected = render 'clusters/clusters/gcp/header' - = render 'clusters/clusters/gcp/form' + - if @valid_gcp_token + = render 'clusters/clusters/gcp/form' + - elsif @authorize_url + = render 'clusters/clusters/gcp/signin_with_google_button' + - else + = render 'clusters/clusters/gcp/gcp_not_configured' - elsif @eks_selected = render 'clusters/clusters/eks/index' - else @@ -35,13 +51,9 @@ - if @valid_gcp_token = render 'clusters/clusters/gcp/form' - elsif @authorize_url - .signin-with-google - - create_account_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://accounts.google.com/SignUpWithoutGmail?service=cloudconsole&continue=https%3A%2F%2Fconsole.cloud.google.com%2Ffreetrial%3Futm_campaign%3D2018_cpanel%26utm_source%3Dgitlab%26utm_medium%3Dreferral' } - = link_to(image_tag('auth_buttons/signin_with_google.png', width: '191px', alt: _('Sign in with Google')), @authorize_url) - = s_('or %{link_start}create a new Google account%{link_end}').html_safe % { link_start: create_account_link, link_end: link_end } + = render 'clusters/clusters/gcp/signin_with_google_button' - else - - documentation_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path("integration/google") } - = s_('Google authentication is not %{link_start}property configured%{link_end}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_start: documentation_link_start, link_end: link_end } + = render 'clusters/clusters/gcp/gcp_not_configured' .tab-pane{ id: 'add-cluster-pane', class: active_when(active_tab == 'add'), role: 'tabpanel' } = render 'clusters/clusters/user/header' diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index cccba48624b..31d5f592d75 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -23,12 +23,15 @@ cluster_type: @cluster.cluster_type, cluster_status: @cluster.status_name, cluster_status_reason: @cluster.status_reason, + provider_type: @cluster.provider_type, + pre_installed_knative: @cluster.knative_pre_installed? ? 'true': 'false', help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'), ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-endpoint'), ingress_dns_help_path: help_page_path('user/project/clusters/index.md', anchor: 'manually-determining-the-external-endpoint'), environments_help_path: help_page_path('ci/environments', anchor: 'defining-environments'), clusters_help_path: help_page_path('user/project/clusters/index.md', anchor: 'deploying-to-a-kubernetes-cluster'), deploy_boards_help_path: help_page_path('user/project/deploy_boards.html', anchor: 'enabling-deploy-boards'), + cloud_run_help_path: help_page_path('user/project/clusters/index.md', anchor: 'cloud-run-on-gke'), manage_prometheus_path: manage_prometheus_path, cluster_id: @cluster.id } } @@ -39,6 +42,6 @@ = render 'banner' - if cluster_environments_path.present? - = render_if_exists 'clusters/clusters/group_cluster_environments', expanded: expanded + = render_if_exists 'clusters/clusters/cluster_environments', expanded: expanded - else = render 'configure', expanded: expanded diff --git a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml index c50b20a83dc..6e7ec1264ea 100644 --- a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml +++ b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml @@ -1,41 +1,40 @@ .blank-state-row - = link_to new_project_path, class: "blank-state-link" do - .blank-state + - if has_start_trial? + = render_if_exists "dashboard/projects/blank_state_ee_trial" + + = link_to new_project_path, class: "blank-state blank-state-link" do + .blank-state-icon + = image_tag("illustrations/welcome/add_new_project") + .blank-state-body + %h3.blank-state-title + Create a project + %p.blank-state-text + Projects are where you store your code, access issues, wiki and other features of GitLab. + + - if current_user.can_create_group? + = link_to new_group_path, class: "blank-state blank-state-link" do .blank-state-icon - = custom_icon("add_new_project", size: 50) + = image_tag("illustrations/welcome/add_new_group") .blank-state-body %h3.blank-state-title - Create a project + Create a group %p.blank-state-text - Projects are where you store your code, access issues, wiki and other features of GitLab. + Groups are a great way to organize projects and people. - - if current_user.can_create_group? - = link_to new_group_path, class: "blank-state-link" do - .blank-state - .blank-state-icon - = custom_icon("add_new_group", size: 50) - .blank-state-body - %h3.blank-state-title - Create a group - %p.blank-state-text - Groups are a great way to organize projects and people. - - = link_to new_admin_user_path, class: "blank-state-link" do - .blank-state - .blank-state-icon - = custom_icon("add_new_user", size: 50) - .blank-state-body - %h3.blank-state-title - Add people - %p.blank-state-text - Add your team members and others to GitLab. - - = link_to admin_root_path, class: "blank-state-link" do - .blank-state + = link_to new_admin_user_path, class: "blank-state blank-state-link" do .blank-state-icon - = custom_icon("configure_server", size: 50) + = image_tag("illustrations/welcome/add_new_user") .blank-state-body %h3.blank-state-title - Configure GitLab + Add people %p.blank-state-text - Make adjustments to how your GitLab instance is set up. + Add your team members and others to GitLab. + + = link_to admin_root_path, class: "blank-state blank-state-link" do + .blank-state-icon + = image_tag("illustrations/welcome/configure_server") + .blank-state-body + %h3.blank-state-title + Configure GitLab + %p.blank-state-text + Make adjustments to how your GitLab instance is set up. diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml index 8d5bddbb288..e3af3405b76 100644 --- a/app/views/dashboard/projects/_blank_state_welcome.html.haml +++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml @@ -2,19 +2,18 @@ .blank-state-row - if current_user.can_create_project? - = link_to new_project_path, class: "blank-state-link" do - .blank-state - .blank-state-icon - = custom_icon("add_new_project", size: 50) - .blank-state-body - %h3.blank-state-title - Create a project - %p.blank-state-text - Projects are where you store your code, access issues, wiki and other features of GitLab. + = link_to new_project_path, class: "blank-state blank-state-link" do + .blank-state-icon + = image_tag("illustrations/welcome/add_new_project") + .blank-state-body + %h3.blank-state-title + Create a project + %p.blank-state-text + Projects are where you store your code, access issues, wiki and other features of GitLab. - else .blank-state .blank-state-icon - = custom_icon("add_new_project", size: 50) + = image_tag("illustrations/welcome/add_new_project") .blank-state-body %h3.blank-state-title Create a project @@ -22,37 +21,34 @@ If you are added to a project, it will be displayed here. - if current_user.can_create_group? - = link_to new_group_path, class: "blank-state-link" do - .blank-state - .blank-state-icon - = custom_icon("add_new_group", size: 50) - .blank-state-body - %h3.blank-state-title - Create a group - %p.blank-state-text - Groups are the best way to manage projects and members. + = link_to new_group_path, class: "blank-state blank-state-link" do + .blank-state-icon + = image_tag("illustrations/welcome/add_new_group") + .blank-state-body + %h3.blank-state-title + Create a group + %p.blank-state-text + Groups are the best way to manage projects and members. - if public_project_count > 0 - = link_to trending_explore_projects_path, class: "blank-state-link" do - .blank-state - .blank-state-icon - = custom_icon("globe", size: 50) - .blank-state-body - %h3.blank-state-title - Explore public projects - %p.blank-state-text - There are - = number_with_delimiter(public_project_count) - public projects on this server. - Public projects are an easy way to allow - everyone to have read-only access. - - = link_to "https://docs.gitlab.com/", class: "blank-state-link" do - .blank-state + = link_to trending_explore_projects_path, class: "blank-state blank-state-link" do .blank-state-icon - = custom_icon("lightbulb", size: 50) + = image_tag("illustrations/welcome/globe") .blank-state-body %h3.blank-state-title - Learn more about GitLab + Explore public projects %p.blank-state-text - Take a look at the documentation to discover all of GitLab's capabilities. + There are + = number_with_delimiter(public_project_count) + public projects on this server. + Public projects are an easy way to allow + everyone to have read-only access. + + = link_to "https://docs.gitlab.com/", class: "blank-state blank-state-link" do + .blank-state-icon + = image_tag("illustrations/welcome/lightbulb") + .blank-state-body + %h3.blank-state-title + Learn more about GitLab + %p.blank-state-text + Take a look at the documentation to discover all of GitLab's capabilities. diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml index 8933c5d7227..a2b1f0d9298 100644 --- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml +++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml @@ -1,18 +1,13 @@ -.blank-state-parent-container{ class: ('has-start-trial-container' if has_start_trial?) } +.blank-state-parent-container .section-container.section-welcome{ class: "#{ 'section-admin-welcome' if current_user.admin? }" } .container.section-body .row - .blank-state-welcome + .blank-state-welcome.w-100 %h2.blank-state-welcome-title - Welcome to GitLab + = _('Welcome to GitLab') %p.blank-state-text - Code, test, and deploy together - .blank-state-row - %div{ class: ('column-large' if has_start_trial?) } - - if current_user.admin? - = render "blank_state_admin_welcome" - - else - = render "blank_state_welcome" - - if has_start_trial? - .column-small - = render_if_exists "blank_state_ee_trial" + = _('Faster releases. Better code. Less pain.') + - if current_user.admin? + = render "blank_state_admin_welcome" + - else + = render "blank_state_welcome" diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml index b649fe91c24..2caa8e0cac4 100644 --- a/app/views/dashboard/snippets/index.html.haml +++ b/app/views/dashboard/snippets/index.html.haml @@ -6,10 +6,6 @@ - if current_user.snippets.exists? = render partial: 'snippets/snippets_scope_menu', locals: { include_private: true } -.d-block.d-sm-none - - = link_to _("New snippet"), new_snippet_path, class: "btn btn-success btn-block", title: _("New snippet") - - if current_user.snippets.exists? = render partial: 'shared/snippets/list', locals: { link_project: true } - else diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index 42cfbbf84f2..5f85235e8fa 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -1,4 +1,7 @@ - page_title "Sign up" -= render 'devise/shared/signup_box' +- if experiment_enabled?(:signup_flow) + = render 'devise/shared/experimental_separate_sign_up_flow_box' +- else + = render 'devise/shared/signup_box' = render 'devise/shared/sign_in_link' diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index 30ed7ed6b29..8f6c3ecbe58 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -4,7 +4,8 @@ - if form_based_providers.any? = render 'devise/shared/tabs_ldap' - else - = render 'devise/shared/tabs_normal' + - unless experiment_enabled?(:signup_flow) + = render 'devise/shared/tabs_normal' .tab-content - if password_authentication_enabled_for_web? || ldap_enabled? || crowd_enabled? = render 'devise/shared/signin_box' diff --git a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml new file mode 100644 index 00000000000..5d163d03c73 --- /dev/null +++ b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml @@ -0,0 +1,31 @@ +- content_for(:page_title, _('Register for GitLab')) +- max_username_length = 255 +.signup-box.p-3.mb-2 + .signup-body + = form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f| + .devise-errors.mt-0 + = render "devise/shared/error_messages", resource: resource + = invisible_captcha + .username.form-group + = f.label :username, class: 'label-bold' + = f.text_field :username, class: "form-control middle js-block-emoji js-validate-length js-validate-username", :data => { :max_length => max_username_length, :max_length_message => s_("SignUp|Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.") + %p.validation-error.gl-field-error-ignore.field-validation.mt-1.hide.cred= _('Username is already taken.') + %p.validation-success.gl-field-error-ignore.field-validation.mt-1.hide.cgreen= _('Username is available.') + %p.validation-pending.gl-field-error-ignore.field-validation.mt-1.hide= _('Checking username availability...') + .form-group + = f.label :email, class: 'label-bold' + = f.email_field :email, class: "form-control middle", data: { qa_selector: 'new_user_email_field' }, required: true, title: _("Please provide a valid email address.") + .form-group.append-bottom-20#password-strength + = f.label :password, class: 'label-bold' + = f.password_field :password, class: "form-control bottom", data: { qa_selector: 'new_user_password_field' }, required: true, pattern: ".{#{@minimum_password_length},}", title: _("Minimum length is %{minimum_password_length} characters.") % { minimum_password_length: @minimum_password_length } + %p.gl-field-hint.text-secondary= _('Minimum length is %{minimum_password_length} characters') % { minimum_password_length: @minimum_password_length } + - if Gitlab::CurrentSettings.current_application_settings.enforce_terms? + .form-group + = check_box_tag :terms_opt_in, '1', false, required: true, data: { qa_selector: 'new_user_accept_terms_checkbox' } + = label_tag :terms_opt_in do + - terms_link = link_to s_("I accept the|Terms of Service and Privacy Policy"), terms_path, target: "_blank" + - accept_terms_label = _("I accept the %{terms_link}") % { terms_link: terms_link } + = accept_terms_label.html_safe + = render_if_exists 'devise/shared/email_opted_in', f: f + .submit-container.mt-3 + = f.submit _("Register"), class: "btn-register btn btn-block btn-success mb-0 p-2", data: { qa_selector: 'new_user_register_button' } diff --git a/app/views/devise/shared/_sign_in_link.html.haml b/app/views/devise/shared/_sign_in_link.html.haml index 77ef103cc47..9a7d8a0a160 100644 --- a/app/views/devise/shared/_sign_in_link.html.haml +++ b/app/views/devise/shared/_sign_in_link.html.haml @@ -1,4 +1,4 @@ -%p +%p.text-center %span.light Already have login and password? = link_to "Sign in", new_session_path(:user, redirect_to_referer: 'yes') diff --git a/app/views/devise/shared/_signin_box.html.haml b/app/views/devise/shared/_signin_box.html.haml index f8f36a8bfff..746d43edbad 100644 --- a/app/views/devise/shared/_signin_box.html.haml +++ b/app/views/devise/shared/_signin_box.html.haml @@ -22,3 +22,8 @@ .login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' } .login-body = render 'devise/sessions/new_base' + +- if experiment_enabled?(:signup_flow) + %p.light.mt-2 + = _("Don't have an account yet?") + = link_to _("Register now"), new_registration_path(:user) diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml index 6750732ab67..8a1b7500abf 100644 --- a/app/views/doorkeeper/applications/show.html.haml +++ b/app/views/doorkeeper/applications/show.html.haml @@ -16,7 +16,7 @@ .input-group %input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true } .input-group-append - = clipboard_button(target: '#application_id', title: _("Copy ID to clipboard"), class: "btn btn btn-default") + = clipboard_button(target: '#application_id', title: _("Copy ID"), class: "btn btn btn-default") %tr %td = _('Secret') @@ -25,7 +25,7 @@ .input-group %input.label.label-monospace.monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true } .input-group-append - = clipboard_button(target: '#secret', title: _("Copy secret to clipboard"), class: "btn btn btn-default") + = clipboard_button(target: '#secret', title: _("Copy secret"), class: "btn btn btn-default") %tr %td = _('Callback URL') diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index 21c418cb0e4..b9e88f3fc47 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -6,11 +6,13 @@ .event-title.d-flex.flex-wrap = inline_event_icon(event) - %span.event-type.d-inline-block.append-right-4.pushed #{event.action_name} #{event.ref_type} - %span.append-right-4 - - commits_link = project_commits_path(project, event.ref_name) - - should_link = event.tag? ? project.repository.tag_exists?(event.ref_name) : project.repository.branch_exists?(event.ref_name) - = link_to_if should_link, event.ref_name, commits_link, class: 'ref-name' + - many_refs = event.ref_count.to_i > 1 + %span.event-type.d-inline-block.append-right-4.pushed= many_refs ? "#{event.action_name} #{event.ref_count} #{event.ref_type.pluralize}" : "#{event.action_name} #{event.ref_type}" + - unless many_refs + %span.append-right-4 + - commits_link = project_commits_path(project, event.ref_name) + - should_link = event.tag? ? project.repository.tag_exists?(event.ref_name) : project.repository.branch_exists?(event.ref_name) + = link_to_if should_link, event.ref_name, commits_link, class: 'ref-name' = render "events/event_scope", event: event diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml index 13df1e57125..44554cab1e9 100644 --- a/app/views/groups/_activities.html.haml +++ b/app/views/groups/_activities.html.haml @@ -1,5 +1,5 @@ .nav-block.activities - = render 'shared/event_filter' + = render 'shared/event_filter', show_group_events: @group.supports_events? .controls = link_to group_path(@group, rss_url_options), class: 'btn d-none d-sm-inline-block has-tooltip' , title: 'Subscribe' do %i.fa.fa-rss diff --git a/app/views/groups/_create_chat_team.html.haml b/app/views/groups/_create_chat_team.html.haml index 561e68a9155..2531993a095 100644 --- a/app/views/groups/_create_chat_team.html.haml +++ b/app/views/groups/_create_chat_team.html.haml @@ -8,7 +8,7 @@ .form-check.js-toggle-container .js-toggle-button.form-check-input= f.check_box(:create_chat_team, { checked: true }, true, false) = f.label :create_chat_team, class: 'form-check-label' do - Create a Mattermost team for this group + = _('Create a Mattermost team for this group') %br %small.light.js-toggle-content Mattermost URL: diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 6d06bb246cb..0e78ce9f656 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -1,4 +1,4 @@ -- breadcrumb_title "General Settings" +- breadcrumb_title _("General Settings") - @content_class = "limit-container-width" unless fluid_layout - expanded = expanded_by_default? @@ -32,7 +32,7 @@ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' } = s_('GroupSettings|Badges') %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + = expanded ? _('Collapse') : _('Expand') %p = s_('GroupSettings|Customize your group badges.') = link_to s_('GroupSettings|Learn more about badges.'), help_page_path('user/project/badges') diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml index 06e05d898d6..376624f4786 100644 --- a/app/views/groups/new.html.haml +++ b/app/views/groups/new.html.haml @@ -40,5 +40,5 @@ = render 'create_chat_team', f: f if Gitlab.config.mattermost.enabled .form-actions - = f.submit 'Create group', class: "btn btn-success" - = link_to 'Cancel', dashboard_groups_path, class: 'btn btn-cancel' + = f.submit _('Create group'), class: "btn btn-success" + = link_to _('Cancel'), dashboard_groups_path, class: 'btn btn-cancel' diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index ba186875a86..8b01e54474a 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -8,21 +8,38 @@ .controls = link_to new_project_path(namespace_id: @group.id), class: "btn btn-sm btn-success" do New project - %ul.content-list + %ul.projects-list.content-list.group-settings-projects - @projects.each do |project| - %li - .list-item-name - %span{ class: visibility_level_color(project.visibility_level) } - = visibility_level_icon(project.visibility_level) - %strong= link_to project.full_name, project - .float-right + %li.project-row{ class: ('no-description' if project.description.blank?) } + .controls + = link_to _('Members'), project_project_members_path(project), id: "edit_#{dom_id(project)}", class: "btn" + = link_to _('Edit'), edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn" + = link_to _('Remove'), project, data: { confirm: remove_project_message(project)}, method: :delete, class: "btn btn-remove" + + .stats + %span.badge.badge-pill + = storage_counter(project.statistics&.storage_size) - if project.archived %span.badge.badge-warning archived - %span.badge.badge-pill - = storage_counter(project.statistics.storage_size) - = link_to 'Members', project_project_members_path(project), id: "edit_#{dom_id(project)}", class: "btn btn-sm" - = link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn btn-sm" - = link_to 'Remove', project, data: { confirm: remove_project_message(project)}, method: :delete, class: "btn btn-sm btn-remove" + + .title + = link_to(project_path(project)) do + .dash-project-avatar + .avatar-container.rect-avatar.s40 + = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40) + %span.project-full-name + %span.namespace-name + - if project.namespace + = project.namespace.human_name + \/ + %span.project-name + = project.name + %span{ class: visibility_level_color(project.visibility_level) } + = visibility_level_icon(project.visibility_level) + + - if project.description.present? + .description + = markdown_field(project, :description) - if @projects.blank? .nothing-here-block This group has no projects yet diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml new file mode 100644 index 00000000000..e85b0713230 --- /dev/null +++ b/app/views/groups/registry/repositories/index.html.haml @@ -0,0 +1,12 @@ +- page_title _("Container Registry") + +%section + .row.registry-placeholder.prepend-bottom-10 + .col-12 + #js-vue-registry-images{ data: { endpoint: group_container_registries_path(@group, format: :json), + "help_page_path" => help_page_path('user/packages/container_registry/index'), + "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), + "containers_error_image" => image_path('illustrations/docker-error-state.svg'), + "repository_url" => "", + is_group_page: true, + character_error: @character_error.to_s } } diff --git a/app/views/groups/runners/_group_runners.html.haml b/app/views/groups/runners/_group_runners.html.haml index fd40ec5a984..f752bc0a702 100644 --- a/app/views/groups/runners/_group_runners.html.haml +++ b/app/views/groups/runners/_group_runners.html.haml @@ -10,6 +10,10 @@ -# Proper policies should be implemented per -# https://gitlab.com/gitlab-org/gitlab-foss/issues/45894 - if can?(current_user, :admin_pipeline, @group) + = render partial: 'ci/runner/how_to_setup_runner_automatically', + locals: { type: 'group', + clusters_path: group_clusters_path(@group) } + %hr = render partial: 'ci/runner/how_to_setup_runner', locals: { registration_token: @group.runners_token, type: 'group', diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml index 64fec260f3b..307309c6ca3 100644 --- a/app/views/groups/settings/_advanced.html.haml +++ b/app/views/groups/settings/_advanced.html.haml @@ -1,12 +1,12 @@ .sub-section - %h4.warning-title Change group path + %h4.warning-title= s_('GroupSettings|Change group path') = form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| = form_errors(@group) .form-group %p - Changing group path can have unintended side effects. + = s_('GroupSettings|Changing group path can have unintended side effects.') = succeed '.' do - = link_to 'Learn more', help_page_path('user/group/index', anchor: 'changing-a-groups-path'), target: '_blank' + = link_to _('Learn more'), help_page_path('user/group/index', anchor: 'changing-a-groups-path'), target: '_blank' .input-group.gl-field-error-anchor .group-root-path.input-group-prepend.has-tooltip{ title: group_path(@group), :'data-placement' => 'bottom' } @@ -18,24 +18,26 @@ = f.text_field :path, placeholder: 'open-source', class: 'form-control', autofocus: local_assigns[:autofocus] || false, required: true, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, - title: 'Please choose a group path with no special characters.', + title: s_('GroupSettings|Please choose a group path with no special characters.'), "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}" - = f.submit 'Change group path', class: 'btn btn-warning' + = f.submit s_('GroupSettings|Change group path'), class: 'btn btn-warning' .sub-section - %h4.warning-title Transfer group + %h4.warning-title= s_('GroupSettings|Transfer group') = form_for @group, url: transfer_group_path(@group), method: :put, html: { class: 'js-group-transfer-form' } do |f| .form-group = dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: 'Search groups', data: { data: parent_group_options(@group) } }) = hidden_field_tag 'new_parent_group_id' %ul - %li Be careful. Changing a group's parent can have unintended #{link_to 'side effects', 'https://docs.gitlab.com/ce/user/project/index.html#redirects-when-changing-repository-paths', target: 'blank'}. - %li You can only transfer the group to a group you manage. - %li You will need to update your local repositories to point to the new location. - %li If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility. - = f.submit 'Transfer group', class: 'btn btn-warning' + - side_effects_link_start = '<a href="https://docs.gitlab.com/ce/user/project/index.html#redirects-when-changing-repository-paths" target="_blank">' + - warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended %{side_effects_link_start}side effects%{side_effects_link_end}.") % { side_effects_link_start: side_effects_link_start, side_effects_link_end:'</a>' } + %li= warning_text.html_safe + %li= s_('GroupSettings|You can only transfer the group to a group you manage.') + %li= s_('GroupSettings|You will need to update your local repositories to point to the new location.') + %li= s_("GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.") + = f.submit s_('GroupSettings|Transfer group'), class: 'btn btn-warning' .sub-section %h4.danger-title= _('Remove group') diff --git a/app/views/groups/settings/ci_cd/_form.html.haml b/app/views/groups/settings/ci_cd/_form.html.haml new file mode 100644 index 00000000000..54e88d11827 --- /dev/null +++ b/app/views/groups/settings/ci_cd/_form.html.haml @@ -0,0 +1,13 @@ +.row.prepend-top-default + .col-lg-12 + = form_for group, url: group_settings_ci_cd_path(group, anchor: 'js-general-pipeline-settings') do |f| + = form_errors(group) + %fieldset.builds-feature + .form-group + = f.label :max_artifacts_size, _('Maximum artifacts size (MB)'), class: 'label-bold' + = f.number_field :max_artifacts_size, class: 'form-control' + %p.form-text.text-muted + = _("Set the maximum file size for each job's artifacts") + = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size-core-only'), target: '_blank' + + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index d21496ee0aa..a3f35b72cc6 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -2,6 +2,21 @@ - page_title "CI / CD" - expanded = expanded_by_default? +- general_expanded = @group.errors.empty? ? expanded : true + +-# Given we only have one field in this form which is also admin-only, +-# we don't want to show an empty section to non-admin users, +- if can?(current_user, :update_max_artifacts_size, @group) + %section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded) } + .settings-header + %h4 + = _("General pipelines") + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = _("Customize your pipeline configuration.") + .settings-content + = render 'groups/settings/ci_cd/form', group: @group %section.settings#ci-variables.no-animate{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 0e6c16f0f06..457d05b4a97 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -5,6 +5,8 @@ = auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity") %div{ class: [("limit-container-width" unless fluid_layout)] } + = render_if_exists 'trials/banner', namespace: @group + = render 'groups/home_panel' .groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } diff --git a/app/views/groups/sidebar/_packages.html.haml b/app/views/groups/sidebar/_packages.html.haml new file mode 100644 index 00000000000..16b902a18b9 --- /dev/null +++ b/app/views/groups/sidebar/_packages.html.haml @@ -0,0 +1,16 @@ +- if group_container_registry_nav? + = nav_link(path: group_packages_nav_link_paths) do + = link_to group_container_registries_path(@group), title: _('Container Registry') do + .nav-icon-container + = sprite_icon('package') + %span.nav-item-name + = _('Packages') + %ul.sidebar-sub-level-items + = nav_link(controller: [:packages, :repositories], html_options: { class: "fly-out-top-item" } ) do + = link_to group_container_registries_path(@group), title: _('Container Registry') do + %strong.fly-out-top-item-name + = _('Packages') + %li.divider.fly-out-top-item + = nav_link(controller: 'groups/container_registries') do + = link_to group_container_registries_path(@group), title: _('Container Registry') do + %span= _('Container Registry') diff --git a/app/views/help/instance_configuration/_ssh_info.html.haml b/app/views/help/instance_configuration/_ssh_info.html.haml index 987cc61b3f6..a7ee37b2784 100644 --- a/app/views/help/instance_configuration/_ssh_info.html.haml +++ b/app/views/help/instance_configuration/_ssh_info.html.haml @@ -1,22 +1,29 @@ - ssh_info = @instance_configuration.settings[:ssh_algorithms_hashes] -- if ssh_info.any? - - content_for :table_content do - %li= link_to 'SSH host keys fingerprints', '#ssh-host-keys-fingerprints' +- content_for :table_content do + %li + = link_to _('SSH host key fingerprints'), '#ssh-host-keys-fingerprints' - - content_for :settings_content do - %h2#ssh-host-keys-fingerprints - SSH host keys fingerprints +- content_for :settings_content do + %h2#ssh-host-keys-fingerprints + = _('SSH host key fingerprints') + - if ssh_info.blank? %p - Below are the fingerprints for the current instance SSH host keys. + = _('SSH host keys are not available on this system. Please use <code>ssh-keyscan</code> command or contact your GitLab administrator for more information.').html_safe + - else + %p + = _('Below are the fingerprints for the current instance SSH host keys.') .table-responsive %table %thead %tr - %th Algorithm - %th MD5 - %th SHA256 + %th + = _('Algorithm') + %th + = _('MD5') + %th + = _('SHA256') %tbody - ssh_info.each do |algorithm| %tr diff --git a/app/views/help/show.html.haml b/app/views/help/show.html.haml index dce27dee9be..dace8a77736 100644 --- a/app/views/help/show.html.haml +++ b/app/views/help/show.html.haml @@ -1,3 +1,5 @@ - page_title @path.split("/").reverse.map(&:humanize) +- @content_class = "limit-container-width" unless fluid_layout + .documentation.md.prepend-top-default = markdown @markdown diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index cdc894ee5a0..b8a421ac9d3 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -508,8 +508,7 @@ .btn-group %a.btn Edit %a.btn.btn-danger Remove - .file-contenta.code - = render 'shared/file_highlight', blob: blob + = render 'shared/file_highlight', blob: blob %h2#markdown Markdown %h4 diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml index 2336e1e83f9..7399ff937ce 100644 --- a/app/views/import/bitbucket/status.html.haml +++ b/app/views/import/bitbucket/status.html.haml @@ -8,7 +8,6 @@ - if @repos.any? %p.light = _('Select projects you want to import.') - %hr %p - if @incompatible_repos.any? = button_tag class: 'btn btn-import btn-success js-import-all' do @@ -19,6 +18,14 @@ = _('Import all projects') = icon('spinner spin', class: 'loading-icon') +.position-relative.ms-no-clear.d-flex.flex-fill.float-right.append-bottom-10 + = form_tag status_import_bitbucket_path, method: 'get' do + = text_field_tag :filter, @filter, class: 'form-control pr-5', placeholder: _('Filter projects'), size: 40, autofocus: true, 'aria-label': _('Search') + .position-absolute.position-top-0.d-flex.align-items-center.text-muted.position-right-0.h-100 + .border-left + %button{ class: 'btn btn-transparent btn-secondary', 'aria-label': _('Search Button'), type: 'submit' } + %i{ class: 'fa fa-search', 'aria-hidden': true } + .table-responsive %table.table.import-jobs %colgroup.import-jobs-from-col @@ -59,7 +66,7 @@ - if current_user.can_select_namespace? - selected = params[:namespace_id] || :current_user - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.owner, path: repo.owner) } : {} - = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace', tabindex: 1 } + = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'select2 js-select-namespace', tabindex: 1 } - else = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true %span.input-group-prepend diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml index aac09801d91..1aaf5883bf4 100644 --- a/app/views/import/bitbucket_server/status.html.haml +++ b/app/views/import/bitbucket_server/status.html.haml @@ -62,7 +62,7 @@ - if current_user.can_select_namespace? - selected = params[:namespace_id] || :extra_group - opts = current_user.can_create_group? ? { extra_group: Group.new(name: sanitize_project_name(repo.project_key), path: sanitize_project_name(repo.project_key)) } : {} - = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace', tabindex: 1 } + = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'select2 js-select-namespace', tabindex: 1 } - else = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true %span.input-group-prepend diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml index d673d7164b3..92572f0308c 100644 --- a/app/views/layouts/_flash.html.haml +++ b/app/views/layouts/_flash.html.haml @@ -5,4 +5,5 @@ - if value %div{ class: "flash-content flash-#{key} rounded" } %span= value - = sprite_icon('close', size: 16, css_class: 'close-icon') + %div{ class: "close-icon-wrapper js-close-icon" } + = sprite_icon('close', size: 16, css_class: 'close-icon') diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 68abfd3f61f..b8c9f0ae1e8 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -1,5 +1,17 @@ - page_description brand_title unless page_description +-# Needs a redirect on the client side since it's using an anchor to distuingish +-# between sign in and registration. We need to inline the JS to not render +-# anything from this page beforehand. +-# Part of an experiment to build a new sign up flow. Will be removed again with +-# https://gitlab.com/gitlab-org/growth/engineering/issues/64 +- if experiment_enabled?(:signup_flow) && current_path?("sessions#new") + = javascript_tag nonce: true do + :plain + if (window.location.hash === '#register-pane') { + window.location.replace("/users/sign_up") + } + - site_name = "GitLab" %head{ prefix: "og: http://ogp.me/ns#" } %meta{ charset: "utf-8" } diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 443a73f5cce..6cdb85456c3 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -17,4 +17,6 @@ %div{ class: "#{(container_class unless @no_container)} #{@content_class}" } .content{ id: "content-body" } = render "layouts/flash", extra_flash_class: 'limit-container-width' + - if Gitlab.com? + = render_if_exists "layouts/privacy_policy_update_callout" = yield diff --git a/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml b/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml new file mode 100644 index 00000000000..2f05717fc0e --- /dev/null +++ b/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml @@ -0,0 +1,26 @@ +!!! 5 +%html.devise-layout-html.navless{ class: system_message_class } + = render "layouts/head" + %body.ui-indigo.signup-page.application.navless{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } } + = header_message + = render "layouts/init_client_detection_flags" + .page-wrap + .container.signup-box-container.navless-container.mt-0 + = render "layouts/broadcast" + .content + = render "layouts/flash" + .row.mb-3 + .col-sm-8.offset-sm-2.col-md-6.offset-md-3.new-session-forms-container + = render_if_exists 'layouts/devise_help_text' + .text-center.signup-heading.mt-3.mb-3 + = image_tag(image_url('logo.svg'), class: 'gitlab-logo', alt: 'GitLab Logo') + - if content_for?(:page_title) + %h2= yield :page_title + = yield + %hr.footer-fixed + .footer-container + .container + .footer-links + = link_to _("Help"), help_path + = link_to _("About GitLab"), "https://about.gitlab.com/" + = footer_message diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index 808290afcad..efe74ddd902 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -21,6 +21,24 @@ - if current_user_menu?(:settings) %li = link_to s_("CurrentUser|Settings"), profile_path, data: { qa_selector: 'settings_link' } + + - if current_user_menu?(:help) + %li.divider.d-md-none + %li.d-md-none + = link_to _("Help"), help_path + %li.d-md-none + = link_to _("Support"), support_url + = render_if_exists "shared/learn_gitlab_menu_item" + %li.d-md-none + = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback" + - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile) + %li.d-md-none + = render 'shared/user_dropdown_contributing_link' + = render_if_exists 'shared/user_dropdown_instance_review' + - if Gitlab.com? + %li.js-canary-link.d-md-none + = link_to _("Switch to GitLab Next"), "https://next.gitlab.com/" + - if current_user_menu?(:sign_out) %li.divider %li diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 14c7b2428b2..d8697be7f7a 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -33,9 +33,9 @@ - if current_user = render 'layouts/header/new_dropdown' - if header_link?(:search) - %li.nav-item.d-none.d-sm-none.d-md-block.m-auto + %li.nav-item.d-none.d-lg-block.m-auto = render 'layouts/search' unless current_controller?(:search) - %li.nav-item.d-inline-block.d-sm-none.d-md-none + %li.nav-item.d-inline-block.d-lg-none = link_to search_path_url, title: _('Search'), aria: { label: _('Search') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = sprite_icon('search', size: 16) - if header_link?(:issues) @@ -58,7 +58,7 @@ = sprite_icon('todo-done', size: 16) %span.badge.badge-pill.todos-count{ class: ('hidden' if todos_pending_count.zero?) } = todos_count_format(todos_pending_count) - %li.nav-item.header-help.dropdown + %li.nav-item.header-help.dropdown.d-none.d-md-block = link_to help_path, class: 'header-help-dropdown-toggle', data: { toggle: "dropdown" } do = sprite_icon('question', size: 16) = sprite_icon('angle-down', css_class: 'caret-down') @@ -73,7 +73,7 @@ = render 'layouts/header/current_user_dropdown' - if has_impersonation_link %li.nav-item.impersonation.ml-0 - = link_to admin_impersonation_path, class: 'nav-link impersonation-btn', method: :delete, title: _('Stop impersonation'), aria: { label: _('Stop impersonation') }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do + = link_to admin_impersonation_path, class: 'nav-link impersonation-btn', method: :delete, title: _('Stop impersonation'), aria: { label: _('Stop impersonation') }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body', qa_selector: 'stop_impersonation_link' } do = icon('user-secret') - if header_link?(:sign_in) %li.nav-item diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index 41d7aa3741a..71977b23481 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -9,7 +9,8 @@ %li = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback" - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile) - = render 'shared/user_dropdown_contributing_link' + %li + = render 'shared/user_dropdown_contributing_link' = render_if_exists 'shared/user_dropdown_instance_review' - if Gitlab.com? %li.js-canary-link diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 7b0824ae2af..5122c2517aa 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -10,37 +10,24 @@ = render "layouts/nav/projects_dropdown/show" - if dashboard_nav_link?(:groups) - = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "home dropdown header-groups qa-groups-dropdown", data: { track_label: "groups_dropdown", track_event: "click_dropdown", track_value: "" } }) do + = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "d-none d-md-block home dropdown header-groups qa-groups-dropdown", data: { track_label: "groups_dropdown", track_event: "click_dropdown", track_value: "" } }) do %button.btn{ type: 'button', data: { toggle: "dropdown" } } = _('Groups') = sprite_icon('angle-down', css_class: 'caret-down') .dropdown-menu.frequent-items-dropdown-menu = render "layouts/nav/groups_dropdown/show" - - if dashboard_nav_link?(:activity) - = nav_link(path: 'dashboard#activity', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do - = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity' do - = _('Activity') - - - if dashboard_nav_link?(:milestones) - = nav_link(controller: 'dashboard/milestones', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do - = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones' do - = _('Milestones') - - - if dashboard_nav_link?(:snippets) - = nav_link(controller: 'dashboard/snippets', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do - = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets qa-snippets-link' do - = _('Snippets') - - = render_if_exists 'layouts/nav/sidebar/analytics_link' - - if any_dashboard_nav_link?([:groups, :milestones, :activity, :snippets]) - %li.header-more.dropdown.d-xl-none{ class: ('d-lg-none' unless has_extra_nav_icons?) } - %a{ href: "#", data: { toggle: "dropdown" } } + %li.header-more.dropdown + %a{ href: "#", data: { toggle: "dropdown", qa_selector: 'more_dropdown' } } = _('More') = sprite_icon('angle-down', css_class: 'caret-down') .dropdown-menu %ul + - if dashboard_nav_link?(:groups) + %li.d-md-none + = link_to dashboard_groups_path do + = _('Groups') - if dashboard_nav_link?(:activity) = nav_link(path: 'dashboard#activity') do = link_to activity_dashboard_path do @@ -53,51 +40,45 @@ - if dashboard_nav_link?(:snippets) = nav_link(controller: 'dashboard/snippets') do - = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets' do + = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', data: { qa_selector: 'snippets_link' } do = _('Snippets') - = render_if_exists 'layouts/nav/sidebar/analytics_more_link' + = render_if_exists 'layouts/nav/sidebar/analytics_link' - %li.dropdown.d-lg-none - = render_if_exists 'dashboard/operations/nav_link_list' + %li.dropdown + = render_if_exists 'dashboard/nav_link_list' - if can?(current_user, :read_instance_statistics) - = nav_link(controller: [:conversational_development_index, :cohorts], html_options: { class: 'd-lg-none' }) do + = nav_link(controller: [:conversational_development_index, :cohorts]) do = link_to instance_statistics_root_path do = _('Instance Statistics') - if current_user.admin? = nav_link(controller: 'admin/dashboard') do - = link_to admin_root_path, class: 'd-lg-none admin-icon qa-admin-area-link' do + = link_to admin_root_path, class: 'admin-icon qa-admin-area-link d-xl-none' do = _('Admin Area') + - if Feature.enabled?(:user_mode_in_session) + - if header_link?(:admin_mode) + = nav_link(controller: 'admin/sessions') do + = link_to destroy_admin_session_path, class: 'd-lg-none lock-open-icon' do + = _('Leave admin mode') + - elsif current_user.admin? + = nav_link(controller: 'admin/sessions') do + = link_to new_admin_session_path, class: 'd-lg-none lock-icon' do + = _('Enter admin mode') - if Gitlab::Sherlock.enabled? %li - = link_to sherlock_transactions_path, class: 'd-lg-none admin-icon' do + = link_to sherlock_transactions_path, class: 'admin-icon' do = _('Sherlock Transactions') + - if current_user.admin? + = nav_link(controller: 'admin/dashboard', html_options: { class: "d-none d-xl-block"}) do + = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = sprite_icon('admin', size: 18) + + -# Shortcut to Dashboard > Projects - if dashboard_nav_link?(:projects) %li.hidden = link_to dashboard_projects_path, class: 'dashboard-shortcuts-projects' do = _('Projects') - - if current_controller?('ide') - %li.line-separator.d-none.d-sm-block - = nav_link(controller: 'ide') do - = link_to '#', class: 'dashboard-shortcuts-web-ide' do - = _('Web IDE') - - %li.dropdown{ class: 'd-none d-lg-block' } - = render_if_exists 'dashboard/operations/nav_link' - - if can?(current_user, :read_instance_statistics) - = nav_link(controller: [:conversational_development_index, :cohorts], html_options: { class: "d-none d-lg-block d-xl-block"}) do - = link_to instance_statistics_root_path, title: _('Instance Statistics'), aria: { label: _('Instance Statistics') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do - = sprite_icon('chart', size: 18) - - if current_user.admin? - = nav_link(controller: 'admin/dashboard', html_options: { class: "d-none d-lg-block d-xl-block"}) do - = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do - = sprite_icon('admin', size: 18) - - if Gitlab::Sherlock.enabled? - %li - = link_to sherlock_transactions_path, class: 'admin-icon d-none d-lg-block d-xl-block', title: _('Sherlock Transactions'), - data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do - = icon('tachometer fw') = render_if_exists 'layouts/nav/geo_primary_node_url' diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index 784fe556123..b33ef26f87d 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -6,7 +6,7 @@ = sprite_icon('admin', size: 24) .sidebar-context-title = _('Admin Area') - %ul.sidebar-top-level-items + %ul.sidebar-top-level-items{ data: { qa_selector: 'admin_sidebar_overview_submenu_content' } } = nav_link(controller: %w(dashboard admin admin/projects users groups jobs runners gitaly_servers), html_options: {class: 'home'}) do = link_to admin_root_path, class: 'shortcuts-tree' do .nav-icon-container @@ -28,7 +28,7 @@ %span = _('Projects') = nav_link(controller: :users) do - = link_to admin_users_path, title: _('Users') do + = link_to admin_users_path, title: _('Users') , data: { qa_selector: 'users_overview_link' } do %span = _('Users') = nav_link(controller: :groups) do @@ -49,13 +49,13 @@ = _('Gitaly Servers') = nav_link(controller: admin_monitoring_nav_links) do - = link_to admin_system_info_path do + = link_to admin_system_info_path, data: { qa_selector: 'admin_monitoring_link' } do .nav-icon-container = sprite_icon('monitor') %span.nav-item-name = _('Monitoring') - %ul.sidebar-sub-level-items + %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_sidebar_monitoring_submenu_content' } } = nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles), html_options: { class: "fly-out-top-item" } ) do = link_to admin_system_info_path do %strong.fly-out-top-item-name @@ -225,7 +225,7 @@ %span.nav-item-name.qa-admin-settings-item = _('Settings') - %ul.sidebar-sub-level-items.qa-admin-sidebar-submenu + %ul.sidebar-sub-level-items.qa-admin-sidebar-settings-submenu = nav_link(controller: :application_settings, html_options: { class: "fly-out-top-item" } ) do = link_to admin_application_settings_path do %strong.fly-out-top-item-name diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 7cc7d1783c4..4930c6cf5f7 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -118,7 +118,7 @@ %strong.fly-out-top-item-name = _('Kubernetes') - = render_if_exists 'groups/sidebar/packages' # EE-specific + = render_if_exists 'groups/sidebar/packages' - if group_sidebar_link?(:group_members) = nav_link(path: 'group_members#index') do diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml index 22ac9ee234d..9be39d14169 100644 --- a/app/views/layouts/nav/sidebar/_profile.html.haml +++ b/app/views/layouts/nav/sidebar/_profile.html.haml @@ -64,7 +64,7 @@ %strong.fly-out-top-item-name = _('Access Tokens') = nav_link(controller: :emails) do - = link_to profile_emails_path do + = link_to profile_emails_path, data: { qa_selector: 'profile_emails_link' } do .nav-icon-container = sprite_icon('mail') %span.nav-item-name @@ -76,7 +76,7 @@ = _('Emails') - if current_user.allow_password_authentication? = nav_link(controller: :passwords) do - = link_to edit_profile_password_path do + = link_to edit_profile_password_path , data: { qa_selector: 'profile_password_link' } do .nav-icon-container = sprite_icon('lock') %span.nav-item-name diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 48fea2bbecf..c84bc0b5cd4 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -182,11 +182,17 @@ = _('Pipelines') - if project_nav_tab? :builds - = nav_link(controller: [:jobs, :artifacts]) do + = nav_link(controller: :jobs) do = link_to project_jobs_path(@project), title: _('Jobs'), class: 'shortcuts-builds' do %span = _('Jobs') + - if Feature.enabled?(:artifacts_management_page, @project) + = nav_link(controller: :artifacts, action: :index) do + = link_to project_artifacts_path(@project), title: _('Artifacts'), class: 'shortcuts-builds' do + %span + = _('Artifacts') + - if project_nav_tab? :pipelines = nav_link(controller: :pipeline_schedules) do = link_to pipeline_schedules_path(@project), title: _('Schedules'), class: 'shortcuts-builds' do diff --git a/app/views/notify/new_release_email.html.haml b/app/views/notify/new_release_email.html.haml new file mode 100644 index 00000000000..45e99f3c07a --- /dev/null +++ b/app/views/notify/new_release_email.html.haml @@ -0,0 +1,18 @@ +- release_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url } +- description_details = { tag: @release.tag, name: @project.name, release_link_start: release_link_start, release_link_end: '</a>'.html_safe } + +%div{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" } + %p + = _("A new Release %{tag} for %{name} was published. Visit the %{release_link_start}Releases page%{release_link_end} to read more about it.").html_safe % description_details + + %p + %h4= _("Assets:") + %ul + - @release.links.each do |link| + %li= link_to(link.name, link.url) + - @release.sources.each do |source| + %li= link_to(_("Download %{format}") % { format: source.format }, source.url) + + %p + %h4= _("Release notes:") + = markdown_field(@release, :description) diff --git a/app/views/notify/new_release_email.text.erb b/app/views/notify/new_release_email.text.erb new file mode 100644 index 00000000000..e03cf2d5fd1 --- /dev/null +++ b/app/views/notify/new_release_email.text.erb @@ -0,0 +1,12 @@ +<%= _("A new Release %{tag} for %{name} was published. Visit the Releases page to read more about it:").html_safe % { tag: @release.tag, name: @project.name } %> <%= @target_url %> + +<%= _("Assets:") %> +<% @release.links.each do |link| -%> + - <%= link.name %>: <%= link.url %> +<% end -%> +<% @release.sources.each do |source| -%> + - <%= _("Download %{format}:") % { format: source.format } %> <%= source.url %> +<% end -%> + +<%= _("Release notes:") %> +<%= @release.description %> diff --git a/app/views/notify/pipeline_failed_email.html.haml b/app/views/notify/pipeline_failed_email.html.haml index 86dcca4a447..f01181857ce 100644 --- a/app/views/notify/pipeline_failed_email.html.haml +++ b/app/views/notify/pipeline_failed_email.html.haml @@ -34,7 +34,7 @@ %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-branch-gray.gif'), style: "display:block;", width: "13", alt: "" }/ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } %a.muted{ href: commits_url(@pipeline), style: "color:#333333;text-decoration:none;" } - = @pipeline.ref + = @pipeline.source_ref %tr %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Commit %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:400;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" } diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb index 722eedf90be..9cd479ef1e6 100644 --- a/app/views/notify/pipeline_failed_email.text.erb +++ b/app/views/notify/pipeline_failed_email.text.erb @@ -1,7 +1,7 @@ Your pipeline has failed. Project: <%= @project.name %> ( <%= project_url(@project) %> ) -Branch: <%= @pipeline.ref %> ( <%= commits_url(@pipeline) %> ) +Branch: <%= @pipeline.source_ref %> ( <%= commits_url(@pipeline) %> ) <% if @merge_request -%> Merge Request: <%= @merge_request.to_reference %> ( <%= merge_request_url(@merge_request) %> ) <% end -%> diff --git a/app/views/notify/pipeline_success_email.html.haml b/app/views/notify/pipeline_success_email.html.haml index 4fe3c4c8269..e575a5569fa 100644 --- a/app/views/notify/pipeline_success_email.html.haml +++ b/app/views/notify/pipeline_success_email.html.haml @@ -34,7 +34,7 @@ %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-branch-gray.gif'), style: "display:block;", width: "13", alt: "" }/ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } %a.muted{ href: commits_url(@pipeline), style: "color:#333333;text-decoration:none;" } - = @pipeline.ref + = @pipeline.source_ref %tr %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Commit %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:400;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" } diff --git a/app/views/notify/pipeline_success_email.text.erb b/app/views/notify/pipeline_success_email.text.erb index 9aadf380f79..4005158dc9e 100644 --- a/app/views/notify/pipeline_success_email.text.erb +++ b/app/views/notify/pipeline_success_email.text.erb @@ -1,7 +1,7 @@ Your pipeline has passed. Project: <%= @project.name %> ( <%= project_url(@project) %> ) -Branch: <%= @pipeline.ref %> ( <%= commits_url(@pipeline) %> ) +Branch: <%= @pipeline.source_ref %> ( <%= commits_url(@pipeline) %> ) <% if @merge_request -%> Merge Request: <%= @merge_request.to_reference %> ( <%= merge_request_url(@merge_request) %> ) <% end -%> diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index 3c20518c038..6ea4eeb66c5 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -13,9 +13,9 @@ = form_for 'email', url: profile_emails_path do |f| .form-group = f.label :email, _('Email'), class: 'label-bold' - = f.text_field :email, class: 'form-control' + = f.text_field :email, class: 'form-control', data: { qa_selector: 'email_address_field' } .prepend-top-default - = f.submit _('Add email address'), class: 'btn btn-success' + = f.submit _('Add email address'), class: 'btn btn-success', data: { qa_selector: 'add_email_address_button' } %hr %h4.prepend-top-0 = _('Linked emails (%{email_count})') % { email_count: @emails.count + 1 } @@ -45,7 +45,7 @@ - if @primary_email === current_user.notification_email %span.badge.badge-info= s_('Profiles|Default notification email') - @emails.each do |email| - %li + %li{ data: { qa_selector: 'email_row_content' } } = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? } %span.float-right - if email.email === current_user.commit_email @@ -58,6 +58,6 @@ - confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}" = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'btn btn-sm btn-warning prepend-left-10' - = link_to profile_email_path(email), data: { confirm: _('Are you sure?')}, method: :delete, class: 'btn btn-sm btn-danger prepend-left-10' do + = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'btn btn-sm btn-danger prepend-left-10' do %span.sr-only= _('Remove') = icon('trash') diff --git a/app/views/profiles/notifications/_group_settings.html.haml b/app/views/profiles/notifications/_group_settings.html.haml index 1776d260e19..33b0aa93d84 100644 --- a/app/views/profiles/notifications/_group_settings.html.haml +++ b/app/views/profiles/notifications/_group_settings.html.haml @@ -12,5 +12,5 @@ = render 'shared/notifications/button', notification_setting: setting, emails_disabled: emails_disabled .table-section.section-30 - = form_for @user.notification_settings.find { |ns| ns.source == group }, url: profile_notifications_group_path(group), method: :put, html: { class: 'update-notifications' } do |f| + = form_for setting, url: profile_notifications_group_path(group), method: :put, html: { class: 'update-notifications' } do |f| = f.select :notification_email, @user.all_emails, { include_blank: 'Global notification email' }, class: 'select2 js-group-notification-email' diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml index ac8c31189d0..0e2b0430fec 100644 --- a/app/views/profiles/passwords/edit.html.haml +++ b/app/views/profiles/passwords/edit.html.haml @@ -20,16 +20,16 @@ - unless @user.password_automatically_set? .form-group = f.label :current_password, _('Current password'), class: 'label-bold' - = f.password_field :current_password, required: true, class: 'form-control' + = f.password_field :current_password, required: true, class: 'form-control', data: { qa_selector: 'current_password_field' } %p.form-text.text-muted = _('You must provide your current password in order to change it.') .form-group = f.label :password, _('New password'), class: 'label-bold' - = f.password_field :password, required: true, class: 'form-control' + = f.password_field :password, required: true, class: 'form-control', data: { qa_selector: 'new_password_field' } .form-group = f.label :password_confirmation, _('Password confirmation'), class: 'label-bold' - = f.password_field :password_confirmation, required: true, class: 'form-control' + = f.password_field :password_confirmation, required: true, class: 'form-control', data: { qa_selector: 'confirm_password_field' } .prepend-top-default.append-bottom-default - = f.submit _('Save password'), class: "btn btn-success append-right-10" + = f.submit _('Save password'), class: "btn btn-success append-right-10", data: { qa_selector: 'save_password_button' } - unless @user.password_automatically_set? = link_to _('I forgot my password'), reset_profile_password_path, method: :put, class: "account-btn-link" diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 0576f51fa83..68b7efc6fb4 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -94,6 +94,7 @@ - else = f.text_field :name, label: s_('Profiles|Full name'), required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you") = f.text_field :id, readonly: true, label: s_('Profiles|User ID'), wrapper: { class: 'col-md-3' } + = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, {}, class: 'input-md' = render_if_exists 'profiles/email_settings', form: f = f.text_field :skype, class: 'input-md', placeholder: s_("Profiles|username") diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index f564ed41760..e4129a91daf 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -10,12 +10,8 @@ %p.append-bottom-0 %p= _('The following items will be exported:') %ul - %li= _('Project and wiki repositories') - %li= _('Project uploads') - %li= _('Project configuration, including services') - %li= _('Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities') - %li= _('LFS objects') - %li= _('Issue Boards') + - project_export_descriptions.each do |desc| + %li= desc %p= _('The following items will NOT be exported:') %ul %li= _('Job traces and artifacts') diff --git a/app/views/projects/_new_project_push_tip.html.haml b/app/views/projects/_new_project_push_tip.html.haml index 22e9522c0e7..e008130436c 100644 --- a/app/views/projects/_new_project_push_tip.html.haml +++ b/app/views/projects/_new_project_push_tip.html.haml @@ -6,6 +6,6 @@ %span = text_field_tag :push_to_create_tip, push_to_create_project_command, class: "js-select-on-focus form-control monospace", readonly: true, aria: { label: _("Push project from command line") } %span.input-group-append - = clipboard_button(text: push_to_create_project_command, title: _("Copy command to clipboard"), class: 'input-group-text', placement: "right") + = clipboard_button(text: push_to_create_project_command, title: _("Copy command"), class: 'input-group-text', placement: "right") %p = link_to("What does this command do?", help_page_path("gitlab-basics/create-project", anchor: "push-to-create-a-new-project"), target: "_blank") diff --git a/app/views/projects/artifacts/_artifact.html.haml b/app/views/projects/artifacts/_artifact.html.haml new file mode 100644 index 00000000000..36e149556e0 --- /dev/null +++ b/app/views/projects/artifacts/_artifact.html.haml @@ -0,0 +1,61 @@ +.gl-responsive-table-row.px-md-3 + .table-section.section-25.section-wrap.commit + .table-mobile-header{ role: 'rowheader' }= _('Job') + .table-mobile-content + .branch-commit.cgray + - if can?(current_user, :read_build, @project) + = link_to project_job_path(@project, artifact.job) do + %span.build-link ##{artifact.job_id} + - else + %span.build-link ##{artifact.job_id} + + - if artifact.job.ref + .icon-container{ "aria-label" => artifact.job.tag? ? _('Tag') : _('Branch') } + = artifact.job.tag? ? sprite_icon('tag', css_class: 'sprite') : sprite_icon('branch', css_class: 'sprite') + = link_to artifact.job.ref, project_ref_path(@project, artifact.job.ref), class: 'ref-name' + - else + .light= _('none') + .icon-container.commit-icon{ "aria-label" => _('Commit') } + = sprite_icon('commit') + + - if artifact.job.sha + = link_to artifact.job.short_sha, project_commit_path(@project, artifact.job.sha), class: 'commit-sha mr-0' + + .table-section.section-15.section-wrap + .table-mobile-header{ role: 'rowheader' }= _('Name') + .table-mobile-content + = artifact.job.name + + .table-section.section-20 + .table-mobile-header{ role: 'rowheader' }= _('Creation date') + .table-mobile-content + %p.finished-at + = icon("calendar") + %span= time_ago_with_tooltip(artifact.created_at) + + .table-section.section-20 + .table-mobile-header{ role: 'rowheader' }= _('Expiration date') + .table-mobile-content + - if artifact.expire_at + %p.finished-at + = icon("calendar") + %span= time_ago_with_tooltip(artifact.expire_at) + + .table-section.section-10 + .table-mobile-header{ role: 'rowheader' }= _('Size') + .table-mobile-content + = number_to_human_size(artifact.size, precision: 2) + + .table-section.table-button-footer.section-10 + .table-action-buttons + .btn-group + - if can?(current_user, :read_build, @project) + = link_to download_project_job_artifacts_path(@project, artifact.job), rel: 'nofollow', download: '', title: _('Download artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Download artifacts') }, class: 'btn btn-build has-tooltip ml-0' do + = sprite_icon('download') + + = link_to browse_project_job_artifacts_path(@project, artifact.job), rel: 'nofollow', title: _('Browse artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Browse artifacts') }, class: 'btn btn-build has-tooltip' do + = sprite_icon('folder-open') + + - if can?(current_user, :destroy_artifacts, @project) + = link_to project_artifact_path(@project, artifact), data: { placement: 'top', container: 'body', confirm: _('Are you sure you want to delete these artifacts?') }, method: :delete, title: _('Delete artifacts'), ref: 'tooltip', aria: { label: _('Delete artifacts') }, class: 'btn btn-remove has-tooltip' do + = sprite_icon('remove') diff --git a/app/views/projects/artifacts/_table.html.haml b/app/views/projects/artifacts/_table.html.haml new file mode 100644 index 00000000000..1963449d704 --- /dev/null +++ b/app/views/projects/artifacts/_table.html.haml @@ -0,0 +1,16 @@ +- if artifacts.blank? + .nothing-here-block= _('No jobs to show') +- else + .table-holder + .ci-table + .gl-responsive-table-row.table-row-header.px-md-3{ role: 'row' } + .table-section.section-25{ role: 'rowheader' }= _('Job') + .table-section.section-15{ role: 'rowheader' }= _('Name') + .table-section.section-20{ role: 'rowheader' }= _('Creation date') + .table-section.section-20{ role: 'rowheader' }= _('Expiration date') + .table-section.section-10{ role: 'rowheader' }= _('Size') + .table-section.section-10{ role: 'rowheader' } + + = render partial: 'artifact', collection: artifacts, as: :artifact + + = paginate artifacts, theme: "gitlab", total_pages: @total_pages diff --git a/app/views/projects/artifacts/index.html.haml b/app/views/projects/artifacts/index.html.haml new file mode 100644 index 00000000000..1ab3e8e67d8 --- /dev/null +++ b/app/views/projects/artifacts/index.html.haml @@ -0,0 +1,10 @@ +- @no_container = true +- page_title _('Artifacts') + +%div{ class: container_class } + .top-area.py-3 + .align-self-center + = _('Total artifacts size: %{total_size}') % { total_size: number_to_human_size(@total_size, precicion: 2) } + + .content-list.builds-content-list + = render "table", artifacts: @artifacts, project: @project diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index 283b845e40d..961b873b571 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -3,20 +3,22 @@ - is_markdown = Gitlab::MarkupHelper.gitlab_markdown?(file_name) .file-holder-bottom-radius.file-holder.file.append-bottom-default - .js-file-title.file-title.clearfix{ data: { current_action: action } } + .js-file-title.file-title.align-items-center.clearfix{ data: { current_action: action } } .editor-ref.block-truncated = sprite_icon('fork', size: 12) = ref - if current_action?(:edit) || current_action?(:update) %span.pull-left.append-right-10 - = text_field_tag 'file_path', (params[:file_path] || @path), - class: 'form-control new-file-path js-file-path-name-input' + = text_field_tag 'file_path', (params[:file_path] || @path), + class: 'form-control new-file-path js-file-path-name-input' + = render 'template_selectors' - if current_action?(:new) || current_action?(:create) %span.pull-left.append-right-10 \/ = text_field_tag 'file_name', params[:file_name], placeholder: "File name", required: true, class: 'form-control new-file-name js-file-path-name-input' + = render 'template_selectors' .file-buttons - if is_markdown diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml index bd46f5a4349..5ecfa135446 100644 --- a/app/views/projects/blob/_template_selectors.html.haml +++ b/app/views/projects/blob/_template_selectors.html.haml @@ -1,17 +1,12 @@ -.template-selectors-menu - .templates-selectors-label - Template +.template-selectors-menu.gl-pl-2 .template-selector-dropdowns-wrap .template-type-selector.js-template-type-selector-wrap.hidden - = dropdown_tag("Choose type", options: { toggle_class: 'js-template-type-selector qa-template-type-dropdown', title: "Choose a template type" } ) + = dropdown_tag(_("Select a template type"), options: { toggle_class: 'js-template-type-selector qa-template-type-dropdown', dropdown_class: 'dropdown-menu-selectable'} ) .license-selector.js-license-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag("Apply a license template", options: { toggle_class: 'js-license-selector qa-license-dropdown', title: "Apply a license", filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-license-selector qa-license-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name } } ) .gitignore-selector.js-gitignore-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag("Apply a .gitignore template", options: { toggle_class: 'js-gitignore-selector qa-gitignore-dropdown', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: gitignore_names(@project) } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitignore-selector qa-gitignore-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitignore_names(@project) } } ) .gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag("Apply a GitLab CI Yaml template", options: { toggle_class: 'js-gitlab-ci-yml-selector qa-gitlab-ci-yml-dropdown', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project) } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector qa-gitlab-ci-yml-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project) } } ) .dockerfile-selector.js-dockerfile-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag("Apply a Dockerfile template", options: { toggle_class: 'js-dockerfile-selector qa-dockerfile-dropdown', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: dockerfile_names(@project) } } ) - .template-selectors-undo-menu.hidden - %span.text-info Template applied - %button.btn.btn-sm.btn-info Undo + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-dockerfile-selector qa-dockerfile-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: dockerfile_names(@project) } } ) diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index 51e42091ab8..870e37488cf 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -11,7 +11,6 @@ .editor-title-row %h3.page-title.blob-edit-page-title Edit file - = render 'template_selectors' .file-editor %ul.nav-links.no-bottom.js-edit-mode.nav.nav-tabs %li.active diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml index 4be87b9e074..c5e3923f1e8 100644 --- a/app/views/projects/blob/new.html.haml +++ b/app/views/projects/blob/new.html.haml @@ -5,7 +5,6 @@ .editor-title-row %h3.page-title.blob-new-page-title New file - = render 'template_selectors' .file-editor = form_tag(project_create_blob_path(@project, @id), method: :post, class: 'js-edit-blob-form js-new-blob-form js-quick-submit js-requires-input', data: blob_editor_paths(@project)) do = render 'projects/blob/editor', ref: @ref diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index 688b8f001c3..7c73bbc7479 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -1,6 +1,6 @@ - breadcrumb_title "Repository" - page_title @blob.path, @ref -- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit) +- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit, limit: 1) .js-signature-container{ data: { 'signatures-path': signatures_path } } diff --git a/app/views/projects/blob/viewers/_audio.html.haml b/app/views/projects/blob/viewers/_audio.html.haml new file mode 100644 index 00000000000..dbdf243c36b --- /dev/null +++ b/app/views/projects/blob/viewers/_audio.html.haml @@ -0,0 +1,2 @@ +.file-content.audio + %audio{ src: blob_raw_path, controls: true, data: { setup: '{}' } } diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index dbff2115f50..3e53cb510b0 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -34,8 +34,9 @@ = _('Merge request') - if branch.name != @repository.root_ref - = link_to project_compare_path(@project, @repository.root_ref, branch.name), + = link_to project_compare_index_path(@project, from: @repository.root_ref, to: branch.name), class: "btn btn-default js-onboarding-compare-branches #{'prepend-left-10' unless merge_project}", + method: :post, title: s_('Branches|Compare') do = s_('Branches|Compare') diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index 09f05b30433..abef33ca01c 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -13,7 +13,7 @@ .input-group = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control qa-ssh-clone-url", readonly: true, aria: { label: 'Project clone URL' } .input-group-append - = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard") + = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard") = render_if_exists 'projects/buttons/geo' - if http_enabled? %li.pt-2 @@ -22,7 +22,7 @@ .input-group = text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control qa-http-clone-url", readonly: true, aria: { label: 'Project clone URL' } .input-group-append - = clipboard_button(target: '#http_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard") + = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard") = render_if_exists 'projects/buttons/geo' = render_if_exists 'projects/buttons/kerberos_clone_field' diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 6c77036a85b..d07407a6d13 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -6,7 +6,7 @@ %strong #{ s_('CommitBoxTitle|Commit') } %span.commit-sha{ data: { qa_selector: 'commit_sha_content' } }= @commit.short_id - = clipboard_button(text: @commit.id, title: _('Copy commit SHA to clipboard')) + = clipboard_button(text: @commit.id, title: _('Copy commit SHA')) %span.d-none.d-sm-inline= _('authored') #{time_ago_with_tooltip(@commit.authored_date)} %span= s_('ByAuthor|by') diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 2c78e74be2f..3a9c7a8bec5 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -6,7 +6,8 @@ - merge_request = local_assigns.fetch(:merge_request, nil) - project = local_assigns.fetch(:project) { merge_request&.project } - ref = local_assigns.fetch(:ref) { merge_request&.source_branch } -- commit_status = commit.present(current_user: current_user).status_for(ref) +- commit = commit.present(current_user: current_user) +- commit_status = commit.status_for(ref) - link = commit_path(project, commit, merge_request: merge_request) @@ -48,12 +49,12 @@ = render partial: 'projects/commit/ajax_signature', locals: { commit: commit } - if commit_status - = render_commit_status(commit, ref: ref) + = render_commit_status(commit, commit_status, ref: ref) .js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } } .commit-sha-group.d-none.d-sm-flex .label.label-monospace.monospace = commit.short_id - = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard"), class: "btn btn-default", container: "body") + = clipboard_button(text: commit.id, title: _("Copy commit SHA"), class: "btn btn-default", container: "body") = link_to_browse_code(project, commit) diff --git a/app/views/projects/cycle_analytics/_overview.html.haml b/app/views/projects/cycle_analytics/_overview.html.haml index 5b0d73b8c68..ea94b637f89 100644 --- a/app/views/projects/cycle_analytics/_overview.html.haml +++ b/app/views/projects/cycle_analytics/_overview.html.haml @@ -9,7 +9,7 @@ Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project. To set up CA, you must first define a production environment by setting up your CI and then deploy to production. %p - %a.btn{ href: help_page_path('user/project/cycle_analytics'), target: '_blank' } Read more + %a.btn{ href: help_page_path('user/analytics/cycle_analytics.md'), target: '_blank' } Read more .col-md-6.overview-image %span.overview-icon = custom_icon ('icon_cycle_analytics_overview') diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 6b56a4ee7ab..7fedd1ab785 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -3,7 +3,7 @@ #cycle-analytics{ "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } } - if @cycle_analytics_no_data %banner{ "v-if" => "!isOverviewDialogDismissed", - "documentation-link": help_page_path('user/project/cycle_analytics'), + "documentation-link": help_page_path('user/analytics/cycle_analytics.md'), "v-on:dismiss-overview-dialog" => "dismissOverviewDialog()" } = icon("spinner spin", "v-show" => "isLoading") .wrapper{ "v-show" => "!isLoading && !hasError" } diff --git a/app/views/projects/deploy_tokens/_new_deploy_token.html.haml b/app/views/projects/deploy_tokens/_new_deploy_token.html.haml index c805ee73acc..f295fa82192 100644 --- a/app/views/projects/deploy_tokens/_new_deploy_token.html.haml +++ b/app/views/projects/deploy_tokens/_new_deploy_token.html.haml @@ -7,12 +7,12 @@ .input-group = text_field_tag 'deploy-token-user', deploy_token.username, readonly: true, class: 'deploy-token-field form-control js-select-on-focus qa-deploy-token-user' .input-group-append - = clipboard_button(text: deploy_token.username, title: s_('DeployTokens|Copy username to clipboard'), placement: 'left') + = clipboard_button(text: deploy_token.username, title: s_('DeployTokens|Copy username'), placement: 'left') %span.deploy-token-help-block.prepend-top-5.text-success= s_("DeployTokens|Use this username as a login.") .form-group .input-group = text_field_tag 'deploy-token', deploy_token.token, readonly: true, class: 'deploy-token-field form-control js-select-on-focus qa-deploy-token' .input-group-append - = clipboard_button(text: deploy_token.token, title: s_('DeployTokens|Copy deploy token to clipboard'), placement: 'left') + = clipboard_button(text: deploy_token.token, title: s_('DeployTokens|Copy deploy token'), placement: 'left') %span.deploy-token-help-block.prepend-top-5.text-danger= s_("DeployTokens|Use this token as a password. Make sure you save it - you won't be able to access it again.") diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml index ef2ab4c698e..8270477ed3f 100644 --- a/app/views/projects/deployments/_deployment.html.haml +++ b/app/views/projects/deployments/_deployment.html.haml @@ -1,31 +1,49 @@ .gl-responsive-table-row.deployment{ role: 'row' } + .table-section.section-15{ role: 'gridcell' } + .table-mobile-header{ role: 'rowheader' }= _("Status") + .table-mobile-content + = render_deployment_status(deployment) + .table-section.section-10{ role: 'gridcell' } .table-mobile-header{ role: 'rowheader' }= _("ID") %strong.table-mobile-content ##{deployment.iid} - .table-section.section-30{ role: 'gridcell' } + .table-section.section-10{ role: 'gridcell' } + .table-mobile-header{ role: 'rowheader' }= _("Triggerer") + .table-mobile-content + - if deployment.deployed_by + = user_avatar(user: deployment.deployed_by, size: 26, css_class: "mr-0 float-none") + + .table-section.section-25{ role: 'gridcell' } .table-mobile-header{ role: 'rowheader' }= _("Commit") = render 'projects/deployments/commit', deployment: deployment - .table-section.section-25.build-column{ role: 'gridcell' } + .table-section.section-10.build-column{ role: 'gridcell' } .table-mobile-header{ role: 'rowheader' }= _("Job") - if deployment.deployable .table-mobile-content .flex-truncate-parent .flex-truncate-child - = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: 'build-link' do + = link_to deployment_path(deployment), class: 'build-link' do #{deployment.deployable.name} (##{deployment.deployable.id}) - - if deployment.deployed_by - %div - by - = user_avatar(user: deployment.deployed_by, size: 20, css_class: "mr-0 float-none") + - else + .badge.badge-info.suggestion-help-hover{ title: s_('Deployment|This deployment was created using the API') } + = s_('Deployment|API') - .table-section.section-15{ role: 'gridcell' } + .table-section.section-10{ role: 'gridcell' } .table-mobile-header{ role: 'rowheader' }= _("Created") + %span.table-mobile-content.flex-truncate-parent + %span.flex-truncate-child + = time_ago_with_tooltip(deployment.created_at) + + .table-section.section-10{ role: 'gridcell' } + .table-mobile-header{ role: 'rowheader' }= _("Deployed") - if deployment.deployed_at - %span.table-mobile-content= time_ago_with_tooltip(deployment.deployed_at) + %span.table-mobile-content.flex-truncate-parent + %span.flex-truncate-child + = time_ago_with_tooltip(deployment.deployed_at) - .table-section.section-20.table-button-footer{ role: 'gridcell' } + .table-section.section-10.table-button-footer{ role: 'gridcell' } .btn-group.table-action-buttons = render 'projects/deployments/actions', deployment: deployment = render 'projects/deployments/rollback', deployment: deployment diff --git a/app/views/projects/deployments/_rollback.haml b/app/views/projects/deployments/_rollback.haml index d6bf8d564de..dffa5e4ba40 100644 --- a/app/views/projects/deployments/_rollback.haml +++ b/app/views/projects/deployments/_rollback.haml @@ -1,4 +1,4 @@ -- if can?(current_user, :create_deployment, deployment) +- if deployment.deployable && can?(current_user, :create_deployment, deployment) - tooltip = deployment.last? ? s_('Environments|Re-deploy to environment') : s_('Environments|Rollback environment') = button_tag class: 'btn btn-default btn-build has-tooltip', type: 'button', data: { toggle: 'modal', target: "#confirm-rollback-modal-#{deployment.id}" }, title: tooltip do - if deployment.last? diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index 2dba3fcd664..cf7fe36af9d 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -21,7 +21,7 @@ = parallel_diff_btn = render 'projects/diffs/stats', diff_files: diff_files -- if render_overflow_warning?(diff_files) +- if render_overflow_warning?(diffs) = render 'projects/diffs/warning', diff_files: diffs .files{ data: { can_create_note: can_create_note } } diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml index c9057f385da..86e6e732610 100644 --- a/app/views/projects/diffs/_stats.html.haml +++ b/app/views/projects/diffs/_stats.html.haml @@ -24,9 +24,9 @@ %a.diff-changed-file{ href: "##{hexdigest(diff_file.file_path)}", title: diff_file.new_path } = sprite_icon(diff_file_changed_icon(diff_file), size: 16, css_class: "#{diff_file_changed_icon_color(diff_file)} diff-file-changed-icon append-right-8") %span.diff-changed-file-content.append-right-8 - - if diff_file.blob&.name + - if diff_file.file_path %strong.diff-changed-file-name - = diff_file.blob.name + = diff_file.file_path - else %strong.diff-changed-blank-file-name = s_('Diffs|No file name available') diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index 75da151f329..c4c39c227c6 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -60,10 +60,13 @@ .table-holder .ci-table.environments{ role: 'grid' } .gl-responsive-table-row.table-row-header{ role: 'row' } + .table-section.section-15{ role: 'columnheader' }= _('Status') .table-section.section-10{ role: 'columnheader' }= _('ID') - .table-section.section-30{ role: 'columnheader' }= _('Commit') - .table-section.section-25{ role: 'columnheader' }= _('Job') - .table-section.section-15{ role: 'columnheader' }= _('Created') + .table-section.section-10{ role: 'columnheader' }= _('Triggerer') + .table-section.section-25{ role: 'columnheader' }= _('Commit') + .table-section.section-10{ role: 'columnheader' }= _('Job') + .table-section.section-10{ role: 'columnheader' }= _('Created') + .table-section.section-10{ role: 'columnheader' }= _('Deployed') = render @deployments diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index 82f035f24da..caaf164a763 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -15,4 +15,12 @@ .table-holder %table.table.files-slider{ class: "table_#{@hex_path} tree-table" } %tbody + .col-12.empty-state.hidden + .svg-250.svg-content + = image_tag('illustrations/profile-page/personal-projects.svg', alt: 'No files svg', lazy: true) + .text-center + %h4 + = _('There are no matching files') + %p.text-secondary + = _('Try using a different search term to find the file you are looking for.') = spinner nil, true diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index de0c21e7cf6..367b8c1138e 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -43,7 +43,10 @@ .issuable-meta %ul.controls - - if issue.closed? + - if issue.moved? + %li.issuable-status + = _('CLOSED (MOVED)') + - elsif issue.closed? %li.issuable-status = _('CLOSED') - if issue.assignees.any? diff --git a/app/views/projects/issues/import_csv/_button.html.haml b/app/views/projects/issues/import_csv/_button.html.haml index acc2c50294f..fe89d2fb748 100644 --- a/app/views/projects/issues/import_csv/_button.html.haml +++ b/app/views/projects/issues/import_csv/_button.html.haml @@ -3,7 +3,7 @@ %button.csv-import-button.btn{ title: _('Import CSV'), class: ('has-tooltip' if type == :icon), data: { toggle: 'modal', target: '.issues-import-modal' } } - if type == :icon - = sprite_icon('upload') + = sprite_icon('import') - else = _('Import CSV') diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 49e482ff1df..2633a3899f7 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -20,4 +20,5 @@ - if new_issue_email = render 'projects/issuable_by_email', email: new_issue_email, issuable_type: 'issue' - else - = render 'shared/empty_states/issues', button_path: new_project_issue_path(@project), show_import_button: true + - new_project_issue_button_path = @project.archived? ? false : new_project_issue_path(@project) + = render 'shared/empty_states/issues', new_project_issue_button_path: new_project_issue_button_path, show_import_button: true diff --git a/app/views/projects/merge_requests/_how_to_merge.html.haml b/app/views/projects/merge_requests/_how_to_merge.html.haml index 928b54ea28f..57205682bda 100644 --- a/app/views/projects/merge_requests/_how_to_merge.html.haml +++ b/app/views/projects/merge_requests/_how_to_merge.html.haml @@ -9,7 +9,7 @@ %p %strong Step 1. Fetch and check out the branch for this merge request - = clipboard_button(target: "pre#merge-info-1", title: "Copy commands to clipboard") + = clipboard_button(target: "pre#merge-info-1", title: _("Copy commands")) %pre.dark#merge-info-1 - if @merge_request.for_fork? :preserve @@ -27,7 +27,7 @@ %p %strong Step 3. Merge the branch and fix any conflicts that come up - = clipboard_button(target: "pre#merge-info-3", title: "Copy commands to clipboard") + = clipboard_button(target: "pre#merge-info-3", title: _("Copy commands")) %pre.dark#merge-info-3 - if @merge_request.for_fork? :preserve @@ -42,7 +42,7 @@ %p %strong Step 4. Push the result of the merge to GitLab - = clipboard_button(target: "pre#merge-info-4", title: "Copy commands to clipboard") + = clipboard_button(target: "pre#merge-info-4", title: _("Copy commands")) %pre.dark#merge-info-4 :preserve git push origin "#{h @merge_request.target_branch}" diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index da90c41e2f5..dee6bc8bae4 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -24,6 +24,7 @@ window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}'; window.gl.mrWidgetData.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/index.md', anchor: 'troubleshooting')}'; + window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.html', anchor: 'security-approvals-in-merge-requests-ultimate')}'; #js-vue-mr-widget.mr-widget diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index 104c68919f0..80d2d2afada 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -1,7 +1,7 @@ - expanded = expanded_by_default? - protocols = Gitlab::UrlSanitizer::ALLOWED_SCHEMES.join('|') -%section.settings.project-mirror-settings.js-mirror-settings.no-animate.qa-mirroring-repositories-settings#js-push-remote-settings{ class: ('expanded' if expanded) } +%section.settings.project-mirror-settings.js-mirror-settings.no-animate#js-push-remote-settings{ class: ('expanded' if expanded), data: { qa_selector: 'mirroring_repositories_settings_section' } } .settings-header %h4= _('Mirroring repositories') %button.btn.js-settings-toggle @@ -59,10 +59,10 @@ - if mirror.disabled? = render 'projects/mirrors/disabled_mirror_badge' - if mirror.last_error.present? - .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error') + .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true', qa_selector: 'mirror_error_badge' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error') %td .btn-group.mirror-actions-group.pull-right{ role: 'group' } - if mirror.ssh_key_auth? - = clipboard_button(text: mirror.ssh_public_key, class: 'btn btn-default', title: _('Copy SSH public key')) + = clipboard_button(text: mirror.ssh_public_key, class: 'btn btn-default', title: _('Copy SSH public key'), qa_selector: 'copy_public_key_button') = render 'shared/remote_mirror_update_button', remote_mirror: mirror %button.js-delete-mirror.qa-delete-mirror.rspec-delete-mirror.btn.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= icon('trash-o') diff --git a/app/views/projects/mirrors/_ssh_host_keys.html.haml b/app/views/projects/mirrors/_ssh_host_keys.html.haml index 7762fb4b844..3279d3eb251 100644 --- a/app/views/projects/mirrors/_ssh_host_keys.html.haml +++ b/app/views/projects/mirrors/_ssh_host_keys.html.haml @@ -3,13 +3,13 @@ - verified_at = mirror.ssh_known_hosts_verified_at .form-group.js-ssh-host-keys-section{ class: ('collapse' unless mirror.ssh_mirror_url?) } - %button.btn.btn-inverted.btn-secondary.inline.js-detect-host-keys.append-right-10{ type: 'button' } + %button.btn.btn-inverted.btn-secondary.inline.js-detect-host-keys.append-right-10{ type: 'button', data: { qa_selector: 'detect_host_keys' } } = icon('spinner spin', class: 'js-spinner d-none') = _('Detect host keys') .fingerprint-ssh-info.js-fingerprint-ssh-info.prepend-top-10.append-bottom-10{ class: ('collapse' unless mirror.ssh_mirror_url?) } %label.label-bold = _('Fingerprints') - .fingerprints-list.js-fingerprints-list + .fingerprints-list.js-fingerprints-list{ data: { qa_selector: 'fingerprints_list' } } - mirror.ssh_known_hosts_fingerprints.each do |fp| %code= fp.fingerprint - if verified_at diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml index 8a6e5fde99b..2f0394538bb 100644 --- a/app/views/projects/notes/_more_actions_dropdown.html.haml +++ b/app/views/projects/notes/_more_actions_dropdown.html.haml @@ -7,7 +7,7 @@ = custom_icon('ellipsis_v') %ul.dropdown-menu.more-actions-dropdown.dropdown-open-left %li - = clipboard_button(text: noteable_note_url(note), title: 'Copy reference to clipboard', button_text: 'Copy link', class: 'btn-clipboard', hide_tooltip: true, hide_button_icon: true) + = clipboard_button(text: noteable_note_url(note), title: _('Copy reference'), button_text: _('Copy link'), class: 'btn-clipboard', hide_tooltip: true, hide_button_icon: true) - unless is_current_user %li = link_to new_abuse_report_path(user_id: note.author.id, ref_url: noteable_note_url(note)) do diff --git a/app/views/projects/pages/_access.html.haml b/app/views/projects/pages/_access.html.haml index 7b6d46964a2..178f0acc5b9 100644 --- a/app/views/projects/pages/_access.html.haml +++ b/app/views/projects/pages/_access.html.haml @@ -1,11 +1,11 @@ - if @project.pages_deployed? .card .card-header - Access pages + = s_('GitLabPages|Access pages') .card-body %p %strong - = _("Your pages are served under:") + = s_('GitLabPages|Your pages are served under:') %p = external_link(@project.pages_url, @project.pages_url) @@ -14,4 +14,4 @@ %p = external_link(domain.url, domain.url) .card-footer.alert-primary - = _("It may take up to 30 minutes before the site is available after the first deployment.") + = s_('GitLabPages|It may take up to 30 minutes before the site is available after the first deployment.') diff --git a/app/views/projects/pages/_destroy.haml b/app/views/projects/pages/_destroy.haml index 138e2864bad..58dbbb5bcfc 100644 --- a/app/views/projects/pages/_destroy.haml +++ b/app/views/projects/pages/_destroy.haml @@ -1,12 +1,14 @@ - if @project.pages_deployed? - if can?(current_user, :remove_pages, @project) .card.border-danger - .card-header.bg-danger.text-white Remove pages + .card-header.bg-danger.text-white + = s_('GitLabPages|Remove pages') .errors-holder .card-body %p - Removing pages will prevent them from being exposed to the outside world. + = s_('GitLabPages|Removing pages will prevent them from being exposed to the outside world.') .form-actions - = link_to 'Remove pages', project_pages_path(@project), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove" + = link_to s_('GitLabPages|Remove pages'), project_pages_path(@project), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn btn-remove" - else - .nothing-here-block Only project maintainers can remove pages + .nothing-here-block + = s_('GitLabPages|Only project maintainers can remove pages') diff --git a/app/views/projects/pages/_https_only.html.haml b/app/views/projects/pages/_https_only.html.haml index 74478ee011c..d8c4a5f0a5d 100644 --- a/app/views/projects/pages/_https_only.html.haml +++ b/app/views/projects/pages/_https_only.html.haml @@ -3,8 +3,9 @@ .form-check = f.check_box :pages_https_only, class: 'form-check-input', disabled: pages_https_only_disabled? = f.label :pages_https_only, class: pages_https_only_label_class do - %strong Force HTTPS (requires valid certificates) + %strong + = s_('GitLabPages|Force HTTPS (requires valid certificates)') - unless pages_https_only_disabled? .prepend-top-10 - = f.submit 'Save', class: 'btn btn-success' + = f.submit s_('GitLabPages|Save'), class: 'btn btn-success' diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml index c4285e7f3d2..b05491f2c6e 100644 --- a/app/views/projects/pages/_list.html.haml +++ b/app/views/projects/pages/_list.html.haml @@ -8,20 +8,25 @@ - @domains.each do |domain| %li.pages-domain-list-item.list-group-item.d-flex.justify-content-between - if verification_enabled - - tooltip, status = domain.unverified? ? [_('Unverified'), 'failed'] : [_('Verified'), 'success'] + - tooltip, status = domain.unverified? ? [s_('GitLabPages|Unverified'), 'failed'] : [s_('GitLabPages|Verified'), 'success'] .domain-status.ci-status-icon.has-tooltip{ class: "ci-status-icon-#{status}", title: tooltip } = sprite_icon("status_#{status}", size: 16 ) .domain-name = external_link(domain.url, domain.url) - if domain.subject %div - %span.badge.badge-gray Certificate: #{domain.subject} + %span.badge.badge-gray + = s_('GitLabPages|Certificate: %{subject}') % { subject: domain.subject } - if domain.expired? - %span.badge.badge-danger Expired + %span.badge.badge-danger + = s_('GitLabPages|Expired') %div - = link_to 'Details', project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped" - = link_to 'Remove', project_pages_domain_path(@project, domain), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove btn-sm btn-grouped" + = link_to s_('GitLabPages|Details'), project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped" + = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn btn-remove btn-sm btn-grouped" - if verification_enabled && domain.unverified? %li.list-group-item.bs-callout-warning - #{domain.domain} is not verified. To learn how to verify ownership, visit your - #{link_to 'domain details', project_pages_domain_path(@project, domain)}. + - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe + - details_link_end = '</a>'.html_safe + = s_('GitLabPages|%{domain} is not verified. To learn how to verify ownership, visit your %{link_start}domain details%{link_end}.').html_safe % { domain: domain.domain, + link_start: details_link_start, + link_end: details_link_end } diff --git a/app/views/projects/pages/_no_domains.html.haml b/app/views/projects/pages/_no_domains.html.haml index 8c93cf7a8ad..8d6e403b93a 100644 --- a/app/views/projects/pages/_no_domains.html.haml +++ b/app/views/projects/pages/_no_domains.html.haml @@ -1,7 +1,6 @@ - if can?(current_user, :update_pages, @project) .card .card-header - Domains + = s_('GitLabPages|Domains') .nothing-here-block - Support for domains and certificates is disabled. - Ask your system's administrator to enable it. + = s_("GitLabPages|Support for domains and certificates is disabled. Ask your system's administrator to enable it.") diff --git a/app/views/projects/pages/_use.html.haml b/app/views/projects/pages/_use.html.haml index 988dabef3a0..ab44fd77e1e 100644 --- a/app/views/projects/pages/_use.html.haml +++ b/app/views/projects/pages/_use.html.haml @@ -1,10 +1,10 @@ - unless @project.pages_deployed? .card.border-info .card-header.bg-info.text-white - Configure pages + = s_('GitLabPages|Configure pages') .card-body %p - Learn how to upload your static site and have it served by - GitLab by following the - = succeed '.' do - = link_to 'documentation on GitLab Pages', help_page_path('user/project/pages/index.md'), target: '_blank' + - link_start = "<a href='#{help_page_path('user/project/pages/index.md')}' target='_blank' rel='noopener noreferrer'>".html_safe + - link_end = '</a>'.html_safe + = s_('GitLabPages|Learn how to upload your static site and have it served by GitLab by following the %{link_start}documentation on GitLab Pages%{link_end}.').html_safe % { link_start: link_start, + link_end: link_end } diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml index 88ab486a248..0e1f281410a 100644 --- a/app/views/projects/pages/show.html.haml +++ b/app/views/projects/pages/show.html.haml @@ -1,17 +1,14 @@ - page_title 'Pages' %h3.page-title.with-button - Pages + = s_('GitLabPages|Pages') - if can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https) - = link_to new_project_pages_domain_path(@project), class: 'btn btn-success float-right', title: 'New Domain' do - New Domain + = link_to new_project_pages_domain_path(@project), class: 'btn btn-success float-right', title: s_('GitLabPages|New Domain') do + = s_('GitLabPages|New Domain') %p.light - With GitLab Pages you can host your static websites on GitLab. - Combined with the power of GitLab CI and the help of GitLab Runner - you can deploy static pages for your individual projects, your user or your group. - + = s_('GitLabPages|With GitLab Pages you can host your static websites on GitLab. Combined with the power of GitLab CI and the help of GitLab Runner you can deploy static pages for your individual projects, your user or your group.') - if Gitlab.config.pages.external_https = render 'https_only' diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 53bb3c7487d..4eec81c9125 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -43,7 +43,7 @@ } } Auto DevOps - if @pipeline.detached_merge_request_pipeline? - %span.js-pipeline-url-mergerequest.badge.badge-info.has-tooltip{ title: _('Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more on the documentation for Pipelines for Merged Results.') } + %span.js-pipeline-url-mergerequest.badge.badge-info.has-tooltip{ title: _('Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for Pipelines for Merged Results.') } detached - if @pipeline.stuck? %span.js-pipeline-url-stuck.badge.badge-warning @@ -58,4 +58,10 @@ = sprite_icon('ellipsis_h', size: 12) %span.js-details-content.hide = link_to @pipeline.sha, project_commit_path(@project, @pipeline.sha), class: "commit-sha commit-hash-full" - = clipboard_button(text: @pipeline.sha, title: "Copy commit SHA to clipboard") + = clipboard_button(text: @pipeline.sha, title: _("Copy commit SHA")) + + .well-segment.related-merge-request-info + .icon-container + = sprite_icon("git-merge") + %span.related-merge-requests + = @pipeline.all_related_merge_request_text diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml index 1913d06a6f8..9dff251101b 100644 --- a/app/views/projects/protected_branches/shared/_branches_list.html.haml +++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml @@ -1,9 +1,9 @@ .protected-branches-list.js-protected-branches-list.qa-protected-branches-list - if @protected_branches.empty? .card-header.bg-white - Protected branch (#{@protected_branches_count}) + = s_("ProtectedBranch|Protected branch (%{protected_branches_count})") % { protected_branches_count: @protected_branches_count } %p.settings-message.text-center - There are currently no protected branches, protect a branch with the form above. + = s_("ProtectedBranch|There are currently no protected branches, protect a branch with the form above.") - else %table.table.table-bordered %colgroup @@ -15,10 +15,15 @@ %col %thead %tr - %th Protected branch (#{@protected_branches_count}) - %th Last commit - %th Allowed to merge - %th Allowed to push + %th + = s_("ProtectedBranch|Branch") + %th + = s_("ProtectedBranch|Allowed to merge") + %th + = s_("ProtectedBranch|Allowed to push") + + = render_if_exists 'projects/protected_branches/ee/code_owner_approval_table_head' + - if can_admin_project %th %tbody diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml index bba4949277d..f84c7b39733 100644 --- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml @@ -2,7 +2,7 @@ %input{ type: 'hidden', name: 'update_section', value: 'js-protected-branches-settings' } .card .card-header - Protect a branch + = s_("ProtectedBranch|Protect a branch") .card-body = form_errors(@protected_branch) .form-group.row @@ -11,22 +11,19 @@ .col-md-10 = render partial: "projects/protected_branches/shared/dropdown", locals: { f: f } .form-text.text-muted - = link_to 'Wildcards', help_page_path('user/project/protected_branches', anchor: 'wildcard-protected-branches') - such as - %code *-stable - or - %code production/* - are supported + - wildcards_url = help_page_url('user/project/protected_branches', anchor: 'wildcard-protected-branches') + - wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url } + = (s_("ProtectedBranch|%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported") % { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>', code_tag_start: '<code>', code_tag_end: '</code>' }).html_safe .form-group.row %label.col-md-2.text-right{ for: 'merge_access_levels_attributes' } - Allowed to merge: + = s_("ProtectedBranch|Allowed to merge:") .col-md-10 = yield :merge_access_levels .form-group.row %label.col-md-2.text-right{ for: 'push_access_levels_attributes' } - Allowed to push: + = s_("ProtectedBranch|Allowed to push:") .col-md-10 = yield :push_access_levels - + = render_if_exists 'projects/protected_branches/ee/code_owner_approval_form' .card-footer - = f.submit 'Protect', class: 'btn-success btn', disabled: true, data: { qa_selector: 'protect_button' } + = f.submit s_('ProtectedBranch|Protect'), class: 'btn-success btn', disabled: true, data: { qa_selector: 'protect_button' } diff --git a/app/views/projects/protected_branches/shared/_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_protected_branch.html.haml index 81dcab1d1ab..4ca6ebe9c78 100644 --- a/app/views/projects/protected_branches/shared/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_protected_branch.html.haml @@ -5,20 +5,19 @@ %span.ref-name= protected_branch.name - if @project.root_ref?(protected_branch.name) - %span.badge.badge-info.prepend-left-5 default - %td - - if protected_branch.wildcard? - - matching_branches = protected_branch.matching(repository.branches) - = link_to pluralize(matching_branches.count, "matching branch"), namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) - - else - - if commit = protected_branch.commit - = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha') - = time_ago_with_tooltip(commit.committed_date) - - else - (branch was deleted from repository) + %span.badge.badge-info.d-inline default + + %div + - if protected_branch.wildcard? + - matching_branches = protected_branch.matching(repository.branches) + = link_to pluralize(matching_branches.count, "matching branch"), namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) + - elsif !protected_branch.commit + %span.text-muted Branch was deleted. = yield + = render_if_exists 'projects/protected_branches/ee/code_owner_approval_table', protected_branch: protected_branch + - if can_admin_project %td = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: "btn btn-warning" diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index d0d06a0df7e..b2e160e37bc 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -1,9 +1,14 @@ +- page_title _("Container Registry") + %section .row.registry-placeholder.prepend-bottom-10 .col-12 #js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json), "help_page_path" => help_page_path('user/packages/container_registry/index'), + "two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'), + "personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'), "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), "containers_error_image" => image_path('illustrations/docker-error-state.svg'), "repository_url" => escape_once(@project.container_registry_url), + "registry_host_url_with_port" => escape_once(registry_config.host_port), character_error: @character_error.to_s } } diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/releases/edit.html.haml new file mode 100644 index 00000000000..88ca64f2af0 --- /dev/null +++ b/app/views/projects/releases/edit.html.haml @@ -0,0 +1,3 @@ +- page_title _('Edit Release') + +#js-edit-release-page{ data: data_for_edit_release_page } diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml index dc56a515d4c..4cc67a8f5d8 100644 --- a/app/views/projects/runners/_specific_runners.html.haml +++ b/app/views/projects/runners/_specific_runners.html.haml @@ -2,28 +2,9 @@ = _('Specific Runners') .bs-callout.help-callout - .append-bottom-10 - %h4= _('Set up a specific Runner automatically') - - %p - - link_to_help_page = link_to(_('Learn more about Kubernetes'), - help_page_path('user/project/clusters/index'), - target: '_blank', - rel: 'noopener noreferrer') - - = _('You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}').html_safe % { link_to_help_page: link_to_help_page } - - %ol - %li - = _('Click the button below to begin the install process by navigating to the Kubernetes page') - %li - = _('Select an existing Kubernetes cluster or create a new one') - %li - = _('From the Kubernetes cluster details view, install Runner from the applications list') - - = link_to _('Install Runner on Kubernetes'), - project_clusters_path(@project), - class: 'btn btn-info' + = render partial: 'ci/runner/how_to_setup_runner_automatically', + locals: { type: 'specific', + clusters_path: project_clusters_path(@project) } %hr = render partial: 'ci/runner/how_to_setup_runner', locals: { registration_token: @project.runners_token, 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 1d5d90593ae..6702786fdb3 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -46,12 +46,12 @@ = form.radio_button :deploy_strategy, 'timed_incremental', class: 'form-check-input' = form.label :deploy_strategy_timed_incremental, class: 'form-check-label' do = s_('CICD|Continuous deployment to production using timed incremental rollout') - = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'timed-incremental-rollout-to-production'), target: '_blank' + = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'timed-incremental-rollout-to-production-premium'), target: '_blank' .form-check = form.radio_button :deploy_strategy, 'manual', class: 'form-check-input' = form.label :deploy_strategy_manual, class: 'form-check-label' do = s_('CICD|Automatic deployment to staging, manual deployment to production') - = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'incremental-rollout-to-production'), target: '_blank' + = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'incremental-rollout-to-production-premium'), target: '_blank' = f.submit _('Save changes'), class: "btn btn-success prepend-top-15", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index 430d6071468..66ed1cadf6a 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -40,12 +40,21 @@ = _('If any job surpasses this timeout threshold, it will be marked as failed. Human readable time input language is accepted like "1 hour". Values without specification represent seconds.') = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank' + - if can?(current_user, :update_max_artifacts_size, @project) + %hr + .form-group + = f.label :max_artifacts_size, _('Maximum artifacts size (MB)'), class: 'label-bold' + = f.number_field :max_artifacts_size, class: 'form-control' + %p.form-text.text-muted + = _("Set the maximum file size for each job's artifacts") + = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size-core-only'), target: '_blank' + %hr .form-group - = f.label :ci_config_path, _('Custom CI config path'), class: 'label-bold' + = f.label :ci_config_path, _('Custom CI configuration path'), class: 'label-bold' = f.text_field :ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml' %p.form-text.text-muted - = _("The path to CI config file. Defaults to <code>.gitlab-ci.yml</code>").html_safe + = _("The path to the CI configuration file. Defaults to <code>.gitlab-ci.yml</code>").html_safe = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-config-path'), target: '_blank' %hr diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index b1432917f1d..c7bd0262c54 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -19,9 +19,15 @@ - else %p = s_("TagsPage|Can't find HEAD commit for this tag") - - if release && release.description.present? - .description.md.prepend-top-default - = markdown_field(release, :description) + + - if release + .text-secondary + = icon('rocket') + = _("Release") + = link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'tag-release-link' + - if release.description.present? + .description.md.prepend-top-default + = markdown_field(release, :description) .row-fixed-content.controls.flex-row = render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name] diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index 5e6d06d980e..a7f739ab13d 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -31,7 +31,7 @@ .col-sm-10 = text_area_tag :message, @message, required: false, class: 'form-control', rows: 5 .form-text.text-muted - = s_('TagsPage|Optionally, add a message to the tag.') + = tag_description_help_text %hr .form-group.row = label_tag :release_description, s_('TagsPage|Release notes'), class: 'col-form-label col-sm-2' diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 41cd044a5b0..38422d4533d 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -41,7 +41,7 @@ %li = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do #{ _('New directory') } - - elsif can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project) + - elsif can_create_mr_from_fork %li - continue_params = { to: project_new_blob_path(@project, @id), notice: edit_in_new_fork_notice, @@ -81,10 +81,15 @@ = render 'projects/find_file_link' - - if can_collaborate + - if can_create_mr_from_fork = succeed " " do - = link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do - = _('Web IDE') + - if can_collaborate || current_user&.already_forked?(@project) + = link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do + = _('Web IDE') + - else + = link_to '#modal-confirm-fork', class: 'btn btn-default qa-web-ide-button', data: { target: '#modal-confirm-fork', toggle: 'modal'} do + = _('Web IDE') + = render 'shared/confirm_fork_modal', fork_path: ide_fork_and_edit_path(@project, @ref, @path) - if show_xcode_link?(@project) .project-action-button.project-xcode.inline diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml index 9899cf9c6de..60de3630bb5 100644 --- a/app/views/projects/triggers/_trigger.html.haml +++ b/app/views/projects/triggers/_trigger.html.haml @@ -2,7 +2,7 @@ %td - if trigger.has_token_exposed? %span= trigger.token - = clipboard_button(text: trigger.token, title: "Copy trigger token to clipboard") + = clipboard_button(text: trigger.token, title: _("Copy trigger token")) - else %span= trigger.short_token diff --git a/app/views/registrations/welcome.html.haml b/app/views/registrations/welcome.html.haml new file mode 100644 index 00000000000..02ab974ecc0 --- /dev/null +++ b/app/views/registrations/welcome.html.haml @@ -0,0 +1,17 @@ +- content_for(:page_title, _('Welcome to GitLab<br>%{username}!' % { username: html_escape(current_user.username) }).html_safe) +- max_name_length = 128 +.text-center.mb-3 + = _('In order to tailor your experience with GitLab<br>we would like to know a bit more about you.').html_safe +.signup-box.p-3.mb-2 + .signup-body + = form_for(current_user, url: users_sign_up_update_role_path, html: { class: 'new_new_user gl-show-field-errors', 'aria-live' => 'assertive' }) do |f| + .devise-errors.mt-0 + = render 'devise/shared/error_messages', resource: current_user + .name.form-group + = f.label :name, _('Full name'), class: 'label-bold' + = f.text_field :name, class: 'form-control top js-block-emoji js-validate-length', :data => { :max_length => max_name_length, :max_length_message => s_('Name is too long (maximum is %{max_length} characters).') % { max_length: max_name_length }, :qa_selector => 'new_user_name_field' }, required: true, title: _('This field is required.') + .form-group + = f.label :role, _('Role'), class: 'label-bold' + = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, {}, class: 'form-control' + .submit-container.mt-3 + = f.submit _('Get started!'), class: 'btn-register btn btn-block mb-0 p-2' diff --git a/app/views/search/_form.html.haml b/app/views/search/_form.html.haml index 464db94b7f4..dc75918eb93 100644 --- a/app/views/search/_form.html.haml +++ b/app/views/search/_form.html.haml @@ -18,4 +18,3 @@ = render 'filter' .d-flex-center.flex-column.flex-lg-row = button_tag _("Search"), class: "btn btn-success btn-search form-control mt-lg-0 ml-lg-1 align-self-end" - = render_if_exists 'search/form_elasticsearch' diff --git a/app/views/search/results/_empty.html.haml b/app/views/search/results/_empty.html.haml index 9d15995bb51..6c7c6de1178 100644 --- a/app/views/search/results/_empty.html.haml +++ b/app/views/search/results/_empty.html.haml @@ -2,5 +2,4 @@ .search_glyph %h4 = icon('search') - = _("We couldn't find any results matching") - %code= @search_term + = search_entries_empty_message(@scope, @search_term) diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml index 9235678bc1d..f300e1d4841 100644 --- a/app/views/search/show.html.haml +++ b/app/views/search/show.html.haml @@ -2,9 +2,10 @@ - page_title @search_term - @hide_breadcrumbs = true -.page-title-holder.d-flex.align-items-center +.page-title-holder.d-sm-flex.align-items-sm-center %h1.page-title< = _('Search') + = render_if_exists 'search/form_elasticsearch', attrs: { class: 'ml-sm-auto' } .prepend-top-default = render 'search/form' diff --git a/app/views/shared/_allow_request_access.html.haml b/app/views/shared/_allow_request_access.html.haml index 2b24bde9e59..ca82f2f3377 100644 --- a/app/views/shared/_allow_request_access.html.haml +++ b/app/views/shared/_allow_request_access.html.haml @@ -3,6 +3,4 @@ .form-check = form.check_box :request_access_enabled, class: 'form-check-input', data: { qa_selector: 'request_access_checkbox' } = form.label :request_access_enabled, class: 'form-check-label' do - %span{ class: label_class }= _('Allow users to request access') - %br - %span.text-muted= _('Allow users to request access if visibility is public or internal.') + %span{ class: label_class }= _('Allow users to request access (if visibility is public or internal)') diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml index 755fd3a17d3..fb03e6e12e3 100644 --- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml +++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml @@ -1,5 +1,5 @@ - if show_auto_devops_implicitly_enabled_banner?(project, current_user) - .qa-auto-devops-banner.auto-devops-implicitly-enabled-banner.alert.alert-warning + .qa-auto-devops-banner.auto-devops-implicitly-enabled-banner.alert.alert-info - more_information_link = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link' - auto_devops_message = s_("AutoDevOps|The Auto DevOps pipeline has been enabled and will be used if no alternative CI configuration file is found. %{more_information_link}") % { more_information_link: more_information_link } = auto_devops_message.html_safe diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index 1e509ea0d1f..cb834878276 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -20,7 +20,7 @@ = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' } .input-group-append - = clipboard_button(target: '#project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard") + = clipboard_button(target: '#project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard") = render_if_exists 'shared/geo_modal_button' diff --git a/app/views/shared/_confirm_fork_modal.html.haml b/app/views/shared/_confirm_fork_modal.html.haml new file mode 100644 index 00000000000..db50ea41387 --- /dev/null +++ b/app/views/shared/_confirm_fork_modal.html.haml @@ -0,0 +1,12 @@ +#modal-confirm-fork.modal.qa-confirm-fork-modal + .modal-dialog + .modal-content + .modal-header + %h3.page-title= _('Fork project?') + %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } + %span{ "aria-hidden": true } × + .modal-body.p-3 + %p= _("You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.") % { tag_start: '', tag_end: ''} + .modal-footer + = link_to _('Cancel'), '#', class: "btn btn-cancel", "data-dismiss" => "modal" + = link_to _('Fork project'), fork_path, class: 'btn btn-success', method: :post diff --git a/app/views/shared/_confirm_modal.html.haml b/app/views/shared/_confirm_modal.html.haml index 8e3b482e27d..ecb462205b0 100644 --- a/app/views/shared/_confirm_modal.html.haml +++ b/app/views/shared/_confirm_modal.html.haml @@ -12,9 +12,8 @@ %p %span.js-warning-text= _('This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention.') %br - Please type - %code.js-confirm-danger-match= phrase - to proceed or close this modal to cancel. + - phrase_code = '<code class="js-confirm-danger-match">%{phrase_name}</code>'.html_safe % { phrase_name: phrase } + = _('Please type %{phrase_code} to proceed or close this modal to cancel.').html_safe % { phrase_code: phrase_code } .form-group = text_field_tag 'confirm_name_input', '', class: 'form-control js-confirm-danger-input qa-confirm-input' diff --git a/app/views/shared/_event_filter.html.haml b/app/views/shared/_event_filter.html.haml index 6612497e7e2..ad9eb325ff0 100644 --- a/app/views/shared/_event_filter.html.haml +++ b/app/views/shared/_event_filter.html.haml @@ -1,3 +1,5 @@ +- show_group_events = local_assigns.fetch(:show_group_events, false) + .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller.flex-fill .fade-left= icon('angle-left') .fade-right= icon('angle-right') @@ -9,6 +11,8 @@ = event_filter_link EventFilter::MERGED, _('Merge events'), s_('EventFilterBy|Filter by merge events') - if event_filter_visible(:issues) = event_filter_link EventFilter::ISSUE, _('Issue events'), s_('EventFilterBy|Filter by issue events') + - if show_group_events + = render_if_exists 'events/epics_filter' - if comments_visible? = event_filter_link EventFilter::COMMENTS, _('Comments'), s_('EventFilterBy|Filter by comments') = event_filter_link EventFilter::TEAM, _('Team'), s_('EventFilterBy|Filter by team') diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml index 973c756f496..959792718ca 100644 --- a/app/views/shared/_group_form.html.haml +++ b/app/views/shared/_group_form.html.haml @@ -30,9 +30,9 @@ - if @group.persisted? .alert.alert-warning.prepend-top-10 - Changing group path can have unintended side effects. + = _('Changing group path can have unintended side effects.') = succeed '.' do - = link_to 'Learn more', help_page_path('user/group/index', anchor: 'changing-a-groups-path'), target: '_blank' + = link_to _('Learn more'), help_page_path('user/group/index', anchor: 'changing-a-groups-path'), target: '_blank' - if @group.persisted? .row diff --git a/app/views/shared/_personal_access_tokens_created_container.html.haml b/app/views/shared/_personal_access_tokens_created_container.html.haml index 42989b145a2..df4577e2862 100644 --- a/app/views/shared/_personal_access_tokens_created_container.html.haml +++ b/app/views/shared/_personal_access_tokens_created_container.html.haml @@ -1,5 +1,5 @@ - container_title = local_assigns.fetch(:container_title, _('Your New Personal Access Token')) -- clipboard_button_title = local_assigns.fetch(:clipboard_button_title, _('Copy personal access token to clipboard')) +- clipboard_button_title = local_assigns.fetch(:clipboard_button_title, _('Copy personal access token')) .created-personal-access-token-container %h5.prepend-top-0 diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml index 4b39c8b06e9..54bd4ba04a0 100644 --- a/app/views/shared/_remote_mirror_update_button.html.haml +++ b/app/views/shared/_remote_mirror_update_button.html.haml @@ -1,5 +1,5 @@ - if remote_mirror.update_in_progress? - %button.btn.disabled{ type: 'button', data: { toggle: 'tooltip', container: 'body' }, title: _('Updating') } + %button.btn.disabled{ type: 'button', data: { toggle: 'tooltip', container: 'body', qa_selector: 'updating_button' }, title: _('Updating') } = icon("refresh spin") - elsif remote_mirror.enabled? = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn qa-update-now-button rspec-update-now-button", data: { toggle: 'tooltip', container: 'body' }, title: _('Update now') do diff --git a/app/views/shared/_user_dropdown_contributing_link.html.haml b/app/views/shared/_user_dropdown_contributing_link.html.haml index 564d21a39be..d4c3e11d051 100644 --- a/app/views/shared/_user_dropdown_contributing_link.html.haml +++ b/app/views/shared/_user_dropdown_contributing_link.html.haml @@ -1,3 +1,2 @@ -%li - = link_to "https://about.gitlab.com/contributing", target: '_blank', class: 'text-nowrap' do - = _("Contribute to GitLab") += link_to "https://about.gitlab.com/contributing", target: '_blank', class: 'text-nowrap' do + = _("Contribute to GitLab") diff --git a/app/views/shared/boards/_switcher.html.haml b/app/views/shared/boards/_switcher.html.haml index 79118630762..09a365a290a 100644 --- a/app/views/shared/boards/_switcher.html.haml +++ b/app/views/shared/boards/_switcher.html.haml @@ -1,4 +1,4 @@ -- parent = board.parent +- parent = board.resource_parent - milestone_filter_opts = { format: :json } - milestone_filter_opts = milestone_filter_opts.merge(only_group_milestones: true) if board.group_board? - weights = Gitlab.ee? ? ([Issue::WEIGHT_ANY] + Issue.weight_options) : [] diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml index bc0dc7f9631..1944c293be1 100644 --- a/app/views/shared/deploy_keys/_form.html.haml +++ b/app/views/shared/deploy_keys/_form.html.haml @@ -6,7 +6,7 @@ .form-group = form.label :title, class: 'col-form-label col-sm-2' - .col-sm-10= form.text_field :title, class: 'form-control' + .col-sm-10= form.text_field :title, class: 'form-control', readonly: ('readonly' unless can?(current_user, :update_deploy_key, deploy_key)) .form-group - if deploy_key.new_record? diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml index 9173b802dd4..325e01bb5c8 100644 --- a/app/views/shared/empty_states/_issues.html.haml +++ b/app/views/shared/empty_states/_issues.html.haml @@ -1,4 +1,4 @@ -- button_path = local_assigns.fetch(:button_path, false) +- button_path = local_assigns.fetch(:new_project_issue_button_path, false) - project_select_button = local_assigns.fetch(:project_select_button, false) - show_import_button = local_assigns.fetch(:show_import_button, false) && can?(current_user, :import_issues, @project) - has_button = button_path || project_select_button @@ -56,4 +56,3 @@ - if show_import_button = render 'projects/issues/import_csv/modal' - diff --git a/app/views/shared/form_elements/_apply_template_warning.html.haml b/app/views/shared/form_elements/_apply_template_warning.html.haml new file mode 100644 index 00000000000..09ca59a520c --- /dev/null +++ b/app/views/shared/form_elements/_apply_template_warning.html.haml @@ -0,0 +1,14 @@ +.form-group.row.js-template-warning.mb-0.hidden.js-issuable-template-warning + .offset-sm-2.col-sm-10 + + .warning_message.mb-0{ role: 'alert' } + %btn.js-close-btn.js-dismiss-btn.close{ type: "button", "aria-hidden": true, "aria-label": _("Close") } + = sprite_icon("close") + + %p + = _("Applying a template will replace the existing issue description. Any changes you have made will be lost.") + + %button.js-override-template.btn.btn-warning.mr-2{ type: 'button' } + = _("Apply template") + %button.js-close-btn.js-cancel-btn.btn.btn-inverted{ type: 'button' } + = _("Cancel") diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml index 24734ed66cf..cec865ec8de 100644 --- a/app/views/shared/issuable/_assignees.html.haml +++ b/app/views/shared/issuable/_assignees.html.haml @@ -7,4 +7,4 @@ = link_to_member(@project, assignee, name: false, title: "Assigned to :name") - if more_assignees_count.positive? - %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{more_assignees_count} more assignees" } } +#{more_assignees_count} + %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{more_assignees_count} more assignees", qa_selector: 'avatar_counter' } } +#{more_assignees_count} diff --git a/app/views/shared/issuable/_board_create_list_dropdown.html.haml b/app/views/shared/issuable/_board_create_list_dropdown.html.haml index 416b4a34651..ae0e5e45afe 100644 --- a/app/views/shared/issuable/_board_create_list_dropdown.html.haml +++ b/app/views/shared/issuable/_board_create_list_dropdown.html.haml @@ -3,6 +3,6 @@ Add list .dropdown-menu.dropdown-extended-height.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels = render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" } - - if can?(current_user, :admin_label, board.parent) + - if can?(current_user, :admin_label, board.resource_parent) = render partial: "shared/issuable/label_page_create", locals: { show_add_list: true, add_list: true, add_list_class: 'd-none' } = dropdown_loading diff --git a/app/views/shared/issuable/_feed_buttons.html.haml b/app/views/shared/issuable/_feed_buttons.html.haml index 83f60fa6fe2..4fed95e2607 100644 --- a/app/views/shared/issuable/_feed_buttons.html.haml +++ b/app/views/shared/issuable/_feed_buttons.html.haml @@ -1,4 +1,4 @@ -= link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: _('Subscribe to RSS feed') do - = icon('rss') += link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip js-rss-button', data: { container: 'body' }, title: _('Subscribe to RSS feed') do + = sprite_icon('rss') = link_to safe_params.merge(calendar_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: _('Subscribe to calendar') do - = custom_icon('icon_calendar') + = sprite_icon('calendar') diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 04a70e406ca..5e2b5f95ee3 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -19,6 +19,7 @@ = render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?) #js-suggestions{ data: { project_path: @project.full_path } } += render 'shared/form_elements/apply_template_warning' = render 'shared/form_elements/description', model: issuable, form: form, project: project - if issuable.respond_to?(:confidential) diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index c9458475aa5..9d580930fb8 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -2,7 +2,7 @@ - board = local_assigns.fetch(:board, nil) - is_not_boards_modal_or_productivity_analytics = type != :boards_modal && type != :productivity_analytics - block_css_class = is_not_boards_modal_or_productivity_analytics ? 'row-content-block second-block' : '' -- user_can_admin_list = board && can?(current_user, :admin_list, board.parent) +- user_can_admin_list = board && can?(current_user, :admin_list, board.resource_parent) .issues-filters{ class: ("w-100" if type == :boards_modal) } .issues-details-filters.filtered-search-block.d-flex.flex-column.flex-md-row{ class: block_css_class, "v-pre" => type == :boards_modal } @@ -17,12 +17,11 @@ .issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row .filtered-search-box - if type != :boards_modal && type != :boards - = dropdown_tag(custom_icon('icon_history'), + = dropdown_tag(_('Recent searches'), options: { wrapper_class: "filtered-search-history-dropdown-wrapper", toggle_class: "filtered-search-history-dropdown-toggle-button", dropdown_class: "filtered-search-history-dropdown", - content_class: "filtered-search-history-dropdown-content", - title: "Recent searches" }) do + content_class: "filtered-search-history-dropdown-content" }) do .js-filtered-search-history-dropdown{ data: { full_path: search_history_storage_prefix } } .filtered-search-box-input-container.droplab-dropdown .scroll-container diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 3b26b8df8a1..c8b2adcf084 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -5,174 +5,178 @@ - signed_in = !!issuable_sidebar.dig(:current_user, :id) - can_edit_issuable = issuable_sidebar.dig(:current_user, :can_edit) -%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } - .issuable-sidebar - .block.issuable-sidebar-header - - if signed_in - %span.issuable-header-text.hide-collapsed.float-left - = _('To Do') - %a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => "Toggle sidebar", title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } } - = sidebar_gutter_toggle_icon - - if signed_in - = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar - - = form_for issuable_type, url: issuable_sidebar[:issuable_json_path], remote: true, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f| - - if signed_in - .block.todo.hide-expanded - = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar, is_collapsed: true - .block.assignee.qa-assignee-block - = render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees - - = render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar - - - milestone = issuable_sidebar[:milestone] || {} - .block.milestone - .sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } } - = icon('clock-o', 'aria-hidden': 'true') - %span.milestone-title.collapse-truncated-title - - if milestone.present? - = milestone[:title] - - else - = _('None') - .title.hide-collapsed - = _('Milestone') - = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - - if can_edit_issuable - = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" } - .value.hide-collapsed - - if milestone.present? - = link_to milestone[:title], milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport', qa_selector: 'milestone_link' } - - else - %span.no-value - = _('None') - - .selectbox.hide-collapsed - = f.hidden_field 'milestone_id', value: milestone[:id], id: nil - = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable_type}[milestone_id]", project_id: issuable_sidebar[:project_id], issuable_id: issuable_sidebar[:id], milestones: issuable_sidebar[:project_milestones_path], ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], use_id: true, default_no: true, selected: milestone[:title], null_default: true, display: 'static' }}) - - #issuable-time-tracker.block - // Fallback while content is loading - .title.hide-collapsed - = _('Time tracking') - = icon('spinner spin', 'aria-hidden': 'true') - - - if issuable_sidebar.has_key?(:due_date) - .block.due_date - .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 'true', boundary: 'viewport' }, title: sidebar_due_date_tooltip_label(issuable_sidebar[:due_date]) } - = icon('calendar', 'aria-hidden': 'true') - %span.js-due-date-sidebar-value - = issuable_sidebar[:due_date].try(:to_s, :medium) || 'None' +- if Feature.enabled?(:vue_issuable_sidebar, @project.group) + %aside#js-vue-issuable-sidebar{ data: { signed_in: signed_in, + sidebar_status_class: sidebar_gutter_collapsed_class } } +- else + %aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } + .issuable-sidebar + .block.issuable-sidebar-header + - if signed_in + %span.issuable-header-text.hide-collapsed.float-left + = _('To Do') + %a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => "Toggle sidebar", title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } } + = sidebar_gutter_toggle_icon + - if signed_in + = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar + + = form_for issuable_type, url: issuable_sidebar[:issuable_json_path], remote: true, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f| + - if signed_in + .block.todo.hide-expanded + = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar, is_collapsed: true + .block.assignee.qa-assignee-block + = render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees + + = render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar + + - milestone = issuable_sidebar[:milestone] || {} + .block.milestone + .sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } } + = icon('clock-o', 'aria-hidden': 'true') + %span.milestone-title.collapse-truncated-title + - if milestone.present? + = milestone[:title] + - else + = _('None') .title.hide-collapsed - = _('Due date') + = _('Milestone') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable - = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "due_date", track_event: "click_edit_button", track_value: "" } + = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" } .value.hide-collapsed - %span.value-content - - if issuable_sidebar[:due_date] - %span.bold= issuable_sidebar[:due_date].to_s(:medium) - - else - %span.no-value - = _('None') + - if milestone.present? + = link_to milestone[:title], milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport', qa_selector: 'milestone_link' } + - else + %span.no-value + = _('None') + + .selectbox.hide-collapsed + = f.hidden_field 'milestone_id', value: milestone[:id], id: nil + = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable_type}[milestone_id]", project_id: issuable_sidebar[:project_id], issuable_id: issuable_sidebar[:id], milestones: issuable_sidebar[:project_milestones_path], ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], use_id: true, default_no: true, selected: milestone[:title], null_default: true, display: 'static' }}) + + #issuable-time-tracker.block + // Fallback while content is loading + .title.hide-collapsed + = _('Time tracking') + = icon('spinner spin', 'aria-hidden': 'true') + + - if issuable_sidebar.has_key?(:due_date) + .block.due_date + .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 'true', boundary: 'viewport' }, title: sidebar_due_date_tooltip_label(issuable_sidebar[:due_date]) } + = icon('calendar', 'aria-hidden': 'true') + %span.js-due-date-sidebar-value + = issuable_sidebar[:due_date].try(:to_s, :medium) || 'None' + .title.hide-collapsed + = _('Due date') + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "due_date", track_event: "click_edit_button", track_value: "" } + .value.hide-collapsed + %span.value-content + - if issuable_sidebar[:due_date] + %span.bold= issuable_sidebar[:due_date].to_s(:medium) + - else + %span.no-value + = _('None') + - if can_edit_issuable + %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable_sidebar[:due_date].nil?) } + \- + %a.js-remove-due-date{ href: "#", role: "button" } + = _('remove due date') - if can_edit_issuable - %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable_sidebar[:due_date].nil?) } - \- - %a.js-remove-due-date{ href: "#", role: "button" } - = _('remove due date') - - if can_edit_issuable - .selectbox.hide-collapsed - = f.hidden_field :due_date, value: issuable_sidebar[:due_date].try(:strftime, 'yy-mm-dd') - .dropdown - %button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable_type}[due_date]", ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], display: 'static' } } - %span.dropdown-toggle-text - = _('Due date') - = icon('chevron-down', 'aria-hidden': 'true') - .dropdown-menu.dropdown-menu-due-date - = dropdown_title(_('Due date')) - = dropdown_content do - .js-due-date-calendar - - - selected_labels = issuable_sidebar[:labels] - .block.labels - .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(selected_labels), data: { placement: "left", container: "body", boundary: 'viewport' } } - = icon('tags', 'aria-hidden': 'true') - %span - = selected_labels.size - .title.hide-collapsed - = _('Labels') - = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - - if can_edit_issuable - = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link qa-edit-link-labels float-right', data: { track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" } - .value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) } - - if selected_labels.any? - - selected_labels.each do |label_hash| - = render_label(label_from_hash(label_hash).present(issuable_subject: nil), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title])) - - else - %span.no-value - = _('None') - .selectbox.hide-collapsed - - selected_labels.each do |label| - = hidden_field_tag "#{issuable_type}[label_names][]", label[:id], id: nil - .dropdown - %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: sidebar_label_dropdown_data(issuable_type, issuable_sidebar) } - %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) } - = multi_label_name(selected_labels, "Labels") - = icon('chevron-down', 'aria-hidden': 'true') - .dropdown-menu.dropdown-select.dropdown-menu-paging.qa-dropdown-menu-labels.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height - = render partial: "shared/issuable/label_page_default" - - if issuable_sidebar.dig(:current_user, :can_admin_label) - = render partial: "shared/issuable/label_page_create" - - = render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar - - - if issuable_sidebar.has_key?(:confidential) + .selectbox.hide-collapsed + = f.hidden_field :due_date, value: issuable_sidebar[:due_date].try(:strftime, 'yy-mm-dd') + .dropdown + %button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable_type}[due_date]", ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], display: 'static' } } + %span.dropdown-toggle-text + = _('Due date') + = icon('chevron-down', 'aria-hidden': 'true') + .dropdown-menu.dropdown-menu-due-date + = dropdown_title(_('Due date')) + = dropdown_content do + .js-due-date-calendar + + - selected_labels = issuable_sidebar[:labels] + .block.labels + .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(selected_labels), data: { placement: "left", container: "body", boundary: 'viewport' } } + = icon('tags', 'aria-hidden': 'true') + %span + = selected_labels.size + .title.hide-collapsed + = _('Labels') + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link qa-edit-link-labels float-right', data: { track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" } + .value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) } + - if selected_labels.any? + - selected_labels.each do |label_hash| + = render_label(label_from_hash(label_hash).present(issuable_subject: nil), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title])) + - else + %span.no-value + = _('None') + .selectbox.hide-collapsed + - selected_labels.each do |label| + = hidden_field_tag "#{issuable_type}[label_names][]", label[:id], id: nil + .dropdown + %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: sidebar_label_dropdown_data(issuable_type, issuable_sidebar) } + %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) } + = multi_label_name(selected_labels, "Labels") + = icon('chevron-down', 'aria-hidden': 'true') + .dropdown-menu.dropdown-select.dropdown-menu-paging.qa-dropdown-menu-labels.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height + = render partial: "shared/issuable/label_page_default" + - if issuable_sidebar.dig(:current_user, :can_admin_label) + = render partial: "shared/issuable/label_page_create" + + = render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar + + - if issuable_sidebar.has_key?(:confidential) + -# haml-lint:disable InlineJavaScript + %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe + #js-confidential-entry-point + -# haml-lint:disable InlineJavaScript - %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe - #js-confidential-entry-point + %script#js-lock-issue-data{ type: "application/json" }= { is_locked: !!issuable_sidebar[:discussion_locked], is_editable: can_edit_issuable }.to_json.html_safe + #js-lock-entry-point + + .js-sidebar-participants-entry-point + + - if signed_in + - if issuable_sidebar[:project_emails_disabled] + .block.js-emails-disabled + .sidebar-collapsed-icon.has-tooltip{ title: notification_description(:owner_disabled), data: { placement: "left", container: "body", boundary: 'viewport' } } + = notification_setting_icon + .hide-collapsed= notification_description(:owner_disabled) + - else + .js-sidebar-subscriptions-entry-point + + - project_ref = issuable_sidebar[:reference] + .block.project-reference + .sidebar-collapsed-icon.dont-change-state + = clipboard_button(text: project_ref, title: _('Copy reference'), placement: "left", boundary: 'viewport') + .cross-project-reference.hide-collapsed + %span + = _('Reference:') + %cite{ title: project_ref } + = project_ref + = clipboard_button(text: project_ref, title: _('Copy reference'), placement: "left", boundary: 'viewport') + + - if issuable_sidebar.dig(:current_user, :can_move) + .block.js-sidebar-move-issue-block + .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body', boundary: 'viewport' }, title: _('Move issue') } + = custom_icon('icon_arrow_right') + .dropdown.sidebar-move-issue-dropdown.hide-collapsed + %button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button', + data: { toggle: 'dropdown', display: 'static', track_label: "right_sidebar", track_property: "move_issue", track_event: "click_button", track_value: "" } } + = _('Move issue') + .dropdown-menu.dropdown-menu-selectable.dropdown-extended-height + = dropdown_title(_('Move issue')) + = dropdown_filter(_('Search project'), search_id: 'sidebar-move-issue-dropdown-search') + = dropdown_content + = dropdown_loading + = dropdown_footer add_content_class: true do + %button.btn.btn-success.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ type: 'button', disabled: true } + = _('Move') + = icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon') -# haml-lint:disable InlineJavaScript - %script#js-lock-issue-data{ type: "application/json" }= { is_locked: !!issuable_sidebar[:discussion_locked], is_editable: can_edit_issuable }.to_json.html_safe - #js-lock-entry-point - - .js-sidebar-participants-entry-point - - - if signed_in - - if issuable_sidebar[:project_emails_disabled] - .block.js-emails-disabled - .sidebar-collapsed-icon.has-tooltip{ title: notification_description(:owner_disabled), data: { placement: "left", container: "body", boundary: 'viewport' } } - = notification_setting_icon - .hide-collapsed= notification_description(:owner_disabled) - - else - .js-sidebar-subscriptions-entry-point - - - project_ref = issuable_sidebar[:reference] - .block.project-reference - .sidebar-collapsed-icon.dont-change-state - = clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left", boundary: 'viewport') - .cross-project-reference.hide-collapsed - %span - = _('Reference:') - %cite{ title: project_ref } - = project_ref - = clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left", boundary: 'viewport') - - - if issuable_sidebar.dig(:current_user, :can_move) - .block.js-sidebar-move-issue-block - .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body', boundary: 'viewport' }, title: _('Move issue') } - = custom_icon('icon_arrow_right') - .dropdown.sidebar-move-issue-dropdown.hide-collapsed - %button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button', - data: { toggle: 'dropdown', display: 'static', track_label: "right_sidebar", track_property: "move_issue", track_event: "click_button", track_value: "" } } - = _('Move issue') - .dropdown-menu.dropdown-menu-selectable.dropdown-extended-height - = dropdown_title(_('Move issue')) - = dropdown_filter(_('Search project'), search_id: 'sidebar-move-issue-dropdown-search') - = dropdown_content - = dropdown_loading - = dropdown_footer add_content_class: true do - %button.btn.btn-success.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ type: 'button', disabled: true } - = _('Move') - = icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon') - - -# haml-lint:disable InlineJavaScript - %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable_sidebar).to_json.html_safe + %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable_sidebar).to_json.html_safe diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index dfb0e7ed297..e6b8e299e1c 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -11,7 +11,7 @@ = hidden_field_tag "#{issuable_type}[assignee_ids][]", 0, id: nil - else - assignees.each do |assignee| - = hidden_field_tag "#{issuable_type}[assignee_ids][]", assignee.id, id: nil, data: { avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username } + = hidden_field_tag "#{issuable_type}[assignee_ids][]", assignee.id, id: nil, data: assignee_sidebar_data(assignee, merge_request: @merge_request) - options = { toggle_class: 'js-user-search js-author-search', title: _('Assign to'), diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index ced6af50501..22a6d5e33f0 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -142,10 +142,10 @@ - if milestone_ref.present? .block.reference .sidebar-collapsed-icon.dont-change-state - = clipboard_button(text: milestone_ref, title: "Copy reference to clipboard", placement: "left", boundary: 'viewport') + = clipboard_button(text: milestone_ref, title: _("Copy reference"), placement: "left", boundary: 'viewport') .cross-project-reference.hide-collapsed %span Reference: %cite{ title: milestone_ref } = milestone_ref - = clipboard_button(text: milestone_ref, title: "Copy reference to clipboard", placement: "left", boundary: 'viewport') + = clipboard_button(text: milestone_ref, title: _("Copy reference"), placement: "left", boundary: 'viewport') diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index bcce7cb52fb..67dad9b7a75 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -12,7 +12,9 @@ - css_class += " no-description" if project.description.blank? && !show_last_commit_as_description - cache_key = project_list_cache_key(project, pipeline_status: pipeline_status) - updated_tooltip = time_ago_with_tooltip(project.last_activity_date) -- css_controls_class = compact_mode ? "" : "flex-lg-row justify-content-lg-between" +- show_pipeline_status_icon = pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project) +- css_controls_class = compact_mode ? [] : ["flex-lg-row", "justify-content-lg-between"] +- css_controls_class << "with-pipeline-status" if show_pipeline_status_icon - avatar_container_class = project.creator && use_creator_avatar ? '' : 'rect-avatar' %li.project-row.d-flex{ class: css_class } @@ -58,12 +60,12 @@ .description.d-none.d-sm-block.append-right-default = markdown_field(project, :description) - .controls.d-flex.flex-sm-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0.text-secondary{ class: css_controls_class } + .controls.d-flex.flex-sm-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0.text-secondary{ class: css_controls_class.join(" ") } .icon-container.d-flex.align-items-center - - if pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project) + - if show_pipeline_status_icon - pipeline_path = pipelines_project_commit_path(project.pipeline_status.project, project.pipeline_status.sha, ref: project.pipeline_status.ref) %span.icon-wrapper.pipeline-status - = render 'ci/status/icon', status: project.commit.last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path + = render 'ci/status/icon', status: project.last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path - if project.archived %span.d-flex.icon-wrapper.badge.badge-warning archived diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index 69481293f90..8d94a87a775 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -46,5 +46,5 @@ %strong.embed-toggle-list-item= _("Share") %input.js-snippet-url-area.snippet-embed-input.form-control{ type: "text", autocomplete: 'off', value: snippet_embed } .input-group-append - = clipboard_button(title: s_('Copy to clipboard'), class: 'js-clipboard-btn snippet-clipboard-btn btn btn-default', target: '.js-snippet-url-area') + = clipboard_button(title: _('Copy'), class: 'js-clipboard-btn snippet-clipboard-btn btn btn-default', target: '.js-snippet-url-area') .clearfix diff --git a/app/workers/admin_email_worker.rb b/app/workers/admin_email_worker.rb index f69e74b2674..be05d2a6752 100644 --- a/app/workers/admin_email_worker.rb +++ b/app/workers/admin_email_worker.rb @@ -4,6 +4,8 @@ class AdminEmailWorker include ApplicationWorker include CronjobQueue + feature_category_not_owned! + def perform send_repository_check_mail if Gitlab::CurrentSettings.repository_checks_enabled end diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index a33afd436b0..b161cc65602 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -119,6 +119,8 @@ - container_repository:delete_container_repository - container_repository:cleanup_container_repository +- notifications:new_release + - default - mailers # ActionMailer::DeliveryJob.queue_name @@ -173,3 +175,4 @@ - delete_stored_files - import_issues_csv - project_daily_statistics +- create_evidence diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb index c9ddeb08613..577c439f4a2 100644 --- a/app/workers/authorized_projects_worker.rb +++ b/app/workers/authorized_projects_worker.rb @@ -4,6 +4,8 @@ class AuthorizedProjectsWorker include ApplicationWorker prepend WaitableWorker + feature_category :authentication_and_authorization + # This is a workaround for a Ruby 2.3.7 bug. rspec-mocks cannot restore the # visibility of prepended modules. See https://github.com/rspec/rspec-mocks/issues/1231 # for more details. diff --git a/app/workers/auto_merge_process_worker.rb b/app/workers/auto_merge_process_worker.rb index cd81cdbc60c..e4dccb891ce 100644 --- a/app/workers/auto_merge_process_worker.rb +++ b/app/workers/auto_merge_process_worker.rb @@ -4,6 +4,7 @@ class AutoMergeProcessWorker include ApplicationWorker queue_namespace :auto_merge + feature_category :continuous_delivery def perform(merge_request_id) MergeRequest.find_by_id(merge_request_id).try do |merge_request| diff --git a/app/workers/background_migration_worker.rb b/app/workers/background_migration_worker.rb index b83412b5e6e..20e2cdd7f96 100644 --- a/app/workers/background_migration_worker.rb +++ b/app/workers/background_migration_worker.rb @@ -3,6 +3,8 @@ class BackgroundMigrationWorker include ApplicationWorker + feature_category_not_owned! + # The minimum amount of time between processing two jobs of the same migration # class. # diff --git a/app/workers/build_hooks_worker.rb b/app/workers/build_hooks_worker.rb index b0c3676714c..15b31acf3e5 100644 --- a/app/workers/build_hooks_worker.rb +++ b/app/workers/build_hooks_worker.rb @@ -5,6 +5,7 @@ class BuildHooksWorker include PipelineQueue queue_namespace :pipeline_hooks + feature_category :continuous_integration # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) diff --git a/app/workers/build_queue_worker.rb b/app/workers/build_queue_worker.rb index 67d5b0f5f5b..6584fba4c65 100644 --- a/app/workers/build_queue_worker.rb +++ b/app/workers/build_queue_worker.rb @@ -5,6 +5,7 @@ class BuildQueueWorker include PipelineQueue queue_namespace :pipeline_processing + feature_category :continuous_integration # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) diff --git a/app/workers/chat_notification_worker.rb b/app/workers/chat_notification_worker.rb index 25a306e94d8..3bc2edad62c 100644 --- a/app/workers/chat_notification_worker.rb +++ b/app/workers/chat_notification_worker.rb @@ -3,6 +3,8 @@ class ChatNotificationWorker include ApplicationWorker + feature_category :chatops + RESCHEDULE_INTERVAL = 2.seconds # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/workers/ci/archive_traces_cron_worker.rb b/app/workers/ci/archive_traces_cron_worker.rb index ad7a29719ac..74f389175b9 100644 --- a/app/workers/ci/archive_traces_cron_worker.rb +++ b/app/workers/ci/archive_traces_cron_worker.rb @@ -5,6 +5,8 @@ module Ci include ApplicationWorker include CronjobQueue + feature_category :continuous_integration + # rubocop: disable CodeReuse/ActiveRecord def perform # Archive stale live traces which still resides in redis or database diff --git a/app/workers/ci/build_prepare_worker.rb b/app/workers/ci/build_prepare_worker.rb index 1a35a74ae53..20208c18d03 100644 --- a/app/workers/ci/build_prepare_worker.rb +++ b/app/workers/ci/build_prepare_worker.rb @@ -6,6 +6,7 @@ module Ci include PipelineQueue queue_namespace :pipeline_processing + feature_category :continuous_integration def perform(build_id) Ci::Build.find_by_id(build_id).try do |build| diff --git a/app/workers/ci/build_schedule_worker.rb b/app/workers/ci/build_schedule_worker.rb index da219adffc6..f22ec4c7810 100644 --- a/app/workers/ci/build_schedule_worker.rb +++ b/app/workers/ci/build_schedule_worker.rb @@ -6,6 +6,7 @@ module Ci include PipelineQueue queue_namespace :pipeline_processing + feature_category :continuous_integration def perform(build_id) ::Ci::Build.find_by_id(build_id).try do |build| diff --git a/app/workers/cleanup_container_repository_worker.rb b/app/workers/cleanup_container_repository_worker.rb index 0331fc7b01c..83fb3e58d29 100644 --- a/app/workers/cleanup_container_repository_worker.rb +++ b/app/workers/cleanup_container_repository_worker.rb @@ -4,6 +4,7 @@ class CleanupContainerRepositoryWorker include ApplicationWorker queue_namespace :container_repository + feature_category :container_registry attr_reader :container_repository, :current_user diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb index 2b36ccb8304..62748808ff1 100644 --- a/app/workers/concerns/application_worker.rb +++ b/app/workers/concerns/application_worker.rb @@ -8,6 +8,7 @@ module ApplicationWorker extend ActiveSupport::Concern include Sidekiq::Worker # rubocop:disable Cop/IncludeSidekiqWorker + include WorkerAttributes included do set_queue diff --git a/app/workers/concerns/auto_devops_queue.rb b/app/workers/concerns/auto_devops_queue.rb index aba928ccaab..61e3c1544bd 100644 --- a/app/workers/concerns/auto_devops_queue.rb +++ b/app/workers/concerns/auto_devops_queue.rb @@ -5,5 +5,6 @@ module AutoDevopsQueue included do queue_namespace :auto_devops + feature_category :auto_devops end end diff --git a/app/workers/concerns/chaos_queue.rb b/app/workers/concerns/chaos_queue.rb index e406509d12d..c5db10491f2 100644 --- a/app/workers/concerns/chaos_queue.rb +++ b/app/workers/concerns/chaos_queue.rb @@ -5,5 +5,6 @@ module ChaosQueue included do queue_namespace :chaos + feature_category :chaos_engineering end end diff --git a/app/workers/concerns/cluster_queue.rb b/app/workers/concerns/cluster_queue.rb index e44b40c36c9..180b86b0124 100644 --- a/app/workers/concerns/cluster_queue.rb +++ b/app/workers/concerns/cluster_queue.rb @@ -8,5 +8,6 @@ module ClusterQueue included do queue_namespace :gcp_cluster + feature_category :kubernetes_configuration end end diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb index eeeff6e93a0..b856a9329dd 100644 --- a/app/workers/concerns/gitlab/github_import/object_importer.rb +++ b/app/workers/concerns/gitlab/github_import/object_importer.rb @@ -12,6 +12,8 @@ module Gitlab include GithubImport::Queue include ReschedulingMethods include NotifyUponDeath + + feature_category :importers end # project - An instance of `Project` to import the data into. diff --git a/app/workers/concerns/gitlab/github_import/queue.rb b/app/workers/concerns/gitlab/github_import/queue.rb index 59b621f16ab..7cc23dd7c0b 100644 --- a/app/workers/concerns/gitlab/github_import/queue.rb +++ b/app/workers/concerns/gitlab/github_import/queue.rb @@ -7,6 +7,7 @@ module Gitlab included do queue_namespace :github_importer + feature_category :importers # If a job produces an error it may block a stage from advancing # forever. To prevent this from happening we prevent jobs from going to diff --git a/app/workers/concerns/object_pool_queue.rb b/app/workers/concerns/object_pool_queue.rb index 5b648df9c72..c2e84470fba 100644 --- a/app/workers/concerns/object_pool_queue.rb +++ b/app/workers/concerns/object_pool_queue.rb @@ -8,5 +8,6 @@ module ObjectPoolQueue included do queue_namespace :object_pool + feature_category :gitaly end end diff --git a/app/workers/concerns/pipeline_background_queue.rb b/app/workers/concerns/pipeline_background_queue.rb index bbb8ad0c982..0a23780b807 100644 --- a/app/workers/concerns/pipeline_background_queue.rb +++ b/app/workers/concerns/pipeline_background_queue.rb @@ -8,5 +8,6 @@ module PipelineBackgroundQueue included do queue_namespace :pipeline_background + feature_category :continuous_integration end end diff --git a/app/workers/concerns/pipeline_queue.rb b/app/workers/concerns/pipeline_queue.rb index 3aaed4669e5..27cbf6eb61c 100644 --- a/app/workers/concerns/pipeline_queue.rb +++ b/app/workers/concerns/pipeline_queue.rb @@ -8,5 +8,6 @@ module PipelineQueue included do queue_namespace :pipeline_default + feature_category :continuous_integration end end diff --git a/app/workers/concerns/repository_check_queue.rb b/app/workers/concerns/repository_check_queue.rb index 216d67e5dbc..76f6e1c2e91 100644 --- a/app/workers/concerns/repository_check_queue.rb +++ b/app/workers/concerns/repository_check_queue.rb @@ -6,7 +6,7 @@ module RepositoryCheckQueue included do queue_namespace :repository_check - sidekiq_options retry: false + feature_category :source_code_management end end diff --git a/app/workers/concerns/todos_destroyer_queue.rb b/app/workers/concerns/todos_destroyer_queue.rb index 8e2b1d30579..1bbccbfb1f9 100644 --- a/app/workers/concerns/todos_destroyer_queue.rb +++ b/app/workers/concerns/todos_destroyer_queue.rb @@ -8,5 +8,6 @@ module TodosDestroyerQueue included do queue_namespace :todos_destroyer + feature_category :issue_tracking end end diff --git a/app/workers/create_evidence_worker.rb b/app/workers/create_evidence_worker.rb new file mode 100644 index 00000000000..027dbd2f101 --- /dev/null +++ b/app/workers/create_evidence_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateEvidenceWorker + include ApplicationWorker + + feature_category :release_governance + + def perform(release_id) + release = Release.find_by_id(release_id) + return unless release + + Evidence.create!(release: release) + end +end diff --git a/app/workers/create_gpg_signature_worker.rb b/app/workers/create_gpg_signature_worker.rb index e3fb5d479ae..fc36a2adccd 100644 --- a/app/workers/create_gpg_signature_worker.rb +++ b/app/workers/create_gpg_signature_worker.rb @@ -3,6 +3,8 @@ class CreateGpgSignatureWorker include ApplicationWorker + feature_category :source_code_management + # rubocop: disable CodeReuse/ActiveRecord def perform(commit_shas, project_id) # Older versions of Git::BranchPushService may push a single commit ID on diff --git a/app/workers/create_note_diff_file_worker.rb b/app/workers/create_note_diff_file_worker.rb index 0850250f7e3..ca200bd17b4 100644 --- a/app/workers/create_note_diff_file_worker.rb +++ b/app/workers/create_note_diff_file_worker.rb @@ -3,6 +3,8 @@ class CreateNoteDiffFileWorker include ApplicationWorker + feature_category :source_code_management + def perform(diff_note_id) diff_note = DiffNote.find(diff_note_id) diff --git a/app/workers/create_pipeline_worker.rb b/app/workers/create_pipeline_worker.rb index 037b4a57d4b..70412ffd095 100644 --- a/app/workers/create_pipeline_worker.rb +++ b/app/workers/create_pipeline_worker.rb @@ -5,6 +5,7 @@ class CreatePipelineWorker include PipelineQueue queue_namespace :pipeline_creation + feature_category :continuous_integration def perform(project_id, user_id, ref, source, params = {}) project = Project.find(project_id) diff --git a/app/workers/delete_container_repository_worker.rb b/app/workers/delete_container_repository_worker.rb index 42e66513ff1..e70b4fb0a58 100644 --- a/app/workers/delete_container_repository_worker.rb +++ b/app/workers/delete_container_repository_worker.rb @@ -5,6 +5,7 @@ class DeleteContainerRepositoryWorker include ExclusiveLeaseGuard queue_namespace :container_repository + feature_category :container_registry LEASE_TIMEOUT = 1.hour diff --git a/app/workers/delete_diff_files_worker.rb b/app/workers/delete_diff_files_worker.rb index f518dfe871c..e0c1724f1f7 100644 --- a/app/workers/delete_diff_files_worker.rb +++ b/app/workers/delete_diff_files_worker.rb @@ -3,6 +3,8 @@ class DeleteDiffFilesWorker include ApplicationWorker + feature_category :source_code_management + # rubocop: disable CodeReuse/ActiveRecord def perform(merge_request_diff_id) merge_request_diff = MergeRequestDiff.find(merge_request_diff_id) diff --git a/app/workers/delete_merged_branches_worker.rb b/app/workers/delete_merged_branches_worker.rb index 017d7fd1cb0..44b3db30d0d 100644 --- a/app/workers/delete_merged_branches_worker.rb +++ b/app/workers/delete_merged_branches_worker.rb @@ -3,6 +3,8 @@ class DeleteMergedBranchesWorker include ApplicationWorker + feature_category :source_code_management + def perform(project_id, user_id) begin project = Project.find(project_id) diff --git a/app/workers/delete_stored_files_worker.rb b/app/workers/delete_stored_files_worker.rb index ff7931849d8..8a693a64055 100644 --- a/app/workers/delete_stored_files_worker.rb +++ b/app/workers/delete_stored_files_worker.rb @@ -3,6 +3,8 @@ class DeleteStoredFilesWorker include ApplicationWorker + feature_category_not_owned! + def perform(class_name, keys) klass = begin class_name.constantize diff --git a/app/workers/delete_user_worker.rb b/app/workers/delete_user_worker.rb index efa8794b214..0e49e787d8a 100644 --- a/app/workers/delete_user_worker.rb +++ b/app/workers/delete_user_worker.rb @@ -3,6 +3,8 @@ class DeleteUserWorker include ApplicationWorker + feature_category :authentication_and_authorization + def perform(current_user_id, delete_user_id, options = {}) delete_user = User.find(delete_user_id) current_user = User.find(current_user_id) diff --git a/app/workers/deployments/finished_worker.rb b/app/workers/deployments/finished_worker.rb index c9d448d5d18..79a1caccc92 100644 --- a/app/workers/deployments/finished_worker.rb +++ b/app/workers/deployments/finished_worker.rb @@ -5,6 +5,7 @@ module Deployments include ApplicationWorker queue_namespace :deployment + feature_category :continuous_delivery def perform(deployment_id) Deployment.find_by_id(deployment_id).try(:execute_hooks) diff --git a/app/workers/deployments/success_worker.rb b/app/workers/deployments/success_worker.rb index da517f3fb26..f6520307186 100644 --- a/app/workers/deployments/success_worker.rb +++ b/app/workers/deployments/success_worker.rb @@ -5,12 +5,13 @@ module Deployments include ApplicationWorker queue_namespace :deployment + feature_category :continuous_delivery def perform(deployment_id) Deployment.find_by_id(deployment_id).try do |deployment| break unless deployment.success? - UpdateDeploymentService.new(deployment).execute + Deployments::AfterCreateService.new(deployment).execute end end end diff --git a/app/workers/detect_repository_languages_worker.rb b/app/workers/detect_repository_languages_worker.rb index 838c3be78f0..954d0f9336b 100644 --- a/app/workers/detect_repository_languages_worker.rb +++ b/app/workers/detect_repository_languages_worker.rb @@ -6,6 +6,7 @@ class DetectRepositoryLanguagesWorker include ExclusiveLeaseGuard sidekiq_options retry: 1 + feature_category :source_code_management LEASE_TIMEOUT = 300 diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb index e70bf17d5a9..c82728be329 100644 --- a/app/workers/email_receiver_worker.rb +++ b/app/workers/email_receiver_worker.rb @@ -3,6 +3,8 @@ class EmailReceiverWorker include ApplicationWorker + feature_category :issue_tracking + def perform(raw) return unless Gitlab::IncomingEmail.enabled? diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb index ed3e354e4c2..2231c91a720 100644 --- a/app/workers/emails_on_push_worker.rb +++ b/app/workers/emails_on_push_worker.rb @@ -5,6 +5,8 @@ class EmailsOnPushWorker attr_reader :email, :skip_premailer + feature_category :source_code_management + def perform(project_id, recipients, push_data, options = {}) options.symbolize_keys! options.reverse_merge!( diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb index 6f0e0fd33f7..9545227fa31 100644 --- a/app/workers/expire_build_artifacts_worker.rb +++ b/app/workers/expire_build_artifacts_worker.rb @@ -4,6 +4,8 @@ class ExpireBuildArtifactsWorker include ApplicationWorker include CronjobQueue + feature_category :continuous_integration + def perform if Feature.enabled?(:ci_new_expire_job_artifacts_service, default_enabled: true) perform_efficient_artifacts_removal diff --git a/app/workers/expire_build_instance_artifacts_worker.rb b/app/workers/expire_build_instance_artifacts_worker.rb index 71e61dcb878..db5240d5c8e 100644 --- a/app/workers/expire_build_instance_artifacts_worker.rb +++ b/app/workers/expire_build_instance_artifacts_worker.rb @@ -3,6 +3,8 @@ class ExpireBuildInstanceArtifactsWorker include ApplicationWorker + feature_category :continuous_integration + # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) build = Ci::Build diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb index 5499e12e49b..ad119917774 100644 --- a/app/workers/git_garbage_collect_worker.rb +++ b/app/workers/git_garbage_collect_worker.rb @@ -4,6 +4,7 @@ class GitGarbageCollectWorker include ApplicationWorker sidekiq_options retry: false + feature_category :gitaly # Timeout set to 24h LEASE_TIMEOUT = 86400 diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb index 0b3437a8a33..44e69e48694 100644 --- a/app/workers/gitlab/github_import/advance_stage_worker.rb +++ b/app/workers/gitlab/github_import/advance_stage_worker.rb @@ -10,6 +10,7 @@ module Gitlab include ApplicationWorker sidekiq_options dead: false + feature_category :importers INTERVAL = 30.seconds.to_i diff --git a/app/workers/gitlab/github_import/stage/finish_import_worker.rb b/app/workers/gitlab/github_import/stage/finish_import_worker.rb index a779e631516..ee64f62637e 100644 --- a/app/workers/gitlab/github_import/stage/finish_import_worker.rb +++ b/app/workers/gitlab/github_import/stage/finish_import_worker.rb @@ -8,6 +8,10 @@ module Gitlab include GithubImport::Queue include StageMethods + # technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991 + sidekiq_options memory_killer_memory_growth_kb: ENV.fetch('MEMORY_KILLER_FINISH_IMPORT_WORKER_MEMORY_GROWTH_KB', 50).to_i + sidekiq_options memory_killer_max_memory_growth_kb: ENV.fetch('MEMORY_KILLER_FINISH_IMPORT_WORKER_MAX_MEMORY_GROWTH_KB', 200_000).to_i + # project - An instance of Project. def import(_, project) project.after_import diff --git a/app/workers/gitlab/github_import/stage/import_repository_worker.rb b/app/workers/gitlab/github_import/stage/import_repository_worker.rb index 4d16cef1130..b5e30470070 100644 --- a/app/workers/gitlab/github_import/stage/import_repository_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_repository_worker.rb @@ -8,6 +8,10 @@ module Gitlab include GithubImport::Queue include StageMethods + # technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991 + sidekiq_options memory_killer_memory_growth_kb: ENV.fetch('MEMORY_KILLER_IMPORT_REPOSITORY_WORKER_MEMORY_GROWTH_KB', 50).to_i + sidekiq_options memory_killer_max_memory_growth_kb: ENV.fetch('MEMORY_KILLER_IMPORT_REPOSITORY_WORKER_MAX_MEMORY_GROWTH_KB', 300_000).to_i + # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) diff --git a/app/workers/gitlab_shell_worker.rb b/app/workers/gitlab_shell_worker.rb index 0e4d40acc5c..9766331cf4b 100644 --- a/app/workers/gitlab_shell_worker.rb +++ b/app/workers/gitlab_shell_worker.rb @@ -4,6 +4,8 @@ class GitlabShellWorker include ApplicationWorker include Gitlab::ShellAdapter + feature_category :source_code_management + def perform(action, *arg) gitlab_shell.__send__(action, *arg) # rubocop:disable GitlabSecurity/PublicSend end diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_usage_ping_worker.rb index a5e22f88a3b..ad8302a844a 100644 --- a/app/workers/gitlab_usage_ping_worker.rb +++ b/app/workers/gitlab_usage_ping_worker.rb @@ -6,6 +6,8 @@ class GitlabUsagePingWorker include ApplicationWorker include CronjobQueue + feature_category_not_owned! + # Retry for up to approximately three hours then give up. sidekiq_options retry: 10, dead: false diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb index b4a3ddcae51..553fd359baf 100644 --- a/app/workers/group_destroy_worker.rb +++ b/app/workers/group_destroy_worker.rb @@ -4,6 +4,8 @@ class GroupDestroyWorker include ApplicationWorker include ExceptionBacktrace + feature_category :groups + def perform(group_id, user_id) begin group = Group.find(group_id) diff --git a/app/workers/hashed_storage/base_worker.rb b/app/workers/hashed_storage/base_worker.rb index 237e278c537..1ab2108f6bb 100644 --- a/app/workers/hashed_storage/base_worker.rb +++ b/app/workers/hashed_storage/base_worker.rb @@ -3,6 +3,9 @@ module HashedStorage class BaseWorker include ExclusiveLeaseGuard + include WorkerAttributes + + feature_category :source_code_management LEASE_TIMEOUT = 30.seconds.to_i LEASE_KEY_SEGMENT = 'project_migrate_hashed_storage_worker' diff --git a/app/workers/hashed_storage/migrator_worker.rb b/app/workers/hashed_storage/migrator_worker.rb index 49e347d4060..72a3faec5f4 100644 --- a/app/workers/hashed_storage/migrator_worker.rb +++ b/app/workers/hashed_storage/migrator_worker.rb @@ -5,6 +5,7 @@ module HashedStorage include ApplicationWorker queue_namespace :hashed_storage + feature_category :source_code_management # @param [Integer] start initial ID of the batch # @param [Integer] finish last ID of the batch diff --git a/app/workers/hashed_storage/rollbacker_worker.rb b/app/workers/hashed_storage/rollbacker_worker.rb index a4da8443787..8babdcfb96d 100644 --- a/app/workers/hashed_storage/rollbacker_worker.rb +++ b/app/workers/hashed_storage/rollbacker_worker.rb @@ -5,6 +5,7 @@ module HashedStorage include ApplicationWorker queue_namespace :hashed_storage + feature_category :source_code_management # @param [Integer] start initial ID of the batch # @param [Integer] finish last ID of the batch diff --git a/app/workers/import_export_project_cleanup_worker.rb b/app/workers/import_export_project_cleanup_worker.rb index da3debdeede..07c29d40b54 100644 --- a/app/workers/import_export_project_cleanup_worker.rb +++ b/app/workers/import_export_project_cleanup_worker.rb @@ -4,6 +4,8 @@ class ImportExportProjectCleanupWorker include ApplicationWorker include CronjobQueue + feature_category :importers + def perform ImportExportCleanUpService.new.execute end diff --git a/app/workers/import_issues_csv_worker.rb b/app/workers/import_issues_csv_worker.rb index d44fdfec8ae..d9834320318 100644 --- a/app/workers/import_issues_csv_worker.rb +++ b/app/workers/import_issues_csv_worker.rb @@ -3,6 +3,8 @@ class ImportIssuesCsvWorker include ApplicationWorker + feature_category :issue_tracking + sidekiq_retries_exhausted do |job| Upload.find(job['args'][2]).destroy end @@ -12,7 +14,7 @@ class ImportIssuesCsvWorker @project = Project.find(project_id) @upload = Upload.find(upload_id) - importer = Issues::ImportCsvService.new(@user, @project, @upload.build_uploader) + importer = Issues::ImportCsvService.new(@user, @project, @upload.retrieve_uploader) importer.execute @upload.destroy diff --git a/app/workers/invalid_gpg_signature_update_worker.rb b/app/workers/invalid_gpg_signature_update_worker.rb index fc8a731b427..573efdf9fb1 100644 --- a/app/workers/invalid_gpg_signature_update_worker.rb +++ b/app/workers/invalid_gpg_signature_update_worker.rb @@ -3,6 +3,8 @@ class InvalidGpgSignatureUpdateWorker include ApplicationWorker + feature_category :source_code_management + # rubocop: disable CodeReuse/ActiveRecord def perform(gpg_key_id) gpg_key = GpgKey.find_by(id: gpg_key_id) diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb index 29631c6b7ac..a133ed6ed1b 100644 --- a/app/workers/irker_worker.rb +++ b/app/workers/irker_worker.rb @@ -6,6 +6,8 @@ require 'socket' class IrkerWorker include ApplicationWorker + feature_category :integrations + def perform(project_id, chans, colors, push_data, settings) project = Project.find(project_id) diff --git a/app/workers/issue_due_scheduler_worker.rb b/app/workers/issue_due_scheduler_worker.rb index 476cba47ad7..d4d47659ef0 100644 --- a/app/workers/issue_due_scheduler_worker.rb +++ b/app/workers/issue_due_scheduler_worker.rb @@ -4,6 +4,8 @@ class IssueDueSchedulerWorker include ApplicationWorker include CronjobQueue + feature_category :issue_tracking + # rubocop: disable CodeReuse/ActiveRecord def perform project_ids = Issue.opened.due_tomorrow.group(:project_id).pluck(:project_id).map { |id| [id] } diff --git a/app/workers/mail_scheduler/issue_due_worker.rb b/app/workers/mail_scheduler/issue_due_worker.rb index 1e1dde1e829..6df816de71f 100644 --- a/app/workers/mail_scheduler/issue_due_worker.rb +++ b/app/workers/mail_scheduler/issue_due_worker.rb @@ -5,6 +5,8 @@ module MailScheduler include ApplicationWorker include MailSchedulerQueue + feature_category :issue_tracking + # rubocop: disable CodeReuse/ActiveRecord def perform(project_id) Issue.opened.due_tomorrow.in_projects(project_id).preload(:project).find_each do |issue| diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb index 421fbf04e28..0d06dab3b2e 100644 --- a/app/workers/mail_scheduler/notification_service_worker.rb +++ b/app/workers/mail_scheduler/notification_service_worker.rb @@ -7,6 +7,8 @@ module MailScheduler include ApplicationWorker include MailSchedulerQueue + feature_category :issue_tracking + def perform(meth, *args) check_arguments!(args) diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb index ee864b733cd..70b909afea8 100644 --- a/app/workers/merge_worker.rb +++ b/app/workers/merge_worker.rb @@ -3,6 +3,8 @@ class MergeWorker include ApplicationWorker + feature_category :source_code_management + def perform(merge_request_id, current_user_id, params) params = params.with_indifferent_access current_user = User.find(current_user_id) diff --git a/app/workers/migrate_external_diffs_worker.rb b/app/workers/migrate_external_diffs_worker.rb index debac97af2c..d248e2b5500 100644 --- a/app/workers/migrate_external_diffs_worker.rb +++ b/app/workers/migrate_external_diffs_worker.rb @@ -1,8 +1,10 @@ -# frozen_string_literal: false +# frozen_string_literal: true class MigrateExternalDiffsWorker include ApplicationWorker + feature_category :source_code_management + def perform(merge_request_diff_id) diff = MergeRequestDiff.find_by_id(merge_request_diff_id) return unless diff diff --git a/app/workers/namespaceless_project_destroy_worker.rb b/app/workers/namespaceless_project_destroy_worker.rb index f6e98746055..113afc268f2 100644 --- a/app/workers/namespaceless_project_destroy_worker.rb +++ b/app/workers/namespaceless_project_destroy_worker.rb @@ -10,6 +10,8 @@ class NamespacelessProjectDestroyWorker include ApplicationWorker include ExceptionBacktrace + feature_category :authentication_and_authorization + def perform(project_id) begin project = Project.unscoped.find(project_id) @@ -31,6 +33,6 @@ class NamespacelessProjectDestroyWorker def unlink_fork(project) merge_requests = project.forked_from_project.merge_requests.opened.from_project(project) - merge_requests.update_all(state: 'closed') + merge_requests.update_all(state_id: MergeRequest.available_states[:closed]) end end diff --git a/app/workers/namespaces/prune_aggregation_schedules_worker.rb b/app/workers/namespaces/prune_aggregation_schedules_worker.rb index 4e40feee702..16259ffbfa6 100644 --- a/app/workers/namespaces/prune_aggregation_schedules_worker.rb +++ b/app/workers/namespaces/prune_aggregation_schedules_worker.rb @@ -5,6 +5,8 @@ module Namespaces include ApplicationWorker include CronjobQueue + feature_category :source_code_management + # Worker to prune pending rows on Namespace::AggregationSchedule # It's scheduled to run once a day at 1:05am. def perform diff --git a/app/workers/namespaces/root_statistics_worker.rb b/app/workers/namespaces/root_statistics_worker.rb index 0c1ca5eb975..fd772c8cff6 100644 --- a/app/workers/namespaces/root_statistics_worker.rb +++ b/app/workers/namespaces/root_statistics_worker.rb @@ -5,6 +5,7 @@ module Namespaces include ApplicationWorker queue_namespace :update_namespace_statistics + feature_category :source_code_management def perform(namespace_id) namespace = Namespace.find(namespace_id) diff --git a/app/workers/namespaces/schedule_aggregation_worker.rb b/app/workers/namespaces/schedule_aggregation_worker.rb index b7d580220d6..87e135fbf21 100644 --- a/app/workers/namespaces/schedule_aggregation_worker.rb +++ b/app/workers/namespaces/schedule_aggregation_worker.rb @@ -5,6 +5,7 @@ module Namespaces include ApplicationWorker queue_namespace :update_namespace_statistics + feature_category :source_code_management def perform(namespace_id) return unless aggregation_schedules_table_exists? diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb index 85b53973f56..1b0fec597e7 100644 --- a/app/workers/new_issue_worker.rb +++ b/app/workers/new_issue_worker.rb @@ -4,6 +4,8 @@ class NewIssueWorker include ApplicationWorker include NewIssuable + feature_category :issue_tracking + def perform(issue_id, user_id) return unless objects_found?(issue_id, user_id) diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb index fa48c1b29a8..0a5b2f86331 100644 --- a/app/workers/new_merge_request_worker.rb +++ b/app/workers/new_merge_request_worker.rb @@ -4,6 +4,8 @@ class NewMergeRequestWorker include ApplicationWorker include NewIssuable + feature_category :source_code_management + def perform(merge_request_id, user_id) return unless objects_found?(merge_request_id, user_id) diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb index 7648af3a8b9..d0d2a563738 100644 --- a/app/workers/new_note_worker.rb +++ b/app/workers/new_note_worker.rb @@ -3,6 +3,8 @@ class NewNoteWorker include ApplicationWorker + feature_category :issue_tracking + # Keep extra parameter to preserve backwards compatibility with # old `NewNoteWorker` jobs (can remove later) # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/workers/new_release_worker.rb b/app/workers/new_release_worker.rb new file mode 100644 index 00000000000..28d2517238e --- /dev/null +++ b/app/workers/new_release_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class NewReleaseWorker + include ApplicationWorker + + queue_namespace :notifications + feature_category :release_orchestration + + def perform(release_id) + release = Release.with_project_and_namespace.find_by_id(release_id) + return unless release + + NotificationService.new.send_new_release_notifications(release) + end +end diff --git a/app/workers/object_storage/background_move_worker.rb b/app/workers/object_storage/background_move_worker.rb index 8dff65e46e3..55f8e1c3ede 100644 --- a/app/workers/object_storage/background_move_worker.rb +++ b/app/workers/object_storage/background_move_worker.rb @@ -6,6 +6,7 @@ module ObjectStorage include ObjectStorageQueue sidekiq_options retry: 5 + feature_category_not_owned! def perform(uploader_class_name, subject_class_name, file_field, subject_id) uploader_class = uploader_class_name.constantize @@ -22,7 +23,7 @@ module ObjectStorage def build_uploader(subject, mount_point) case subject - when Upload then subject.build_uploader(mount_point) + when Upload then subject.retrieve_uploader(mount_point) else subject.send(mount_point) # rubocop:disable GitlabSecurity/PublicSend end diff --git a/app/workers/object_storage/migrate_uploads_worker.rb b/app/workers/object_storage/migrate_uploads_worker.rb index 55ac7cd9b3c..01e6fdb2d3e 100644 --- a/app/workers/object_storage/migrate_uploads_worker.rb +++ b/app/workers/object_storage/migrate_uploads_worker.rb @@ -5,6 +5,8 @@ module ObjectStorage include ApplicationWorker include ObjectStorageQueue + feature_category_not_owned! + SanityCheckError = Class.new(StandardError) class MigrationResult @@ -119,7 +121,7 @@ module ObjectStorage end def build_uploaders(uploads) - uploads.map { |upload| upload.build_uploader(@mounted_as) } + uploads.map { |upload| upload.retrieve_uploader(@mounted_as) } end def migrate(uploads) diff --git a/app/workers/pages_domain_removal_cron_worker.rb b/app/workers/pages_domain_removal_cron_worker.rb index 79f38e1b89f..25e747c78d0 100644 --- a/app/workers/pages_domain_removal_cron_worker.rb +++ b/app/workers/pages_domain_removal_cron_worker.rb @@ -4,6 +4,8 @@ class PagesDomainRemovalCronWorker include ApplicationWorker include CronjobQueue + feature_category :pages + def perform PagesDomain.for_removal.find_each do |domain| domain.destroy! diff --git a/app/workers/pages_domain_ssl_renewal_cron_worker.rb b/app/workers/pages_domain_ssl_renewal_cron_worker.rb index e5dde07a648..f7a243e9b3b 100644 --- a/app/workers/pages_domain_ssl_renewal_cron_worker.rb +++ b/app/workers/pages_domain_ssl_renewal_cron_worker.rb @@ -4,6 +4,8 @@ class PagesDomainSslRenewalCronWorker include ApplicationWorker include CronjobQueue + feature_category :pages + def perform return unless ::Gitlab::LetsEncrypt.enabled? diff --git a/app/workers/pages_domain_ssl_renewal_worker.rb b/app/workers/pages_domain_ssl_renewal_worker.rb index 87fd8059946..4db7d22ef7e 100644 --- a/app/workers/pages_domain_ssl_renewal_worker.rb +++ b/app/workers/pages_domain_ssl_renewal_worker.rb @@ -3,6 +3,8 @@ class PagesDomainSslRenewalWorker include ApplicationWorker + feature_category :pages + def perform(domain_id) domain = PagesDomain.find_by_id(domain_id) return unless domain&.enabled? diff --git a/app/workers/pages_domain_verification_cron_worker.rb b/app/workers/pages_domain_verification_cron_worker.rb index 60703c83e9e..bb3a7fede9a 100644 --- a/app/workers/pages_domain_verification_cron_worker.rb +++ b/app/workers/pages_domain_verification_cron_worker.rb @@ -4,6 +4,8 @@ class PagesDomainVerificationCronWorker include ApplicationWorker include CronjobQueue + feature_category :pages + def perform return if Gitlab::Database.read_only? diff --git a/app/workers/pages_domain_verification_worker.rb b/app/workers/pages_domain_verification_worker.rb index 7817b2ee5fc..b0888036498 100644 --- a/app/workers/pages_domain_verification_worker.rb +++ b/app/workers/pages_domain_verification_worker.rb @@ -3,6 +3,8 @@ class PagesDomainVerificationWorker include ApplicationWorker + feature_category :pages + # rubocop: disable CodeReuse/ActiveRecord def perform(domain_id) return if Gitlab::Database.read_only? diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb index fa0dfa2ff4b..484d9053849 100644 --- a/app/workers/pages_worker.rb +++ b/app/workers/pages_worker.rb @@ -4,6 +4,7 @@ class PagesWorker include ApplicationWorker sidekiq_options retry: 3 + feature_category :pages def perform(action, *arg) send(action, *arg) # rubocop:disable GitlabSecurity/PublicSend diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb index 96524d93f8d..96f3725dbbe 100644 --- a/app/workers/pipeline_process_worker.rb +++ b/app/workers/pipeline_process_worker.rb @@ -5,6 +5,7 @@ class PipelineProcessWorker include PipelineQueue queue_namespace :pipeline_processing + feature_category :continuous_integration # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id, build_ids = nil) diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb index 9410fd1a786..f500ea08353 100644 --- a/app/workers/pipeline_schedule_worker.rb +++ b/app/workers/pipeline_schedule_worker.rb @@ -4,6 +4,8 @@ class PipelineScheduleWorker include ApplicationWorker include CronjobQueue + feature_category :continuous_integration + def perform Ci::PipelineSchedule.runnable_schedules.preloaded.find_in_batches do |schedules| schedules.each do |schedule| diff --git a/app/workers/plugin_worker.rb b/app/workers/plugin_worker.rb index c293e28be4a..e708031abdf 100644 --- a/app/workers/plugin_worker.rb +++ b/app/workers/plugin_worker.rb @@ -4,6 +4,7 @@ class PluginWorker include ApplicationWorker sidekiq_options retry: false + feature_category :integrations def perform(file_name, data) success, message = Gitlab::Plugin.execute(file_name, data) diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 843ba3e980e..a3bc7e5b9c9 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -3,7 +3,7 @@ class PostReceive include ApplicationWorker - PIPELINE_PROCESS_LIMIT = 4 + feature_category :source_code_management def perform(gl_repository, identifier, changes, push_options = {}) project, repo_type = Gitlab::GlRepository.parse(gl_repository) @@ -37,53 +37,31 @@ class PostReceive end def process_project_changes(post_received) - changes = [] - refs = Set.new user = identify_user(post_received) + return false unless user + project = post_received.project + push_options = post_received.push_options + changes = post_received.changes + # We only need to expire certain caches once per push - expire_caches(post_received) - - post_received.enum_for(:changes_refs).with_index do |(oldrev, newrev, ref), index| - service_klass = - if Gitlab::Git.tag_ref?(ref) - Git::TagPushService - elsif Gitlab::Git.branch_ref?(ref) - Git::BranchPushService - end - - if service_klass - service_klass.new( - post_received.project, - user, - oldrev: oldrev, - newrev: newrev, - ref: ref, - push_options: post_received.push_options, - create_pipelines: index < PIPELINE_PROCESS_LIMIT || Feature.enabled?(:git_push_create_all_pipelines, post_received.project) - ).execute - end - - changes << Gitlab::DataBuilder::Repository.single_change(oldrev, newrev, ref) - refs << ref - end + expire_caches(post_received, post_received.project.repository) + enqueue_repository_cache_update(post_received) - after_project_changes_hooks(post_received, user, refs.to_a, changes) + process_ref_changes(project, user, push_options: push_options, changes: changes) + update_remote_mirrors(post_received) + after_project_changes_hooks(project, user, changes.refs, changes.repository_data) end - # Expire the project, branch, and tag cache once per push. Schedule an - # update for the repository size and commit count if necessary. - def expire_caches(post_received) - project = post_received.project - - project.repository.expire_status_cache if project.empty_repo? - project.repository.expire_branches_cache if post_received.includes_branches? - project.repository.expire_caches_for_tags if post_received.includes_tags? - - enqueue_repository_cache_update(post_received) + # Expire the repository status, branch, and tag cache once per push. + def expire_caches(post_received, repository) + repository.expire_status_cache if repository.empty? + repository.expire_branches_cache if post_received.includes_branches? + repository.expire_caches_for_tags if post_received.includes_tags? end + # Schedule an update for the repository size and commit count if necessary. def enqueue_repository_cache_update(post_received) stats_to_invalidate = [:repository_size] stats_to_invalidate << :commit_count if post_received.includes_default_branch? @@ -96,9 +74,25 @@ class PostReceive ) end - def after_project_changes_hooks(post_received, user, refs, changes) - hook_data = Gitlab::DataBuilder::Repository.update(post_received.project, user, changes, refs) - SystemHooksService.new.execute_hooks(hook_data, :repository_update_hooks) + def process_ref_changes(project, user, params = {}) + return unless params[:changes].any? + + Git::ProcessRefChangesService.new(project, user, params).execute + end + + def update_remote_mirrors(post_received) + return unless post_received.includes_branches? || post_received.includes_tags? + + project = post_received.project + return unless project.has_remote_mirror? + + project.mark_stuck_remote_mirrors_as_failed! + project.update_remote_mirrors + end + + def after_project_changes_hooks(project, user, refs, changes) + repository_update_hook_data = Gitlab::DataBuilder::Repository.update(project, user, changes, refs) + SystemHooksService.new.execute_hooks(repository_update_hook_data, :repository_update_hooks) Gitlab::UsageDataCounters::SourceCodeCounter.count(:pushes) end @@ -110,7 +104,10 @@ class PostReceive user = identify_user(post_received) return false unless user - ::Git::WikiPushService.new(post_received.project, user, changes: post_received.enum_for(:changes_refs)).execute + # We only need to expire certain caches once per push + expire_caches(post_received, post_received.project.wiki.repository) + + ::Git::WikiPushService.new(post_received.project, user, changes: post_received.changes).execute end def log(message) diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index f6ebe4ab006..1e4561fc6ea 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -10,6 +10,8 @@ class ProcessCommitWorker include ApplicationWorker + feature_category :source_code_management + # project_id - The ID of the project this commit belongs to. # user_id - The ID of the user that pushed the commit. # commit_hash - Hash containing commit details to use for constructing a diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb index e3f1f61991c..57a01c0dd8e 100644 --- a/app/workers/project_cache_worker.rb +++ b/app/workers/project_cache_worker.rb @@ -5,6 +5,8 @@ class ProjectCacheWorker include ApplicationWorker LEASE_TIMEOUT = 15.minutes.to_i + feature_category :source_code_management + # project_id - The ID of the project for which to flush the cache. # files - An Array containing extra types of files to refresh such as # `:readme` to flush the README and `:changelog` to flush the diff --git a/app/workers/project_daily_statistics_worker.rb b/app/workers/project_daily_statistics_worker.rb index 101f5c28459..19c2fd67763 100644 --- a/app/workers/project_daily_statistics_worker.rb +++ b/app/workers/project_daily_statistics_worker.rb @@ -3,6 +3,8 @@ class ProjectDailyStatisticsWorker include ApplicationWorker + feature_category :source_code_management + def perform(project_id) project = Project.find_by_id(project_id) diff --git a/app/workers/project_destroy_worker.rb b/app/workers/project_destroy_worker.rb index 4447e867240..1d20837faa2 100644 --- a/app/workers/project_destroy_worker.rb +++ b/app/workers/project_destroy_worker.rb @@ -4,6 +4,8 @@ class ProjectDestroyWorker include ApplicationWorker include ExceptionBacktrace + feature_category :source_code_management + def perform(project_id, user_id, params) project = Project.find(project_id) user = User.find(user_id) diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb index ed9da39c7c3..bbcf3b72718 100644 --- a/app/workers/project_export_worker.rb +++ b/app/workers/project_export_worker.rb @@ -5,6 +5,7 @@ class ProjectExportWorker include ExceptionBacktrace sidekiq_options retry: 3 + feature_category :source_code_management def perform(current_user_id, project_id, after_export_strategy = {}, params = {}) current_user = User.find(current_user_id) diff --git a/app/workers/project_service_worker.rb b/app/workers/project_service_worker.rb index 25567cec08b..8041404fc71 100644 --- a/app/workers/project_service_worker.rb +++ b/app/workers/project_service_worker.rb @@ -4,6 +4,7 @@ class ProjectServiceWorker include ApplicationWorker sidekiq_options dead: false + feature_category :integrations def perform(hook_id, data) data = data.with_indifferent_access diff --git a/app/workers/propagate_service_template_worker.rb b/app/workers/propagate_service_template_worker.rb index 3ccd7615697..73a2b453207 100644 --- a/app/workers/propagate_service_template_worker.rb +++ b/app/workers/propagate_service_template_worker.rb @@ -4,6 +4,8 @@ class PropagateServiceTemplateWorker include ApplicationWorker + feature_category :source_code_management + LEASE_TIMEOUT = 4.hours.to_i # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/workers/prune_old_events_worker.rb b/app/workers/prune_old_events_worker.rb index dc4b7670131..f421e8dbf59 100644 --- a/app/workers/prune_old_events_worker.rb +++ b/app/workers/prune_old_events_worker.rb @@ -4,15 +4,17 @@ class PruneOldEventsWorker include ApplicationWorker include CronjobQueue + feature_category_not_owned! + # rubocop: disable CodeReuse/ActiveRecord def perform - # Contribution calendar shows maximum 12 months of events, we retain 2 years for data integrity. + # Contribution calendar shows maximum 12 months of events, we retain 3 years for data integrity. # Double nested query is used because MySQL doesn't allow DELETE subqueries on the same table. Event.unscoped.where( '(id IN (SELECT id FROM (?) ids_to_remove))', Event.unscoped.where( 'created_at < ?', - (2.years + 1.day).ago) + (3.years + 1.day).ago) .select(:id) .limit(10_000)) .delete_all diff --git a/app/workers/prune_web_hook_logs_worker.rb b/app/workers/prune_web_hook_logs_worker.rb index 38054069f4e..8e48b45fc34 100644 --- a/app/workers/prune_web_hook_logs_worker.rb +++ b/app/workers/prune_web_hook_logs_worker.rb @@ -6,6 +6,8 @@ class PruneWebHookLogsWorker include ApplicationWorker include CronjobQueue + feature_category :integrations + # The maximum number of rows to remove in a single job. DELETE_LIMIT = 50_000 diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb index b30864db802..af4a3def062 100644 --- a/app/workers/reactive_caching_worker.rb +++ b/app/workers/reactive_caching_worker.rb @@ -3,6 +3,8 @@ class ReactiveCachingWorker include ApplicationWorker + feature_category_not_owned! + def perform(class_name, id, *args) klass = begin class_name.constantize diff --git a/app/workers/rebase_worker.rb b/app/workers/rebase_worker.rb index 8d06adcd993..7343226fdcd 100644 --- a/app/workers/rebase_worker.rb +++ b/app/workers/rebase_worker.rb @@ -5,6 +5,8 @@ class RebaseWorker include ApplicationWorker + feature_category :source_code_management + def perform(merge_request_id, current_user_id) current_user = User.find(current_user_id) merge_request = MergeRequest.find(merge_request_id) diff --git a/app/workers/remote_mirror_notification_worker.rb b/app/workers/remote_mirror_notification_worker.rb index 368abfeda99..8bc19230caf 100644 --- a/app/workers/remote_mirror_notification_worker.rb +++ b/app/workers/remote_mirror_notification_worker.rb @@ -3,6 +3,8 @@ class RemoteMirrorNotificationWorker include ApplicationWorker + feature_category :source_code_management + def perform(remote_mirror_id) remote_mirror = RemoteMirror.find_by_id(remote_mirror_id) diff --git a/app/workers/remove_expired_group_links_worker.rb b/app/workers/remove_expired_group_links_worker.rb index 25128caf72f..147b412b772 100644 --- a/app/workers/remove_expired_group_links_worker.rb +++ b/app/workers/remove_expired_group_links_worker.rb @@ -4,6 +4,8 @@ class RemoveExpiredGroupLinksWorker include ApplicationWorker include CronjobQueue + feature_category :authentication_and_authorization + def perform ProjectGroupLink.expired.destroy_all # rubocop: disable DestroyAll end diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb index 3497a1f9280..75f06fd9f6b 100644 --- a/app/workers/remove_expired_members_worker.rb +++ b/app/workers/remove_expired_members_worker.rb @@ -4,6 +4,8 @@ class RemoveExpiredMembersWorker include ApplicationWorker include CronjobQueue + feature_category :authentication_and_authorization + def perform Member.expired.find_each do |member| Members::DestroyService.new.execute(member, skip_authorization: true) diff --git a/app/workers/remove_unreferenced_lfs_objects_worker.rb b/app/workers/remove_unreferenced_lfs_objects_worker.rb index 95e7a9f537f..7f2c23f4685 100644 --- a/app/workers/remove_unreferenced_lfs_objects_worker.rb +++ b/app/workers/remove_unreferenced_lfs_objects_worker.rb @@ -4,6 +4,8 @@ class RemoveUnreferencedLfsObjectsWorker include ApplicationWorker include CronjobQueue + feature_category :source_code_management + def perform LfsObject.destroy_unreferenced end diff --git a/app/workers/repository_archive_cache_worker.rb b/app/workers/repository_archive_cache_worker.rb index c1dff8ced90..ebc83c1b17a 100644 --- a/app/workers/repository_archive_cache_worker.rb +++ b/app/workers/repository_archive_cache_worker.rb @@ -4,6 +4,8 @@ class RepositoryArchiveCacheWorker include ApplicationWorker include CronjobQueue + feature_category :source_code_management + def perform RepositoryArchiveCleanUpService.new.execute end diff --git a/app/workers/repository_check/dispatch_worker.rb b/app/workers/repository_check/dispatch_worker.rb index 0a7d9a14c6a..d2bd5f9b967 100644 --- a/app/workers/repository_check/dispatch_worker.rb +++ b/app/workers/repository_check/dispatch_worker.rb @@ -7,6 +7,8 @@ module RepositoryCheck include ::EachShardWorker include ExclusiveLeaseGuard + feature_category :source_code_management + LEASE_TIMEOUT = 1.hour def perform diff --git a/app/workers/repository_cleanup_worker.rb b/app/workers/repository_cleanup_worker.rb index aa26c173a72..dd2cbd42d1f 100644 --- a/app/workers/repository_cleanup_worker.rb +++ b/app/workers/repository_cleanup_worker.rb @@ -4,6 +4,7 @@ class RepositoryCleanupWorker include ApplicationWorker sidekiq_options retry: 3 + feature_category :source_code_management sidekiq_retries_exhausted do |msg, err| next if err.is_a?(ActiveRecord::RecordNotFound) diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index 35e9c58eb13..0adf745c7ac 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -6,6 +6,8 @@ class RepositoryForkWorker include ProjectStartImport include ProjectImportOptions + feature_category :source_code_management + def perform(*args) target_project_id = args.shift target_project = Project.find(target_project_id) diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index 5be439ecbc5..bc2d0366fdd 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -6,6 +6,12 @@ class RepositoryImportWorker include ProjectStartImport include ProjectImportOptions + feature_category :importers + + # technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991 + sidekiq_options memory_killer_memory_growth_kb: ENV.fetch('MEMORY_KILLER_REPOSITORY_IMPORT_WORKER_MEMORY_GROWTH_KB', 50).to_i + sidekiq_options memory_killer_max_memory_growth_kb: ENV.fetch('MEMORY_KILLER_REPOSITORY_IMPORT_WORKER_MAX_MEMORY_GROWTH_KB', 300_000).to_i + def perform(project_id) @project = Project.find(project_id) diff --git a/app/workers/repository_remove_remote_worker.rb b/app/workers/repository_remove_remote_worker.rb index a85e9fa9394..3e55ebc77ed 100644 --- a/app/workers/repository_remove_remote_worker.rb +++ b/app/workers/repository_remove_remote_worker.rb @@ -4,6 +4,8 @@ class RepositoryRemoveRemoteWorker include ApplicationWorker include ExclusiveLeaseGuard + feature_category :source_code_management + LEASE_TIMEOUT = 1.hour attr_reader :project, :remote_name diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb index d13c7641eb3..b4d96546fa4 100644 --- a/app/workers/repository_update_remote_mirror_worker.rb +++ b/app/workers/repository_update_remote_mirror_worker.rb @@ -7,6 +7,7 @@ class RepositoryUpdateRemoteMirrorWorker include Gitlab::ExclusiveLeaseHelpers sidekiq_options retry: 3, dead: false + feature_category :source_code_management LOCK_WAIT_TIME = 30.seconds MAX_TRIES = 3 diff --git a/app/workers/requests_profiles_worker.rb b/app/workers/requests_profiles_worker.rb index ae022d43e29..6ab020afb10 100644 --- a/app/workers/requests_profiles_worker.rb +++ b/app/workers/requests_profiles_worker.rb @@ -4,6 +4,8 @@ class RequestsProfilesWorker include ApplicationWorker include CronjobQueue + feature_category :source_code_management + def perform Gitlab::RequestProfiler.remove_all_profiles end diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index 659f8b80397..853f774875a 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -5,6 +5,7 @@ class RunPipelineScheduleWorker include PipelineQueue queue_namespace :pipeline_creation + feature_category :continuous_integration # rubocop: disable CodeReuse/ActiveRecord def perform(schedule_id, user_id) diff --git a/app/workers/schedule_migrate_external_diffs_worker.rb b/app/workers/schedule_migrate_external_diffs_worker.rb index 70910f7ca04..8abb5922b54 100644 --- a/app/workers/schedule_migrate_external_diffs_worker.rb +++ b/app/workers/schedule_migrate_external_diffs_worker.rb @@ -1,10 +1,12 @@ -# frozen_string_literal: false +# frozen_string_literal: true class ScheduleMigrateExternalDiffsWorker include ApplicationWorker include CronjobQueue include Gitlab::ExclusiveLeaseHelpers + feature_category :source_code_management + def perform in_lock(self.class.name.underscore, ttl: 2.hours, retries: 0) do MergeRequests::MigrateExternalDiffsService.enqueue! diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb index 7e002d8822c..971edb1f14f 100644 --- a/app/workers/stuck_ci_jobs_worker.rb +++ b/app/workers/stuck_ci_jobs_worker.rb @@ -4,6 +4,8 @@ class StuckCiJobsWorker include ApplicationWorker include CronjobQueue + feature_category :continuous_integration + EXCLUSIVE_LEASE_KEY = 'stuck_ci_builds_worker_lease' BUILD_RUNNING_OUTDATED_TIMEOUT = 1.hour diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb index a9ff5b22b25..4993cd1220c 100644 --- a/app/workers/stuck_import_jobs_worker.rb +++ b/app/workers/stuck_import_jobs_worker.rb @@ -4,6 +4,8 @@ class StuckImportJobsWorker include ApplicationWorker include CronjobQueue + feature_category :importers + IMPORT_JOBS_EXPIRATION = 15.hours.to_i def perform diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb index e840ae47421..024863ab530 100644 --- a/app/workers/stuck_merge_jobs_worker.rb +++ b/app/workers/stuck_merge_jobs_worker.rb @@ -4,6 +4,8 @@ class StuckMergeJobsWorker include ApplicationWorker include CronjobQueue + feature_category :source_code_management + def self.logger Rails.logger # rubocop:disable Gitlab/RailsLogger end @@ -31,7 +33,7 @@ class StuckMergeJobsWorker def apply_current_state!(completed_jids, completed_ids) merge_requests = MergeRequest.where(id: completed_ids) - merge_requests.where.not(merge_commit_sha: nil).update_all(state: :merged) + merge_requests.where.not(merge_commit_sha: nil).update_all(state_id: MergeRequest.available_states[:merged]) merge_requests_to_reopen = merge_requests.where(merge_commit_sha: nil) diff --git a/app/workers/system_hook_push_worker.rb b/app/workers/system_hook_push_worker.rb index 15e369ebcfb..fc6237f359a 100644 --- a/app/workers/system_hook_push_worker.rb +++ b/app/workers/system_hook_push_worker.rb @@ -3,6 +3,8 @@ class SystemHookPushWorker include ApplicationWorker + feature_category :source_code_management + def perform(push_data, hook_id) SystemHooksService.new.execute_hooks(push_data, hook_id) end diff --git a/app/workers/trending_projects_worker.rb b/app/workers/trending_projects_worker.rb index 55b599ba38f..4c8ee1ee425 100644 --- a/app/workers/trending_projects_worker.rb +++ b/app/workers/trending_projects_worker.rb @@ -4,6 +4,8 @@ class TrendingProjectsWorker include ApplicationWorker include CronjobQueue + feature_category :source_code_management + def perform Rails.logger.info('Refreshing trending projects') # rubocop:disable Gitlab/RailsLogger diff --git a/app/workers/update_external_pull_requests_worker.rb b/app/workers/update_external_pull_requests_worker.rb index c5acfa82ada..8b0952528fa 100644 --- a/app/workers/update_external_pull_requests_worker.rb +++ b/app/workers/update_external_pull_requests_worker.rb @@ -3,6 +3,8 @@ class UpdateExternalPullRequestsWorker include ApplicationWorker + feature_category :source_code_management + def perform(project_id, user_id, ref) project = Project.find_by_id(project_id) return unless project diff --git a/app/workers/update_head_pipeline_for_merge_request_worker.rb b/app/workers/update_head_pipeline_for_merge_request_worker.rb index 4ec2b9d8fbe..77859abfea4 100644 --- a/app/workers/update_head_pipeline_for_merge_request_worker.rb +++ b/app/workers/update_head_pipeline_for_merge_request_worker.rb @@ -5,6 +5,7 @@ class UpdateHeadPipelineForMergeRequestWorker include PipelineQueue queue_namespace :pipeline_processing + feature_category :continuous_integration def perform(merge_request_id) MergeRequest.find_by_id(merge_request_id).try do |merge_request| diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb index 6c0e472e05a..8e1703cdd0b 100644 --- a/app/workers/update_merge_requests_worker.rb +++ b/app/workers/update_merge_requests_worker.rb @@ -3,6 +3,8 @@ class UpdateMergeRequestsWorker include ApplicationWorker + feature_category :source_code_management + LOG_TIME_THRESHOLD = 90 # seconds # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/workers/update_project_statistics_worker.rb b/app/workers/update_project_statistics_worker.rb index 9a29cc12707..e36cebf6f4f 100644 --- a/app/workers/update_project_statistics_worker.rb +++ b/app/workers/update_project_statistics_worker.rb @@ -1,10 +1,11 @@ - # frozen_string_literal: true # Worker for updating project statistics. class UpdateProjectStatisticsWorker include ApplicationWorker + feature_category :source_code_management + # project_id - The ID of the project for which to flush the cache. # statistics - An Array containing columns from ProjectStatistics to # refresh, if empty all columns will be refreshed diff --git a/app/workers/upload_checksum_worker.rb b/app/workers/upload_checksum_worker.rb index 834dcaa435d..d35367145b8 100644 --- a/app/workers/upload_checksum_worker.rb +++ b/app/workers/upload_checksum_worker.rb @@ -3,6 +3,8 @@ class UploadChecksumWorker include ApplicationWorker + feature_category :geo_replication + def perform(upload_id) upload = Upload.find(upload_id) upload.calculate_checksum! diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb index 09219a24a16..fd7ca93683e 100644 --- a/app/workers/web_hook_worker.rb +++ b/app/workers/web_hook_worker.rb @@ -3,6 +3,7 @@ class WebHookWorker include ApplicationWorker + feature_category :integrations sidekiq_options retry: 4, dead: false def perform(hook_id, data, hook_name) |