diff options
Diffstat (limited to 'app')
1886 files changed, 29565 insertions, 13635 deletions
diff --git a/app/assets/images/favicon-yellow.png b/app/assets/images/favicon-yellow.png Binary files differindex 2d5289818b4..a80827808fc 100644 --- a/app/assets/images/favicon-yellow.png +++ b/app/assets/images/favicon-yellow.png diff --git a/app/assets/images/select2-spinner.gif b/app/assets/images/select2-spinner.gif Binary files differnew file mode 100644 index 00000000000..5b33f7e54f4 --- /dev/null +++ b/app/assets/images/select2-spinner.gif diff --git a/app/assets/images/select2.png b/app/assets/images/select2.png Binary files differnew file mode 100644 index 00000000000..1d804ffb996 --- /dev/null +++ b/app/assets/images/select2.png diff --git a/app/assets/images/select2x2.png b/app/assets/images/select2x2.png Binary files differnew file mode 100644 index 00000000000..4bdd5c961d4 --- /dev/null +++ b/app/assets/images/select2x2.png diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 85eb08cc97d..7cebb88f3a4 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import _ from 'underscore'; import axios from './lib/utils/axios_utils'; +import { joinPaths } from './lib/utils/url_utility'; const Api = { groupsPath: '/api/:version/groups.json', @@ -11,7 +12,8 @@ const Api = { groupProjectsPath: '/api/:version/groups/:id/projects.json', projectsPath: '/api/:version/projects.json', projectPath: '/api/:version/projects/:id', - projectLabelsPath: '/:namespace_path/:project_path/labels', + projectLabelsPath: '/:namespace_path/:project_path/-/labels', + projectMergeRequestsPath: '/api/:version/projects/:id/merge_requests', projectMergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions', @@ -111,6 +113,22 @@ const Api = { return axios.get(url); }, + /** + * Get all Merge Requests for a project, eventually filtering based on + * supplied parameters + * @param projectPath + * @param params + * @returns {Promise} + */ + projectMergeRequests(projectPath, params = {}) { + const url = Api.buildUrl(Api.projectMergeRequestsPath).replace( + ':id', + encodeURIComponent(projectPath), + ); + + return axios.get(url, { params }); + }, + // Return Merge Request for project projectMergeRequest(projectPath, mergeRequestId, params = {}) { const url = Api.buildUrl(Api.projectMergeRequestPath) @@ -322,11 +340,7 @@ const Api = { }, buildUrl(url) { - let urlRoot = ''; - if (gon.relative_url_root != null) { - urlRoot = gon.relative_url_root; - } - return urlRoot + url.replace(':version', gon.api_version); + return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version)); }, }; diff --git a/app/assets/javascripts/group_avatar.js b/app/assets/javascripts/avatar_picker.js index dcda625f587..d38e0b4abaa 100644 --- a/app/assets/javascripts/group_avatar.js +++ b/app/assets/javascripts/avatar_picker.js @@ -1,11 +1,12 @@ import $ from 'jquery'; -export default function groupAvatar() { - $('.js-choose-group-avatar-button').on('click', function onClickGroupAvatar() { +export default function initAvatarPicker() { + $('.js-choose-avatar-button').on('click', function onClickAvatar() { const form = $(this).closest('form'); - return form.find('.js-group-avatar-input').click(); + return form.find('.js-avatar-input').click(); }); - $('.js-group-avatar-input').on('change', function onChangeAvatarInput() { + + $('.js-avatar-input').on('change', function onChangeAvatarInput() { const form = $(this).closest('form'); const filename = $(this) .val() diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 73ce3e760ab..743f11625bc 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -8,6 +8,7 @@ import { updateTooltipTitle } from './lib/utils/common_utils'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; import flash from './flash'; import axios from './lib/utils/axios_utils'; +import bp from './breakpoints'; const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd'; const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd'; @@ -264,7 +265,10 @@ export class AwardsHandler { const css = { top: `${$addBtn.offset().top + $addBtn.outerHeight()}px`, }; - if (position === 'right') { + // for xs screen we position the element on center + if (bp.getBreakpointSize() === 'xs') { + css.left = '5%'; + } else if (position === 'right') { css.left = `${$addBtn.offset().left - $menu.outerWidth() + 20}px`; $menu.addClass('is-aligned-right'); } else { diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js new file mode 100644 index 00000000000..3bbbaa86b51 --- /dev/null +++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js @@ -0,0 +1,15 @@ +import { sprintf, __ } from '~/locale'; + +export default { + computed: { + resolveButtonTitle() { + let title = __('Mark comment as resolved'); + + if (this.resolvedBy) { + title = sprintf(__('Resolved by %{name}'), { name: this.resolvedBy.name }); + } + + return title; + }, + }, +}; diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js index 9a33a060c76..c3541e62568 100644 --- a/app/assets/javascripts/behaviors/copy_to_clipboard.js +++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import Clipboard from 'clipboard'; +import { sprintf, __ } from '~/locale'; function showTooltip(target, title) { const $target = $(target); @@ -16,7 +17,7 @@ function showTooltip(target, title) { } function genericSuccess(e) { - showTooltip(e.trigger, 'Copied'); + showTooltip(e.trigger, __('Copied')); // Clear the selection and blur the trigger so it loses its border e.clearSelection(); $(e.trigger).blur(); @@ -33,7 +34,7 @@ function genericError(e) { } else { key = 'Ctrl'; } - showTooltip(e.trigger, `Press ${key}-C to copy`); + showTooltip(e.trigger, sprintf(__(`Press %{key}-C to copy`), { key })); } export default function initCopyToClipboard() { diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js index 9482a9f166d..318b7f77c7b 100644 --- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js @@ -10,10 +10,10 @@ export class CopyAsGFM { const isIOS = /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent); if (isIOS) return; - $(document).on('copy', '.md, .wiki', e => { + $(document).on('copy', '.md', e => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformGFMSelection); }); - $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', e => { + $(document).on('copy', 'pre.code.highlight, table.code td.line_content', e => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformCodeSelection); }); $(document).on('paste', '.js-gfm-input', CopyAsGFM.pasteGFM); @@ -99,7 +99,7 @@ export class CopyAsGFM { } static transformGFMSelection(documentFragment) { - const gfmElements = documentFragment.querySelectorAll('.md, .wiki'); + const gfmElements = documentFragment.querySelectorAll('.md'); switch (gfmElements.length) { case 0: { return documentFragment; @@ -173,7 +173,9 @@ export class CopyAsGFM { wrapEl.appendChild(node.cloneNode(true)); const doc = DOMParser.fromSchema(schema.default).parse(wrapEl); - const res = markdownSerializer.default.serialize(doc); + const res = markdownSerializer.default.serialize(doc, { + tightLists: true, + }); return res; }) .catch(() => {}); diff --git a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js index 55c68139ded..b7200150925 100644 --- a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js +++ b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { parseBoolean } from '~/lib/utils/common_utils'; -import GfmAutoComplete from '~/gfm_auto_complete'; +import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; export default function initGFMInput() { $('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => { diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js index 20c7fa8a9ab..9a2e2c03213 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js @@ -1,6 +1,7 @@ /* eslint-disable class-methods-use-this */ import { Node } from 'tiptap'; +import { __ } from '~/locale'; // Transforms generated HTML back to GFM for Banzai::Filter::TableOfContentsFilter export default class TableOfContents extends Node { @@ -22,7 +23,7 @@ export default class TableOfContents extends Node { priority: 51, }, ], - toDOM: () => ['p', { class: 'table-of-contents' }, 'Table of Contents'], + toDOM: () => ['p', { class: 'table-of-contents' }, __('Table of Contents')], }; } diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index fc9286d15e6..bfb073fdcdc 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -4,6 +4,7 @@ import renderMath from './render_math'; import renderMermaid from './render_mermaid'; import highlightCurrentUser from './highlight_current_user'; import initUserPopovers from '../../user_popovers'; +import initMRPopovers from '../../mr_popover'; // Render GitLab flavoured Markdown // @@ -15,6 +16,7 @@ $.fn.renderGFM = function renderGFM() { renderMermaid(this.find('.js-render-mermaid')); highlightCurrentUser(this.find('.gfm-project_member').get()); initUserPopovers(this.find('.gfm-project_member').get()); + initMRPopovers(this.find('.gfm-merge_request').get()); return this; }; diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index 35380ca49fb..d0b7f3ff7a2 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -1,4 +1,5 @@ import flash from '~/flash'; +import { sprintf, __ } from '../../locale'; // Renders diagrams and flowcharts from text using Mermaid in any element with the // `js-render-mermaid` class. @@ -14,6 +15,9 @@ import flash from '~/flash'; // </pre> // +// This is an arbitrary number; Can be iterated upon when suitable. +const MAX_CHAR_LIMIT = 5000; + export default function renderMermaid($els) { if (!$els.length) return; @@ -34,6 +38,21 @@ export default function renderMermaid($els) { $els.each((i, el) => { const source = el.textContent; + /** + * Restrict the rendering to a certain amount of character to + * prevent mermaidjs from hanging up the entire thread and + * causing a DoS. + */ + if (source && source.length > MAX_CHAR_LIMIT) { + el.textContent = sprintf( + __( + 'Cannot render the image. Maximum character count (%{charLimit}) has been exceeded.', + ), + { charLimit: MAX_CHAR_LIMIT }, + ); + return; + } + // Remove any extra spans added by the backend syntax highlighting. Object.assign(el, { textContent: source }); diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index 7adccbb062f..35874140bf9 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -22,7 +22,7 @@ function MarkdownPreview() {} // Minimum number of users referenced before triggering a warning MarkdownPreview.prototype.referenceThreshold = 10; -MarkdownPreview.prototype.emptyMessage = 'Nothing to preview.'; +MarkdownPreview.prototype.emptyMessage = __('Nothing to preview.'); MarkdownPreview.prototype.ajaxCache = {}; @@ -40,7 +40,7 @@ MarkdownPreview.prototype.showPreview = function($form) { preview.text(this.emptyMessage); this.hideReferencedUsers($form); } else { - preview.addClass('md-preview-loading').text('Loading...'); + preview.addClass('md-preview-loading').text(__('Loading...')); this.fetchMarkdownPreview( mdText, url, diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js index c1ea67f9293..530ab0bd4d9 100644 --- a/app/assets/javascripts/behaviors/quick_submit.js +++ b/app/assets/javascripts/behaviors/quick_submit.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import '../commons/bootstrap'; import { isInIssuePage } from '../lib/utils/common_utils'; +import { __ } from '~/locale'; // Quick Submit behavior // @@ -65,7 +66,9 @@ $(document).on( } const $this = $(this); - const title = isMac() ? 'You can also press ⌘-Enter' : 'You can also press Ctrl-Enter'; + const title = isMac() + ? __('You can also press ⌘-Enter') + : __('You can also press Ctrl-Enter'); $this.tooltip({ container: 'body', diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 680f2031409..c8eb96a625c 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -37,7 +37,7 @@ export default class ShortcutsIssuable extends Shortcuts { } // Sanity check: Make sure the selected text comes from a discussion : it can either contain a message... - let foundMessage = !!documentFragment.querySelector('.md, .wiki'); + let foundMessage = Boolean(documentFragment.querySelector('.md')); // ... Or come from a message if (!foundMessage) { @@ -46,7 +46,7 @@ export default class ShortcutsIssuable extends Shortcuts { let node = e; do { // Text nodes don't define the `matches` method - if (node.matches && node.matches('.md, .wiki')) { + if (node.matches && node.matches('.md')) { foundMessage = true; } node = node.parentNode; diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js index b88e69a07bf..2e537d8c000 100644 --- a/app/assets/javascripts/blob/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq_viewer.js @@ -1,8 +1,9 @@ import Flash from '../flash'; import BalsamiqViewer from './balsamiq/balsamiq_viewer'; +import { __ } from '~/locale'; function onError() { - const flash = new Flash('Balsamiq file could not be loaded.'); + const flash = new Flash(__('Balsamiq file could not be loaded.')); return flash; } diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js index cd3251ad1ca..9010cd0c3c1 100644 --- a/app/assets/javascripts/blob/blob_file_dropzone.js +++ b/app/assets/javascripts/blob/blob_file_dropzone.js @@ -5,6 +5,7 @@ import Dropzone from 'dropzone'; import { visitUrl } from '../lib/utils/url_utility'; import { HIDDEN_CLASS } from '../lib/utils/constants'; import csrf from '../lib/utils/csrf'; +import { sprintf, __ } from '~/locale'; Dropzone.autoDiscover = false; @@ -73,7 +74,7 @@ export default class BlobFileDropzone { .html(errorMessage) .text(); $('.dropzone-alerts') - .html(`Error uploading file: "${stripped}"`) + .html(sprintf(__('Error uploading file: %{stripped}'), { stripped })) .show(); this.removeFile(file); }, @@ -84,7 +85,7 @@ export default class BlobFileDropzone { e.stopPropagation(); if (dropzone[0].dropzone.getQueuedFiles().length === 0) { // eslint-disable-next-line no-alert - alert('Please select a file'); + alert(__('Please select a file')); return false; } toggleLoading(submitButton, submitButtonLoadingIcon, true); diff --git a/app/assets/javascripts/blob/sketch/index.js b/app/assets/javascripts/blob/sketch/index.js index 57c1baa9886..dbff03dc734 100644 --- a/app/assets/javascripts/blob/sketch/index.js +++ b/app/assets/javascripts/blob/sketch/index.js @@ -1,5 +1,6 @@ import JSZip from 'jszip'; import JSZipUtils from 'jszip-utils'; +import { __ } from '~/locale'; export default class SketchLoader { constructor(container) { @@ -56,10 +57,10 @@ export default class SketchLoader { const errorMsg = document.createElement('p'); errorMsg.className = 'prepend-top-default append-bottom-default text-center'; - errorMsg.textContent = ` + errorMsg.textContent = __(` Cannot show preview. For previews on sketch files, they must have the file format introduced by Sketch version 43 and above. - `; + `); this.container.appendChild(errorMsg); this.removeLoadingIcon(); diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js index 4718b642617..659d57e6a6f 100644 --- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js +++ b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js @@ -1,11 +1,12 @@ import FileTemplateSelector from '../file_template_selector'; +import { __ } from '~/locale'; export default class DockerfileSelector extends FileTemplateSelector { constructor({ mediator }) { super(mediator); this.config = { key: 'dockerfile', - name: 'Dockerfile', + name: __('Dockerfile'), pattern: /(Dockerfile)/, type: 'dockerfiles', dropdown: '.js-dockerfile-selector', diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index d0359fc5fe9..d246a1f6064 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import Flash from '../../flash'; import { handleLocationHash } from '../../lib/utils/common_utils'; import axios from '../../lib/utils/axios_utils'; +import { __ } from '~/locale'; export default class BlobViewer { constructor() { @@ -26,7 +27,7 @@ export default class BlobViewer { promise .then(module => module.default(viewer)) .catch(error => { - Flash('Error loading file viewer.'); + Flash(__('Error loading file viewer.')); throw error; }); @@ -106,16 +107,19 @@ export default class BlobViewer { if (!this.copySourceBtn) return; if (this.simpleViewer.getAttribute('data-loaded')) { - this.copySourceBtn.setAttribute('title', 'Copy source to clipboard'); + this.copySourceBtn.setAttribute('title', __('Copy source to clipboard')); 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 source to load to copy it to the clipboard'), ); this.copySourceBtn.classList.add('disabled'); } else { - this.copySourceBtn.setAttribute('title', 'Switch to the source to copy it to the clipboard'); + this.copySourceBtn.setAttribute( + 'title', + __('Switch to the source to copy it to the clipboard'), + ); this.copySourceBtn.classList.add('disabled'); } @@ -158,7 +162,7 @@ export default class BlobViewer { this.toggleCopyButtonState(); }) - .catch(() => new Flash('Error loading viewer')); + .catch(() => new Flash(__('Error loading viewer'))); } static loadViewer(viewerParam) { diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index 5f64175362d..6aaf5bf7296 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -13,7 +13,7 @@ export default () => { if (editBlobForm.length) { const urlRoot = editBlobForm.data('relativeUrlRoot'); const assetsPath = editBlobForm.data('assetsPrefix'); - const filePath = editBlobForm.data('blobFilename'); + const filePath = `${editBlobForm.data('blobFilename')}`; const currentAction = $('.js-file-title').data('currentAction'); const projectId = editBlobForm.data('project-id'); const isMarkdown = editBlobForm.data('is-markdown'); diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js new file mode 100644 index 00000000000..3178bda93b8 --- /dev/null +++ b/app/assets/javascripts/boards/boards_util.js @@ -0,0 +1,7 @@ +export function getMilestone() { + return null; +} + +export default { + getMilestone, +}; diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index fb6e5291a61..45b9e57f9ab 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -54,7 +54,10 @@ export default Vue.extend({ return `${n__('%d issue', '%d issues', issuesSize)}`; }, isNewIssueShown() { - return this.list.type === 'backlog' || (!this.disabled && this.list.type !== 'closed'); + return ( + this.list.type === 'backlog' || + (!this.disabled && this.list.type !== 'closed' && this.list.type !== 'blank') + ); }, }, watch: { diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue index 667eea17d44..1cbd31729cd 100644 --- a/app/assets/javascripts/boards/components/board_blank_state.vue +++ b/app/assets/javascripts/boards/components/board_blank_state.vue @@ -1,6 +1,5 @@ <script> /* global ListLabel */ -import _ from 'underscore'; import Cookies from 'js-cookie'; import boardsStore from '../stores/boards_store'; @@ -29,8 +28,6 @@ export default { }); }); - boardsStore.state.lists = _.sortBy(boardsStore.state.lists, 'position'); - // Save the labels gl.boardService .generateDefaultLists() @@ -60,11 +57,15 @@ export default { </script> <template> - <div class="board-blank-state"> + <div class="board-blank-state p-3"> <p>Add the following default lists to your Issue Board with one click:</p> - <ul class="board-blank-state-list"> + <ul class="list-unstyled board-blank-state-list"> <li v-for="(label, index) in predefinedLabels" :key="index"> - <span :style="{ backgroundColor: label.color }" class="label-color"> </span> + <span + :style="{ backgroundColor: label.color }" + class="label-color position-relative d-inline-block rounded" + > + </span> {{ label.title }} </li> </ul> diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index f569322ab70..179148b6887 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -66,7 +66,7 @@ export default { eventHub.$emit('clearDetailIssue'); } else { eventHub.$emit('newDetailIssue', this.issue); - boardsStore.detail.list = this.list; + boardsStore.setListDetail(this.list); } } }, @@ -83,7 +83,7 @@ export default { }" :index="index" :data-issue-id="issue.id" - class="board-card" + class="board-card p-3 rounded" @mousedown="mouseDown" @mousemove="mouseMove" @mouseup="showIssue($event)" diff --git a/app/assets/javascripts/boards/components/board_delete.js b/app/assets/javascripts/boards/components/board_delete.js index a5f9d65e4d5..a06db359c94 100644 --- a/app/assets/javascripts/boards/components/board_delete.js +++ b/app/assets/javascripts/boards/components/board_delete.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import Vue from 'vue'; +import { __ } from '~/locale'; export default Vue.extend({ props: { @@ -13,7 +14,7 @@ export default Vue.extend({ $(this.$el).tooltip('hide'); // eslint-disable-next-line no-alert - if (window.confirm('Are you sure you want to delete this list?')) { + if (window.confirm(__('Are you sure you want to delete this list?'))) { this.list.destroy(); } }, diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index f3f341ece5c..b1a8b13f3ac 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -142,8 +142,10 @@ export default { const card = this.$refs.issue[e.oldIndex]; card.showDetail = false; - boardsStore.moving.list = card.list; - boardsStore.moving.issue = boardsStore.moving.list.findIssue(+e.item.dataset.issueId); + + const { list } = card; + const issue = list.findIssue(Number(e.item.dataset.issueId)); + boardsStore.startMoving(list, issue); sortableStart(); }, @@ -221,7 +223,10 @@ export default { </script> <template> - <div class="board-list-component"> + <div + :class="{ 'd-none': !list.isExpanded, 'd-flex flex-column': list.isExpanded }" + class="board-list-component position-relative h-100" + > <div v-if="loading" class="board-list-loading text-center" aria-label="Loading issues"> <gl-loading-icon /> </div> @@ -236,7 +241,7 @@ export default { :data-board="list.id" :data-board-type="list.type" :class="{ 'is-smaller': showIssueForm }" - class="board-list js-board-list" + class="board-list w-100 h-100 list-unstyled mb-0 p-1 js-board-list" > <board-card v-for="(issue, index) in issues" diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 28d96dab605..cc6af8e88cd 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,6 +1,7 @@ <script> import $ from 'jquery'; import { GlButton } from '@gitlab/ui'; +import { getMilestone } from 'ee_else_ce/boards/boards_util'; import eventHub from '../eventhub'; import ProjectSelect from './project_select.vue'; import ListIssue from '../models/issue'; @@ -51,11 +52,14 @@ export default { const labels = this.list.label ? [this.list.label] : []; const assignees = this.list.assignee ? [this.list.assignee] : []; + const milestone = getMilestone(this.list); + const issue = new ListIssue({ title: this.title, labels, subscribed: true, assignees, + milestone, project_id: this.selectedProject.id, }); @@ -68,8 +72,8 @@ export default { // Need this because our jQuery very kindly disables buttons on ALL form submissions $(this.$refs.submitButton).enable(); - boardsStore.detail.issue = issue; - boardsStore.detail.list = this.list; + boardsStore.setIssueDetail(issue); + boardsStore.setListDetail(this.list); }) .catch(() => { // Need this because our jQuery very kindly disables buttons on ALL form submissions @@ -95,7 +99,7 @@ export default { <template> <div class="board-new-issue-form"> - <div class="board-card"> + <div class="board-card position-relative p-3 rounded"> <form @submit="submit($event)"> <div v-if="error" class="flash-container"> <div class="flash-alert">An error occurred. Please try again.</div> diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index e637e1f1223..c587b276fa3 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -2,19 +2,21 @@ import $ from 'jquery'; import Vue from 'vue'; -import Flash from '../../flash'; -import { sprintf, __ } from '../../locale'; -import Sidebar from '../../right_sidebar'; -import eventHub from '../../sidebar/event_hub'; -import AssigneeTitle from '../../sidebar/components/assignees/assignee_title.vue'; -import Assignees from '../../sidebar/components/assignees/assignees.vue'; -import DueDateSelectors from '../../due_date_select'; +import Flash from '~/flash'; +import { sprintf, __ } from '~/locale'; +import Sidebar from '~/right_sidebar'; +import eventHub from '~/sidebar/event_hub'; +import DueDateSelectors from '~/due_date_select'; +import IssuableContext from '~/issuable_context'; +import LabelsSelect from '~/labels_select'; +import AssigneeTitle from '~/sidebar/components/assignees/assignee_title.vue'; +import Assignees from '~/sidebar/components/assignees/assignees.vue'; +import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue'; +import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; +import MilestoneSelect from '~/milestone_select'; import RemoveBtn from './sidebar/remove_issue.vue'; -import IssuableContext from '../../issuable_context'; -import LabelsSelect from '../../labels_select'; -import Subscriptions from '../../sidebar/components/subscriptions/subscriptions.vue'; -import MilestoneSelect from '../../milestone_select'; import boardsStore from '../stores/boards_store'; +import { isScopedLabel } from '~/lib/utils/common_utils'; export default Vue.extend({ components: { @@ -22,6 +24,7 @@ export default Vue.extend({ Assignees, RemoveBtn, Subscriptions, + TimeTracker, }, props: { currentUser: { @@ -42,7 +45,7 @@ export default Vue.extend({ return Object.keys(this.issue).length; }, milestoneTitle() { - return this.issue.milestone ? this.issue.milestone.title : 'No Milestone'; + return this.issue.milestone ? this.issue.milestone.title : __('No Milestone'); }, canRemove() { return !this.list.preset; @@ -138,5 +141,11 @@ export default Vue.extend({ Flash(__('An error occurred while saving assignees')); }); }, + showScopedLabels(label) { + return boardsStore.scopedLabels.enabled && isScopedLabel(label); + }, + helpLink() { + return boardsStore.scopedLabels.helpLink; + }, }, }); diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 90ab3a76342..a8516f178fc 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -1,13 +1,16 @@ <script> +import _ from 'underscore'; import { GlTooltipDirective } from '@gitlab/ui'; import { sprintf, __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; -import eventHub from '../eventhub'; import IssueDueDate from './issue_due_date.vue'; import IssueTimeEstimate from './issue_time_estimate.vue'; import boardsStore from '../stores/boards_store'; +import IssueCardInnerScopedLabel from './issue_card_inner_scoped_label.vue'; +import { isScopedLabel } from '~/lib/utils/common_utils'; export default { components: { @@ -16,10 +19,13 @@ export default { TooltipOnTruncate, IssueDueDate, IssueTimeEstimate, + IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'), + IssueCardInnerScopedLabel, }, directives: { GlTooltip: GlTooltipDirective, }, + mixins: [issueCardInner], props: { issue: { type: Object, @@ -92,6 +98,12 @@ export default { const { referencePath, groupId } = this.issue; return !groupId ? referencePath.split('#')[0] : null; }, + orderedLabels() { + return _.sortBy(this.issue.labels, 'title'); + }, + helpLink() { + return boardsStore.scopedLabels.helpLink; + }, }, methods: { isIndexLessThanlimit(index) { @@ -123,31 +135,7 @@ export default { const labelTitle = encodeURIComponent(label.title); const filter = `label_name[]=${labelTitle}`; - this.applyFilter(filter); - }, - filterByWeight(weight) { - if (!this.updateFilters) return; - - const issueWeight = encodeURIComponent(weight); - const filter = `weight=${issueWeight}`; - - this.applyFilter(filter); - }, - applyFilter(filter) { - const filterPath = boardsStore.filter.path.split('&'); - const filterIndex = filterPath.indexOf(filter); - - if (filterIndex === -1) { - filterPath.push(filter); - } else { - filterPath.splice(filterIndex, 1); - } - - boardsStore.filter.path = filterPath.join('&'); - - boardsStore.updateFiltersUrl(); - - eventHub.$emit('updateTokens'); + boardsStore.toggleFilter(filter); }, labelStyle(label) { return { @@ -155,12 +143,15 @@ export default { color: label.textColor, }; }, + showScopedLabel(label) { + return boardsStore.scopedLabels.enabled && isScopedLabel(label); + }, }, }; </script> <template> <div> - <div class="board-card-header"> + <div class="d-flex board-card-header" dir="auto"> <h4 class="board-card-title append-bottom-0 prepend-top-0"> <icon v-if="issue.confidential" @@ -175,27 +166,37 @@ export default { </h4> </div> <div v-if="showLabelFooter" class="board-card-labels prepend-top-4 d-flex flex-wrap"> - <button - v-for="label in issue.labels" - v-if="showLabel(label)" - :key="label.id" - v-gl-tooltip - :style="labelStyle(label)" - :title="label.description" - class="badge color-label append-right-4 prepend-top-4" - type="button" - @click="filterByLabel(label)" - > - {{ label.title }} - </button> + <template v-for="label in orderedLabels" v-if="showLabel(label)"> + <issue-card-inner-scoped-label + v-if="showScopedLabel(label)" + :key="label.id" + :label="label" + :label-style="labelStyle(label)" + :scoped-labels-documentation-link="helpLink" + @scoped-label-click="filterByLabel($event)" + /> + + <button + v-else + :key="label.id" + v-gl-tooltip + :style="labelStyle(label)" + :title="label.description" + class="badge color-label append-right-4 prepend-top-4" + type="button" + @click="filterByLabel(label)" + > + {{ label.title }} + </button> + </template> </div> <div class="board-card-footer d-flex justify-content-between align-items-end"> <div - class="d-flex align-items-start flex-wrap-reverse board-card-number-container js-board-card-number-container" + class="d-flex align-items-start flex-wrap-reverse board-card-number-container overflow-hidden js-board-card-number-container" > <span v-if="issue.referencePath" - class="board-card-number d-flex append-right-8 prepend-top-8" + class="board-card-number overflow-hidden d-flex append-right-8 prepend-top-8" > <tooltip-on-truncate v-if="issueReferencePath" @@ -209,10 +210,14 @@ 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" + :weight="issue.weight" + @click="filterByWeight(issue.weight)" /> </span> </div> - <div class="board-card-assignee"> + <div class="board-card-assignee d-flex"> <user-avatar-link v-for="(assignee, index) in issue.assignees" v-if="shouldRenderAssignee(index)" diff --git a/app/assets/javascripts/boards/components/issue_card_inner_scoped_label.vue b/app/assets/javascripts/boards/components/issue_card_inner_scoped_label.vue new file mode 100644 index 00000000000..fa4c68964cb --- /dev/null +++ b/app/assets/javascripts/boards/components/issue_card_inner_scoped_label.vue @@ -0,0 +1,45 @@ +<script> +import { GlLink, GlTooltip } from '@gitlab/ui'; + +export default { + components: { + GlTooltip, + GlLink, + }, + props: { + label: { + type: Object, + required: true, + }, + labelStyle: { + type: Object, + required: true, + }, + scopedLabelsDocumentationLink: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <span + class="d-inline-block position-relative scoped-label-wrapper append-right-4 prepend-top-4 board-label" + > + <a @click="$emit('scoped-label-click', label)"> + <span :ref="'labelTitleRef'" :style="labelStyle" class="badge label color-label"> + {{ label.title }} + </span> + <gl-tooltip :target="() => $refs.labelTitleRef" placement="top" boundary="viewport"> + <span class="font-weight-bold scoped-label-tooltip-title">{{ __('Scoped label') }}</span + ><br /> + {{ label.description }} + </gl-tooltip> + </a> + + <gl-link :href="scopedLabelsDocumentationLink" target="_blank" class="label scoped-label" + ><i class="fa fa-question-circle" :style="labelStyle"></i + ></gl-link> + </span> +</template> diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index 9c4c6632976..3bc7f13a9e6 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -53,7 +53,7 @@ export default { } else if (timeDifference === -1) { return __('Yesterday'); } else if (timeDifference > 0 && timeDifference < 7) { - return dateFormat(issueDueDate, 'dddd', true); + return dateFormat(issueDueDate, 'dddd'); } return standardDateFormat; @@ -82,7 +82,11 @@ export default { <template> <span> <span ref="issueDueDate" :class="cssClass" class="board-card-info card-number"> - <icon :class="{ 'text-danger': isPastDue, 'board-card-info-icon': true }" name="calendar" /> + <icon + :class="{ 'text-danger': isPastDue }" + class="board-card-info-icon align-top" + name="calendar" + /> <time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{ body }}</time> diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue index 5acc3025b2c..98c1d29db16 100644 --- a/app/assets/javascripts/boards/components/issue_time_estimate.vue +++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue @@ -28,7 +28,7 @@ export default { <template> <span> <span ref="issueTimeEstimate" class="board-card-info card-number"> - <icon name="hourglass" css-classes="board-card-info-icon" /><time + <icon name="hourglass" css-classes="board-card-info-icon align-top" /><time class="board-card-info-text" >{{ timeEstimate }}</time > diff --git a/app/assets/javascripts/boards/components/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue index 2a0008467c4..091700de93f 100644 --- a/app/assets/javascripts/boards/components/modal/empty_state.vue +++ b/app/assets/javascripts/boards/components/modal/empty_state.vue @@ -42,8 +42,8 @@ export default { </script> <template> - <section class="empty-state"> - <div class="row"> + <section class="empty-state d-flex mt-0 h-100"> + <div class="row w-100 my-auto mx-0"> <div class="col-12 col-md-6 order-md-last"> <aside class="svg-content d-none d-md-block"><img :src="emptyStateSvg" /></aside> </div> diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue index 1f0961e02d8..1cfa6d39362 100644 --- a/app/assets/javascripts/boards/components/modal/header.vue +++ b/app/assets/javascripts/boards/components/modal/header.vue @@ -50,8 +50,8 @@ export default { </script> <template> <div> - <header class="add-issues-header form-actions"> - <h2> + <header class="add-issues-header border-top-0 form-actions"> + <h2 class="m-0"> Add issues <button type="button" @@ -65,7 +65,7 @@ export default { </h2> </header> <modal-tabs v-if="!loading && issuesCount > 0" /> - <div v-if="showSearch" class="add-issues-search append-bottom-10"> + <div v-if="showSearch" class="d-flex append-bottom-10"> <modal-filters :store="filter" /> <button ref="selectAllBtn" diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue index 1e5761cf268..defa1f75ba2 100644 --- a/app/assets/javascripts/boards/components/modal/index.vue +++ b/app/assets/javascripts/boards/components/modal/index.vue @@ -124,7 +124,7 @@ export default { data.issues.forEach(issueObj => { const issue = new ListIssue(issueObj); const foundSelectedIssue = ModalStore.findSelectedIssue(issue); - issue.selected = !!foundSelectedIssue; + issue.selected = Boolean(foundSelectedIssue); this.issues.push(issue); }); @@ -143,8 +143,11 @@ export default { }; </script> <template> - <div v-if="showAddIssuesModal" class="add-issues-modal"> - <div class="add-issues-container"> + <div + v-if="showAddIssuesModal" + class="add-issues-modal d-flex position-fixed position-top-0 position-bottom-0 position-left-0 position-right-0 h-100" + > + <div class="add-issues-container d-flex flex-column m-auto rounded"> <modal-header :project-id="projectId" :milestone-path="milestonePath" @@ -161,8 +164,10 @@ export default { :new-issue-path="newIssuePath" :empty-state-svg="emptyStateSvg" /> - <section v-if="loading || filterLoading" class="add-issues-list text-center"> - <div class="add-issues-list-loading"><gl-loading-icon /></div> + <section v-if="loading || filterLoading" class="add-issues-list d-flex h-100 text-center"> + <div class="add-issues-list-loading w-100 align-self-center"> + <gl-loading-icon size="md" /> + </div> </section> <modal-footer /> </div> diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue index e9ed2de859d..28d2019af2f 100644 --- a/app/assets/javascripts/boards/components/modal/list.vue +++ b/app/assets/javascripts/boards/components/modal/list.vue @@ -117,7 +117,7 @@ export default { }; </script> <template> - <section ref="list" class="add-issues-list add-issues-list-columns"> + <section ref="list" class="add-issues-list add-issues-list-columns d-flex h-100"> <div v-if="issuesCount > 0 && issues.length === 0" class="empty-state add-issues-empty-state-filter text-center" @@ -129,7 +129,7 @@ export default { <div v-for="issue in group" v-if="showIssue(issue)" :key="issue.id" class="board-card-parent"> <div :class="{ 'is-active': issue.selected }" - class="board-card" + class="board-card position-relative p-3 rounded" @click="toggleIssue($event, issue)" > <issue-card-inner :issue="issue" :issue-link-base="issueLinkBase" :root-path="rootPath" /> diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 10577da9305..c8a9cb1c296 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -2,13 +2,16 @@ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; -import _ from 'underscore'; import CreateLabelDropdown from '../../create_label'; import boardsStore from '../stores/boards_store'; $(document) .off('created.label') - .on('created.label', (e, label) => { + .on('created.label', (e, label, addNewList) => { + if (!addNewList) { + return; + } + boardsStore.new({ title: label.title, position: boardsStore.state.lists.length - 2, @@ -74,8 +77,6 @@ export default function initNewListDropdown() { color: label.color, }, }); - - boardsStore.state.lists = _.sortBy(boardsStore.state.lists, 'position'); } }, }); diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue index a2b8a0af236..4ab2b17301f 100644 --- a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue +++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue @@ -48,7 +48,7 @@ export default Vue.extend({ list.removeIssue(issue); }); - boardsStore.detail.issue = {}; + boardsStore.clearDetailIssue(); }, /** * Build the default patch request. diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index c14d69c5d18..6b54e8baefb 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -1,6 +1,8 @@ +import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import FilteredSearchContainer from '../filtered_search/container'; import FilteredSearchManager from '../filtered_search/filtered_search_manager'; import boardsStore from './stores/boards_store'; +import { isEE } from '~/lib/utils/common_utils'; export default class FilteredSearchBoards extends FilteredSearchManager { constructor(store, updateUrl = false, cantEdit = []) { @@ -8,6 +10,8 @@ export default class FilteredSearchBoards extends FilteredSearchManager { page: 'boards', isGroupDecendent: true, stateFiltersSelector: '.issues-state-filters', + isGroup: isEE(), + filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, }); this.store = store; diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index f88e9b55988..f2f37d22b97 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,11 +1,10 @@ import $ from 'jquery'; -import _ from 'underscore'; import Vue from 'vue'; import Flash from '~/flash'; import { __ } from '~/locale'; -import '~/vue_shared/models/label'; -import '~/vue_shared/models/assignee'; +import './models/label'; +import './models/assignee'; import FilteredSearchBoards from './filtered_search_boards'; import eventHub from './eventhub'; @@ -24,7 +23,11 @@ import BoardSidebar from './components/board_sidebar'; import initNewListDropdown from './components/new_list_dropdown'; import BoardAddIssuesModal from './components/modal/index.vue'; import '~/vue_shared/vue_resource_interceptor'; -import { NavigationType, parseBoolean } from '~/lib/utils/common_utils'; +import { + NavigationType, + convertObjectPropsToCamelCase, + parseBoolean, +} from '~/lib/utils/common_utils'; let issueBoardsApp; @@ -58,6 +61,7 @@ export default () => { state: boardsStore.state, loading: true, boardsEndpoint: $boardApp.dataset.boardsEndpoint, + recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint, listsEndpoint: $boardApp.dataset.listsEndpoint, boardId: $boardApp.dataset.boardId, disabled: parseBoolean($boardApp.dataset.disabled), @@ -75,6 +79,7 @@ export default () => { created() { gl.boardService = new BoardService({ boardsEndpoint: this.boardsEndpoint, + recentBoardsEndpoint: this.recentBoardsEndpoint, listsEndpoint: this.listsEndpoint, bulkUpdatePath: this.bulkUpdatePath, boardId: this.boardId, @@ -100,24 +105,29 @@ export default () => { gl.boardService .all() .then(res => res.data) - .then(data => { - data.forEach(board => { - const list = boardsStore.addList(board, this.defaultAvatar); - - if (list.type === 'closed') { - list.position = Infinity; - } else if (list.type === 'backlog') { - list.position = -1; + .then(lists => { + lists.forEach(listObj => { + let { position } = listObj; + if (listObj.list_type === 'closed') { + position = Infinity; + } else if (listObj.list_type === 'backlog') { + position = -1; } - }); - this.state.lists = _.sortBy(this.state.lists, 'position'); + boardsStore.addList( + { + ...listObj, + position, + }, + this.defaultAvatar, + ); + }); boardsStore.addBlankState(); this.loading = false; }) .catch(() => { - Flash('An error occurred while fetching the board lists. Please try again.'); + Flash(__('An error occurred while fetching the board lists. Please try again.')); }); }, methods: { @@ -131,9 +141,25 @@ export default () => { BoardService.getIssueInfo(sidebarInfoEndpoint) .then(res => res.data) .then(data => { + const { + subscribed, + totalTimeSpent, + timeEstimate, + humanTimeEstimate, + humanTotalTimeSpent, + weight, + epic, + } = convertObjectPropsToCamelCase(data); + newIssue.setFetchingState('subscriptions', false); newIssue.updateData({ - subscribed: data.subscribed, + humanTimeSpent: humanTotalTimeSpent, + timeSpent: totalTimeSpent, + humanTimeEstimate, + timeEstimate, + subscribed, + weight, + epic, }); }) .catch(() => { @@ -142,10 +168,10 @@ export default () => { }); } - boardsStore.detail.issue = newIssue; + boardsStore.setIssueDetail(newIssue); }, clearDetailIssue() { - boardsStore.detail.issue = {}; + boardsStore.clearDetailIssue(); }, toggleSubscription(id) { const { issue } = boardsStore.detail; @@ -201,7 +227,7 @@ export default () => { }, tooltipTitle() { if (this.disabled) { - return 'Please add a list to your board first'; + return __('Please add a list to your board first'); } return ''; diff --git a/app/assets/javascripts/boards/mixins/issue_card_inner.js b/app/assets/javascripts/boards/mixins/issue_card_inner.js new file mode 100644 index 00000000000..8000237da6d --- /dev/null +++ b/app/assets/javascripts/boards/mixins/issue_card_inner.js @@ -0,0 +1,5 @@ +export default { + methods: { + filterByWeight() {}, + }, +}; diff --git a/app/assets/javascripts/vue_shared/models/assignee.js b/app/assets/javascripts/boards/models/assignee.js index 4a29b0d0581..4a29b0d0581 100644 --- a/app/assets/javascripts/vue_shared/models/assignee.js +++ b/app/assets/javascripts/boards/models/assignee.js diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index dd92d3c8552..f858b162c6b 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -4,7 +4,8 @@ /* global ListAssignee */ import Vue from 'vue'; -import '~/vue_shared/models/label'; +import './label'; +import { isEE, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import IssueProject from './project'; import boardsStore from '../stores/boards_store'; @@ -28,7 +29,6 @@ class ListIssue { this.referencePath = obj.reference_path; this.path = obj.real_path; this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint; - this.milestone_id = obj.milestone_id; this.project_id = obj.project_id; this.timeEstimate = obj.time_estimate; this.assignableLabelsEndpoint = obj.assignable_labels_endpoint; @@ -39,6 +39,7 @@ class ListIssue { if (obj.milestone) { this.milestone = new ListMilestone(obj.milestone); + this.milestone_id = obj.milestone.id; } obj.labels.forEach(label => { @@ -88,6 +89,19 @@ class ListIssue { this.assignees = []; } + addMilestone(milestone) { + const miletoneId = this.milestone ? this.milestone.id : null; + if (isEE && milestone.id !== miletoneId) { + this.milestone = new ListMilestone(milestone); + } + } + + removeMilestone(removeMilestone) { + if (isEE && removeMilestone && removeMilestone.id === this.milestone.id) { + this.milestone = {}; + } + } + getLists() { return boardsStore.state.lists.filter(list => list.findIssue(this.id)); } @@ -119,7 +133,17 @@ class ListIssue { } const projectPath = this.project ? this.project.path : ''; - return Vue.http.patch(`${this.path}.json`, data); + return Vue.http.patch(`${this.path}.json`, data).then(({ body = {} } = {}) => { + /** + * Since post implementation of Scoped labels, server can reject + * same key-ed labels. To keep the UI and server Model consistent, + * we're just assigning labels that server echo's back to us when we + * PATCH the said object. + */ + if (body) { + this.labels = convertObjectPropsToCamelCase(body.labels, { deep: true }); + } + }); } } diff --git a/app/assets/javascripts/boards/models/label.js b/app/assets/javascripts/boards/models/label.js new file mode 100644 index 00000000000..cd2a2c0137f --- /dev/null +++ b/app/assets/javascripts/boards/models/label.js @@ -0,0 +1,11 @@ +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +export default class ListLabel { + constructor(obj) { + Object.assign(this, convertObjectPropsToCamelCase(obj, { dropKeys: ['priority'] }), { + priority: obj.priority !== null ? obj.priority : Infinity, + }); + } +} + +window.ListLabel = ListLabel; diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 9f6d9a853da..a9d88f19146 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -2,10 +2,11 @@ /* global ListIssue */ import { __ } from '~/locale'; -import ListLabel from '~/vue_shared/models/label'; -import ListAssignee from '~/vue_shared/models/assignee'; -import { urlParamsToObject } from '~/lib/utils/common_utils'; +import ListLabel from './label'; +import ListAssignee from './assignee'; +import { isEE, urlParamsToObject } from '~/lib/utils/common_utils'; import boardsStore from '../stores/boards_store'; +import ListMilestone from './milestone'; const PER_PAGE = 20; @@ -36,8 +37,8 @@ class List { this.type = obj.list_type; const typeInfo = this.getTypeInfo(this.type); - this.preset = !!typeInfo.isPreset; - this.isExpandable = !!typeInfo.isExpandable; + this.preset = Boolean(typeInfo.isPreset); + this.isExpandable = Boolean(typeInfo.isExpandable); this.isExpanded = true; this.page = 1; this.loading = true; @@ -51,6 +52,9 @@ class List { } else if (obj.user) { this.assignee = new ListAssignee(obj.user); this.title = this.assignee.name; + } else if (isEE && obj.milestone) { + this.milestone = new ListMilestone(obj.milestone); + this.title = this.milestone.title; } if (!typeInfo.isBlank && this.id) { @@ -69,12 +73,14 @@ class List { } save() { - const entity = this.label || this.assignee; + const entity = this.label || this.assignee || this.milestone; let entityType = ''; if (this.label) { entityType = 'label_id'; - } else { + } else if (this.assignee) { entityType = 'assignee_id'; + } else if (isEE && this.milestone) { + entityType = 'milestone_id'; } return gl.boardService @@ -84,6 +90,7 @@ class List { this.id = data.id; this.type = data.list_type; this.position = data.position; + this.label = data.label; return this.getIssues(); }); @@ -192,6 +199,13 @@ class List { issue.addAssignee(this.assignee); } + if (isEE && this.milestone) { + if (listFrom && listFrom.type === 'milestone') { + issue.removeMilestone(listFrom.milestone); + } + issue.addMilestone(this.milestone); + } + if (listFrom) { this.issuesSize += 1; diff --git a/app/assets/javascripts/boards/models/milestone.js b/app/assets/javascripts/boards/models/milestone.js index 17d15278a74..6f81d6bc6f8 100644 --- a/app/assets/javascripts/boards/models/milestone.js +++ b/app/assets/javascripts/boards/models/milestone.js @@ -1,7 +1,16 @@ -class ListMilestone { +import { isEE } from '~/lib/utils/common_utils'; + +export default class ListMilestone { constructor(obj) { this.id = obj.id; this.title = obj.title; + + if (isEE) { + this.path = obj.path; + this.state = obj.state; + this.webUrl = obj.web_url || obj.webUrl; + this.description = obj.description; + } } } diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js index 3de6eb056c2..7d463f17ab1 100644 --- a/app/assets/javascripts/boards/services/board_service.js +++ b/app/assets/javascripts/boards/services/board_service.js @@ -2,12 +2,13 @@ import axios from '../../lib/utils/axios_utils'; import { mergeUrlParams } from '../../lib/utils/url_utility'; export default class BoardService { - constructor({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId }) { + constructor({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) { this.boardsEndpoint = boardsEndpoint; this.boardId = boardId; this.listsEndpoint = listsEndpoint; this.listsEndpointGenerate = `${listsEndpoint}/generate.json`; this.bulkUpdatePath = bulkUpdatePath; + this.recentBoardsEndpoint = `${recentBoardsEndpoint}.json`; } generateBoardsPath(id) { diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js new file mode 100644 index 00000000000..da82b52330a --- /dev/null +++ b/app/assets/javascripts/boards/stores/actions.js @@ -0,0 +1,65 @@ +const notImplemented = () => { + throw new Error('Not implemented!'); +}; + +export default { + setEndpoints: () => { + notImplemented(); + }, + + fetchLists: () => { + notImplemented(); + }, + + generateDefaultLists: () => { + notImplemented(); + }, + + createList: () => { + notImplemented(); + }, + + updateList: () => { + notImplemented(); + }, + + deleteList: () => { + notImplemented(); + }, + + fetchIssuesForList: () => { + notImplemented(); + }, + + moveIssue: () => { + notImplemented(); + }, + + createNewIssue: () => { + notImplemented(); + }, + + fetchBacklog: () => { + notImplemented(); + }, + + bulkUpdateIssues: () => { + notImplemented(); + }, + + fetchIssue: () => { + notImplemented(); + }, + + toggleIssueSubscription: () => { + notImplemented(); + }, + + showPage: () => { + notImplemented(); + }, + + toggleEmptyState: () => { + notImplemented(); + }, +}; diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 802796208c2..4b3b44574a8 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -5,14 +5,27 @@ import $ from 'jquery'; import _ from 'underscore'; import Vue from 'vue'; import Cookies from 'js-cookie'; +import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee'; import { getUrlParamsArray, parseBoolean } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import eventHub from '../eventhub'; const boardsStore = { disabled: false, + scopedLabels: { + helpLink: '', + enabled: false, + }, filter: { path: '', }, - state: {}, + state: { + currentBoard: { + labels: [], + }, + currentPage: '', + reload: false, + }, detail: { issue: {}, }, @@ -27,9 +40,13 @@ const boardsStore = { issue: {}, }; }, + showPage(page) { + this.state.reload = false; + this.state.currentPage = page; + }, addList(listObj, defaultAvatar) { const list = new List(listObj, defaultAvatar); - this.state.lists.push(list); + this.state.lists = _.sortBy([...this.state.lists, list], 'position'); return list; }, @@ -63,11 +80,9 @@ const boardsStore = { this.addList({ id: 'blank', list_type: 'blank', - title: 'Welcome to your Issue Board!', + title: __('Welcome to your Issue Board!'), position: 0, }); - - this.state.lists = _.sortBy(this.state.lists, 'position'); }, removeBlankState() { this.removeList('blank'); @@ -95,6 +110,11 @@ const boardsStore = { }); listFrom.update(); }, + + startMoving(list, issue) { + Object.assign(this.moving, { list, issue }); + }, + moveIssueToList(listFrom, listTo, issue, newIndex) { const issueTo = listTo.findIssue(issue.id); const issueLists = issue.getLists(); @@ -169,11 +189,43 @@ const boardsStore = { findListByLabelId(id) { return this.state.lists.find(list => list.type === 'label' && list.label.id === id); }, + + toggleFilter(filter) { + const filterPath = this.filter.path.split('&'); + const filterIndex = filterPath.indexOf(filter); + + if (filterIndex === -1) { + filterPath.push(filter); + } else { + filterPath.splice(filterIndex, 1); + } + + this.filter.path = filterPath.join('&'); + + this.updateFiltersUrl(); + + eventHub.$emit('updateTokens'); + }, + + setListDetail(newList) { + this.detail.list = newList; + }, + updateFiltersUrl() { window.history.pushState(null, null, `?${this.filter.path}`); }, + + clearDetailIssue() { + this.setIssueDetail({}); + }, + + setIssueDetail(issueDetail) { + this.detail.issue = issueDetail; + }, }; +BoardsStoreEE.initEESpecific(boardsStore); + // hacks added in order to allow milestone_select to function properly // TODO: remove these diff --git a/app/assets/javascripts/boards/stores/boards_store_ee.js b/app/assets/javascripts/boards/stores/boards_store_ee.js new file mode 100644 index 00000000000..09e3a938fbe --- /dev/null +++ b/app/assets/javascripts/boards/stores/boards_store_ee.js @@ -0,0 +1,5 @@ +// this is just to make ee_else_ce happy and will be cleaned up in https://gitlab.com/gitlab-org/gitlab-ce/issues/59807 + +export default { + initEESpecific() {}, +}; diff --git a/app/assets/javascripts/boards/stores/index.js b/app/assets/javascripts/boards/stores/index.js new file mode 100644 index 00000000000..f70395a3771 --- /dev/null +++ b/app/assets/javascripts/boards/stores/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import state from 'ee_else_ce/boards/stores/state'; +import actions from 'ee_else_ce/boards/stores/actions'; +import mutations from 'ee_else_ce/boards/stores/mutations'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + state, + actions, + mutations, + }); diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js new file mode 100644 index 00000000000..fcdfa6799b6 --- /dev/null +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -0,0 +1,21 @@ +export const SET_ENDPOINTS = 'SET_ENDPOINTS'; +export const REQUEST_ADD_LIST = 'REQUEST_ADD_LIST'; +export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS'; +export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_LIST_ERROR'; +export const REQUEST_UPDATE_LIST = 'REQUEST_UPDATE_LIST'; +export const RECEIVE_UPDATE_LIST_SUCCESS = 'RECEIVE_UPDATE_LIST_SUCCESS'; +export const RECEIVE_UPDATE_LIST_ERROR = 'RECEIVE_UPDATE_LIST_ERROR'; +export const REQUEST_REMOVE_LIST = 'REQUEST_REMOVE_LIST'; +export const RECEIVE_REMOVE_LIST_SUCCESS = 'RECEIVE_REMOVE_LIST_SUCCESS'; +export const RECEIVE_REMOVE_LIST_ERROR = 'RECEIVE_REMOVE_LIST_ERROR'; +export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE'; +export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS'; +export const RECEIVE_ADD_ISSUE_ERROR = 'RECEIVE_ADD_ISSUE_ERROR'; +export const REQUEST_MOVE_ISSUE = 'REQUEST_MOVE_ISSUE'; +export const RECEIVE_MOVE_ISSUE_SUCCESS = 'RECEIVE_MOVE_ISSUE_SUCCESS'; +export const RECEIVE_MOVE_ISSUE_ERROR = 'RECEIVE_MOVE_ISSUE_ERROR'; +export const REQUEST_UPDATE_ISSUE = 'REQUEST_UPDATE_ISSUE'; +export const RECEIVE_UPDATE_ISSUE_SUCCESS = 'RECEIVE_UPDATE_ISSUE_SUCCESS'; +export const RECEIVE_UPDATE_ISSUE_ERROR = 'RECEIVE_UPDATE_ISSUE_ERROR'; +export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE'; +export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js new file mode 100644 index 00000000000..77ba68be07e --- /dev/null +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -0,0 +1,91 @@ +import * as mutationTypes from './mutation_types'; + +const notImplemented = () => { + throw new Error('Not implemented!'); +}; + +export default { + [mutationTypes.SET_ENDPOINTS]: () => { + notImplemented(); + }, + + [mutationTypes.REQUEST_ADD_LIST]: () => { + notImplemented(); + }, + + [mutationTypes.RECEIVE_ADD_LIST_SUCCESS]: () => { + notImplemented(); + }, + + [mutationTypes.RECEIVE_ADD_LIST_ERROR]: () => { + notImplemented(); + }, + + [mutationTypes.REQUEST_UPDATE_LIST]: () => { + notImplemented(); + }, + + [mutationTypes.RECEIVE_UPDATE_LIST_SUCCESS]: () => { + notImplemented(); + }, + + [mutationTypes.RECEIVE_UPDATE_LIST_ERROR]: () => { + notImplemented(); + }, + + [mutationTypes.REQUEST_REMOVE_LIST]: () => { + notImplemented(); + }, + + [mutationTypes.RECEIVE_REMOVE_LIST_SUCCESS]: () => { + notImplemented(); + }, + + [mutationTypes.RECEIVE_REMOVE_LIST_ERROR]: () => { + notImplemented(); + }, + + [mutationTypes.REQUEST_ADD_ISSUE]: () => { + notImplemented(); + }, + + [mutationTypes.RECEIVE_ADD_ISSUE_SUCCESS]: () => { + notImplemented(); + }, + + [mutationTypes.RECEIVE_ADD_ISSUE_ERROR]: () => { + notImplemented(); + }, + + [mutationTypes.REQUEST_MOVE_ISSUE]: () => { + notImplemented(); + }, + + [mutationTypes.RECEIVE_MOVE_ISSUE_SUCCESS]: () => { + notImplemented(); + }, + + [mutationTypes.RECEIVE_MOVE_ISSUE_ERROR]: () => { + notImplemented(); + }, + + [mutationTypes.REQUEST_UPDATE_ISSUE]: () => { + notImplemented(); + }, + + [mutationTypes.RECEIVE_UPDATE_ISSUE_SUCCESS]: () => { + notImplemented(); + }, + + [mutationTypes.RECEIVE_UPDATE_ISSUE_ERROR]: () => { + notImplemented(); + }, + + [mutationTypes.SET_CURRENT_PAGE]: () => { + notImplemented(); + }, + + [mutationTypes.TOGGLE_EMPTY_STATE]: () => { + notImplemented(); + }, +}; diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js new file mode 100644 index 00000000000..dd16abb01a5 --- /dev/null +++ b/app/assets/javascripts/boards/stores/state.js @@ -0,0 +1,3 @@ +export default () => ({ + // ... +}); diff --git a/app/assets/javascripts/branches/branches_delete_modal.js b/app/assets/javascripts/branches/branches_delete_modal.js index f34496f84c6..f4c3fa185d8 100644 --- a/app/assets/javascripts/branches/branches_delete_modal.js +++ b/app/assets/javascripts/branches/branches_delete_modal.js @@ -23,7 +23,7 @@ class DeleteModal { const branchData = e.currentTarget.dataset; this.branchName = branchData.branchName || ''; this.deletePath = branchData.deletePath || ''; - this.isMerged = !!branchData.isMerged; + this.isMerged = Boolean(branchData.isMerged); this.updateModal(); } diff --git a/app/assets/javascripts/breakpoints.js b/app/assets/javascripts/breakpoints.js index 7951348d8b2..93aacba0e8e 100644 --- a/app/assets/javascripts/breakpoints.js +++ b/app/assets/javascripts/breakpoints.js @@ -14,6 +14,9 @@ const BreakpointInstance = { return breakpoint; }, + isDesktop() { + return ['lg', 'md'].includes(this.getBreakpointSize()); + }, }; export default BreakpointInstance; diff --git a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js index 592e1fd1c31..0bba2a2e160 100644 --- a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js +++ b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js @@ -27,15 +27,24 @@ function generateErrorBoxContent(errors) { // Used for the variable list on CI/CD projects/groups settings page export default class AjaxVariableList { - constructor({ container, saveButton, errorBox, formField = 'variables', saveEndpoint }) { + constructor({ + container, + saveButton, + errorBox, + formField = 'variables', + saveEndpoint, + maskableRegex, + }) { this.container = container; this.saveButton = saveButton; this.errorBox = errorBox; this.saveEndpoint = saveEndpoint; + this.maskableRegex = maskableRegex; this.variableList = new VariableList({ container: this.container, formField, + maskableRegex, }); this.bindEvents(); diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci_variable_list/ci_variable_list.js index 5b20fa141cd..0303e4e51dd 100644 --- a/app/assets/javascripts/ci_variable_list/ci_variable_list.js +++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js @@ -16,9 +16,10 @@ function createEnvironmentItem(value) { } export default class VariableList { - constructor({ container, formField }) { + constructor({ container, formField, maskableRegex }) { this.$container = $(container); this.formField = formField; + this.maskableRegex = new RegExp(maskableRegex); this.environmentDropdownMap = new WeakMap(); this.inputMap = { @@ -26,6 +27,10 @@ export default class VariableList { selector: '.js-ci-variable-input-id', default: '', }, + variable_type: { + selector: '.js-ci-variable-input-variable-type', + default: 'env_var', + }, key: { selector: '.js-ci-variable-input-key', default: '', @@ -40,6 +45,12 @@ export default class VariableList { // converted. we need the value as a string. default: $('.js-ci-variable-input-protected').attr('data-default'), }, + masked: { + selector: '.js-ci-variable-input-masked', + // use `attr` instead of `data` as we don't want the value to be + // converted. we need the value as a string. + default: $('.js-ci-variable-input-masked').attr('data-default'), + }, environment_scope: { // We can't use a `.js-` class here because // gl_dropdown replaces the <input> and doesn't copy over the class @@ -88,13 +99,16 @@ export default class VariableList { } }); - // Always make sure there is an empty last row - this.$container.on('input trigger-change', inputSelector, () => { + this.$container.on('input trigger-change', inputSelector, e => { + // Always make sure there is an empty last row const $lastRow = this.$container.find('.js-row').last(); if (this.checkIfRowTouched($lastRow)) { this.insertRow($lastRow); } + + // If masked, validate value against regex + this.validateMaskability($(e.currentTarget).closest('.js-row')); }); } @@ -171,12 +185,32 @@ export default class VariableList { checkIfRowTouched($row) { return Object.keys(this.inputMap).some(name => { + // Row should not qualify as touched if only switches have been touched + if (['protected', 'masked'].includes(name)) return false; + const entry = this.inputMap[name]; const $el = $row.find(entry.selector); return $el.length && $el.val() !== entry.default; }); } + validateMaskability($row) { + const invalidInputClass = 'gl-field-error-outline'; + + const variableValue = $row.find(this.inputMap.secret_value.selector).val(); + const isValueMaskable = this.maskableRegex.test(variableValue) || variableValue === ''; + const isMaskedChecked = $row.find(this.inputMap.masked.selector).val() === 'true'; + + // Show a validation error if the user wants to mask an unmaskable variable value + $row + .find(this.inputMap.secret_value.selector) + .toggleClass(invalidInputClass, isMaskedChecked && !isValueMaskable); + $row + .find('.js-secret-value-placeholder') + .toggleClass(invalidInputClass, isMaskedChecked && !isValueMaskable); + $row.find('.masking-validation-error').toggle(isMaskedChecked && !isValueMaskable); + } + toggleEnableRow(isEnabled = true) { this.$container.find(this.inputMap.key.selector).attr('disabled', !isEnabled); this.$container.find('.js-row-remove-button').attr('disabled', !isEnabled); diff --git a/app/assets/javascripts/ci_variable_list/native_form_variable_list.js b/app/assets/javascripts/ci_variable_list/native_form_variable_list.js index e7111c666a2..fdbefd8c313 100644 --- a/app/assets/javascripts/ci_variable_list/native_form_variable_list.js +++ b/app/assets/javascripts/ci_variable_list/native_form_variable_list.js @@ -19,6 +19,7 @@ export default function setupNativeFormVariableList({ container, formField = 'va const isTouched = variableList.checkIfRowTouched($lastRow); if (!isTouched) { $lastRow.find('input, textarea').attr('name', ''); + $lastRow.find('select').attr('name', ''); } }); } diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 6ebd1ad109e..aacfa0d87e6 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -1,23 +1,21 @@ import Visibility from 'visibilityjs'; import Vue from 'vue'; +import AccessorUtilities from '~/lib/utils/accessor'; +import { GlToast } from '@gitlab/ui'; import PersistentUserCallout from '../persistent_user_callout'; import { s__, sprintf } from '../locale'; import Flash from '../flash'; import Poll from '../lib/utils/poll'; import initSettingsPanels from '../settings_panels'; import eventHub from './event_hub'; -import { - APPLICATION_STATUS, - REQUEST_SUBMITTED, - REQUEST_FAILURE, - UPGRADE_REQUESTED, - UPGRADE_REQUEST_FAILURE, -} from './constants'; +import { APPLICATION_STATUS, INGRESS, INGRESS_DOMAIN_SUFFIX } from './constants'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; import Applications from './components/applications.vue'; import setupToggleButtons from '../toggle_buttons'; +Vue.use(GlToast); + /** * Cluster page has 2 separate parts: * Toggle button and applications section @@ -36,6 +34,7 @@ export default class Clusters { installRunnerPath, installJupyterPath, installKnativePath, + updateKnativePath, installPrometheusPath, managePrometheusPath, hasRbac, @@ -45,8 +44,10 @@ export default class Clusters { helpPath, ingressHelpPath, ingressDnsHelpPath, + clusterId, } = document.querySelector('.js-edit-cluster-form').dataset; + this.clusterId = clusterId; this.store = new ClustersStore(); this.store.setHelpPaths(helpPath, ingressHelpPath, ingressDnsHelpPath); this.store.setManagePrometheusPath(managePrometheusPath); @@ -62,6 +63,7 @@ export default class Clusters { installPrometheusEndpoint: installPrometheusPath, installJupyterEndpoint: installJupyterPath, installKnativeEndpoint: installKnativePath, + updateKnativeEndpoint: updateKnativePath, }); this.installApplication = this.installApplication.bind(this); @@ -70,10 +72,18 @@ export default class Clusters { this.errorContainer = document.querySelector('.js-cluster-error'); this.successContainer = document.querySelector('.js-cluster-success'); this.creatingContainer = document.querySelector('.js-cluster-creating'); + this.unreachableContainer = document.querySelector('.js-cluster-api-unreachable'); + this.authenticationFailureContainer = document.querySelector( + '.js-cluster-authentication-failure', + ); this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason'); this.successApplicationContainer = document.querySelector('.js-cluster-application-notice'); this.showTokenButton = document.querySelector('.js-show-cluster-token'); this.tokenField = document.querySelector('.js-cluster-token'); + this.ingressDomainHelpText = document.querySelector('.js-ingress-domain-help-text'); + this.ingressDomainSnippet = this.ingressDomainHelpText.querySelector( + '.js-ingress-domain-snippet', + ); Clusters.initDismissableCallout(); initSettingsPanels(); @@ -119,24 +129,35 @@ export default class Clusters { static initDismissableCallout() { const callout = document.querySelector('.js-cluster-security-warning'); + PersistentUserCallout.factory(callout); + } - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new + addBannerCloseHandler(el, status) { + el.querySelector('.js-close-banner').addEventListener('click', () => { + el.classList.add('hidden'); + this.setBannerDismissedState(status, true); + }); } addListeners() { if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken); eventHub.$on('installApplication', this.installApplication); - eventHub.$on('upgradeApplication', data => this.upgradeApplication(data)); - eventHub.$on('upgradeFailed', appId => this.upgradeFailed(appId)); - eventHub.$on('dismissUpgradeSuccess', appId => this.dismissUpgradeSuccess(appId)); + eventHub.$on('updateApplication', data => this.updateApplication(data)); + eventHub.$on('saveKnativeDomain', data => this.saveKnativeDomain(data)); + eventHub.$on('setKnativeHostname', data => this.setKnativeHostname(data)); + eventHub.$on('uninstallApplication', data => this.uninstallApplication(data)); + // Add event listener to all the banner close buttons + this.addBannerCloseHandler(this.unreachableContainer, 'unreachable'); + this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure'); } removeListeners() { if (this.showTokenButton) this.showTokenButton.removeEventListener('click', this.showToken); eventHub.$off('installApplication', this.installApplication); - eventHub.$off('upgradeApplication', this.upgradeApplication); - eventHub.$off('upgradeFailed', this.upgradeFailed); - eventHub.$off('dismissUpgradeSuccess', this.dismissUpgradeSuccess); + eventHub.$off('updateApplication', this.updateApplication); + eventHub.$off('saveKnativeDomain'); + eventHub.$off('setKnativeHostname'); + eventHub.$off('uninstallApplication'); } initPolling() { @@ -177,6 +198,10 @@ export default class Clusters { this.checkForNewInstalls(prevApplicationMap, this.store.state.applications); this.updateContainer(prevStatus, this.store.state.status, this.store.state.statusReason); + this.toggleIngressDomainHelpText( + prevApplicationMap[INGRESS], + this.store.state.applications[INGRESS], + ); } showToken() { @@ -195,6 +220,8 @@ export default class Clusters { this.errorContainer.classList.add('hidden'); this.successContainer.classList.add('hidden'); this.creatingContainer.classList.add('hidden'); + this.unreachableContainer.classList.add('hidden'); + this.authenticationFailureContainer.classList.add('hidden'); } checkForNewInstalls(prevApplicationMap, newApplicationMap) { @@ -218,9 +245,32 @@ export default class Clusters { } } + setBannerDismissedState(status, isDismissed) { + if (AccessorUtilities.isLocalStorageAccessSafe()) { + window.localStorage.setItem( + `cluster_${this.clusterId}_banner_dismissed`, + `${status}_${isDismissed}`, + ); + } + } + + isBannerDismissed(status) { + let bannerState; + if (AccessorUtilities.isLocalStorageAccessSafe()) { + bannerState = window.localStorage.getItem(`cluster_${this.clusterId}_banner_dismissed`); + } + + return bannerState === `${status}_true`; + } + updateContainer(prevStatus, status, error) { this.hideAll(); + if (this.isBannerDismissed(status)) { + return; + } + this.setBannerDismissedState(status, false); + // We poll all the time but only want the `created` banner to show when newly created if (this.store.state.status !== 'created' || prevStatus !== this.store.state.status) { switch (status) { @@ -231,6 +281,12 @@ export default class Clusters { this.errorContainer.classList.remove('hidden'); this.errorReasonContainer.textContent = error; break; + case 'unreachable': + this.unreachableContainer.classList.remove('hidden'); + break; + case 'authentication_failure': + this.authenticationFailureContainer.classList.remove('hidden'); + break; case 'scheduled': case 'creating': this.creatingContainer.classList.remove('hidden'); @@ -241,14 +297,14 @@ export default class Clusters { } } - installApplication(data) { - const appId = data.id; - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUBMITTED); + installApplication({ id: appId, params }) { this.store.updateAppProperty(appId, 'requestReason', null); this.store.updateAppProperty(appId, 'statusReason', null); - this.service.installApplication(appId, data.params).catch(() => { - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); + this.store.installApplication(appId); + + return this.service.installApplication(appId, params).catch(() => { + this.store.notifyInstallFailure(appId); this.store.updateAppProperty( appId, 'requestReason', @@ -257,19 +313,48 @@ export default class Clusters { }); } - upgradeApplication(data) { - const appId = data.id; - this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUESTED); - this.store.updateAppProperty(appId, 'status', APPLICATION_STATUS.UPDATING); - this.service.installApplication(appId, data.params).catch(() => this.upgradeFailed(appId)); + uninstallApplication({ id: appId }) { + this.store.updateAppProperty(appId, 'requestReason', null); + this.store.updateAppProperty(appId, 'statusReason', null); + + this.store.uninstallApplication(appId); + + return this.service.uninstallApplication(appId).catch(() => { + this.store.notifyUninstallFailure(appId); + this.store.updateAppProperty( + appId, + 'requestReason', + s__('ClusterIntegration|Request to begin uninstalling failed'), + ); + }); } - upgradeFailed(appId) { - this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUEST_FAILURE); + updateApplication({ id: appId, params }) { + this.store.updateApplication(appId); + this.service.installApplication(appId, params).catch(() => { + this.store.notifyUpdateFailure(appId); + }); } - dismissUpgradeSuccess(appId) { - this.store.updateAppProperty(appId, 'requestStatus', null); + toggleIngressDomainHelpText({ externalIp }, { externalIp: newExternalIp }) { + if (externalIp !== newExternalIp) { + this.ingressDomainHelpText.classList.toggle('hide', !newExternalIp); + this.ingressDomainSnippet.textContent = `${newExternalIp}${INGRESS_DOMAIN_SUFFIX}`; + } + } + + saveKnativeDomain(data) { + const appId = data.id; + this.store.updateApplication(appId); + this.service.updateApplication(appId, data.params).catch(() => { + this.store.notifyUpdateFailure(appId); + }); + } + + setKnativeHostname(data) { + const appId = data.id; + this.store.updateAppProperty(appId, 'isEditingHostName', true); + this.store.updateAppProperty(appId, 'hostname', data.hostname); } destroy() { diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 5952e93b9a7..4771090aa7e 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -1,17 +1,15 @@ <script> /* eslint-disable vue/require-default-prop */ -import { GlLink } from '@gitlab/ui'; +import { GlLink, GlModalDirective } from '@gitlab/ui'; import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; -import { s__, sprintf } from '../../locale'; +import { s__, __, sprintf } from '~/locale'; import eventHub from '../event_hub'; import identicon from '../../vue_shared/components/identicon.vue'; import loadingButton from '../../vue_shared/components/loading_button.vue'; -import { - APPLICATION_STATUS, - REQUEST_SUBMITTED, - REQUEST_FAILURE, - UPGRADE_REQUESTED, -} from '../constants'; +import UninstallApplicationButton from './uninstall_application_button.vue'; +import UninstallApplicationConfirmationModal from './uninstall_application_confirmation_modal.vue'; + +import { APPLICATION_STATUS } from '../constants'; export default { components: { @@ -19,6 +17,11 @@ export default { identicon, TimeagoTooltip, GlLink, + UninstallApplicationButton, + UninstallApplicationConfirmationModal, + }, + directives: { + GlModalDirective, }, props: { id: { @@ -47,6 +50,11 @@ export default { required: false, default: false, }, + uninstallable: { + type: Boolean, + required: false, + default: false, + }, status: { type: String, required: false, @@ -55,13 +63,19 @@ export default { type: String, required: false, }, - requestStatus: { + requestReason: { type: String, required: false, }, - requestReason: { - type: String, + installed: { + type: Boolean, required: false, + default: false, + }, + installFailed: { + type: Boolean, + required: false, + default: false, }, version: { type: String, @@ -71,9 +85,33 @@ export default { type: String, required: false, }, - upgradeAvailable: { + updateAvailable: { + type: Boolean, + required: false, + }, + updateable: { + type: Boolean, + default: true, + }, + updateSuccessful: { + type: Boolean, + required: false, + default: false, + }, + updateFailed: { + type: Boolean, + required: false, + default: false, + }, + uninstallFailed: { type: Boolean, required: false, + default: false, + }, + uninstallSuccessful: { + type: Boolean, + required: false, + default: false, }, installApplicationRequestParams: { type: Object, @@ -89,34 +127,17 @@ export default { return Object.values(APPLICATION_STATUS).includes(this.status); }, isInstalling() { - return ( - this.status === APPLICATION_STATUS.SCHEDULED || - this.status === APPLICATION_STATUS.INSTALLING || - (this.requestStatus === REQUEST_SUBMITTED && !this.statusReason && !this.isInstalled) - ); - }, - isInstalled() { - return ( - this.status === APPLICATION_STATUS.INSTALLED || - this.status === APPLICATION_STATUS.UPDATED || - this.status === APPLICATION_STATUS.UPDATING || - this.status === APPLICATION_STATUS.UPDATE_ERRORED - ); + return this.status === APPLICATION_STATUS.INSTALLING; }, canInstall() { - if (this.isInstalling) { - return false; - } - return ( this.status === APPLICATION_STATUS.NOT_INSTALLABLE || this.status === APPLICATION_STATUS.INSTALLABLE || - this.status === APPLICATION_STATUS.ERROR || this.isUnknownStatus ); }, hasLogo() { - return !!this.logoUrl; + return Boolean(this.logoUrl); }, identiconId() { // generate a deterministic integer id for the identicon background @@ -125,8 +146,14 @@ export default { rowJsClass() { return `js-cluster-application-row-${this.id}`; }, + displayUninstallButton() { + return this.installed && this.uninstallable; + }, + displayInstallButton() { + return !this.installed || !this.uninstallable; + }, installButtonLoading() { - return !this.status || this.status === APPLICATION_STATUS.SCHEDULED || this.isInstalling; + return !this.status || this.isInstalling; }, installButtonDisabled() { // Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but @@ -142,11 +169,11 @@ export default { installButtonLabel() { let label; if (this.canInstall) { - label = s__('ClusterIntegration|Install'); + label = __('Install'); } else if (this.isInstalling) { - label = s__('ClusterIntegration|Installing'); - } else if (this.isInstalled) { - label = s__('ClusterIntegration|Installed'); + label = __('Installing'); + } else if (this.installed) { + label = __('Installed'); } return label; @@ -155,80 +182,78 @@ export default { return this.manageLink && this.status === APPLICATION_STATUS.INSTALLED; }, manageButtonLabel() { - return s__('ClusterIntegration|Manage'); + return __('Manage'); }, hasError() { - return ( - !this.isInstalling && - (this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE) - ); + return this.installFailed || this.uninstallFailed; }, generalErrorDescription() { - return sprintf(s__('ClusterIntegration|Something went wrong while installing %{title}'), { - title: this.title, - }); - }, - versionLabel() { - if (this.upgradeFailed) { - return s__('ClusterIntegration|Upgrade failed'); - } else if (this.isUpgrading) { - return s__('ClusterIntegration|Upgrading'); + let errorDescription; + + if (this.installFailed) { + errorDescription = s__('ClusterIntegration|Something went wrong while installing %{title}'); + } else if (this.uninstallFailed) { + errorDescription = s__( + 'ClusterIntegration|Something went wrong while uninstalling %{title}', + ); } - return s__('ClusterIntegration|Upgraded'); - }, - upgradeRequested() { - return this.requestStatus === UPGRADE_REQUESTED; - }, - upgradeSuccessful() { - return this.status === APPLICATION_STATUS.UPDATED; + return sprintf(errorDescription, { title: this.title }); }, - upgradeFailed() { - if (this.isUpgrading) { - return false; + versionLabel() { + if (this.updateFailed) { + return __('Update failed'); + } else if (this.isUpdating) { + return __('Updating'); } - return this.status === APPLICATION_STATUS.UPDATE_ERRORED; - }, - upgradeFailureDescription() { - return sprintf( - s__( - 'ClusterIntegration|Something went wrong when upgrading %{title}. Please check the logs and try again.', - ), - { - title: this.title, - }, - ); + return __('Updated'); }, - upgradeSuccessDescription() { - return sprintf(s__('ClusterIntegration|%{title} upgraded successfully.'), { + updateFailureDescription() { + return s__('ClusterIntegration|Update failed. Please check the logs and try again.'); + }, + updateSuccessDescription() { + return sprintf(s__('ClusterIntegration|%{title} updated successfully.'), { title: this.title, }); }, - upgradeButtonLabel() { + updateButtonLabel() { let label; - if (this.upgradeAvailable && !this.upgradeFailed && !this.isUpgrading) { - label = s__('ClusterIntegration|Upgrade'); - } else if (this.isUpgrading) { - label = s__('ClusterIntegration|Upgrading'); - } else if (this.upgradeFailed) { - label = s__('ClusterIntegration|Retry upgrade'); + if (this.updateAvailable && !this.updateFailed && !this.isUpdating) { + label = __('Update'); + } else if (this.isUpdating) { + label = __('Updating'); + } else if (this.updateFailed) { + label = __('Retry update'); } return label; }, - isUpgrading() { + isUpdating() { // Since upgrading is handled asynchronously on the backend we need this check to prevent any delay on the frontend - return ( - this.status === APPLICATION_STATUS.UPDATING || - (this.upgradeRequested && !this.upgradeSuccessful) - ); + return this.status === APPLICATION_STATUS.UPDATING; + }, + shouldShowUpdateDetails() { + // This method only returns true when; + // Update was successful OR Update failed + // AND new update is unavailable AND version information is present. + return (this.updateSuccessful || this.updateFailed) && !this.updateAvailable && this.version; + }, + uninstallSuccessDescription() { + return sprintf(s__('ClusterIntegration|%{title} uninstalled successfully.'), { + title: this.title, + }); }, }, watch: { - status() { - if (this.status === APPLICATION_STATUS.UPDATE_ERRORED) { - eventHub.$emit('upgradeFailed', this.id); + updateSuccessful(updateSuccessful) { + if (updateSuccessful) { + this.$toast.show(this.updateSuccessDescription); + } + }, + uninstallSuccessful(uninstallSuccessful) { + if (uninstallSuccessful) { + this.$toast.show(this.uninstallSuccessDescription); } }, }, @@ -239,14 +264,16 @@ export default { params: this.installApplicationRequestParams, }); }, - upgradeClicked() { - eventHub.$emit('upgradeApplication', { + updateClicked() { + eventHub.$emit('updateApplication', { id: this.id, params: this.installApplicationRequestParams, }); }, - dismissUpgradeSuccess() { - eventHub.$emit('dismissUpgradeSuccess', this.id); + uninstallConfirmed() { + eventHub.$emit('uninstallApplication', { + id: this.id, + }); }, }, }; @@ -256,7 +283,7 @@ export default { <div :class="[ rowJsClass, - isInstalled && 'cluster-application-installed', + installed && 'cluster-application-installed', disabled && 'cluster-application-disabled', ]" class="cluster-application-row gl-responsive-table-row gl-responsive-table-row-col-span" @@ -279,16 +306,12 @@ export default { target="blank" rel="noopener noreferrer" class="js-cluster-application-title" + >{{ title }}</a > - {{ title }} - </a> - <span v-else class="js-cluster-application-title"> {{ title }} </span> + <span v-else class="js-cluster-application-title">{{ title }}</span> </strong> <slot name="description"></slot> - <div - v-if="hasError || isUnknownStatus" - class="cluster-application-error text-danger prepend-top-10" - > + <div v-if="hasError" class="cluster-application-error text-danger prepend-top-10"> <p class="js-cluster-application-general-error-message append-bottom-0"> {{ generalErrorDescription }} </p> @@ -302,50 +325,38 @@ export default { </ul> </div> - <div - v-if="(upgradeSuccessful || upgradeFailed) && !upgradeAvailable" - class="form-text text-muted label p-0 js-cluster-application-upgrade-details" - > - {{ versionLabel }} - - <span v-if="upgradeSuccessful"> to</span> - - <gl-link - v-if="upgradeSuccessful" - :href="chartRepo" - target="_blank" - class="js-cluster-application-upgrade-version" + <div v-if="updateable"> + <div + v-if="shouldShowUpdateDetails" + class="form-text text-muted label p-0 js-cluster-application-update-details" > - chart v{{ version }} - </gl-link> - </div> + {{ versionLabel }} + <span v-if="updateSuccessful">to</span> - <div - v-if="upgradeFailed && !isUpgrading" - class="bs-callout bs-callout-danger cluster-application-banner mt-2 mb-0 js-cluster-application-upgrade-failure-message" - > - {{ upgradeFailureDescription }} - </div> - - <div - v-if="upgradeRequested && upgradeSuccessful" - class="bs-callout bs-callout-success cluster-application-banner mt-2 mb-0 p-0 pl-3" - > - {{ upgradeSuccessDescription }} + <gl-link + v-if="updateSuccessful" + :href="chartRepo" + target="_blank" + class="js-cluster-application-update-version" + >chart v{{ version }}</gl-link + > + </div> - <button class="close cluster-application-banner-close" @click="dismissUpgradeSuccess"> - × - </button> + <div + v-if="updateFailed && !isUpdating" + class="bs-callout bs-callout-danger cluster-application-banner mt-2 mb-0 js-cluster-application-update-details" + > + {{ updateFailureDescription }} + </div> + <loading-button + v-if="updateAvailable || updateFailed || isUpdating" + class="btn btn-primary js-cluster-application-update-button mt-2" + :loading="isUpdating" + :disabled="isUpdating" + :label="updateButtonLabel" + @click="updateClicked" + /> </div> - - <loading-button - v-if="upgradeAvailable || upgradeFailed || isUpgrading" - class="btn btn-primary js-cluster-application-upgrade-button mt-2" - :loading="isUpgrading" - :disabled="isUpgrading" - :label="upgradeButtonLabel" - @click="upgradeClicked" - /> </div> <div :class="{ 'section-25': showManageButton, 'section-15': !showManageButton }" @@ -353,18 +364,30 @@ export default { role="gridcell" > <div v-if="showManageButton" class="btn-group table-action-buttons"> - <a :href="manageLink" :class="{ disabled: disabled }" class="btn"> - {{ manageButtonLabel }} - </a> + <a :href="manageLink" :class="{ disabled: disabled }" class="btn">{{ + manageButtonLabel + }}</a> </div> <div class="btn-group table-action-buttons"> <loading-button + v-if="displayInstallButton" :loading="installButtonLoading" :disabled="disabled || installButtonDisabled" :label="installButtonLabel" class="js-cluster-application-install-button" @click="installClicked" /> + <uninstall-application-button + v-if="displayUninstallButton" + v-gl-modal-directive="'uninstall-' + id" + :status="status" + class="js-cluster-application-uninstall-button" + /> + <uninstall-application-confirmation-modal + :application="id" + :application-title="title" + @confirm="uninstallConfirmed()" + /> </div> </div> </div> diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 0cf187d4189..970f5a7b297 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -1,6 +1,7 @@ <script> import _ from 'underscore'; import helmInstallIllustration from '@gitlab/svgs/dist/illustrations/kubernetes-installation.svg'; +import { GlLoadingIcon } from '@gitlab/ui'; import elasticsearchLogo from 'images/cluster_app_logos/elasticsearch.png'; import gitlabLogo from 'images/cluster_app_logos/gitlab.png'; import helmLogo from 'images/cluster_app_logos/helm.png'; @@ -14,12 +15,18 @@ import prometheusLogo from 'images/cluster_app_logos/prometheus.png'; import { s__, sprintf } from '../../locale'; import applicationRow from './application_row.vue'; import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; +import KnativeDomainEditor from './knative_domain_editor.vue'; import { CLUSTER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import eventHub from '~/clusters/event_hub'; export default { components: { applicationRow, clipboardButton, + LoadingButton, + GlLoadingIcon, + KnativeDomainEditor, }, props: { type: { @@ -86,53 +93,26 @@ export default { ingressInstalled() { return this.applications.ingress.status === APPLICATION_STATUS.INSTALLED; }, - ingressExternalIp() { - return this.applications.ingress.externalIp; + ingressExternalEndpoint() { + return this.applications.ingress.externalIp || this.applications.ingress.externalHostname; }, certManagerInstalled() { return this.applications.cert_manager.status === APPLICATION_STATUS.INSTALLED; }, ingressDescription() { - const extraCostParagraph = sprintf( - _.escape( - s__( - `ClusterIntegration|%{boldNotice} This will add some extra resources - like a load balancer, which may incur additional costs depending on - the hosting provider your Kubernetes cluster is installed on. If you are using - Google Kubernetes Engine, you can %{pricingLink}.`, - ), - ), - { - boldNotice: `<strong>${_.escape(s__('ClusterIntegration|Note:'))}</strong>`, - pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb" target="_blank" rel="noopener noreferrer"> - ${_.escape(s__('ClusterIntegration|check the pricing here'))}</a>`, - }, - false, - ); - - const externalIpParagraph = sprintf( + return sprintf( _.escape( s__( - `ClusterIntegration|After installing Ingress, you will need to point your wildcard DNS - at the generated external IP address in order to view your app after it is deployed. %{ingressHelpLink}`, + `ClusterIntegration|Installing Ingress may incur additional costs. Learn more about %{pricingLink}.`, ), ), { - ingressHelpLink: `<a href="${this.ingressHelpPath}"> - ${_.escape(s__('ClusterIntegration|More information'))} - </a>`, + pricingLink: `<strong><a href="https://cloud.google.com/compute/pricing#lb" + target="_blank" rel="noopener noreferrer"> + ${_.escape(s__('ClusterIntegration|pricing'))}</a></strong>`, }, false, ); - - return ` - <p> - ${extraCostParagraph} - </p> - <p class="settings-message append-bottom-0"> - ${externalIpParagraph} - </p> - `; }, certManagerDescription() { return sprintf( @@ -173,16 +153,27 @@ export default { jupyterHostname() { return this.applications.jupyter.hostname; }, - knativeInstalled() { - return this.applications.knative.status === APPLICATION_STATUS.INSTALLED; - }, - knativeExternalIp() { - return this.applications.knative.externalIp; + knative() { + return this.applications.knative; }, }, created() { this.helmInstallIllustration = helmInstallIllustration; }, + methods: { + saveKnativeDomain(hostname) { + eventHub.$emit('saveKnativeDomain', { + id: 'knative', + params: { hostname }, + }); + }, + setKnativeHostname(hostname) { + eventHub.$emit('setKnativeHostname', { + id: 'knative', + hostname, + }); + }, + }, }; </script> @@ -192,9 +183,9 @@ export default { <p class="append-bottom-0"> {{ s__(`ClusterIntegration|Choose which applications to install on your Kubernetes cluster. - Helm Tiller is required to install any of the following applications.`) + Helm Tiller is required to install any of the following applications.`) }} - <a :href="helpPath"> {{ __('More information') }} </a> + <a :href="helpPath">{{ __('More information') }}</a> </p> <div class="cluster-application-list prepend-top-10"> @@ -206,15 +197,20 @@ export default { :status-reason="applications.helm.statusReason" :request-status="applications.helm.requestStatus" :request-reason="applications.helm.requestReason" + :installed="applications.helm.installed" + :install-failed="applications.helm.installFailed" + :uninstallable="applications.helm.uninstallable" + :uninstall-successful="applications.helm.uninstallSuccessful" + :uninstall-failed="applications.helm.uninstallFailed" class="rounded-top" title-link="https://docs.helm.sh/" > <div slot="description"> {{ s__(`ClusterIntegration|Helm streamlines installing - and managing Kubernetes applications. - Tiller runs inside of your Kubernetes Cluster, - and manages releases of your charts.`) + and managing Kubernetes applications. + Tiller runs inside of your Kubernetes Cluster, + and manages releases of your charts.`) }} </div> </application-row> @@ -222,7 +218,7 @@ export default { <div class="svg-container" v-html="helmInstallIllustration"></div> {{ s__(`ClusterIntegration|You must first install Helm Tiller before - installing the applications below`) + installing the applications below`) }} </div> <application-row @@ -233,6 +229,11 @@ export default { :status-reason="applications.ingress.statusReason" :request-status="applications.ingress.requestStatus" :request-reason="applications.ingress.requestReason" + :installed="applications.ingress.installed" + :install-failed="applications.ingress.installFailed" + :uninstallable="applications.ingress.uninstallable" + :uninstall-successful="applications.ingress.uninstallSuccessful" + :uninstall-failed="applications.ingress.uninstallFailed" :disabled="!helmInstalled" title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/" > @@ -240,37 +241,40 @@ export default { <p> {{ s__(`ClusterIntegration|Ingress gives you a way to route - requests to services based on the request host or path, - centralizing a number of services into a single entrypoint.`) + requests to services based on the request host or path, + centralizing a number of services into a single entrypoint.`) }} </p> <template v-if="ingressInstalled"> <div class="form-group"> - <label for="ingress-ip-address"> - {{ s__('ClusterIntegration|Ingress IP Address') }} - </label> - <div v-if="ingressExternalIp" class="input-group"> + <label for="ingress-endpoint">{{ s__('ClusterIntegration|Ingress Endpoint') }}</label> + <div v-if="ingressExternalEndpoint" class="input-group"> <input - id="ingress-ip-address" - :value="ingressExternalIp" + id="ingress-endpoint" + :value="ingressExternalEndpoint" type="text" - class="form-control js-ip-address" + class="form-control js-endpoint" readonly /> <span class="input-group-append"> <clipboard-button - :text="ingressExternalIp" - :title="s__('ClusterIntegration|Copy Ingress IP Address to clipboard')" + :text="ingressExternalEndpoint" + :title="s__('ClusterIntegration|Copy Ingress Endpoint to clipboard')" class="input-group-text js-clipboard-btn" /> </span> </div> - <input v-else type="text" class="form-control js-ip-address" readonly value="?" /> + <div v-else class="input-group"> + <input type="text" class="form-control js-endpoint" readonly /> + <gl-loading-icon + class="position-absolute align-self-center ml-2 js-ingress-ip-loading-icon" + /> + </div> <p class="form-text text-muted"> {{ s__(`ClusterIntegration|Point a wildcard DNS to this - generated IP address in order to access + generated endpoint in order to access your application after it has been deployed.`) }} <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> @@ -279,19 +283,20 @@ export default { </p> </div> - <p v-if="!ingressExternalIp" class="settings-message js-no-ip-message"> + <p v-if="!ingressExternalEndpoint" class="settings-message js-no-endpoint-message"> {{ - s__(`ClusterIntegration|The IP address is in + s__(`ClusterIntegration|The endpoint is in the process of being assigned. Please check your Kubernetes cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) }} - - <a :href="ingressHelpPath" target="_blank" rel="noopener noreferrer"> + <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> {{ __('More information') }} </a> </p> </template> - <div v-html="ingressDescription"></div> + <template v-if="!ingressInstalled"> + <div class="bs-callout bs-callout-info" v-html="ingressDescription"></div> + </template> </div> </application-row> <application-row @@ -302,7 +307,12 @@ export default { :status-reason="applications.cert_manager.statusReason" :request-status="applications.cert_manager.requestStatus" :request-reason="applications.cert_manager.requestReason" + :installed="applications.cert_manager.installed" + :install-failed="applications.cert_manager.installFailed" :install-application-request-params="{ email: applications.cert_manager.email }" + :uninstallable="applications.cert_manager.uninstallable" + :uninstall-successful="applications.cert_manager.uninstallSuccessful" + :uninstall-failed="applications.cert_manager.uninstallFailed" :disabled="!helmInstalled" title-link="https://cert-manager.readthedocs.io/en/latest/#" > @@ -324,22 +334,20 @@ export default { <p class="form-text text-muted"> {{ s__(`ClusterIntegration|Issuers represent a certificate authority. - You must provide an email address for your Issuer. `) + You must provide an email address for your Issuer. `) }} <a href="http://docs.cert-manager.io/en/latest/reference/issuers.html?highlight=email" target="_blank" rel="noopener noreferrer" + >{{ __('More information') }}</a > - {{ __('More information') }} - </a> </p> </div> </div> </template> </application-row> <application-row - v-if="isProjectCluster" id="prometheus" :logo-url="prometheusLogo" :title="applications.prometheus.title" @@ -348,13 +356,17 @@ export default { :status-reason="applications.prometheus.statusReason" :request-status="applications.prometheus.requestStatus" :request-reason="applications.prometheus.requestReason" + :installed="applications.prometheus.installed" + :install-failed="applications.prometheus.installFailed" + :uninstallable="applications.prometheus.uninstallable" + :uninstall-successful="applications.prometheus.uninstallSuccessful" + :uninstall-failed="applications.prometheus.uninstallFailed" :disabled="!helmInstalled" title-link="https://prometheus.io/docs/introduction/overview/" > <div slot="description" v-html="prometheusDescription"></div> </application-row> <application-row - v-if="isProjectCluster" id="runner" :logo-url="gitlabLogo" :title="applications.runner.title" @@ -364,16 +376,23 @@ export default { :request-reason="applications.runner.requestReason" :version="applications.runner.version" :chart-repo="applications.runner.chartRepo" - :upgrade-available="applications.runner.upgradeAvailable" + :update-available="applications.runner.updateAvailable" + :installed="applications.runner.installed" + :install-failed="applications.runner.installFailed" + :update-successful="applications.runner.updateSuccessful" + :update-failed="applications.runner.updateFailed" + :uninstallable="applications.runner.uninstallable" + :uninstall-successful="applications.runner.uninstallSuccessful" + :uninstall-failed="applications.runner.uninstallFailed" :disabled="!helmInstalled" title-link="https://docs.gitlab.com/runner/" > <div slot="description"> {{ - s__(`ClusterIntegration|GitLab Runner connects to this - project's repository and executes CI/CD jobs, - pushing results back and deploying, - applications to production.`) + s__(`ClusterIntegration|GitLab Runner connects to the + repository and executes CI/CD jobs, + pushing results back and deploying + applications to production.`) }} </div> </application-row> @@ -386,6 +405,11 @@ export default { :status-reason="applications.jupyter.statusReason" :request-status="applications.jupyter.requestStatus" :request-reason="applications.jupyter.requestReason" + :installed="applications.jupyter.installed" + :install-failed="applications.jupyter.installFailed" + :uninstallable="applications.jupyter.uninstallable" + :uninstall-successful="applications.jupyter.uninstallSuccessful" + :uninstall-failed="applications.jupyter.uninstallFailed" :install-application-request-params="{ hostname: applications.jupyter.hostname }" :disabled="!helmInstalled" title-link="https://jupyterhub.readthedocs.io/en/stable/" @@ -394,18 +418,16 @@ export default { <p> {{ s__(`ClusterIntegration|JupyterHub, a multi-user Hub, spawns, - manages, and proxies multiple instances of the single-user - Jupyter notebook server. JupyterHub can be used to serve - notebooks to a class of students, a corporate data science group, - or a scientific research group.`) + manages, and proxies multiple instances of the single-user + Jupyter notebook server. JupyterHub can be used to serve + notebooks to a class of students, a corporate data science group, + or a scientific research group.`) }} </p> - <template v-if="ingressExternalIp"> + <template v-if="ingressExternalEndpoint"> <div class="form-group"> - <label for="jupyter-hostname"> - {{ s__('ClusterIntegration|Jupyter Hostname') }} - </label> + <label for="jupyter-hostname">{{ s__('ClusterIntegration|Jupyter Hostname') }}</label> <div class="input-group"> <input @@ -445,13 +467,20 @@ export default { :status-reason="applications.knative.statusReason" :request-status="applications.knative.requestStatus" :request-reason="applications.knative.requestReason" + :installed="applications.knative.installed" + :install-failed="applications.knative.installFailed" :install-application-request-params="{ hostname: applications.knative.hostname }" + :uninstallable="applications.knative.uninstallable" + :uninstall-successful="applications.knative.uninstallSuccessful" + :uninstall-failed="applications.knative.uninstallFailed" + :updateable="false" :disabled="!helmInstalled" + v-bind="applications.knative" title-link="https://github.com/knative/docs" > <div slot="description"> <span v-if="!rbac"> - <p v-if="!rbac" class="bs-callout bs-callout-info append-bottom-0"> + <p v-if="!rbac" class="rbac-notice bs-callout bs-callout-info append-bottom-0"> {{ s__(`ClusterIntegration|You must have an RBAC-enabled cluster to install Knative.`) @@ -465,82 +494,19 @@ export default { <p> {{ s__(`ClusterIntegration|Knative extends Kubernetes to provide - a set of middleware components that are essential to build modern, - source-centric, and container-based applications that can run - anywhere: on premises, in the cloud, or even in a third-party data center.`) + a set of middleware components that are essential to build modern, + source-centric, and container-based applications that can run + anywhere: on premises, in the cloud, or even in a third-party data center.`) }} </p> - <template v-if="knativeInstalled"> - <div class="form-group"> - <label for="knative-domainname"> - {{ s__('ClusterIntegration|Knative Domain Name:') }} - </label> - <input - id="knative-domainname" - v-model="applications.knative.hostname" - type="text" - class="form-control js-domainname" - readonly - /> - </div> - </template> - <template v-else-if="helmInstalled && rbac"> - <div class="form-group"> - <label for="knative-domainname"> - {{ s__('ClusterIntegration|Knative Domain Name:') }} - </label> - <input - id="knative-domainname" - v-model="applications.knative.hostname" - type="text" - class="form-control js-domainname" - /> - </div> - </template> - <template v-if="knativeInstalled"> - <div class="form-group"> - <label for="knative-ip-address"> - {{ s__('ClusterIntegration|Knative IP Address:') }} - </label> - <div v-if="knativeExternalIp" class="input-group"> - <input - id="knative-ip-address" - :value="knativeExternalIp" - type="text" - class="form-control js-ip-address" - readonly - /> - <span class="input-group-append"> - <clipboard-button - :text="knativeExternalIp" - :title="s__('ClusterIntegration|Copy Knative IP Address to clipboard')" - class="input-group-text js-clipboard-btn" - /> - </span> - </div> - <input v-else type="text" class="form-control js-ip-address" readonly value="?" /> - </div> - - <p v-if="!knativeExternalIp" class="settings-message js-no-ip-message"> - {{ - s__(`ClusterIntegration|The IP address is in - the process of being assigned. Please check your Kubernetes - cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) - }} - </p> - - <p> - {{ - s__(`ClusterIntegration|Point a wildcard DNS to this - generated IP address in order to access - your application after it has been deployed.`) - }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> - {{ __('More information') }} - </a> - </p> - </template> + <knative-domain-editor + v-if="knative.installed || (helmInstalled && rbac)" + :knative="knative" + :ingress-dns-help-path="ingressDnsHelpPath" + @save="saveKnativeDomain" + @set="setKnativeHostname" + /> </div> </application-row> </div> diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue new file mode 100644 index 00000000000..480228619a5 --- /dev/null +++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue @@ -0,0 +1,150 @@ +<script> +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +import { APPLICATION_STATUS } from '~/clusters/constants'; + +const { UPDATING, UNINSTALLING } = APPLICATION_STATUS; + +export default { + components: { + LoadingButton, + ClipboardButton, + GlLoadingIcon, + }, + props: { + knative: { + type: Object, + required: true, + }, + ingressDnsHelpPath: { + type: String, + default: '', + }, + }, + computed: { + saveButtonDisabled() { + return [UNINSTALLING, UPDATING].includes(this.knative.status); + }, + saving() { + return [UPDATING].includes(this.knative.status); + }, + saveButtonLabel() { + return this.saving ? this.__('Saving') : this.__('Save changes'); + }, + knativeInstalled() { + return this.knative.installed; + }, + knativeExternalEndpoint() { + return this.knative.externalIp || this.knative.externalHostname; + }, + knativeUpdateSuccessful() { + return this.knative.updateSuccessful; + }, + knativeHostname: { + get() { + return this.knative.hostname; + }, + set(hostname) { + this.$emit('set', hostname); + }, + }, + }, + watch: { + knativeUpdateSuccessful(updateSuccessful) { + if (updateSuccessful) { + this.$toast.show(s__('ClusterIntegration|Knative domain name was updated successfully.')); + } + }, + }, +}; +</script> + +<template> + <div class="row"> + <div + v-if="knative.updateFailed" + class="bs-callout bs-callout-danger cluster-application-banner col-12 mt-2 mb-2 js-cluster-knative-domain-name-failure-message" + > + {{ s__('ClusterIntegration|Something went wrong while updating Knative domain name.') }} + </div> + + <template> + <div + :class="{ 'col-md-6': knativeInstalled, 'col-12': !knativeInstalled }" + class="form-group col-sm-12 mb-0" + > + <label for="knative-domainname"> + <strong>{{ s__('ClusterIntegration|Knative Domain Name:') }}</strong> + </label> + <input + id="knative-domainname" + v-model="knativeHostname" + type="text" + class="form-control js-knative-domainname" + /> + </div> + </template> + <template v-if="knativeInstalled"> + <div class="form-group col-sm-12 col-md-6 pl-md-0 mb-0 mt-3 mt-md-0"> + <label for="knative-endpoint"> + <strong>{{ s__('ClusterIntegration|Knative Endpoint:') }}</strong> + </label> + <div v-if="knativeExternalEndpoint" class="input-group"> + <input + id="knative-endpoint" + :value="knativeExternalEndpoint" + type="text" + class="form-control js-knative-endpoint" + readonly + /> + <span class="input-group-append"> + <clipboard-button + :text="knativeExternalEndpoint" + :title="s__('ClusterIntegration|Copy Knative Endpoint to clipboard')" + class="input-group-text js-knative-endpoint-clipboard-btn" + /> + </span> + </div> + <div v-else class="input-group"> + <input type="text" class="form-control js-endpoint" readonly /> + <gl-loading-icon + class="position-absolute align-self-center ml-2 js-knative-ip-loading-icon" + /> + </div> + </div> + + <p class="form-text text-muted col-12"> + {{ + s__( + `ClusterIntegration|To access your application after deployment, point a wildcard DNS to the Knative Endpoint.`, + ) + }} + <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> + {{ __('More information') }} + </a> + </p> + + <p + v-if="!knativeExternalEndpoint" + class="settings-message js-no-knative-endpoint-message mt-2 mr-3 mb-0 ml-3" + > + {{ + s__(`ClusterIntegration|The endpoint is in + the process of being assigned. Please check your Kubernetes + cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) + }} + </p> + + <loading-button + class="btn-success js-knative-save-domain-button mt-3 ml-3" + :loading="saving" + :disabled="saveButtonDisabled" + :label="saveButtonLabel" + @click="$emit('save', knativeHostname)" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/clusters/components/uninstall_application_button.vue b/app/assets/javascripts/clusters/components/uninstall_application_button.vue new file mode 100644 index 00000000000..ef4bcbe14dd --- /dev/null +++ b/app/assets/javascripts/clusters/components/uninstall_application_button.vue @@ -0,0 +1,33 @@ +<script> +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import { APPLICATION_STATUS } from '~/clusters/constants'; + +const { UPDATING, UNINSTALLING } = APPLICATION_STATUS; + +export default { + components: { + LoadingButton, + }, + props: { + status: { + type: String, + required: true, + }, + }, + computed: { + disabled() { + return [UNINSTALLING, UPDATING].includes(this.status); + }, + loading() { + return this.status === UNINSTALLING; + }, + label() { + return this.loading ? this.__('Uninstalling') : this.__('Uninstall'); + }, + }, +}; +</script> + +<template> + <loading-button :label="label" :disabled="disabled" :loading="loading" /> +</template> diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue new file mode 100644 index 00000000000..65827f1cb6a --- /dev/null +++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue @@ -0,0 +1,74 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; +import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uninstall_button_click'; +import { INGRESS, CERT_MANAGER, PROMETHEUS, RUNNER, KNATIVE, JUPYTER } from '../constants'; + +const CUSTOM_APP_WARNING_TEXT = { + [INGRESS]: s__( + 'ClusterIntegration|The associated load balancer and IP will be deleted and cannot be restored.', + ), + [CERT_MANAGER]: s__( + 'ClusterIntegration|The associated certifcate will be deleted and cannot be restored.', + ), + [PROMETHEUS]: s__('ClusterIntegration|All data will be deleted and cannot be restored.'), + [RUNNER]: s__('ClusterIntegration|Any running pipelines will be canceled.'), + [KNATIVE]: s__('ClusterIntegration|The associated IP will be deleted and cannot be restored.'), + [JUPYTER]: '', +}; + +export default { + components: { + GlModal, + }, + mixins: [trackUninstallButtonClickMixin], + props: { + application: { + type: String, + required: true, + }, + applicationTitle: { + type: String, + required: true, + }, + }, + computed: { + title() { + return sprintf(s__('ClusterIntegration|Uninstall %{appTitle}'), { + appTitle: this.applicationTitle, + }); + }, + warningText() { + return sprintf( + s__('ClusterIntegration|You are about to uninstall %{appTitle} from your cluster.'), + { + appTitle: this.applicationTitle, + }, + ); + }, + customAppWarningText() { + return CUSTOM_APP_WARNING_TEXT[this.application]; + }, + modalId() { + return `uninstall-${this.application}`; + }, + }, + methods: { + confirmUninstall() { + this.trackUninstallButtonClick(this.application); + this.$emit('confirm'); + }, + }, +}; +</script> +<template> + <gl-modal + ok-variant="danger" + cancel-variant="light" + :ok-title="title" + :modal-id="modalId" + :title="title" + @ok="confirmUninstall()" + >{{ warningText }} {{ customAppWarningText }}</gl-modal + > +</template> diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index 39022879d91..8fd752092c9 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -7,6 +7,7 @@ export const CLUSTER_TYPE = { // These need to match what is returned from the server export const APPLICATION_STATUS = { + NO_STATUS: null, NOT_INSTALLABLE: 'not_installable', INSTALLABLE: 'installable', SCHEDULED: 'scheduled', @@ -15,16 +16,35 @@ export const APPLICATION_STATUS = { UPDATING: 'updating', UPDATED: 'updated', UPDATE_ERRORED: 'update_errored', + UNINSTALLING: 'uninstalling', + UNINSTALL_ERRORED: 'uninstall_errored', ERROR: 'errored', }; +/* + * The application cannot be in any of the following states without + * not being installed. + */ +export const APPLICATION_INSTALLED_STATUSES = [ + APPLICATION_STATUS.INSTALLED, + APPLICATION_STATUS.UPDATING, + APPLICATION_STATUS.UNINSTALLING, +]; + // These are only used client-side -export const REQUEST_SUBMITTED = 'request-submitted'; -export const REQUEST_FAILURE = 'request-failure'; -export const UPGRADE_REQUESTED = 'upgrade-requested'; -export const UPGRADE_REQUEST_FAILURE = 'upgrade-request-failure'; + +export const UPDATE_EVENT = 'update'; +export const INSTALL_EVENT = 'install'; +export const UNINSTALL_EVENT = 'uninstall'; + +export const HELM = 'helm'; export const INGRESS = 'ingress'; export const JUPYTER = 'jupyter'; export const KNATIVE = 'knative'; export const RUNNER = 'runner'; export const CERT_MANAGER = 'cert_manager'; +export const PROMETHEUS = 'prometheus'; + +export const APPLICATIONS = [HELM, INGRESS, JUPYTER, KNATIVE, RUNNER, CERT_MANAGER, PROMETHEUS]; + +export const INGRESS_DOMAIN_SUFFIX = '.nip.io'; diff --git a/app/assets/javascripts/clusters/mixins/track_uninstall_button_click.js b/app/assets/javascripts/clusters/mixins/track_uninstall_button_click.js new file mode 100644 index 00000000000..18f65b234d3 --- /dev/null +++ b/app/assets/javascripts/clusters/mixins/track_uninstall_button_click.js @@ -0,0 +1,5 @@ +export default { + methods: { + trackUninstallButtonClick: () => {}, + }, +}; diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js new file mode 100644 index 00000000000..17ea4d77795 --- /dev/null +++ b/app/assets/javascripts/clusters/services/application_state_machine.js @@ -0,0 +1,174 @@ +import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT, UNINSTALL_EVENT } from '../constants'; + +const { + NO_STATUS, + SCHEDULED, + NOT_INSTALLABLE, + INSTALLABLE, + INSTALLING, + INSTALLED, + ERROR, + UPDATING, + UPDATED, + UPDATE_ERRORED, + UNINSTALLING, + UNINSTALL_ERRORED, +} = APPLICATION_STATUS; + +const applicationStateMachine = { + /* When the application initially loads, it will have `NO_STATUS` + * It will transition from `NO_STATUS` once the async backend call is completed + */ + [NO_STATUS]: { + on: { + [SCHEDULED]: { + target: INSTALLING, + }, + [NOT_INSTALLABLE]: { + target: NOT_INSTALLABLE, + }, + [INSTALLABLE]: { + target: INSTALLABLE, + }, + [INSTALLING]: { + target: INSTALLING, + }, + [INSTALLED]: { + target: INSTALLED, + }, + [ERROR]: { + target: INSTALLABLE, + effects: { + installFailed: true, + }, + }, + [UPDATING]: { + target: UPDATING, + }, + [UPDATED]: { + target: INSTALLED, + }, + [UPDATE_ERRORED]: { + target: INSTALLED, + effects: { + updateFailed: true, + }, + }, + [UNINSTALLING]: { + target: UNINSTALLING, + }, + [UNINSTALL_ERRORED]: { + target: INSTALLED, + effects: { + uninstallFailed: true, + }, + }, + }, + }, + [NOT_INSTALLABLE]: { + on: { + [INSTALLABLE]: { + target: INSTALLABLE, + }, + }, + }, + [INSTALLABLE]: { + on: { + [INSTALL_EVENT]: { + target: INSTALLING, + effects: { + installFailed: false, + }, + }, + // This is possible in artificial environments for E2E testing + [INSTALLED]: { + target: INSTALLED, + }, + }, + }, + [INSTALLING]: { + on: { + [INSTALLED]: { + target: INSTALLED, + }, + [ERROR]: { + target: INSTALLABLE, + effects: { + installFailed: true, + }, + }, + }, + }, + [INSTALLED]: { + on: { + [UPDATE_EVENT]: { + target: UPDATING, + effects: { + updateFailed: false, + updateSuccessful: false, + }, + }, + [UNINSTALL_EVENT]: { + target: UNINSTALLING, + effects: { + uninstallFailed: false, + uninstallSuccessful: false, + }, + }, + }, + }, + [UPDATING]: { + on: { + [UPDATED]: { + target: INSTALLED, + effects: { + updateSuccessful: true, + }, + }, + [UPDATE_ERRORED]: { + target: INSTALLED, + effects: { + updateFailed: true, + }, + }, + }, + }, + [UNINSTALLING]: { + on: { + [INSTALLABLE]: { + target: INSTALLABLE, + effects: { + uninstallSuccessful: true, + }, + }, + [UNINSTALL_ERRORED]: { + target: INSTALLED, + effects: { + uninstallFailed: true, + }, + }, + }, + }, +}; + +/** + * Determines an application new state based on the application current state + * and an event. If the application current state cannot handle a given event, + * the current state is returned. + * + * @param {*} application + * @param {*} event + */ +const transitionApplicationState = (application, event) => { + const newState = applicationStateMachine[application.status].on[event]; + + return newState + ? { + ...application, + status: newState.target, + ...newState.effects, + } + : application; +}; + +export default transitionApplicationState; diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js index 89dda4b7902..01f3732de7e 100644 --- a/app/assets/javascripts/clusters/services/clusters_service.js +++ b/app/assets/javascripts/clusters/services/clusters_service.js @@ -12,6 +12,9 @@ export default class ClusterService { jupyter: this.options.installJupyterEndpoint, knative: this.options.installKnativeEndpoint, }; + this.appUpdateEndpointMap = { + knative: this.options.updateKnativeEndpoint, + }; } fetchData() { @@ -22,6 +25,14 @@ export default class ClusterService { return axios.post(this.appInstallEndpointMap[appId], params); } + updateApplication(appId, params) { + return axios.patch(this.appUpdateEndpointMap[appId], params); + } + + uninstallApplication(appId, params) { + return axios.delete(this.appInstallEndpointMap[appId], params); + } + static updateCluster(endpoint, data) { return axios.put(endpoint, data); } diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index d309678be27..f64f0ca616f 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -1,6 +1,31 @@ import { s__ } from '../../locale'; import { parseBoolean } from '../../lib/utils/common_utils'; -import { INGRESS, JUPYTER, KNATIVE, CERT_MANAGER, RUNNER } from '../constants'; +import { + INGRESS, + JUPYTER, + KNATIVE, + CERT_MANAGER, + RUNNER, + APPLICATION_INSTALLED_STATUSES, + APPLICATION_STATUS, + INSTALL_EVENT, + UPDATE_EVENT, + UNINSTALL_EVENT, +} from '../constants'; +import transitionApplicationState from '../services/application_state_machine'; + +const isApplicationInstalled = appStatus => APPLICATION_INSTALLED_STATUSES.includes(appStatus); + +const applicationInitialState = { + status: null, + statusReason: null, + requestReason: null, + installed: false, + installFailed: false, + uninstallable: false, + uninstallFailed: false, + uninstallSuccessful: false, +}; export default class ClusterStore { constructor() { @@ -12,61 +37,47 @@ export default class ClusterStore { statusReason: null, applications: { helm: { + ...applicationInitialState, title: s__('ClusterIntegration|Helm Tiller'), - status: null, - statusReason: null, - requestStatus: null, - requestReason: null, }, ingress: { + ...applicationInitialState, title: s__('ClusterIntegration|Ingress'), - status: null, - statusReason: null, - requestStatus: null, - requestReason: null, externalIp: null, + externalHostname: null, }, cert_manager: { + ...applicationInitialState, title: s__('ClusterIntegration|Cert-Manager'), - status: null, - statusReason: null, - requestStatus: null, - requestReason: null, email: null, }, runner: { + ...applicationInitialState, title: s__('ClusterIntegration|GitLab Runner'), - status: null, - statusReason: null, - requestStatus: null, - requestReason: null, version: null, chartRepo: 'https://gitlab.com/charts/gitlab-runner', - upgradeAvailable: null, + updateAvailable: null, + updateSuccessful: false, + updateFailed: false, }, prometheus: { + ...applicationInitialState, title: s__('ClusterIntegration|Prometheus'), - status: null, - statusReason: null, - requestStatus: null, - requestReason: null, }, jupyter: { + ...applicationInitialState, title: s__('ClusterIntegration|JupyterHub'), - status: null, - statusReason: null, - requestStatus: null, - requestReason: null, hostname: null, }, knative: { + ...applicationInitialState, title: s__('ClusterIntegration|Knative'), - status: null, - statusReason: null, - requestStatus: null, - requestReason: null, hostname: null, + isEditingHostName: false, externalIp: null, + externalHostname: null, + updateSuccessful: false, + updateFailed: false, }, }, }; @@ -94,6 +105,36 @@ export default class ClusterStore { this.state.statusReason = reason; } + installApplication(appId) { + this.handleApplicationEvent(appId, INSTALL_EVENT); + } + + notifyInstallFailure(appId) { + this.handleApplicationEvent(appId, APPLICATION_STATUS.ERROR); + } + + updateApplication(appId) { + this.handleApplicationEvent(appId, UPDATE_EVENT); + } + + notifyUpdateFailure(appId) { + this.handleApplicationEvent(appId, APPLICATION_STATUS.UPDATE_ERRORED); + } + + uninstallApplication(appId) { + this.handleApplicationEvent(appId, UNINSTALL_EVENT); + } + + notifyUninstallFailure(appId) { + this.handleApplicationEvent(appId, APPLICATION_STATUS.UNINSTALL_ERRORED); + } + + handleApplicationEvent(appId, event) { + const currentAppState = this.state.applications[appId]; + + this.state.applications[appId] = transitionApplicationState(currentAppState, event); + } + updateAppProperty(appId, prop, value) { this.state.applications[appId][prop] = value; } @@ -108,17 +149,23 @@ export default class ClusterStore { status, status_reason: statusReason, version, - update_available: upgradeAvailable, + update_available: updateAvailable, + can_uninstall: uninstallable, } = serverAppEntry; + const currentApplicationState = this.state.applications[appId] || {}; + const nextApplicationState = transitionApplicationState(currentApplicationState, status); this.state.applications[appId] = { - ...(this.state.applications[appId] || {}), - status, + ...currentApplicationState, + ...nextApplicationState, statusReason, + installed: isApplicationInstalled(nextApplicationState.status), + uninstallable, }; if (appId === INGRESS) { this.state.applications.ingress.externalIp = serverAppEntry.external_ip; + this.state.applications.ingress.externalHostname = serverAppEntry.external_hostname; } else if (appId === CERT_MANAGER) { this.state.applications.cert_manager.email = this.state.applications.cert_manager.email || serverAppEntry.email; @@ -129,13 +176,17 @@ export default class ClusterStore { ? `jupyter.${this.state.applications.ingress.externalIp}.nip.io` : ''); } else if (appId === KNATIVE) { - this.state.applications.knative.hostname = - serverAppEntry.hostname || this.state.applications.knative.hostname; + if (!this.state.applications.knative.isEditingHostName) { + this.state.applications.knative.hostname = + serverAppEntry.hostname || this.state.applications.knative.hostname; + } this.state.applications.knative.externalIp = serverAppEntry.external_ip || this.state.applications.knative.externalIp; + this.state.applications.knative.externalHostname = + serverAppEntry.external_hostname || this.state.applications.knative.externalHostname; } else if (appId === RUNNER) { this.state.applications.runner.version = version; - this.state.applications.runner.upgradeAvailable = upgradeAvailable; + this.state.applications.runner.updateAvailable = updateAvailable; } }); } diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index d4ecfa4aa93..bc666aef54b 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -71,29 +71,39 @@ export default class ImageFile { // eslint-disable-next-line class-methods-use-this initDraggable($el, padding, callback) { var dragging = false; - var $body = $('body'); - var $offsetEl = $el.parent(); - - $el.off('mousedown').on('mousedown', function() { + const $body = $('body'); + const $offsetEl = $el.parent(); + const dragStart = function() { dragging = true; $body.css('user-select', 'none'); - }); + }; + const dragStop = function() { + dragging = false; + $body.css('user-select', ''); + }; + const dragMove = function(e) { + const moveX = e.pageX || e.touches[0].pageX; + const left = moveX - ($offsetEl.offset().left + padding); + if (!dragging) return; + + callback(e, left); + }; + + $el + .off('mousedown') + .off('touchstart') + .on('mousedown', dragStart) + .on('touchstart', dragStart); $body .off('mouseup') .off('mousemove') - .on('mouseup', function() { - dragging = false; - $body.css('user-select', ''); - }) - .on('mousemove', function(e) { - var left; - if (!dragging) return; - - left = e.pageX - ($offsetEl.offset().left + padding); - - callback(e, left); - }); + .off('touchend') + .off('touchmove') + .on('mouseup', dragStop) + .on('touchend', dragStop) + .on('mousemove', dragMove) + .on('touchmove', dragMove); } prepareFrames(view) { diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js index fba30aea9ae..e5e1cbb1e62 100644 --- a/app/assets/javascripts/commons/bootstrap.js +++ b/app/assets/javascripts/commons/bootstrap.js @@ -16,3 +16,63 @@ $.fn.extend({ .removeClass('disabled'); }, }); + +/* + Starting with bootstrap 4.3.1, bootstrap sanitizes html used for tooltips / popovers. + This extends the default whitelists with more elements / attributes: + https://getbootstrap.com/docs/4.3/getting-started/javascript/#sanitizer + */ +const whitelist = $.fn.tooltip.Constructor.Default.whiteList; + +const inputAttributes = ['value', 'type']; + +const dataAttributes = [ + 'data-toggle', + 'data-placement', + 'data-container', + 'data-title', + 'data-class', + 'data-clipboard-text', + 'data-placement', +]; + +// Whitelisting data attributes +whitelist['*'] = [ + ...whitelist['*'], + ...dataAttributes, + 'title', + 'width height', + 'abbr', + 'datetime', + 'name', + 'width', + 'height', +]; + +// Whitelist missing elements: +whitelist.label = ['for']; +whitelist.button = [...inputAttributes]; +whitelist.input = [...inputAttributes]; + +whitelist.tt = []; +whitelist.samp = []; +whitelist.kbd = []; +whitelist.var = []; +whitelist.dfn = []; +whitelist.cite = []; +whitelist.big = []; +whitelist.address = []; +whitelist.dl = []; +whitelist.dt = []; +whitelist.dd = []; +whitelist.abbr = []; +whitelist.acronym = []; +whitelist.blockquote = []; +whitelist.del = []; +whitelist.ins = []; +whitelist['gl-emoji'] = []; + +// Whitelisting SVG tags and attributes +whitelist.svg = ['viewBox']; +whitelist.use = ['xlink:href']; +whitelist.path = ['d']; diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js index 009153d0703..2f268419bff 100644 --- a/app/assets/javascripts/commons/jquery.js +++ b/app/assets/javascripts/commons/jquery.js @@ -3,7 +3,7 @@ import 'jquery'; // common jQuery plugins import 'jquery-ujs'; import 'vendor/jquery.endless-scroll'; -import 'vendor/jquery.caret'; -import 'vendor/jquery.atwho'; +import 'jquery.caret'; // must be imported before at.js +import 'at.js'; import 'vendor/jquery.scrollTo'; import 'jquery.waitforimages'; diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js index bffc025ced3..d0cc4897aeb 100644 --- a/app/assets/javascripts/commons/polyfills.js +++ b/app/assets/javascripts/commons/polyfills.js @@ -1,18 +1,21 @@ // ECMAScript polyfills -import 'core-js/fn/array/fill'; -import 'core-js/fn/array/find'; -import 'core-js/fn/array/find-index'; -import 'core-js/fn/array/from'; -import 'core-js/fn/array/includes'; -import 'core-js/fn/object/assign'; -import 'core-js/fn/object/values'; -import 'core-js/fn/promise'; -import 'core-js/fn/string/code-point-at'; -import 'core-js/fn/string/from-code-point'; -import 'core-js/fn/string/includes'; -import 'core-js/fn/symbol'; -import 'core-js/es6/map'; -import 'core-js/es6/weak-map'; +import 'core-js/es/array/fill'; +import 'core-js/es/array/find'; +import 'core-js/es/array/find-index'; +import 'core-js/es/array/from'; +import 'core-js/es/array/includes'; +import 'core-js/es/object/assign'; +import 'core-js/es/object/values'; +import 'core-js/es/object/entries'; +import 'core-js/es/promise'; +import 'core-js/es/promise/finally'; +import 'core-js/es/string/code-point-at'; +import 'core-js/es/string/from-code-point'; +import 'core-js/es/string/includes'; +import 'core-js/es/symbol'; +import 'core-js/es/map'; +import 'core-js/es/weak-map'; +import 'core-js/modules/web.url'; // Browser polyfills import 'formdata-polyfill'; diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index 37a3ceb5341..5bfe158ceda 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -40,7 +40,7 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = ( }, selectable: true, filterable: true, - filterRemote: !!$dropdown.data('refsUrl'), + filterRemote: Boolean($dropdown.data('refsUrl')), fieldName: $dropdown.data('fieldName'), filterInput: 'input[type="search"]', renderRow: function(ref) { diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index 50efecb3475..b62ec8a651b 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -4,6 +4,12 @@ import _ from 'underscore'; import bp from './breakpoints'; import { parseBoolean } from '~/lib/utils/common_utils'; +// NOTE: at 1200px nav sidebar should not overlap the content +// https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24555#note_134136110 +const NAV_SIDEBAR_BREAKPOINT = 1200; + +export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed'; + export default class ContextualSidebar { constructor() { this.initDomElements(); @@ -26,44 +32,58 @@ export default class ContextualSidebar { bindEvents() { if (!this.$sidebar.length) return; - document.addEventListener('click', e => { - if ( - !e.target.closest('.nav-sidebar') && - (bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'md') - ) { - this.toggleCollapsedSidebar(true, true); - } - }); this.$openSidebar.on('click', () => this.toggleSidebarNav(true)); this.$closeSidebar.on('click', () => this.toggleSidebarNav(false)); this.$overlay.on('click', () => this.toggleSidebarNav(false)); this.$sidebarToggle.on('click', () => { - const value = !this.$sidebar.hasClass('sidebar-collapsed-desktop'); - this.toggleCollapsedSidebar(value, true); + if (!ContextualSidebar.isDesktopBreakpoint()) { + this.toggleSidebarNav(!this.$sidebar.hasClass('sidebar-expanded-mobile')); + } else { + const value = !this.$sidebar.hasClass('sidebar-collapsed-desktop'); + this.toggleCollapsedSidebar(value, true); + } + }); + this.$page.on('transitionstart transitionend', () => { + $(document).trigger('content.resize'); }); $(window).on('resize', () => _.debounce(this.render(), 100)); } + // TODO: use the breakpoints from breakpoints.js once they have been updated for bootstrap 4 + // See documentation: https://design.gitlab.com/regions/navigation#contextual-navigation + static isDesktopBreakpoint = () => bp.windowWidth() >= NAV_SIDEBAR_BREAKPOINT; static setCollapsedCookie(value) { - if (bp.getBreakpointSize() !== 'lg') { + if (!ContextualSidebar.isDesktopBreakpoint()) { return; } Cookies.set('sidebar_collapsed', value, { expires: 365 * 10 }); } toggleSidebarNav(show) { - this.$sidebar.toggleClass('sidebar-expanded-mobile', show); - this.$overlay.toggleClass('mobile-nav-open', show); + const breakpoint = bp.getBreakpointSize(); + const dbp = ContextualSidebar.isDesktopBreakpoint(); + + this.$sidebar.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show); + this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? show : false); + this.$overlay.toggleClass( + 'mobile-nav-open', + breakpoint === 'xs' || breakpoint === 'sm' ? show : false, + ); this.$sidebar.removeClass('sidebar-collapsed-desktop'); } toggleCollapsedSidebar(collapsed, saveCookie) { const breakpoint = bp.getBreakpointSize(); + const dbp = ContextualSidebar.isDesktopBreakpoint(); if (this.$sidebar.length) { this.$sidebar.toggleClass('sidebar-collapsed-desktop', collapsed); - this.$page.toggleClass('page-with-icon-sidebar', breakpoint === 'sm' ? true : collapsed); + this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? !collapsed : false); + this.$page.toggleClass( + 'page-with-icon-sidebar', + breakpoint === 'xs' || breakpoint === 'sm' ? true : collapsed, + ); } if (saveCookie) { @@ -84,13 +104,11 @@ export default class ContextualSidebar { render() { if (!this.$sidebar.length) return; - const breakpoint = bp.getBreakpointSize(); - - if (breakpoint === 'sm' || breakpoint === 'md') { - this.toggleCollapsedSidebar(true, false); - } else if (breakpoint === 'lg') { + if (!ContextualSidebar.isDesktopBreakpoint()) { + this.toggleSidebarNav(false); + } else { const collapse = parseBoolean(Cookies.get('sidebar_collapsed')); - this.toggleCollapsedSidebar(collapse, false); + this.toggleCollapsedSidebar(collapse, true); } } } diff --git a/app/assets/javascripts/create_item_dropdown.js b/app/assets/javascripts/create_item_dropdown.js index 916b190f469..fa0f04c7d82 100644 --- a/app/assets/javascripts/create_item_dropdown.js +++ b/app/assets/javascripts/create_item_dropdown.js @@ -12,7 +12,7 @@ export default class CreateItemDropdown { this.fieldName = options.fieldName; this.onSelect = options.onSelect || (() => {}); this.getDataOption = options.getData; - this.getDataRemote = !!options.filterRemote; + this.getDataRemote = Boolean(options.filterRemote); this.createNewItemFromValueOption = options.createNewItemFromValue; this.$dropdown = options.$dropdown; this.$dropdownContainer = this.$dropdown.parent(); diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js index 28ca7d97314..eac0e37bcaa 100644 --- a/app/assets/javascripts/create_label.js +++ b/app/assets/javascripts/create_label.js @@ -14,6 +14,7 @@ export default class CreateLabelDropdown { this.$newLabelField = $('#new_label_name', this.$el); this.$newColorField = $('#new_label_color', this.$el); this.$colorPreview = $('.js-dropdown-label-color-preview', this.$el); + this.$addList = $('.js-add-list', this.$el); this.$newLabelError = $('.js-label-error', this.$el); this.$newLabelCreateButton = $('.js-new-label-btn', this.$el); this.$colorSuggestions = $('.suggest-colors-dropdown a', this.$el); @@ -21,6 +22,8 @@ export default class CreateLabelDropdown { this.$newLabelError.hide(); this.$newLabelCreateButton.disable(); + this.addListDefault = this.$addList.is(':checked'); + this.cleanBinding(); this.addBinding(); } @@ -83,6 +86,8 @@ export default class CreateLabelDropdown { this.$newColorField.val('').trigger('change'); + this.$addList.prop('checked', this.addListDefault); + this.$colorPreview .css('background-color', '') .parent() @@ -116,9 +121,9 @@ export default class CreateLabelDropdown { this.$newLabelError.html(errors).show(); } else { + const addNewList = this.$addList.is(':checked'); this.$dropdownBack.trigger('click'); - - $(document).trigger('created.label', label); + $(document).trigger('created.label', [label, addNewList]); } }, ); diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index 02aa507ba03..8f5cece0788 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -118,7 +118,7 @@ export default class CreateMergeRequestDropdown { this.branchCreated = true; window.location.href = data.url; }) - .catch(() => Flash('Failed to create a branch for this issue. Please try again.')); + .catch(() => Flash(__('Failed to create a branch for this issue. Please try again.'))); } createMergeRequest() { @@ -130,7 +130,7 @@ export default class CreateMergeRequestDropdown { this.mergeRequestCreated = true; window.location.href = data.url; }) - .catch(() => Flash('Failed to create Merge Request. Please try again.')); + .catch(() => Flash(__('Failed to create Merge Request. Please try again.'))); } disable() { @@ -227,7 +227,7 @@ export default class CreateMergeRequestDropdown { .catch(() => { this.unavailable(); this.disable(); - new Flash('Failed to get ref.'); + new Flash(__('Failed to get ref.')); this.isGettingRef = false; diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index 4de425b48e7..3f0a9f2602c 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -12,6 +12,7 @@ import stageStagingComponent from './components/stage_staging_component.vue'; import stageTestComponent from './components/stage_test_component.vue'; import CycleAnalyticsService from './cycle_analytics_service'; import CycleAnalyticsStore from './cycle_analytics_store'; +import { __ } from '~/locale'; Vue.use(Translate); @@ -61,7 +62,7 @@ export default () => { methods: { handleError() { this.store.setErrorState(true); - return new Flash('There was an error while fetching cycle analytics data.'); + return new Flash(__('There was an error while fetching cycle analytics data.')); }, initDropdown() { const $dropdown = $('.js-ca-dropdown'); diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js index 4ae4ceabc21..f66e07ba31a 100644 --- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js @@ -3,6 +3,7 @@ import $ from 'jquery'; import Vue from 'vue'; +import { __ } from '~/locale'; const CommentAndResolveBtn = Vue.extend({ props: { @@ -31,15 +32,15 @@ const CommentAndResolveBtn = Vue.extend({ buttonText: function() { if (this.isDiscussionResolved) { if (this.textareaIsEmpty) { - return 'Unresolve discussion'; + return __('Unresolve discussion'); } else { - return 'Comment & unresolve discussion'; + return __('Comment & unresolve discussion'); } } else { if (this.textareaIsEmpty) { - return 'Resolve discussion'; + return __('Resolve discussion'); } else { - return 'Comment & resolve discussion'; + return __('Comment & resolve discussion'); } } }, diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js index 5bdeaaade68..b5a781cbc92 100644 --- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js +++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js @@ -5,6 +5,7 @@ import Vue from 'vue'; import collapseIcon from '../icons/collapse_icon.svg'; import Notes from '../../notes'; import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; +import { n__ } from '~/locale'; const DiffNoteAvatars = Vue.extend({ components: { @@ -44,7 +45,7 @@ const DiffNoteAvatars = Vue.extend({ if (this.discussion) { const extra = this.discussion.notesCount() - this.shownAvatars; - return `${extra} more comment${extra > 1 ? 's' : ''}`; + return n__('%d more comment', '%d more comments', extra); } return ''; diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js index 8542a6e718a..fe4088cadda 100644 --- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js +++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js @@ -3,6 +3,7 @@ import $ from 'jquery'; import Vue from 'vue'; +import { __ } from '~/locale'; import DiscussionMixins from '../mixins/discussion'; @@ -23,9 +24,9 @@ const JumpToDiscussion = Vue.extend({ computed: { buttonText: function() { if (this.discussionId) { - return 'Jump to next unresolved discussion'; + return __('Jump to next unresolved discussion'); } else { - return 'Jump to first unresolved discussion'; + return __('Jump to first unresolved discussion'); } }, allResolved: function() { diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js index a69b34b0db8..87e7dd18e0c 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js @@ -4,6 +4,7 @@ import $ from 'jquery'; import Vue from 'vue'; import Flash from '../../flash'; +import { sprintf, __ } from '~/locale'; const ResolveBtn = Vue.extend({ props: { @@ -55,12 +56,14 @@ const ResolveBtn = Vue.extend({ }, buttonText() { if (this.isResolved) { - return `Resolved by ${this.resolvedByName}`; + return sprintf(__('Resolved by %{resolvedByName}'), { + resolvedByName: this.resolvedByName, + }); } else if (this.canResolve) { - return 'Mark as resolved'; + return __('Mark as resolved'); } - return 'Unable to resolve'; + return __('Unable to resolve'); }, isResolved() { if (this.note) { @@ -132,7 +135,8 @@ const ResolveBtn = Vue.extend({ this.updateTooltip(); }) .catch( - () => new Flash('An error occurred when trying to resolve a comment. Please try again.'), + () => + new Flash(__('An error occurred when trying to resolve a comment. Please try again.')), ); }, }, diff --git a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js index 6fcad187b35..4b204fdfeb0 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js +++ b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js @@ -3,6 +3,7 @@ /* global ResolveService */ import Vue from 'vue'; +import { __ } from '~/locale'; const ResolveDiscussionBtn = Vue.extend({ props: { @@ -41,9 +42,9 @@ const ResolveDiscussionBtn = Vue.extend({ }, buttonText: function() { if (this.isDiscussionResolved) { - return 'Unresolve discussion'; + return __('Unresolve discussion'); } else { - return 'Resolve discussion'; + return __('Resolve discussion'); } }, loading: function() { diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js index e69eaad4423..0687028ca54 100644 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ b/app/assets/javascripts/diff_notes/services/resolve.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import Flash from '../../flash'; import '../../vue_shared/vue_resource_interceptor'; +import { __ } from '~/locale'; window.gl = window.gl || {}; @@ -49,7 +50,8 @@ class ResolveServiceClass { discussion.updateHeadline(data); }) .catch( - () => new Flash('An error occurred when trying to resolve a discussion. Please try again.'), + () => + new Flash(__('An error occurred when trying to resolve a discussion. Please try again.')), ); } diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 8f47931d14a..11d6672cacf 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -5,6 +5,7 @@ import { __ } from '~/locale'; import createFlash from '~/flash'; import { GlLoadingIcon } from '@gitlab/ui'; import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; +import Mousetrap from 'mousetrap'; import eventHub from '../../notes/event_hub'; import CompareVersions from './compare_versions.vue'; import DiffFile from './diff_file.vue'; @@ -18,6 +19,8 @@ import { MIN_TREE_WIDTH, MAX_TREE_WIDTH, TREE_HIDE_STATS_WIDTH, + MR_TREE_SHOW_KEY, + CENTERED_LIMITED_CONTAINER_CLASSES, } from '../constants'; export default { @@ -61,6 +64,11 @@ export default { required: false, default: '', }, + isFluidLayout: { + type: Boolean, + required: false, + default: false, + }, }, data() { const treeWidth = @@ -87,7 +95,7 @@ export default { emailPatchPath: state => state.diffs.emailPatchPath, }), ...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion']), - ...mapGetters('diffs', ['isParallelView']), + ...mapGetters('diffs', ['isParallelView', 'currentDiffIndex']), ...mapGetters(['isNotesFetched', 'getNoteableData']), targetBranch() { return { @@ -112,6 +120,9 @@ export default { hideFileStats() { return this.treeWidth <= TREE_HIDE_STATS_WIDTH; }, + isLimitedContainer() { + return !this.showTreeList && !this.isParallelView && !this.isFluidLayout; + }, }, watch: { diffViewType() { @@ -146,9 +157,13 @@ export default { this.adjustView(); eventHub.$once('fetchedNotesData', this.setDiscussions); eventHub.$once('fetchDiffData', this.fetchData); + eventHub.$on('refetchDiffData', this.refetchDiffData); + this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES; }, beforeDestroy() { eventHub.$off('fetchDiffData', this.fetchData); + eventHub.$off('refetchDiffData', this.refetchDiffData); + this.removeEventListeners(); }, methods: { ...mapActions(['startTaskList']), @@ -159,10 +174,20 @@ export default { 'assignDiscussionsToDiff', 'setHighlightedRow', 'cacheTreeListWidth', + 'scrollToFile', + 'toggleShowTreeList', ]), - fetchData() { + refetchDiffData() { + this.assignedDiscussions = false; + this.fetchData(false); + }, + fetchData(toggleTree = true) { this.fetchDiffFiles() .then(() => { + if (toggleTree) { + this.hideTreeListIfJustOneFile(); + } + requestIdleCallback( () => { this.setDiscussions(); @@ -195,9 +220,42 @@ export default { adjustView() { if (this.shouldShow) { this.$nextTick(() => { - window.mrTabs.resetViewContainer(); - window.mrTabs.expandViewContainer(this.showTreeList); + this.setEventListeners(); }); + } else { + this.removeEventListeners(); + } + }, + setEventListeners() { + Mousetrap.bind(['[', 'k', ']', 'j'], (e, combo) => { + switch (combo) { + case '[': + case 'k': + this.jumpToFile(-1); + break; + case ']': + case 'j': + this.jumpToFile(+1); + break; + default: + break; + } + }); + }, + removeEventListeners() { + Mousetrap.unbind(['[', 'k', ']', 'j']); + }, + jumpToFile(step) { + const targetIndex = this.currentDiffIndex + step; + if (targetIndex >= 0 && targetIndex < this.diffFiles.length) { + this.scrollToFile(this.diffFiles[targetIndex].file_path); + } + }, + hideTreeListIfJustOneFile() { + const storedTreeShow = localStorage.getItem(MR_TREE_SHOW_KEY); + + if ((storedTreeShow === null && this.diffFiles.length <= 1) || storedTreeShow === 'false') { + this.toggleShowTreeList(false); } }, }, @@ -214,6 +272,7 @@ export default { :merge-request-diffs="mergeRequestDiffs" :merge-request-diff="mergeRequestDiff" :target-branch="targetBranch" + :is-limited-container="isLimitedContainer" /> <hidden-files-warning @@ -243,7 +302,12 @@ export default { /> <tree-list :hide-file-stats="hideFileStats" /> </div> - <div class="diff-files-holder"> + <div + class="diff-files-holder" + :class="{ + [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer, + }" + > <commit-widget v-if="commit" :commit="commit" /> <template v-if="renderDiffFiles"> <diff-file diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index c02a8740a42..bd7259ce3ee 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -69,7 +69,7 @@ export default { :link-href="authorUrl" :img-src="authorAvatar" :img-alt="authorName" - :img-size="36" + :img-size="40" class="avatar-cell d-none d-sm-block" /> <div class="commit-detail flex-list"> @@ -113,9 +113,10 @@ export default { <commit-pipeline-status v-if="commit.pipeline_status_path" :endpoint="commit.pipeline_status_path" + class="d-inline-flex" /> <div class="commit-sha-group"> - <div class="label label-monospace" v-text="commit.short_id"></div> + <div class="label label-monospace monospace" v-text="commit.short_id"></div> <clipboard-button :text="commit.id" :title="__('Copy commit SHA to clipboard')" diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 0bf2dde8b96..363ebad1594 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -7,6 +7,7 @@ import Icon from '~/vue_shared/components/icon.vue'; import CompareVersionsDropdown from './compare_versions_dropdown.vue'; import SettingsDropdown from './settings_dropdown.vue'; import DiffStats from './diff_stats.vue'; +import { CENTERED_LIMITED_CONTAINER_CLASSES } from '../constants'; export default { components: { @@ -35,6 +36,11 @@ export default { required: false, default: null, }, + isLimitedContainer: { + type: Boolean, + required: false, + default: false, + }, }, computed: { ...mapGetters('diffs', ['hasCollapsedFile', 'diffFilesLength']), @@ -62,6 +68,9 @@ export default { return this.mergeRequestDiff.base_version_path; }, }, + created() { + this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES; + }, mounted() { polyfillSticky(this.$el); }, @@ -77,8 +86,13 @@ export default { </script> <template> - <div class="mr-version-controls" :class="{ 'is-fileTreeOpen': showTreeList }"> - <div class="mr-version-menus-container content-block"> + <div class="mr-version-controls border-top border-bottom"> + <div + class="mr-version-menus-container content-block" + :class="{ + [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer, + }" + > <button v-gl-tooltip.hover type="button" @@ -125,9 +139,9 @@ export default { > {{ __('Show latest version') }} </gl-button> - <a v-show="hasCollapsedFile" class="btn btn-default append-right-8" @click="expandAllFiles"> + <gl-button v-show="hasCollapsedFile" class="append-right-8" @click="expandAllFiles"> {{ __('Expand all') }} - </a> + </gl-button> <settings-dropdown /> </div> </div> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index cb92093db32..d59b1136677 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -1,10 +1,14 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; +import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_diffable.vue'; import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_preview.vue'; import InlineDiffView from './inline_diff_view.vue'; import ParallelDiffView from './parallel_diff_view.vue'; +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import NoteForm from '../../notes/components/note_form.vue'; import ImageDiffOverlay from './image_diff_overlay.vue'; import DiffDiscussions from './diff_discussions.vue'; @@ -14,6 +18,7 @@ import { diffViewerModes } from '~/ide/constants'; export default { components: { + GlLoadingIcon, InlineDiffView, ParallelDiffView, DiffViewer, @@ -22,7 +27,10 @@ export default { ImageDiffOverlay, NotDiffableViewer, NoPreviewViewer, + userAvatarLink, + DiffFileDrafts: () => import('ee_component/batch_comments/components/diff_file_drafts.vue'), }, + mixins: [diffLineNoteFormMixin, draftCommentsMixin], props: { diffFile: { type: Object, @@ -41,7 +49,7 @@ export default { }), ...mapGetters('diffs', ['isInlineView', 'isParallelView']), ...mapGetters('diffs', ['getCommentFormForDiffFile']), - ...mapGetters(['getNoteableData', 'noteableType']), + ...mapGetters(['getNoteableData', 'noteableType', 'getUserData']), diffMode() { return getDiffMode(this.diffFile); }, @@ -58,10 +66,16 @@ export default { return this.diffViewerMode === diffViewerModes.not_diffable; }, diffFileCommentForm() { - return this.getCommentFormForDiffFile(this.diffFile.file_hash); + return this.getCommentFormForDiffFile(this.diffFileHash); }, showNotesContainer() { - return this.diffFile.discussions.length || this.diffFileCommentForm; + return this.imageDiscussions.length || this.diffFileCommentForm; + }, + diffFileHash() { + return this.diffFile.file_hash; + }, + author() { + return this.getUserData; }, }, methods: { @@ -101,6 +115,7 @@ export default { :diff-lines="diffFile.parallel_diff_lines || []" :help-page-path="helpPagePath" /> + <gl-loading-icon v-if="diffFile.renderingLines" size="md" class="mt-3" /> </template> <not-diffable-viewer v-else-if="notDiffable" /> <no-preview-viewer v-else-if="noPreview" /> @@ -112,18 +127,26 @@ export default { :new-sha="diffFile.diff_refs.head_sha" :old-path="diffFile.old_path" :old-sha="diffFile.diff_refs.base_sha" - :file-hash="diffFile.file_hash" + :file-hash="diffFileHash" :project-path="projectPath" :a-mode="diffFile.a_mode" :b-mode="diffFile.b_mode" > <image-diff-overlay slot="image-overlay" - :discussions="diffFile.discussions" - :file-hash="diffFile.file_hash" + :discussions="imageDiscussions" + :file-hash="diffFileHash" :can-comment="getNoteableData.current_user.can_create_note" /> <div v-if="showNotesContainer" class="note-container"> + <user-avatar-link + v-if="diffFileCommentForm && author" + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + class="d-none d-sm-block new-comment" + /> <diff-discussions v-if="diffFile.discussions.length" class="diff-file-discussions" @@ -131,14 +154,16 @@ export default { :should-collapse-discussions="true" :render-avatar-badge="true" /> + <diff-file-drafts :file-hash="diffFileHash" class="diff-file-discussions" /> <note-form v-if="diffFileCommentForm" ref="noteForm" :is-editing="false" :save-button-title="__('Comment')" class="diff-comment-form new-note discussion-form discussion-form-container" + @handleFormUpdateAddToReview="addToReview" @handleFormUpdate="handleSaveNote" - @cancelForm="closeDiffFileCommentForm(diffFile.file_hash)" + @cancelForm="closeDiffFileCommentForm(diffFileHash)" /> </div> </diff-viewer> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 1141a197c6a..f5876a73eff 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -73,13 +73,23 @@ export default { if (!newVal && oldVal && !this.hasDiffLines) { this.handleLoadCollapsedDiff(); } + + this.setFileCollapsed({ filePath: this.file.file_path, collapsed: newVal }); + }, + 'file.viewer.collapsed': function setIsCollapsed(newVal) { + this.isCollapsed = newVal; }, }, created() { eventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.handleLoadCollapsedDiff); }, methods: { - ...mapActions('diffs', ['loadCollapsedDiff', 'assignDiscussionsToDiff', 'setRenderIt']), + ...mapActions('diffs', [ + 'loadCollapsedDiff', + 'assignDiscussionsToDiff', + 'setRenderIt', + 'setFileCollapsed', + ]), handleToggle() { if (!this.hasDiffLines) { this.handleLoadCollapsedDiff(); @@ -160,26 +170,24 @@ export default { </div> <gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" /> <template v-else> - <div v-if="errorMessage" class="diff-viewer"> - <div class="nothing-here-block" v-html="errorMessage"></div> + <div :id="`diff-content-${file.file_hash}`"> + <div v-if="errorMessage" class="diff-viewer"> + <div class="nothing-here-block" v-html="errorMessage"></div> + </div> + <div v-else-if="isCollapsed" class="nothing-here-block diff-collapsed"> + {{ __('This diff is collapsed.') }} + <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{ + __('Click to expand it.') + }}</a> + </div> + <diff-content + v-else + :class="{ hidden: isCollapsed || isFileTooLarge }" + :diff-file="file" + :help-page-path="helpPagePath" + /> </div> - <div v-else-if="isCollapsed" class="nothing-here-block diff-collapsed"> - {{ __('This diff is collapsed.') }} - <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{ - __('Click to expand it.') - }}</a> - </div> - <diff-content - v-else - :class="{ hidden: isCollapsed || isFileTooLarge }" - :diff-file="file" - :help-page-path="helpPagePath" - /> </template> - <div v-if="isFileTooLarge" class="nothing-here-block diff-collapsed js-too-large-diff"> - {{ __('This source diff could not be displayed because it is too large.') }} - <span v-html="viewBlobLink"></span> - </div> </div> </template> diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 2b801898345..eb9f1465945 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -1,19 +1,23 @@ <script> import _ from 'underscore'; import { mapActions, mapGetters } from 'vuex'; -import { polyfillSticky } from '~/lib/utils/sticky'; +import { polyfillSticky, stickyMonitor } from '~/lib/utils/sticky'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import Icon from '~/vue_shared/components/icon.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; -import { GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective, GlTooltip, GlLoadingIcon } from '@gitlab/ui'; import { truncateSha } from '~/lib/utils/text_utility'; import { __, s__, sprintf } from '~/locale'; import { diffViewerModes } from '~/ide/constants'; import EditButton from './edit_button.vue'; import DiffStats from './diff_stats.vue'; +import { scrollToElement, contentTop } from '~/lib/utils/common_utils'; export default { components: { + GlTooltip, + GlLoadingIcon, + GlButton, ClipboardButton, EditButton, Icon, @@ -63,6 +67,9 @@ export default { hasExpandedDiscussions() { return this.diffHasExpandedDiscussions(this.diffFile); }, + diffContentIDSelector() { + return `#diff-content-${this.diffFile.file_hash}`; + }, icon() { if (this.diffFile.submodule) { return 'archive'; @@ -74,6 +81,11 @@ export default { if (this.diffFile.submodule) { return this.diffFile.submodule_tree_url || this.diffFile.submodule_link; } + + if (!this.discussionPath) { + return this.diffContentIDSelector; + } + return this.discussionPath; }, filePath() { @@ -100,9 +112,7 @@ export default { const truncatedContentSha = _.escape(truncateSha(this.diffFile.content_sha)); return sprintf( s__('MergeRequests|View file @ %{commitId}'), - { - commitId: `<span class="commit-sha">${truncatedContentSha}</span>`, - }, + { commitId: truncatedContentSha }, false, ); }, @@ -125,12 +135,23 @@ export default { isModeChanged() { return this.diffFile.viewer.name === diffViewerModes.mode_changed; }, + showExpandDiffToFullFileEnabled() { + return gon.features.expandDiffFullFile && !this.diffFile.is_fully_expanded; + }, + expandDiffToFullFileTitle() { + if (this.diffFile.isShowingFullFile) { + return s__('MRDiff|Show changes only'); + } + return s__('MRDiff|Show full file'); + }, }, mounted() { polyfillSticky(this.$refs.header); + const fileHeaderHeight = this.$refs.header.clientHeight; + stickyMonitor(this.$refs.header, contentTop() - fileHeaderHeight - 1, false); }, methods: { - ...mapActions('diffs', ['toggleFileDiscussions']), + ...mapActions('diffs', ['toggleFileDiscussions', 'toggleFullDiff']), handleToggleFile(e, checkTarget) { if ( !checkTarget || @@ -146,6 +167,18 @@ export default { handleToggleDiscussions() { this.toggleFileDiscussions(this.diffFile); }, + handleFileNameClick(e) { + const isLinkToOtherPage = + this.diffFile.submodule_tree_url || this.diffFile.submodule_link || this.discussionPath; + + if (!isLinkToOtherPage) { + e.preventDefault(); + const selector = this.diffContentIDSelector; + + scrollToElement(document.querySelector(selector)); + window.location.hash = selector; + } + }, }, }; </script> @@ -165,7 +198,14 @@ export default { class="diff-toggle-caret append-right-5" @click.stop="handleToggle" /> - <a v-once ref="titleWrapper" :href="titleLink" class="append-right-4 js-title-wrapper"> + <a + v-once + id="diffFile.file_path" + ref="titleWrapper" + class="append-right-4 js-title-wrapper" + :href="titleLink" + @click="handleFileNameClick" + > <file-icon :file-name="filePath" :size="18" @@ -200,7 +240,7 @@ export default { css-class="btn-default btn-transparent btn-clipboard" /> - <small v-if="isModeChanged" ref="fileMode"> + <small v-if="isModeChanged" ref="fileMode" class="mr-1"> {{ diffFile.a_mode }} → {{ diffFile.b_mode }} </small> @@ -212,48 +252,71 @@ export default { class="file-actions d-none d-sm-block" > <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" /> - <template v-if="diffFile.blob && diffFile.blob.readable_text"> - <button - :disabled="!diffHasDiscussions(diffFile)" - :class="{ active: hasExpandedDiscussions }" - :title="s__('MergeRequests|Toggle comments for this file')" - class="js-btn-vue-toggle-comments btn" - type="button" - @click="handleToggleDiscussions" - > - <icon name="comment" /> - </button> + <div class="btn-group" role="group"> + <template v-if="diffFile.blob && diffFile.blob.readable_text"> + <span v-gl-tooltip.hover :title="s__('MergeRequests|Toggle comments for this file')"> + <gl-button + :disabled="!diffHasDiscussions(diffFile)" + :class="{ active: hasExpandedDiscussions }" + class="js-btn-vue-toggle-comments btn" + type="button" + @click="handleToggleDiscussions" + > + <icon name="comment" /> + </gl-button> + </span> - <edit-button - v-if="!diffFile.deleted_file" - :can-current-user-fork="canCurrentUserFork" - :edit-path="diffFile.edit_path" - :can-modify-blob="diffFile.can_modify_blob" - @showForkMessage="showForkMessage" - /> - </template> + <edit-button + v-if="!diffFile.deleted_file" + :can-current-user-fork="canCurrentUserFork" + :edit-path="diffFile.edit_path" + :can-modify-blob="diffFile.can_modify_blob" + @showForkMessage="showForkMessage" + /> + </template> - <a - v-if="diffFile.replaced_view_path" - :href="diffFile.replaced_view_path" - class="btn view-file js-view-file" - v-html="viewReplacedFileButtonText" - > - </a> - <a :href="diffFile.view_path" class="btn view-file js-view-file" v-html="viewFileButtonText"> - </a> + <a + v-if="diffFile.replaced_view_path" + :href="diffFile.replaced_view_path" + class="btn view-file js-view-replaced-file" + v-html="viewReplacedFileButtonText" + > + </a> + <gl-button + v-if="!diffFile.is_fully_expanded" + ref="expandDiffToFullFileButton" + v-gl-tooltip.hover + :title="expandDiffToFullFileTitle" + class="expand-file js-expand-file" + @click="toggleFullDiff(diffFile.file_path)" + > + <gl-loading-icon v-if="diffFile.isLoadingFullFile" color="dark" inline /> + <icon v-else-if="diffFile.isShowingFullFile" name="doc-changes" /> + <icon v-else name="doc-expand" /> + </gl-button> + <gl-button + ref="viewButton" + v-gl-tooltip.hover + :href="diffFile.view_path" + target="blank" + class="view-file js-view-file-button" + :title="viewFileButtonText" + > + <icon name="doc-text" /> + </gl-button> - <a - v-if="diffFile.external_url" - v-gl-tooltip.hover - :href="diffFile.external_url" - :title="`View on ${diffFile.formatted_external_url}`" - target="_blank" - rel="noopener noreferrer" - class="btn btn-file-option" - > - <icon name="external-link" /> - </a> + <a + v-if="diffFile.external_url" + v-gl-tooltip.hover + :href="diffFile.external_url" + :title="`View on ${diffFile.formatted_external_url}`" + target="_blank" + rel="noopener noreferrer" + class="btn btn-file-option js-external-url" + > + <icon name="external-link" /> + </a> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue index 0c0a0faa59d..7cf3d90d468 100644 --- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue +++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue @@ -86,7 +86,6 @@ export default { :key="note.id" :img-src="note.author.avatar_url" :tooltip-text="getTooltipText(note)" - :size="19" class="diff-comment-avatar js-diff-comment-avatar" @click.native="toggleDiscussions" /> diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue index 6709df48637..1281f9b17ef 100644 --- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue +++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue @@ -84,8 +84,6 @@ export default { }, shouldShowCommentButton() { return ( - this.isLoggedIn && - this.showCommentButton && this.isHover && !this.isMatchLine && !this.isContextLine && @@ -102,6 +100,9 @@ export default { } return this.showCommentButton && this.hasDiscussions; }, + shouldRenderCommentButton() { + return this.isLoggedIn && this.showCommentButton; + }, }, methods: { ...mapActions('diffs', ['loadMoreLines', 'showCommentForm', 'setHighlightedRow']), @@ -167,6 +168,7 @@ export default { > <template v-else> <button + v-if="shouldRenderCommentButton" v-show="shouldShowCommentButton" type="button" class="add-diff-note js-add-diff-note-button qa-diff-comment" diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index 18edbe286ba..c209b857652 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -1,15 +1,18 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; import { s__ } from '~/locale'; +import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; import noteForm from '../../notes/components/note_form.vue'; import autosave from '../../notes/mixins/autosave'; +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import { DIFF_NOTE_TYPE } from '../constants'; export default { components: { noteForm, + userAvatarLink, }, - mixins: [autosave], + mixins: [autosave, diffLineNoteFormMixin], props: { diffFileHash: { type: String, @@ -40,17 +43,29 @@ export default { diffViewType: state => state.diffs.diffViewType, }), ...mapGetters('diffs', ['getDiffFileByHash']), - ...mapGetters(['isLoggedIn', 'noteableType', 'getNoteableData', 'getNotesDataByProp']), + ...mapGetters([ + 'isLoggedIn', + 'noteableType', + 'getNoteableData', + 'getNotesDataByProp', + 'getUserData', + ]), + author() { + return this.getUserData; + }, formData() { return { noteableData: this.noteableData, noteableType: this.noteableType, noteTargetLine: this.noteTargetLine, diffViewType: this.diffViewType, - diffFile: this.getDiffFileByHash(this.diffFileHash), + diffFile: this.diffFile, linePosition: this.linePosition, }; }, + diffFile() { + return this.getDiffFileByHash(this.diffFileHash); + }, }, mounted() { if (this.isLoggedIn) { @@ -95,14 +110,24 @@ export default { <template> <div class="content discussion-form discussion-form-container discussion-notes"> + <user-avatar-link + v-if="author" + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + class="d-none d-sm-block" + /> <note-form ref="noteForm" :is-editing="true" :line-code="line.line_code" :line="line" :help-page-path="helpPagePath" + :diff-file="diffFile" save-button-title="Comment" class="diff-comment-form" + @handleFormUpdateAddToReview="addToReview" @cancelForm="handleCancelCommentForm" @handleFormUpdate="handleSaveNote" /> diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue index d174b13e133..0f3e9208d21 100644 --- a/app/assets/javascripts/diffs/components/diff_table_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -89,17 +89,19 @@ export default { classNameMap() { const { type } = this.line; - return { - hll: this.isHighlighted, - [type]: type, - [LINE_UNFOLD_CLASS_NAME]: this.isMatchLine, - [LINE_HOVER_CLASS_NAME]: - this.isLoggedIn && - this.isHover && - !this.isMatchLine && - !this.isContextLine && - !this.isMetaLine, - }; + return [ + type, + { + hll: this.isHighlighted, + [LINE_UNFOLD_CLASS_NAME]: this.isMatchLine, + [LINE_HOVER_CLASS_NAME]: + this.isLoggedIn && + this.isHover && + !this.isMatchLine && + !this.isContextLine && + !this.isMetaLine, + }, + ]; }, lineNumber() { return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line; diff --git a/app/assets/javascripts/diffs/components/edit_button.vue b/app/assets/javascripts/diffs/components/edit_button.vue index 5d38d545ce8..dcb79cd5e16 100644 --- a/app/assets/javascripts/diffs/components/edit_button.vue +++ b/app/assets/javascripts/diffs/components/edit_button.vue @@ -1,5 +1,15 @@ <script> +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; + export default { + components: { + GlButton, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { editPath: { type: String, @@ -17,12 +27,7 @@ export default { }, methods: { handleEditClick(evt) { - if (!this.canCurrentUserFork || this.canModifyBlob) { - // if we can Edit, do default Edit button behavior - return; - } - - if (this.canCurrentUserFork) { + if (this.canCurrentUserFork && !this.canModifyBlob) { evt.preventDefault(); this.$emit('showForkMessage'); } @@ -32,5 +37,13 @@ export default { </script> <template> - <a :href="editPath" class="btn btn-default js-edit-blob" @click="handleEditClick"> Edit </a> + <gl-button + v-gl-tooltip.top + :href="editPath" + :title="__('Edit file')" + class="js-edit-blob" + @click.native="handleEditClick" + > + <icon name="pencil" /> + </gl-button> </template> diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue index 4a83c5a72a5..703a281308e 100644 --- a/app/assets/javascripts/diffs/components/image_diff_overlay.vue +++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue @@ -1,6 +1,7 @@ <script> import { mapActions, mapGetters } from 'vuex'; import _ from 'underscore'; +import imageDiffMixin from 'ee_else_ce/diffs/mixins/image_diff'; import Icon from '~/vue_shared/components/icon.vue'; export default { @@ -8,6 +9,7 @@ export default { components: { Icon, }, + mixins: [imageDiffMixin], props: { discussions: { type: [Array, Object], @@ -48,7 +50,6 @@ export default { }, }, methods: { - ...mapActions(['toggleDiscussion']), ...mapActions('diffs', ['openDiffFileCommentForm']), getImageDimensions() { return { @@ -105,15 +106,15 @@ export default { v-for="(discussion, index) in allDiscussions" :key="discussion.id" :style="getPosition(discussion)" - :class="badgeClass" + :class="[badgeClass, { 'is-draft': discussion.isDraft }]" :disabled="!shouldToggleDiscussion" class="js-image-badge" type="button" - @click="toggleDiscussion({ discussionId: discussion.id })" + @click="clickedToggle(discussion)" > <icon v-if="showCommentIcon" name="image-comment-dark" /> <template v-else> - {{ index + 1 }} + {{ toggleText(discussion, index) }} </template> </button> <button diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue index 69146f1f6fd..1faa0493e79 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue @@ -41,7 +41,7 @@ export default { <template> <tr v-if="shouldRender" :class="className" class="notes_holder"> - <td class="notes_content" colspan="3"> + <td class="notes-content" colspan="3"> <div class="content"> <diff-discussions v-if="line.discussions.length" diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue index c764cbeb8e0..2d5262baeec 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue @@ -1,12 +1,11 @@ <script> -import { mapGetters, mapActions, mapState } from 'vuex'; +import { mapActions, mapState } from 'vuex'; import DiffTableCell from './diff_table_cell.vue'; import { NEW_LINE_TYPE, OLD_LINE_TYPE, CONTEXT_LINE_TYPE, CONTEXT_LINE_CLASS_NAME, - PARALLEL_DIFF_VIEW_TYPE, LINE_POSITION_LEFT, LINE_POSITION_RIGHT, } from '../constants'; @@ -45,16 +44,16 @@ export default { return this.line.line_code !== null && this.line.line_code === state.diffs.highlightedRow; }, }), - ...mapGetters('diffs', ['isInlineView']), isContextLine() { return this.line.type === CONTEXT_LINE_TYPE; }, classNameMap() { - return { - [this.line.type]: this.line.type, - [CONTEXT_LINE_CLASS_NAME]: this.isContextLine, - [PARALLEL_DIFF_VIEW_TYPE]: this.isParallelView, - }; + return [ + this.line.type, + { + [CONTEXT_LINE_CLASS_NAME]: this.isContextLine, + }, + ]; }, inlineRowId() { return this.line.line_code || `${this.fileHash}_${this.line.old_line}_${this.line.new_line}`; diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index e781397214d..8c76a555b62 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -1,5 +1,6 @@ <script> import { mapGetters } from 'vuex'; +import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments'; import inlineDiffTableRow from './inline_diff_table_row.vue'; import inlineDiffCommentRow from './inline_diff_comment_row.vue'; @@ -7,7 +8,10 @@ export default { components: { inlineDiffCommentRow, inlineDiffTableRow, + InlineDraftCommentRow: () => + import('ee_component/batch_comments/components/inline_draft_comment_row.vue'), }, + mixins: [draftCommentsMixin], props: { diffFile: { type: Object, @@ -54,6 +58,11 @@ export default { :line="line" :help-page-path="helpPagePath" /> + <inline-draft-comment-row + v-if="shouldRenderDraftRow(diffFile.file_hash, line)" + :key="`draft_${index}`" + :draft="draftForLine(diffFile.file_hash, line)" + /> </template> </tbody> </table> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue index 370cb6e339a..d2e54edca85 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue @@ -87,7 +87,7 @@ export default { <template> <tr v-if="shouldRender" :class="className" class="notes_holder"> - <td class="notes_content parallel old" colspan="2"> + <td class="notes-content parallel old" colspan="2"> <div v-if="shouldRenderDiscussionsOnLeft" class="content"> <diff-discussions v-if="line.left.discussions.length" @@ -105,7 +105,7 @@ export default { line-position="left" /> </td> - <td class="notes_content parallel new" colspan="2"> + <td class="notes-content parallel new" colspan="2"> <div v-if="shouldRenderDiscussionsOnRight" class="content"> <diff-discussions v-if="line.right.discussions.length" diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue index caf0df8a4e3..c60246bf8ef 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue @@ -140,7 +140,7 @@ export default { :id="line.left.line_code" :class="parallelViewLeftLineType" class="line_content parallel left-side" - @mousedown.native="handleParallelLineMouseDown" + @mousedown="handleParallelLineMouseDown" v-html="line.left.rich_text" ></td> </template> @@ -171,7 +171,7 @@ export default { }, ]" class="line_content parallel right-side" - @mousedown.native="handleParallelLineMouseDown" + @mousedown="handleParallelLineMouseDown" v-html="line.right.rich_text" ></td> </template> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index 1bf693380db..41a80d99850 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -1,5 +1,6 @@ <script> import { mapGetters } from 'vuex'; +import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments'; import parallelDiffTableRow from './parallel_diff_table_row.vue'; import parallelDiffCommentRow from './parallel_diff_comment_row.vue'; @@ -7,7 +8,10 @@ export default { components: { parallelDiffTableRow, parallelDiffCommentRow, + ParallelDraftCommentRow: () => + import('ee_component/batch_comments/components/parallel_draft_comment_row.vue'), }, + mixins: [draftCommentsMixin], props: { diffFile: { type: Object, @@ -34,30 +38,34 @@ export default { </script> <template> - <div + <table :class="$options.userColorScheme" :data-commit-id="commitId" class="code diff-wrap-lines js-syntax-highlight text-file" > - <table> - <tbody> - <template v-for="(line, index) in diffLines"> - <parallel-diff-table-row - :key="line.line_code" - :file-hash="diffFile.file_hash" - :context-lines-path="diffFile.context_lines_path" - :line="line" - :is-bottom="index + 1 === diffLinesLength" - /> - <parallel-diff-comment-row - :key="`dcr-${line.line_code || index}`" - :line="line" - :diff-file-hash="diffFile.file_hash" - :line-index="index" - :help-page-path="helpPagePath" - /> - </template> - </tbody> - </table> - </div> + <tbody> + <template v-for="(line, index) in diffLines"> + <parallel-diff-table-row + :key="line.line_code" + :file-hash="diffFile.file_hash" + :context-lines-path="diffFile.context_lines_path" + :line="line" + :is-bottom="index + 1 === diffLinesLength" + /> + <parallel-diff-comment-row + :key="`dcr-${line.line_code || index}`" + :line="line" + :diff-file-hash="diffFile.file_hash" + :line-index="index" + :help-page-path="helpPagePath" + /> + <parallel-draft-comment-row + v-if="shouldRenderParallelDraftRow(diffFile.file_hash, line)" + :key="`drafts-${index}`" + :line="line" + :diff-file-content-sha="diffFile.file_hash" + /> + </template> + </tbody> + </table> </template> diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 8fc3af15bea..30be2e68e76 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -1,6 +1,7 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import { GlTooltipDirective } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import FileRow from '~/vue_shared/components/file_row.vue'; import FileRowStats from './file_row_stats.vue'; @@ -30,8 +31,9 @@ export default { filteredTreeList() { const search = this.search.toLowerCase().trim(); - if (search === '' || this.$options.fuzzyFileFinderEnabled) + if (search === '') { return this.renderTreeList ? this.tree : this.allBlobs; + } return this.allBlobs.reduce((acc, folder) => { const tree = folder.tree.filter(f => f.path.toLowerCase().indexOf(search) >= 0); @@ -51,13 +53,14 @@ export default { }, }, methods: { - ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile', 'toggleFileFinder']), + ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']), clearSearch() { this.search = ''; }, }, - shortcutKeyCharacter: `${/Mac/i.test(navigator.userAgent) ? '⌘' : 'Ctrl'}+P`, - diffTreeFiltering: gon.features && gon.features.diffTreeFiltering, + searchPlaceholder: sprintf(s__('MergeRequest|Filter files or search with %{modifier_key}+p'), { + modifier_key: /Mac/i.test(navigator.userAgent) ? 'cmd' : 'ctrl', + }), }; </script> @@ -66,36 +69,24 @@ export default { <div class="append-bottom-8 position-relative tree-list-search d-flex"> <div class="flex-fill d-flex"> <icon name="search" class="position-absolute tree-list-icon" /> - <template v-if="$options.diffTreeFiltering"> - <input - v-model="search" - :placeholder="s__('MergeRequest|Filter files')" - type="search" - class="form-control" - /> - <button - v-show="search" - :aria-label="__('Clear search')" - type="button" - class="position-absolute bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0" - @click="clearSearch" - > - <icon name="close" /> - </button> - </template> - <template v-else> - <button - type="button" - class="form-control text-left text-secondary" - @click="toggleFileFinder(true)" - > - {{ s__('MergeRequest|Search files') }} - </button> - <span - class="position-absolute text-secondary diff-tree-search-shortcut" - v-html="$options.shortcutKeyCharacter" - ></span> - </template> + <label for="diff-tree-search" class="sr-only">{{ $options.searchPlaceholder }}</label> + <input + id="diff-tree-search" + v-model="search" + :placeholder="$options.searchPlaceholder" + type="search" + name="diff-tree-search" + class="form-control" + /> + <button + v-show="search" + :aria-label="__('Clear search')" + type="button" + class="position-absolute bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0" + @click="clearSearch" + > + <icon name="close" /> + </button> </div> </div> <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll"> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 7002655ea49..d84e1af11f3 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -42,3 +42,18 @@ export const INITIAL_TREE_WIDTH = 320; export const MIN_TREE_WIDTH = 240; export const MAX_TREE_WIDTH = 400; export const TREE_HIDE_STATS_WIDTH = 260; + +export const OLD_LINE_KEY = 'old_line'; +export const NEW_LINE_KEY = 'new_line'; +export const TYPE_KEY = 'type'; +export const LEFT_LINE_KEY = 'left'; + +export const CENTERED_LIMITED_CONTAINER_CLASSES = + 'container-limited limit-container-width mx-lg-auto px-3'; + +export const MAX_RENDERING_DIFF_LINES = 500; +export const MAX_RENDERING_BULK_ROWS = 30; +export const MIN_RENDERING_MS = 2; +export const START_RENDERING_INDEX = 200; +export const INLINE_DIFF_LINES_KEY = 'highlighted_diff_lines'; +export const PARALLEL_DIFF_LINES_KEY = 'parallel_diff_lines'; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 63954d9d412..1d897bca1dd 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -71,6 +71,7 @@ export default function initDiffsApp(store) { helpPagePath: dataset.helpPagePath, currentUser: JSON.parse(dataset.currentUserData) || {}, changesEmptyStateIllustration: dataset.changesEmptyStateIllustration, + isFluidLayout: parseBoolean(dataset.isFluidLayout), }; }, computed: { @@ -97,6 +98,7 @@ export default function initDiffsApp(store) { helpPagePath: this.helpPagePath, shouldShow: this.activeTab === 'diffs', changesEmptyStateIllustration: this.changesEmptyStateIllustration, + isFluidLayout: this.isFluidLayout, }, }); }, diff --git a/app/assets/javascripts/diffs/mixins/draft_comments.js b/app/assets/javascripts/diffs/mixins/draft_comments.js new file mode 100644 index 00000000000..dfb71bf38ce --- /dev/null +++ b/app/assets/javascripts/diffs/mixins/draft_comments.js @@ -0,0 +1,10 @@ +export default { + computed: { + shouldRenderDraftRow: () => () => false, + shouldRenderParallelDraftRow: () => () => false, + draftForLine: () => () => ({}), + imageDiscussions() { + return this.diffFile.discussions; + }, + }, +}; diff --git a/app/assets/javascripts/diffs/mixins/image_diff.js b/app/assets/javascripts/diffs/mixins/image_diff.js new file mode 100644 index 00000000000..9067ea6f8b3 --- /dev/null +++ b/app/assets/javascripts/diffs/mixins/image_diff.js @@ -0,0 +1,13 @@ +import { mapActions } from 'vuex'; + +export default { + methods: { + ...mapActions(['toggleDiscussion']), + clickedToggle(discussion) { + this.toggleDiscussion({ discussionId: discussion.id }); + }, + toggleText(discussion, index) { + return index + 1; + }, + }, +}; diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 82ff2e3be76..479afc50113 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -7,7 +7,12 @@ import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/uti import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility'; import TreeWorker from '../workers/tree_worker'; import eventHub from '../../notes/event_hub'; -import { getDiffPositionByLineCode, getNoteFormData } from './utils'; +import { + getDiffPositionByLineCode, + getNoteFormData, + convertExpandLines, + idleCallback, +} from './utils'; import * as types from './mutation_types'; import { PARALLEL_DIFF_VIEW_TYPE, @@ -17,6 +22,16 @@ import { TREE_LIST_STORAGE_KEY, WHITESPACE_STORAGE_KEY, TREE_LIST_WIDTH_STORAGE_KEY, + OLD_LINE_KEY, + NEW_LINE_KEY, + TYPE_KEY, + LEFT_LINE_KEY, + MAX_RENDERING_DIFF_LINES, + MAX_RENDERING_BULK_ROWS, + MIN_RENDERING_MS, + START_RENDERING_INDEX, + INLINE_DIFF_LINES_KEY, + PARALLEL_DIFF_LINES_KEY, } from '../constants'; import { diffViewerModes } from '~/ide/constants'; @@ -37,7 +52,7 @@ export const fetchDiffFiles = ({ state, commit }) => { }); return axios - .get(state.endpoint, { params: { w: state.showWhitespace ? null : '1' } }) + .get(mergeUrlParams({ w: state.showWhitespace ? '0' : '1' }, state.endpoint)) .then(res => { commit(types.SET_LOADING, false); commit(types.SET_MERGE_REQUEST_DIFFS, res.data.merge_request_diffs || []); @@ -52,7 +67,9 @@ export const fetchDiffFiles = ({ state, commit }) => { }; export const setHighlightedRow = ({ commit }, lineCode) => { + const fileHash = lineCode.split('_')[0]; commit(types.SET_HIGHLIGHTED_ROW, lineCode); + commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash); }; // This is adding line discussions to the actual lines in the diff tree @@ -108,7 +125,8 @@ export const startRenderDiffsQueue = ({ state, commit }) => { new Promise(resolve => { const nextFile = state.diffFiles.find( file => - !file.renderIt && (!file.viewer.collapsed || !file.viewer.name === diffViewerModes.text), + !file.renderIt && + (file.viewer && (!file.viewer.collapsed || !file.viewer.name === diffViewerModes.text)), ); if (nextFile) { @@ -193,11 +211,12 @@ export const scrollToLineIfNeededParallel = (_, line) => { } }; -export const loadCollapsedDiff = ({ commit, getters }, file) => +export const loadCollapsedDiff = ({ commit, getters, state }, file) => axios .get(file.load_collapsed_diff_url, { params: { commit_id: getters.commitId, + w: state.showWhitespace ? '0' : '1', }, }) .then(res => { @@ -262,13 +281,14 @@ export const scrollToFile = ({ state, commit }, path) => { document.location.hash = fileHash; commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash); - - setTimeout(() => commit(types.UPDATE_CURRENT_DIFF_FILE_ID, ''), 1000); }; -export const toggleShowTreeList = ({ commit, state }) => { +export const toggleShowTreeList = ({ commit, state }, saving = true) => { commit(types.TOGGLE_SHOW_TREE_LIST); - localStorage.setItem(MR_TREE_SHOW_KEY, state.showTreeList); + + if (saving) { + localStorage.setItem(MR_TREE_SHOW_KEY, state.showTreeList); + } }; export const openDiffFileCommentForm = ({ commit, getters }, formData) => { @@ -297,8 +317,10 @@ export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = fals localStorage.setItem(WHITESPACE_STORAGE_KEY, showWhitespace); if (pushState) { - historyPushState(showWhitespace ? '?w=0' : '?w=1'); + historyPushState(mergeUrlParams({ w: showWhitespace ? '0' : '1' }, window.location.href)); } + + eventHub.$emit('refetchDiffData'); }; export const toggleFileFinder = ({ commit }, visible) => { @@ -309,5 +331,129 @@ export const cacheTreeListWidth = (_, size) => { localStorage.setItem(TREE_LIST_WIDTH_STORAGE_KEY, size); }; +export const requestFullDiff = ({ commit }, filePath) => commit(types.REQUEST_FULL_DIFF, filePath); +export const receiveFullDiffSucess = ({ commit }, { filePath }) => + commit(types.RECEIVE_FULL_DIFF_SUCCESS, { filePath }); +export const receiveFullDiffError = ({ commit }, filePath) => { + commit(types.RECEIVE_FULL_DIFF_ERROR, filePath); + createFlash(s__('MergeRequest|Error loading full diff. Please try again.')); +}; + +export const setExpandedDiffLines = ({ commit, state }, { file, data }) => { + const expandedDiffLines = { + highlighted_diff_lines: convertExpandLines({ + diffLines: file.highlighted_diff_lines, + typeKey: TYPE_KEY, + oldLineKey: OLD_LINE_KEY, + newLineKey: NEW_LINE_KEY, + data, + mapLine: ({ line, oldLine, newLine }) => + Object.assign(line, { + old_line: oldLine, + new_line: newLine, + line_code: `${file.file_hash}_${oldLine}_${newLine}`, + }), + }), + parallel_diff_lines: convertExpandLines({ + diffLines: file.parallel_diff_lines, + typeKey: [LEFT_LINE_KEY, TYPE_KEY], + oldLineKey: [LEFT_LINE_KEY, OLD_LINE_KEY], + newLineKey: [LEFT_LINE_KEY, NEW_LINE_KEY], + data, + mapLine: ({ line, oldLine, newLine }) => ({ + left: { + ...line, + old_line: oldLine, + line_code: `${file.file_hash}_${oldLine}_${newLine}`, + }, + right: { + ...line, + new_line: newLine, + line_code: `${file.file_hash}_${newLine}_${oldLine}`, + }, + }), + }), + }; + const currentDiffLinesKey = + state.diffViewType === INLINE_DIFF_VIEW_TYPE ? INLINE_DIFF_LINES_KEY : PARALLEL_DIFF_LINES_KEY; + const hiddenDiffLinesKey = + state.diffViewType === INLINE_DIFF_VIEW_TYPE ? PARALLEL_DIFF_LINES_KEY : INLINE_DIFF_LINES_KEY; + + commit(types.SET_HIDDEN_VIEW_DIFF_FILE_LINES, { + filePath: file.file_path, + lines: expandedDiffLines[hiddenDiffLinesKey], + }); + + if (expandedDiffLines[currentDiffLinesKey].length > MAX_RENDERING_DIFF_LINES) { + let index = START_RENDERING_INDEX; + commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { + filePath: file.file_path, + lines: expandedDiffLines[currentDiffLinesKey].slice(0, index), + }); + commit(types.TOGGLE_DIFF_FILE_RENDERING_MORE, file.file_path); + + const idleCb = t => { + const startIndex = index; + + while ( + t.timeRemaining() >= MIN_RENDERING_MS && + index !== expandedDiffLines[currentDiffLinesKey].length && + index - startIndex !== MAX_RENDERING_BULK_ROWS + ) { + const line = expandedDiffLines[currentDiffLinesKey][index]; + + if (line) { + commit(types.ADD_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: file.file_path, line }); + index += 1; + } + } + + if (index !== expandedDiffLines[currentDiffLinesKey].length) { + idleCallback(idleCb); + } else { + commit(types.TOGGLE_DIFF_FILE_RENDERING_MORE, file.file_path); + } + }; + + idleCallback(idleCb); + } else { + commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { + filePath: file.file_path, + lines: expandedDiffLines[currentDiffLinesKey], + }); + } +}; + +export const fetchFullDiff = ({ dispatch }, file) => + axios + .get(file.context_lines_path, { + params: { + full: true, + from_merge_request: true, + }, + }) + .then(({ data }) => { + dispatch('receiveFullDiffSucess', { filePath: file.file_path }); + dispatch('setExpandedDiffLines', { file, data }); + }) + .catch(() => dispatch('receiveFullDiffError', file.file_path)); + +export const toggleFullDiff = ({ dispatch, getters, state }, filePath) => { + const file = state.diffFiles.find(f => f.file_path === filePath); + + dispatch('requestFullDiff', filePath); + + if (file.isShowingFullFile) { + dispatch('loadCollapsedDiff', file) + .then(() => dispatch('assignDiscussionsToDiff', getters.getDiffFileDiscussions(file))) + .catch(() => dispatch('receiveFullDiffError', filePath)); + } else { + dispatch('fetchFullDiff', file); + } +}; + +export const setFileCollapsed = ({ commit }, { filePath, collapsed }) => + commit(types.SET_FILE_COLLAPSED, { filePath, collapsed }); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 4e7e5306995..bc27e263bff 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -100,5 +100,12 @@ export const diffFilesLength = state => state.diffFiles.length; export const getCommentFormForDiffFile = state => fileHash => state.commentForms.find(form => form.fileHash === fileHash); +/** + * Returns index of a currently selected diff in diffFiles + * @returns {number} + */ +export const currentDiffIndex = state => + Math.max(0, state.diffFiles.findIndex(diff => diff.file_hash === state.currentDiffFileId)); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 47f78a5db54..cf4dd93dbfb 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -1,13 +1,10 @@ import Cookies from 'js-cookie'; import { getParameterValues } from '~/lib/utils/url_utility'; -import bp from '~/breakpoints'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME, MR_TREE_SHOW_KEY } from '../../constants'; +import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants'; const viewTypeFromQueryString = getParameterValues('view')[0]; const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME); const defaultViewType = INLINE_DIFF_VIEW_TYPE; -const storedTreeShow = localStorage.getItem(MR_TREE_SHOW_KEY); export default () => ({ isLoading: true, @@ -23,8 +20,7 @@ export default () => ({ diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType, tree: [], treeEntries: {}, - showTreeList: - storedTreeShow === null ? bp.getBreakpointSize() !== 'xs' : parseBoolean(storedTreeShow), + showTreeList: true, currentDiffFileId: '', projectPath: '', commentForms: [], diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 71ad108ce88..6bb24c97139 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -23,3 +23,13 @@ export const SET_TREE_DATA = 'SET_TREE_DATA'; export const SET_RENDER_TREE_LIST = 'SET_RENDER_TREE_LIST'; export const SET_SHOW_WHITESPACE = 'SET_SHOW_WHITESPACE'; export const TOGGLE_FILE_FINDER_VISIBLE = 'TOGGLE_FILE_FINDER_VISIBLE'; + +export const REQUEST_FULL_DIFF = 'REQUEST_FULL_DIFF'; +export const RECEIVE_FULL_DIFF_SUCCESS = 'RECEIVE_FULL_DIFF_SUCCESS'; +export const RECEIVE_FULL_DIFF_ERROR = 'RECEIVE_FULL_DIFF_ERROR'; +export const SET_FILE_COLLAPSED = 'SET_FILE_COLLAPSED'; + +export const SET_HIDDEN_VIEW_DIFF_FILE_LINES = 'SET_HIDDEN_VIEW_DIFF_FILE_LINES'; +export const SET_CURRENT_VIEW_DIFF_FILE_LINES = 'SET_CURRENT_VIEW_DIFF_FILE_LINES'; +export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINES'; +export const TOGGLE_DIFF_FILE_RENDERING_MORE = 'TOGGLE_DIFF_FILE_RENDERING_MORE'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 5a27388863c..67bc1724738 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -102,7 +102,10 @@ export default { [types.EXPAND_ALL_FILES](state) { state.diffFiles = state.diffFiles.map(file => ({ ...file, - collapsed: false, + viewer: { + ...file.viewer, + collapsed: false, + }, })); }, @@ -155,7 +158,9 @@ export default { } if (!file.parallel_diff_lines || !file.highlighted_diff_lines) { - file.discussions = (file.discussions || []).concat(discussion); + file.discussions = (file.discussions || []) + .filter(d => d.id !== discussion.id) + .concat(discussion); } return file; @@ -248,4 +253,53 @@ export default { [types.TOGGLE_FILE_FINDER_VISIBLE](state, visible) { state.fileFinderVisible = visible; }, + [types.REQUEST_FULL_DIFF](state, filePath) { + const file = findDiffFile(state.diffFiles, filePath, 'file_path'); + + file.isLoadingFullFile = true; + }, + [types.RECEIVE_FULL_DIFF_ERROR](state, filePath) { + const file = findDiffFile(state.diffFiles, filePath, 'file_path'); + + file.isLoadingFullFile = false; + }, + [types.RECEIVE_FULL_DIFF_SUCCESS](state, { filePath }) { + const file = findDiffFile(state.diffFiles, filePath, 'file_path'); + + file.isShowingFullFile = true; + file.isLoadingFullFile = false; + }, + [types.SET_FILE_COLLAPSED](state, { filePath, collapsed }) { + const file = state.diffFiles.find(f => f.file_path === filePath); + + if (file && file.viewer) { + file.viewer.collapsed = collapsed; + } + }, + [types.SET_HIDDEN_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) { + const file = state.diffFiles.find(f => f.file_path === filePath); + const hiddenDiffLinesKey = + state.diffViewType === 'inline' ? 'parallel_diff_lines' : 'highlighted_diff_lines'; + + file[hiddenDiffLinesKey] = lines; + }, + [types.SET_CURRENT_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) { + const file = state.diffFiles.find(f => f.file_path === filePath); + const currentDiffLinesKey = + state.diffViewType === 'inline' ? 'highlighted_diff_lines' : 'parallel_diff_lines'; + + file[currentDiffLinesKey] = lines; + }, + [types.ADD_CURRENT_VIEW_DIFF_FILE_LINES](state, { filePath, line }) { + const file = state.diffFiles.find(f => f.file_path === filePath); + const currentDiffLinesKey = + state.diffViewType === 'inline' ? 'highlighted_diff_lines' : 'parallel_diff_lines'; + + file[currentDiffLinesKey].push(line); + }, + [types.TOGGLE_DIFF_FILE_RENDERING_MORE](state, filePath) { + const file = state.diffFiles.find(f => f.file_path === filePath); + + file.renderingLines = !file.renderingLines; + }, }; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 247d1e65fea..71956255eef 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -15,8 +15,8 @@ import { TREE_TYPE, } from '../constants'; -export function findDiffFile(files, hash) { - return files.filter(file => file.file_hash === hash)[0]; +export function findDiffFile(files, match, matchKey = 'file_hash') { + return files.find(file => file[matchKey] === match); } export const getReversePosition = linePosition => { @@ -250,7 +250,10 @@ export function prepareDiffData(diffData) { renderIt: showingLines < LINES_TO_BE_RENDERED_DIRECTLY, collapsed: file.viewer.name === diffViewerModes.text && showingLines > MAX_LINES_TO_BE_RENDERED, + isShowingFullFile: false, + isLoadingFullFile: false, discussions: [], + renderingLines: false, }); } } @@ -411,3 +414,43 @@ export const getDiffMode = diffFile => { diffModes.replaced ); }; + +export const convertExpandLines = ({ + diffLines, + data, + typeKey, + oldLineKey, + newLineKey, + mapLine, +}) => { + const dataLength = data.length; + const lines = []; + + for (let i = 0, diffLinesLength = diffLines.length; i < diffLinesLength; i += 1) { + const line = diffLines[i]; + + if (_.property(typeKey)(line) === 'match') { + const beforeLine = diffLines[i - 1]; + const afterLine = diffLines[i + 1]; + const newLineProperty = _.property(newLineKey); + const beforeLineIndex = newLineProperty(beforeLine) || 0; + const afterLineIndex = newLineProperty(afterLine) - 1 || dataLength; + + lines.push( + ...data.slice(beforeLineIndex, afterLineIndex).map((l, index) => + mapLine({ + line: Object.assign(l, { hasForm: false, discussions: [] }), + oldLine: (_.property(oldLineKey)(beforeLine) || 0) + index + 1, + newLine: (newLineProperty(beforeLine) || 0) + index + 1, + }), + ), + ); + } else { + lines.push(line); + } + } + + return lines; +}; + +export const idleCallback = cb => requestIdleCallback(cb); diff --git a/app/assets/javascripts/diffs/workers/tree_worker.js b/app/assets/javascripts/diffs/workers/tree_worker.js index 534d737c77e..415c463fd19 100644 --- a/app/assets/javascripts/diffs/workers/tree_worker.js +++ b/app/assets/javascripts/diffs/workers/tree_worker.js @@ -4,6 +4,11 @@ import { generateTreeList } from '../store/utils'; // eslint-disable-next-line no-restricted-globals self.addEventListener('message', e => { const { data } = e; + + if (data === undefined) { + return; + } + const { treeEntries, tree } = generateTreeList(data); // eslint-disable-next-line no-restricted-globals diff --git a/app/assets/javascripts/dirty_submit/dirty_submit_form.js b/app/assets/javascripts/dirty_submit/dirty_submit_form.js index 00e41dd0301..0fcaec9531c 100644 --- a/app/assets/javascripts/dirty_submit/dirty_submit_form.js +++ b/app/assets/javascripts/dirty_submit/dirty_submit_form.js @@ -1,4 +1,5 @@ import _ from 'underscore'; +import $ from 'jquery'; class DirtySubmitForm { constructor(form) { @@ -20,12 +21,18 @@ class DirtySubmitForm { } registerListeners() { - const throttledUpdateDirtyInput = _.throttle( - event => this.updateDirtyInput(event), - DirtySubmitForm.THROTTLE_DURATION, + const getThrottledHandlerForInput = _.memoize(() => + _.throttle(event => this.updateDirtyInput(event), DirtySubmitForm.THROTTLE_DURATION), ); + + const throttledUpdateDirtyInput = event => { + const throttledHandler = getThrottledHandlerForInput(event.target.name); + throttledHandler(event); + }; + this.form.addEventListener('input', throttledUpdateDirtyInput); this.form.addEventListener('change', throttledUpdateDirtyInput); + $(this.form).on('change.select2', throttledUpdateDirtyInput); this.form.addEventListener('submit', event => this.formSubmit(event)); } diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 9987fbcb6a7..0ff26445a6a 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -4,6 +4,7 @@ import _ from 'underscore'; import './behaviors/preview_markdown'; import csrf from './lib/utils/csrf'; import axios from './lib/utils/axios_utils'; +import { n__, __ } from '~/locale'; Dropzone.autoDiscover = false; @@ -90,7 +91,7 @@ export default function dropzoneInput(form) { if (!processingFileCount) $attachButton.removeClass('hide'); addFileToForm(response.link.url); }, - error: (file, errorMessage = 'Attaching the file failed.', xhr) => { + error: (file, errorMessage = __('Attaching the file failed.'), xhr) => { // If 'error' event is fired by dropzone, the second parameter is error message. // If the 'errorMessage' parameter is empty, the default error message is set. // If the 'error' event is fired by backend (xhr) error response, the third parameter is @@ -273,19 +274,11 @@ export default function dropzoneInput(form) { }; updateAttachingMessage = (files, messageContainer) => { - let attachingMessage; const filesCount = files.filter(file => file.status === 'uploading' || file.status === 'queued') .length; + const attachingMessage = n__('Attaching a file', 'Attaching %d files', filesCount); - // Dinamycally change uploading files text depending on files number in - // dropzone files queue. - if (filesCount > 1) { - attachingMessage = `Attaching ${filesCount} files -`; - } else { - attachingMessage = 'Attaching a file -'; - } - - messageContainer.text(attachingMessage); + messageContainer.text(`${attachingMessage} -`); }; form.find('.markdown-selector').click(function onMarkdownClick(e) { diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index cb1b1173190..3c650397a19 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -104,7 +104,7 @@ class DueDateSelect { const dateObj = new Date(dateArray[0], dateArray[1] - 1, dateArray[2]); this.displayedDate = dateFormat(dateObj, 'mmm d, yyyy'); } else { - this.displayedDate = 'No due date'; + this.displayedDate = __('None'); } } @@ -132,7 +132,7 @@ class DueDateSelect { submitSelectedDate(isDropdown) { const selectedDateValue = this.datePayload[this.abilityName].due_date; - const hasDueDate = this.displayedDate !== 'No due date'; + const hasDueDate = this.displayedDate !== __('None'); const displayedDateStyle = hasDueDate ? 'bold' : 'no-value'; this.$loading.removeClass('hidden').fadeIn(); diff --git a/app/assets/javascripts/emoji/no_emoji_validator.js b/app/assets/javascripts/emoji/no_emoji_validator.js index 0fd4dd74953..384d62a133a 100644 --- a/app/assets/javascripts/emoji/no_emoji_validator.js +++ b/app/assets/javascripts/emoji/no_emoji_validator.js @@ -1,10 +1,11 @@ import { __ } from '~/locale'; import emojiRegex from 'emoji-regex'; +import InputValidator from '../validators/input_validator'; -const invalidInputClass = 'gl-field-error-outline'; - -export default class NoEmojiValidator { +export default class NoEmojiValidator extends InputValidator { constructor(opts = {}) { + super(); + const container = opts.container || ''; this.noEmojiEmelents = document.querySelectorAll(`${container} .js-block-emoji`); @@ -19,45 +20,14 @@ export default class NoEmojiValidator { const { value } = this.inputDomElement; + this.errorMessage = __('Invalid input, please avoid emojis'); + this.validatePattern(value); this.setValidationStateAndMessage(); } validatePattern(value) { const pattern = emojiRegex(); - this.hasEmojis = new RegExp(pattern).test(value); - - if (this.hasEmojis) { - this.inputDomElement.setCustomValidity(__('Invalid input, please avoid emojis')); - } else { - this.inputDomElement.setCustomValidity(''); - } - } - - setValidationStateAndMessage() { - if (!this.inputDomElement.checkValidity()) { - this.setInvalidState(); - } else { - this.clearFieldValidationState(); - } - } - - clearFieldValidationState() { - this.inputDomElement.classList.remove(invalidInputClass); - this.inputErrorMessage.classList.add('hide'); - } - - setInvalidState() { - this.inputDomElement.classList.add(invalidInputClass); - this.setErrorMessage(); - } - - setErrorMessage() { - if (this.hasEmojis) { - this.inputErrorMessage.innerHTML = this.inputDomElement.validationMessage; - } else { - this.inputErrorMessage.innerHTML = this.inputDomElement.title; - } - this.inputErrorMessage.classList.remove('hide'); + this.invalidInput = new RegExp(pattern).test(value); } } diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue new file mode 100644 index 00000000000..70b5c6b0094 --- /dev/null +++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue @@ -0,0 +1,108 @@ +<script> +/** + * Render modal to confirm rollback/redeploy. + */ + +import _ from 'underscore'; +import { GlModal } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; + +import eventHub from '../event_hub'; + +export default { + name: 'ConfirmRollbackModal', + + components: { + GlModal, + }, + + props: { + environment: { + type: Object, + required: true, + }, + }, + + computed: { + modalTitle() { + const title = this.environment.isLastDeployment + ? s__('Environments|Re-deploy environment %{name}?') + : s__('Environments|Rollback environment %{name}?'); + + return sprintf(title, { + name: _.escape(this.environment.name), + }); + }, + + commitShortSha() { + const { last_deployment } = this.environment; + return this.commitData(last_deployment, 'short_id'); + }, + + commitUrl() { + const { last_deployment } = this.environment; + return this.commitData(last_deployment, 'commit_path'); + }, + + commitTitle() { + const { last_deployment } = this.environment; + return this.commitData(last_deployment, 'title'); + }, + + modalText() { + const linkStart = `<a class="commit-sha mr-0" href="${_.escape(this.commitUrl)}">`; + const commitId = _.escape(this.commitShortSha); + const linkEnd = '</a>'; + const name = _.escape(this.name); + const body = this.environment.isLastDeployment + ? s__( + 'Environments|This action will relaunch the job for commit %{linkStart}%{commitId}%{linkEnd}, putting the environment in a previous version. Are you sure you want to continue?', + ) + : s__( + 'Environments|This action will run the job defined by %{name} for commit %{linkStart}%{commitId}%{linkEnd} putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?', + ); + return sprintf( + body, + { + commitId, + linkStart, + linkEnd, + name, + }, + false, + ); + }, + + modalActionText() { + return this.environment.isLastDeployment + ? s__('Environments|Re-deploy') + : s__('Environments|Rollback'); + }, + }, + + methods: { + onOk() { + eventHub.$emit('rollbackEnvironment', this.environment); + }, + + commitData(lastDeployment, key) { + if (lastDeployment && lastDeployment.commit) { + return lastDeployment.commit[key]; + } + + return ''; + }, + }, +}; +</script> +<template> + <gl-modal + :title="modalTitle" + modal-id="confirm-rollback-modal" + :ok-title="modalActionText" + ok-variant="danger" + @ok="onOk" + > + <p v-html="modalText"></p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index 6ece8b92a30..be80661223c 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -1,14 +1,16 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; -import tablePagination from '../../vue_shared/components/table_pagination.vue'; -import environmentTable from '../components/environments_table.vue'; +import TablePagination from '~/vue_shared/components/table_pagination.vue'; +import containerMixin from 'ee_else_ce/environments/mixins/container_mixin'; +import EnvironmentTable from '../components/environments_table.vue'; export default { components: { - environmentTable, - tablePagination, + EnvironmentTable, + TablePagination, GlLoadingIcon, }, + mixins: [containerMixin], props: { isLoading: { type: Boolean, @@ -47,7 +49,15 @@ export default { <slot name="emptyState"></slot> <div v-if="!isLoading && environments.length > 0" class="table-holder"> - <environment-table :environments="environments" :can-read-environment="canReadEnvironment" /> + <environment-table + :environments="environments" + :can-read-environment="canReadEnvironment" + :canary-deployment-feature-id="canaryDeploymentFeatureId" + :show-canary-deployment-callout="showCanaryDeploymentCallout" + :user-callouts-path="userCalloutsPath" + :lock-promotion-svg-path="lockPromotionSvgPath" + :help-canary-deployments-path="helpCanaryDeploymentsPath" + /> <table-pagination v-if="pagination && pagination.totalPages > 1" diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 503c1b38f71..f0e80cba753 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -3,8 +3,8 @@ import Timeago from 'timeago.js'; import _ from 'underscore'; import { GlTooltipDirective } from '@gitlab/ui'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import { humanize } from '~/lib/utils/text_utility'; import Icon from '~/vue_shared/components/icon.vue'; +import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin'; import ActionsComponent from './environment_actions.vue'; import ExternalUrlComponent from './environment_external_url.vue'; import StopComponent from './environment_stop.vue'; @@ -35,10 +35,10 @@ export default { TerminalButtonComponent, MonitoringButtonComponent, }, - directives: { GlTooltip: GlTooltipDirective, }, + mixins: [environmentItemMixin], props: { model: { @@ -156,7 +156,7 @@ export default { const combinedActions = (manualActions || []).concat(scheduledActions || []); return combinedActions.map(action => ({ ...action, - name: humanize(action.name), + name: action.name, })); }, @@ -459,19 +459,37 @@ export default { class="gl-responsive-table-row" role="row" > - <div - v-gl-tooltip - :title="model.name" - class="table-section section-wrap section-15 text-truncate" - role="gridcell" - > + <div class="table-section section-wrap section-15 text-truncate" role="gridcell"> <div v-if="!model.isFolder" class="table-mobile-header" role="rowheader"> {{ s__('Environments|Environment') }} </div> - <span v-if="!model.isFolder" class="environment-name table-mobile-content"> - <a class="qa-environment-link" :href="environmentPath"> {{ model.name }} </a> + + <span v-if="shouldRenderDeployBoard" class="deploy-board-icon" @click="toggleDeployBoard"> + <icon :name="deployIconName" /> + </span> + + <span + v-if="!model.isFolder" + v-gl-tooltip + :title="model.name" + class="environment-name table-mobile-content" + > + <a class="qa-environment-link" :href="environmentPath"> + <span v-if="model.size === 1">{{ model.name }}</span> + <span v-else>{{ model.name_without_type }}</span> + </a> + <span v-if="isProtected" class="badge badge-success"> + {{ s__('Environments|protected') }} + </span> </span> - <span v-else class="folder-name" role="button" @click="onClickFolder"> + <span + v-else + v-gl-tooltip + :title="model.folderName" + class="folder-name" + role="button" + @click="onClickFolder" + > <icon :name="folderIconName" class="folder-icon" /> <icon name="folder" class="folder-icon" /> @@ -486,22 +504,28 @@ export default { class="table-section section-10 deployment-column d-none d-sm-none d-md-block" role="gridcell" > - <span v-if="shouldRenderDeploymentID"> {{ deploymentInternalId }} </span> + <span v-if="shouldRenderDeploymentID" class="text-break-word"> + {{ deploymentInternalId }} + </span> - <span v-if="!model.isFolder && deploymentHasUser"> + <span v-if="!model.isFolder && deploymentHasUser" class="text-break-word"> by <user-avatar-link :link-href="deploymentUser.web_url" :img-src="deploymentUser.avatar_url" :img-alt="userImageAltDescription" :tooltip-text="deploymentUser.username" - class="js-deploy-user-container" + class="js-deploy-user-container float-none" /> </span> </div> <div class="table-section section-15 d-none d-sm-none d-md-block" role="gridcell"> - <a v-if="shouldRenderBuildName" :href="buildPath" class="build-link flex-truncate-parent"> + <a + v-if="shouldRenderBuildName" + :href="buildPath" + class="build-link cgray flex-truncate-parent" + > <span class="flex-truncate-child">{{ buildName }}</span> </a> </div> @@ -556,6 +580,7 @@ export default { <rollback-component v-if="canRetry" + :environment="model" :is-last-deployment="isLastDeployment" :retry-url="retryUrl" /> diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index 50c86af057c..bafbc00597e 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -5,29 +5,38 @@ * * Makes a post request when the button is clicked. */ +import { GlTooltipDirective, GlLoadingIcon, GlModalDirective, GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; -import { GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; +import ConfirmRollbackModal from './confirm_rollback_modal.vue'; import eventHub from '../event_hub'; export default { components: { Icon, GlLoadingIcon, + GlButton, + ConfirmRollbackModal, }, directives: { GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, }, props: { - retryUrl: { - type: String, - default: '', - }, - isLastDeployment: { type: Boolean, default: true, }, + + environment: { + type: Object, + required: true, + }, + + retryUrl: { + type: String, + required: true, + }, }, data() { return { @@ -45,23 +54,30 @@ export default { methods: { onClick() { - this.isLoading = true; - - eventHub.$emit('postAction', { endpoint: this.retryUrl }); + eventHub.$emit('requestRollbackEnvironment', { + ...this.environment, + retryUrl: this.retryUrl, + isLastDeployment: this.isLastDeployment, + }); + eventHub.$on('rollbackEnvironment', environment => { + if (environment.id === this.environment.id) { + this.isLoading = true; + } + }); }, }, }; </script> <template> - <button + <gl-button v-gl-tooltip + v-gl-modal.confirm-rollback-modal :disabled="isLoading" :title="title" - type="button" - class="btn d-none d-sm-none d-md-block" + class="d-none d-md-block text-secondary" @click="onClick" > <icon v-if="isLastDeployment" name="repeat" /> <icon v-else name="redo" /> <gl-loading-icon v-if="isLoading" /> - </button> + </gl-button> </template> diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue index 6d74d136a94..13195d32cc4 100644 --- a/app/assets/javascripts/environments/components/environment_terminal_button.vue +++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue @@ -39,7 +39,7 @@ export default { :aria-label="title" :href="terminalPath" :class="{ disabled: disabled }" - class="btn terminal-button d-none d-sm-none d-md-block" + class="btn terminal-button d-none d-sm-none d-md-block text-secondary" > <icon name="terminal" /> </a> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index aa2417d3194..ec78240217b 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -1,4 +1,5 @@ <script> +import envrionmentsAppMixin from 'ee_else_ce/environments/mixins/environments_app_mixin'; import Flash from '../../flash'; import { s__ } from '../../locale'; import emptyState from './empty_state.vue'; @@ -6,14 +7,16 @@ import eventHub from '../event_hub'; import environmentsMixin from '../mixins/environments_mixin'; import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; import StopEnvironmentModal from './stop_environment_modal.vue'; +import ConfirmRollbackModal from './confirm_rollback_modal.vue'; export default { components: { emptyState, StopEnvironmentModal, + ConfirmRollbackModal, }, - mixins: [CIPaginationMixin, environmentsMixin], + mixins: [CIPaginationMixin, environmentsMixin, envrionmentsAppMixin], props: { endpoint: { @@ -87,14 +90,15 @@ export default { <template> <div :class="cssContainerClass"> <stop-environment-modal :environment="environmentInStopModal" /> + <confirm-rollback-modal :environment="environmentInRollbackModal" /> <div class="top-area"> <tabs :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" /> <div v-if="canCreateEnvironment && !isLoading" class="nav-controls"> - <a :href="newEnvironmentPath" class="btn btn-success">{{ - s__('Environments|New environment') - }}</a> + <a :href="newEnvironmentPath" class="btn btn-success"> + {{ s__('Environments|New environment') }} + </a> </div> </div> @@ -103,6 +107,11 @@ export default { :environments="state.environments" :pagination="state.paginationInformation" :can-read-environment="canReadEnvironment" + :canary-deployment-feature-id="canaryDeploymentFeatureId" + :show-canary-deployment-callout="showCanaryDeploymentCallout" + :user-callouts-path="userCalloutsPath" + :lock-promotion-svg-path="lockPromotionSvgPath" + :help-canary-deployments-path="helpCanaryDeploymentsPath" @onChangePage="onChangePage" > <empty-state diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index e2c304de00a..55613d815ce 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -3,27 +3,40 @@ * Render environments table. */ import { GlLoadingIcon } from '@gitlab/ui'; -import environmentItem from './environment_item.vue'; +import _ from 'underscore'; +import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin'; +import EnvironmentItem from './environment_item.vue'; export default { components: { - environmentItem, + EnvironmentItem, GlLoadingIcon, + DeployBoard: () => import('ee_component/environments/components/deploy_board_component.vue'), + CanaryDeploymentCallout: () => + import('ee_component/environments/components/canary_deployment_callout.vue'), }, - + mixins: [environmentTableMixin], props: { environments: { type: Array, required: true, default: () => [], }, - canReadEnvironment: { type: Boolean, required: false, default: false, }, }, + computed: { + sortedEnvironments() { + return this.sortEnvironments(this.environments).map(env => + this.shouldRenderFolderContent(env) + ? { ...env, children: this.sortEnvironments(env.children) } + : env, + ); + }, + }, methods: { folderUrl(model) { return `${window.location.pathname}/folders/${model.folderName}`; @@ -31,6 +44,30 @@ export default { shouldRenderFolderContent(env) { return env.isFolder && env.isOpen && env.children && env.children.length > 0; }, + sortEnvironments(environments) { + /* + * The sorting algorithm should sort in the following priorities: + * + * 1. folders first, + * 2. last updated descending, + * 3. by name ascending, + * + * the sorting algorithm must: + * + * 1. Sort by name ascending, + * 2. Reverse (sort by name descending), + * 3. Sort by last deployment ascending, + * 4. Reverse (last deployment descending, name ascending), + * 5. Put folders first. + */ + return _.chain(environments) + .sortBy(env => (env.isFolder ? env.folderName : env.name)) + .reverse() + .sortBy(env => (env.last_deployment ? env.last_deployment.created_at : '0000')) + .reverse() + .sortBy(env => (env.isFolder ? -1 : 1)) + .value(); + }, }, }; </script> @@ -53,7 +90,7 @@ export default { {{ s__('Environments|Updated') }} </div> </div> - <template v-for="(model, i) in environments" :model="model"> + <template v-for="(model, i) in sortedEnvironments" :model="model"> <div is="environment-item" :key="`environment-item-${i}`" @@ -61,6 +98,21 @@ export default { :can-read-environment="canReadEnvironment" /> + <div + v-if="shouldRenderDeployBoard(model)" + :key="`deploy-board-row-${i}`" + class="js-deploy-board-row" + > + <div class="deploy-board-container"> + <deploy-board + :deploy-board-data="model.deployBoardData" + :is-loading="model.isLoadingDeployBoard" + :is-empty="model.isEmptyDeployBoard" + :logs-path="model.logs_path" + /> + </div> + </div> + <template v-if="shouldRenderFolderContent(model)"> <div v-if="model.isLoadingFolderContent" :key="`loading-item-${i}`"> <gl-loading-icon :size="2" class="prepend-top-16" /> @@ -77,13 +129,24 @@ export default { <div :key="`sub-div-${i}`"> <div class="text-center prepend-top-10"> - <a :href="folderUrl(model)" class="btn btn-default">{{ - s__('Environments|Show all') - }}</a> + <a :href="folderUrl(model)" class="btn btn-default"> + {{ s__('Environments|Show all') }} + </a> </div> </div> </template> </template> + + <template v-if="shouldShowCanaryCallout(model)"> + <canary-deployment-callout + :key="`canary-promo-${i}`" + :canary-deployment-feature-id="canaryDeploymentFeatureId" + :user-callouts-path="userCalloutsPath" + :lock-promotion-svg-path="lockPromotionSvgPath" + :help-canary-deployments-path="helpCanaryDeploymentsPath" + :data-js-canary-promo-key="i" + /> + </template> </template> </div> </template> diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js index 56e7f69cad6..c1bfe8d05fe 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import canaryCalloutMixin from 'ee_else_ce/environments/mixins/canary_callout_mixin'; import environmentsFolderApp from './environments_folder_view.vue'; import { parseBoolean } from '../../lib/utils/common_utils'; import Translate from '../../vue_shared/translate'; @@ -11,6 +12,7 @@ export default () => components: { environmentsFolderApp, }, + mixins: [canaryCalloutMixin], data() { const environmentsData = document.querySelector(this.$options.el).dataset; @@ -28,6 +30,7 @@ export default () => folderName: this.folderName, cssContainerClass: this.cssContainerClass, canReadEnvironment: this.canReadEnvironment, + ...this.canaryCalloutProps, }, }); }, diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index 80f0e00400b..6fd0561f682 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -1,4 +1,5 @@ <script> +import folderMixin from 'ee_else_ce/environments/mixins/environments_folder_view_mixin'; import environmentsMixin from '../mixins/environments_mixin'; import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; import StopEnvironmentModal from '../components/stop_environment_modal.vue'; @@ -8,7 +9,7 @@ export default { StopEnvironmentModal, }, - mixins: [environmentsMixin, CIPaginationMixin], + mixins: [environmentsMixin, CIPaginationMixin, folderMixin], props: { endpoint: { @@ -41,7 +42,8 @@ export default { <div v-if="!isLoading" class="top-area"> <h4 class="js-folder-name environments-folder-name"> - {{ s__('Environments|Environments') }} / <b>{{ folderName }}</b> + {{ s__('Environments|Environments') }} / + <b>{{ folderName }}</b> </h4> <tabs :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" /> @@ -52,6 +54,11 @@ export default { :environments="state.environments" :pagination="state.paginationInformation" :can-read-environment="canReadEnvironment" + :canary-deployment-feature-id="canaryDeploymentFeatureId" + :show-canary-deployment-callout="showCanaryDeploymentCallout" + :user-callouts-path="userCalloutsPath" + :lock-promotion-svg-path="lockPromotionSvgPath" + :help-canary-deployments-path="helpCanaryDeploymentsPath" @onChangePage="onChangePage" /> </div> diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index 6af66d0f86e..b53d42f202b 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import canaryCalloutMixin from 'ee_else_ce/environments/mixins/canary_callout_mixin'; import environmentsComponent from './components/environments_app.vue'; import { parseBoolean } from '../lib/utils/common_utils'; import Translate from '../vue_shared/translate'; @@ -11,6 +12,7 @@ export default () => components: { environmentsComponent, }, + mixins: [canaryCalloutMixin], data() { const environmentsData = document.querySelector(this.$options.el).dataset; @@ -32,6 +34,7 @@ export default () => cssContainerClass: this.cssContainerClass, canCreateEnvironment: this.canCreateEnvironment, canReadEnvironment: this.canReadEnvironment, + ...this.canaryCalloutProps, }, }); }, diff --git a/app/assets/javascripts/environments/mixins/canary_callout_mixin.js b/app/assets/javascripts/environments/mixins/canary_callout_mixin.js new file mode 100644 index 00000000000..f6d3d67b777 --- /dev/null +++ b/app/assets/javascripts/environments/mixins/canary_callout_mixin.js @@ -0,0 +1,5 @@ +export default { + computed: { + canaryCalloutProps() {}, + }, +}; diff --git a/app/assets/javascripts/environments/mixins/container_mixin.js b/app/assets/javascripts/environments/mixins/container_mixin.js new file mode 100644 index 00000000000..f2907c120f8 --- /dev/null +++ b/app/assets/javascripts/environments/mixins/container_mixin.js @@ -0,0 +1,29 @@ +export default { + props: { + canaryDeploymentFeatureId: { + type: String, + required: false, + default: null, + }, + showCanaryDeploymentCallout: { + type: Boolean, + required: false, + default: false, + }, + userCalloutsPath: { + type: String, + required: false, + default: null, + }, + lockPromotionSvgPath: { + type: String, + required: false, + default: null, + }, + helpCanaryDeploymentsPath: { + type: String, + required: false, + default: null, + }, + }, +}; diff --git a/app/assets/javascripts/environments/mixins/environment_item_mixin.js b/app/assets/javascripts/environments/mixins/environment_item_mixin.js new file mode 100644 index 00000000000..2dfed36ec99 --- /dev/null +++ b/app/assets/javascripts/environments/mixins/environment_item_mixin.js @@ -0,0 +1,13 @@ +export default { + computed: { + deployIconName() { + return ''; + }, + shouldRenderDeployBoard() { + return false; + }, + }, + methods: { + toggleDeployBoard() {}, + }, +}; diff --git a/app/assets/javascripts/environments/mixins/environments_app_mixin.js b/app/assets/javascripts/environments/mixins/environments_app_mixin.js new file mode 100644 index 00000000000..fc805b9235a --- /dev/null +++ b/app/assets/javascripts/environments/mixins/environments_app_mixin.js @@ -0,0 +1,32 @@ +export default { + props: { + canaryDeploymentFeatureId: { + type: String, + required: false, + default: '', + }, + showCanaryDeploymentCallout: { + type: Boolean, + required: false, + default: false, + }, + userCalloutsPath: { + type: String, + required: false, + default: '', + }, + lockPromotionSvgPath: { + type: String, + required: false, + default: '', + }, + helpCanaryDeploymentsPath: { + type: String, + required: false, + default: '', + }, + }, + metods: { + toggleDeployBoard() {}, + }, +}; diff --git a/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js b/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js new file mode 100644 index 00000000000..e793a7cadf2 --- /dev/null +++ b/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js @@ -0,0 +1,29 @@ +export default { + props: { + canaryDeploymentFeatureId: { + type: String, + required: false, + default: '', + }, + showCanaryDeploymentCallout: { + type: Boolean, + required: false, + default: false, + }, + userCalloutsPath: { + type: String, + required: false, + default: '', + }, + lockPromotionSvgPath: { + type: String, + required: false, + default: '', + }, + helpCanaryDeploymentsPath: { + type: String, + required: false, + default: '', + }, + }, +}; diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index e81a1525df0..a5812b173dc 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -3,13 +3,13 @@ */ import _ from 'underscore'; import Visibility from 'visibilityjs'; +import EnvironmentsStore from 'ee_else_ce/environments/stores/environments_store'; import Poll from '../../lib/utils/poll'; import { getParameterByName } from '../../lib/utils/common_utils'; import { s__ } from '../../locale'; import Flash from '../../flash'; import eventHub from '../event_hub'; -import EnvironmentsStore from '../stores/environments_store'; import EnvironmentsService from '../services/environments_service'; import tablePagination from '../../vue_shared/components/table_pagination.vue'; import environmentTable from '../components/environments_table.vue'; @@ -36,6 +36,7 @@ export default { page: getParameterByName('page') || '1', requestData: {}, environmentInStopModal: {}, + environmentInRollbackModal: {}, }; }, @@ -43,7 +44,11 @@ export default { saveData(resp) { this.isLoading = false; - if (_.isEqual(resp.config.params, this.requestData)) { + // Prevent the absence of the nested flag from causing mismatches + const response = this.filterNilValues(resp.config.params); + const request = this.filterNilValues(this.requestData); + + if (_.isEqual(response, request)) { this.store.storeAvailableCount(resp.data.available_count); this.store.storeStoppedCount(resp.data.stopped_count); this.store.storeEnvironments(resp.data.environments); @@ -51,6 +56,10 @@ export default { } }, + filterNilValues(obj) { + return _.omit(obj, value => _.isUndefined(value) || _.isNull(value)); + }, + /** * Handles URL and query parameter changes. * When the user uses the pagination or the tabs, @@ -64,10 +73,9 @@ export default { // fetch new data return this.service .fetchEnvironments(this.requestData) - .then(response => this.successCallback(response)) - .then(() => { - // restart polling - this.poll.restart({ data: this.requestData }); + .then(response => { + this.successCallback(response); + this.poll.enable({ data: this.requestData, response }); }) .catch(() => { this.errorCallback(); @@ -109,6 +117,10 @@ export default { this.environmentInStopModal = environment; }, + updateRollbackModal(environment) { + this.environmentInRollbackModal = environment; + }, + stopEnvironment(environment) { const endpoint = environment.stop_path; const errorMessage = s__( @@ -116,6 +128,16 @@ export default { ); this.postAction({ endpoint, errorMessage }); }, + + rollbackEnvironment(environment) { + const { retryUrl, isLastDeployment } = environment; + const errorMessage = isLastDeployment + ? s__('Environments|An error occurred while re-deploying the environment, please try again') + : s__( + 'Environments|An error occurred while rolling back the environment, please try again', + ); + this.postAction({ endpoint: retryUrl, errorMessage }); + }, }, computed: { @@ -174,11 +196,17 @@ export default { eventHub.$on('postAction', this.postAction); eventHub.$on('requestStopEnvironment', this.updateStopModal); eventHub.$on('stopEnvironment', this.stopEnvironment); + + eventHub.$on('requestRollbackEnvironment', this.updateRollbackModal); + eventHub.$on('rollbackEnvironment', this.rollbackEnvironment); }, beforeDestroy() { eventHub.$off('postAction', this.postAction); eventHub.$off('requestStopEnvironment', this.updateStopModal); eventHub.$off('stopEnvironment', this.stopEnvironment); + + eventHub.$off('requestRollbackEnvironment', this.updateRollbackModal); + eventHub.$off('rollbackEnvironment', this.rollbackEnvironment); }, }; diff --git a/app/assets/javascripts/environments/mixins/environments_table_mixin.js b/app/assets/javascripts/environments/mixins/environments_table_mixin.js new file mode 100644 index 00000000000..208f1a7373d --- /dev/null +++ b/app/assets/javascripts/environments/mixins/environments_table_mixin.js @@ -0,0 +1,10 @@ +export default { + methods: { + shouldShowCanaryCallout() { + return false; + }, + shouldRenderDeployBoard() { + return false; + }, + }, +}; diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index ac9a31c202c..5fb420e9da5 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -1,4 +1,6 @@ import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { setDeployBoard } from 'ee_else_ce/environments/stores/helpers'; + /** * Environments Store. * @@ -31,6 +33,14 @@ export default class EnvironmentsStore { * If the `size` is bigger than 1, it means it should be rendered as a folder. * In those cases we add `isFolder` key in order to render it properly. * + * Top level environments - when the size is 1 - with `rollout_status` + * can render a deploy board. We add `isDeployBoardVisible` and `deployBoardData` + * keys to those environments. + * The first key will let's us know if we should or not render the deploy board. + * It will be toggled when the user clicks to seee the deploy board. + * + * The second key will allow us to update the environment with the received deploy board data. + * * @param {Array} environments * @returns {Array} */ @@ -63,6 +73,7 @@ export default class EnvironmentsStore { filtered = Object.assign(filtered, env); } + filtered = setDeployBoard(oldEnvironmentState, filtered); return filtered; }); @@ -71,6 +82,20 @@ export default class EnvironmentsStore { return filteredEnvironments; } + /** + * Stores the pagination information needed to render the pagination for the + * table. + * + * Normalizes the headers to uppercase since they can be provided either + * in uppercase or lowercase. + * + * Parses to an integer the normalized ones needed for the pagination component. + * + * Stores the normalized and parsed information. + * + * @param {Object} pagination = {} + * @return {Object} + */ setPagination(pagination = {}) { const normalizedHeaders = normalizeHeaders(pagination); const paginationInformation = parseIntPagination(normalizedHeaders); diff --git a/app/assets/javascripts/environments/stores/helpers.js b/app/assets/javascripts/environments/stores/helpers.js new file mode 100644 index 00000000000..8eba6c00601 --- /dev/null +++ b/app/assets/javascripts/environments/stores/helpers.js @@ -0,0 +1,8 @@ +/** + * Deploy boards are EE only. + * + * @param {Object} environment + * @returns {Object} + */ +// eslint-disable-next-line import/prefer-default-export +export const setDeployBoard = (oldEnvironmentState, environment) => environment; 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 6981afe1ead..43ae54133af 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -48,7 +48,7 @@ export default { } }, methods: { - ...mapActions(['startPolling']), + ...mapActions(['startPolling', 'restartPolling']), }, }; </script> @@ -56,19 +56,17 @@ export default { <template> <div> <div v-if="errorTrackingEnabled"> - <div v-if="loading" class="py-3"><gl-loading-icon :size="3" /></div> + <div v-if="loading" class="py-3"> + <gl-loading-icon :size="3" /> + </div> <div v-else> <div class="d-flex justify-content-end"> - <gl-button class="my-3 ml-auto" variant="primary" :href="externalUrl" target="_blank" - >View in Sentry <icon name="external-link" /> + <gl-button class="my-3 ml-auto" variant="primary" :href="externalUrl" target="_blank"> + {{ __('View in Sentry') }} + <icon name="external-link" /> </gl-button> </div> - <gl-table - :items="errors" - :fields="$options.fields" - :show-empty="true" - :empty-text="__('No errors to display')" - > + <gl-table :items="errors" :fields="$options.fields" :show-empty="true"> <template slot="HEAD_events" slot-scope="data"> <div class="text-right">{{ data.label }}</div> </template> @@ -102,6 +100,14 @@ export default { <time-ago :time="errors.item.lastSeen" class="text-secondary" /> </div> </template> + <template slot="empty"> + <div ref="empty"> + {{ __('No errors to display.') }} + <gl-link class="js-try-again" @click="restartPolling"> + {{ __('Check again') }} + </gl-link> + </div> + </template> </gl-table> </div> </div> diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js index 11aec312368..1e754a4f54f 100644 --- a/app/assets/javascripts/error_tracking/store/actions.js +++ b/app/assets/javascripts/error_tracking/store/actions.js @@ -6,7 +6,7 @@ import { __, sprintf } from '~/locale'; let eTagPoll; -export function startPolling({ commit }, endpoint) { +export function startPolling({ commit, dispatch }, endpoint) { eTagPoll = new Poll({ resource: Service, method: 'getErrorList', @@ -18,8 +18,9 @@ export function startPolling({ commit }, endpoint) { commit(types.SET_ERRORS, data.errors); commit(types.SET_EXTERNAL_URL, data.external_url); commit(types.SET_LOADING, false); + dispatch('stopPolling'); }, - errorCallback: response => { + errorCallback: ({ response }) => { let errorMessage = ''; if (response && response.data && response.data.message) { errorMessage = response.data.message; @@ -36,4 +37,16 @@ export function startPolling({ commit }, endpoint) { eTagPoll.makeRequest(); } +export const stopPolling = () => { + if (eTagPoll) eTagPoll.stop(); +}; + +export function restartPolling({ commit }) { + commit(types.SET_ERRORS, []); + commit(types.SET_EXTERNAL_URL, ''); + commit(types.SET_LOADING, true); + + if (eTagPoll) eTagPoll.restart(); +} + export default () => {}; diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue new file mode 100644 index 00000000000..50eb3e63b7c --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/components/app.vue @@ -0,0 +1,129 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import { GlButton } from '@gitlab/ui'; +import ProjectDropdown from './project_dropdown.vue'; +import ErrorTrackingForm from './error_tracking_form.vue'; + +export default { + components: { ProjectDropdown, ErrorTrackingForm, GlButton }, + props: { + initialApiHost: { + type: String, + required: false, + default: '', + }, + initialEnabled: { + type: String, + required: true, + }, + initialProject: { + type: String, + required: false, + default: null, + }, + initialToken: { + type: String, + required: false, + default: '', + }, + listProjectsEndpoint: { + type: String, + required: true, + }, + operationsSettingsEndpoint: { + type: String, + required: true, + }, + }, + computed: { + ...mapGetters([ + 'dropdownLabel', + 'hasProjects', + 'invalidProjectLabel', + 'isProjectInvalid', + 'projectSelectionLabel', + ]), + ...mapState([ + 'apiHost', + 'connectError', + 'connectSuccessful', + 'enabled', + 'projects', + 'selectedProject', + 'settingsLoading', + 'token', + ]), + }, + created() { + this.setInitialState({ + apiHost: this.initialApiHost, + enabled: this.initialEnabled, + project: this.initialProject, + token: this.initialToken, + listProjectsEndpoint: this.listProjectsEndpoint, + operationsSettingsEndpoint: this.operationsSettingsEndpoint, + }); + }, + methods: { + ...mapActions([ + 'fetchProjects', + 'setInitialState', + 'updateApiHost', + 'updateEnabled', + 'updateSelectedProject', + 'updateSettings', + 'updateToken', + ]), + handleSubmit() { + this.updateSettings(); + }, + }, +}; +</script> + +<template> + <div> + <div class="form-check form-group"> + <input + id="error-tracking-enabled" + :checked="enabled" + class="form-check-input" + type="checkbox" + @change="updateEnabled($event.target.checked)" + /> + <label class="form-check-label" for="error-tracking-enabled">{{ + s__('ErrorTracking|Active') + }}</label> + </div> + <error-tracking-form + :api-host="apiHost" + :connect-error="connectError" + :connect-successful="connectSuccessful" + :token="token" + @handle-connect="fetchProjects" + @update-api-host="updateApiHost" + @update-token="updateToken" + /> + <div class="form-group"> + <project-dropdown + :has-projects="hasProjects" + :invalid-project-label="invalidProjectLabel" + :is-project-invalid="isProjectInvalid" + :dropdown-label="dropdownLabel" + :project-selection-label="projectSelectionLabel" + :projects="projects" + :selected-project="selectedProject" + :token="token" + @select-project="updateSelectedProject" + /> + </div> + <gl-button + :disabled="settingsLoading" + class="js-error-tracking-button" + variant="success" + @click="handleSubmit" + > + {{ __('Save changes') }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue new file mode 100644 index 00000000000..060d8e25227 --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue @@ -0,0 +1,91 @@ +<script> +import { GlButton, GlFormInput } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { GlButton, GlFormInput, Icon }, + props: { + apiHost: { + type: String, + required: true, + }, + connectError: { + type: Boolean, + required: true, + }, + connectSuccessful: { + type: Boolean, + required: true, + }, + token: { + type: String, + required: true, + }, + }, + computed: { + tokenInputState() { + return this.connectError ? false : null; + }, + }, +}; +</script> + +<template> + <div> + <div class="form-group"> + <label class="label-bold" for="error-tracking-api-host">{{ __('Sentry API URL') }}</label> + <div class="row"> + <div class="col-8 col-md-9 gl-pr-0"> + <gl-form-input + id="error-tracking-api-host" + :value="apiHost" + placeholder="https://mysentryserver.com" + @input="$emit('update-api-host', $event)" + /> + </div> + </div> + <p class="form-text text-muted"> + {{ s__('ErrorTracking|Find your hostname in your Sentry account settings page') }} + </p> + </div> + <div class="form-group" :class="{ 'gl-show-field-errors': connectError }"> + <label class="label-bold" for="error-tracking-token">{{ + s__('ErrorTracking|Auth Token') + }}</label> + <div class="row"> + <div class="col-8 col-md-9 gl-pr-0"> + <gl-form-input + id="error-tracking-token" + :value="token" + :state="tokenInputState" + @input="$emit('update-token', $event)" + /> + </div> + <div class="col-4 col-md-3 gl-pl-0"> + <gl-button + class="js-error-tracking-connect prepend-left-5" + @click="$emit('handle-connect')" + > + {{ __('Connect') }} + </gl-button> + <icon + v-show="connectSuccessful" + class="js-error-tracking-connect-success prepend-left-5 text-success align-middle" + :aria-label="__('Projects Successfully Retrieved')" + name="check-circle" + /> + </div> + </div> + <p v-if="connectError" class="gl-field-error"> + {{ s__('ErrorTracking|Connection has failed. Re-check Auth Token and try again.') }} + </p> + <p v-else class="form-text text-muted"> + {{ + s__( + "ErrorTracking|After adding your Auth Token, use the 'Connect' button to load projects", + ) + }} + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue new file mode 100644 index 00000000000..82df02afafd --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue @@ -0,0 +1,82 @@ +<script> +import { GlDropdown, GlDropdownHeader, GlDropdownItem } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { getDisplayName } from '../utils'; + +export default { + components: { + GlDropdown, + GlDropdownHeader, + GlDropdownItem, + Icon, + }, + props: { + dropdownLabel: { + type: String, + required: true, + }, + hasProjects: { + type: Boolean, + required: true, + }, + invalidProjectLabel: { + type: String, + required: true, + }, + isProjectInvalid: { + type: Boolean, + required: true, + }, + projects: { + type: Array, + required: true, + }, + selectedProject: { + type: Object, + required: false, + default: null, + }, + projectSelectionLabel: { + type: String, + required: true, + }, + token: { + type: String, + required: true, + }, + }, + methods: { + getDisplayName, + }, +}; +</script> + +<template> + <div :class="{ 'gl-show-field-errors': isProjectInvalid }"> + <label class="label-bold" for="project-dropdown">{{ __('Project') }}</label> + <div class="row"> + <gl-dropdown + id="project-dropdown" + class="col-8 col-md-9 gl-pr-0" + :disabled="!hasProjects" + menu-class="w-100 mw-100" + toggle-class="dropdown-menu-toggle w-100 gl-field-error-outline" + :text="dropdownLabel" + > + <gl-dropdown-item + v-for="project in projects" + :key="`${project.organizationSlug}.${project.slug}`" + class="w-100" + @click="$emit('select-project', project)" + >{{ getDisplayName(project) }}</gl-dropdown-item + > + </gl-dropdown> + </div> + <p v-if="isProjectInvalid" class="js-project-dropdown-error gl-field-error"> + {{ invalidProjectLabel }} + </p> + <p v-else-if="!hasProjects" class="js-project-dropdown-label form-text text-muted"> + {{ projectSelectionLabel }} + </p> + </div> +</template> diff --git a/app/assets/javascripts/error_tracking_settings/index.js b/app/assets/javascripts/error_tracking_settings/index.js new file mode 100644 index 00000000000..ce315963723 --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/index.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import ErrorTrackingSettings from './components/app.vue'; +import createStore from './store'; + +export default () => { + const formContainerEl = document.querySelector('.js-error-tracking-form'); + const { + dataset: { apiHost, enabled, project, token, listProjectsEndpoint, operationsSettingsEndpoint }, + } = formContainerEl; + + return new Vue({ + el: formContainerEl, + store: createStore(), + render(createElement) { + return createElement(ErrorTrackingSettings, { + props: { + initialApiHost: apiHost, + initialEnabled: enabled, + initialProject: project, + initialToken: token, + listProjectsEndpoint, + operationsSettingsEndpoint, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js new file mode 100644 index 00000000000..95105797807 --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/store/actions.js @@ -0,0 +1,91 @@ +import { __ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import createFlash from '~/flash'; +import { transformFrontendSettings } from '../utils'; +import * as types from './mutation_types'; + +export const requestProjects = ({ commit }) => { + commit(types.RESET_CONNECT); +}; + +export const receiveProjectsSuccess = ({ commit }, projects) => { + commit(types.UPDATE_CONNECT_SUCCESS); + commit(types.RECEIVE_PROJECTS, projects); +}; + +export const receiveProjectsError = ({ commit }) => { + commit(types.UPDATE_CONNECT_ERROR); + commit(types.CLEAR_PROJECTS); +}; + +export const fetchProjects = ({ dispatch, state }) => { + dispatch('requestProjects'); + return axios + .post(state.listProjectsEndpoint, { + error_tracking_setting: { + api_host: state.apiHost, + token: state.token, + }, + }) + .then(({ data: { projects } }) => { + dispatch('receiveProjectsSuccess', projects); + }) + .catch(() => { + dispatch('receiveProjectsError'); + }); +}; + +export const requestSettings = ({ commit }) => { + commit(types.UPDATE_SETTINGS_LOADING, true); +}; + +export const receiveSettingsError = ({ commit }, { response = {} }) => { + const message = response.data && response.data.message ? response.data.message : ''; + + createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert'); + commit(types.UPDATE_SETTINGS_LOADING, false); +}; + +export const updateSettings = ({ dispatch, state }) => { + dispatch('requestSettings'); + return axios + .patch(state.operationsSettingsEndpoint, { + project: { + error_tracking_setting_attributes: { + ...transformFrontendSettings(state), + }, + }, + }) + .then(() => { + refreshCurrentPage(); + }) + .catch(err => { + dispatch('receiveSettingsError', err); + }); +}; + +export const updateApiHost = ({ commit }, apiHost) => { + commit(types.UPDATE_API_HOST, apiHost); + commit(types.RESET_CONNECT); +}; + +export const updateEnabled = ({ commit }, enabled) => { + commit(types.UPDATE_ENABLED, enabled); +}; + +export const updateToken = ({ commit }, token) => { + commit(types.UPDATE_TOKEN, token); + commit(types.RESET_CONNECT); +}; + +export const updateSelectedProject = ({ commit }, selectedProject) => { + commit(types.UPDATE_SELECTED_PROJECT, selectedProject); +}; + +export const setInitialState = ({ commit }, data) => { + commit(types.SET_INITIAL_STATE, data); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/error_tracking_settings/store/getters.js b/app/assets/javascripts/error_tracking_settings/store/getters.js new file mode 100644 index 00000000000..d77e5f15469 --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/store/getters.js @@ -0,0 +1,44 @@ +import _ from 'underscore'; +import { __, s__, sprintf } from '~/locale'; +import { getDisplayName } from '../utils'; + +export const hasProjects = state => Boolean(state.projects) && state.projects.length > 0; + +export const isProjectInvalid = (state, getters) => + Boolean(state.selectedProject) && + getters.hasProjects && + !state.projects.some(project => _.isMatch(state.selectedProject, project)); + +export const dropdownLabel = (state, getters) => { + if (state.selectedProject !== null) { + return getDisplayName(state.selectedProject); + } + if (!getters.hasProjects) { + return s__('ErrorTracking|No projects available'); + } + return s__('ErrorTracking|Select project'); +}; + +export const invalidProjectLabel = state => { + if (state.selectedProject) { + return sprintf( + __('Project "%{name}" is no longer available. Select another project to continue.'), + { + name: state.selectedProject.name, + }, + ); + } + return ''; +}; + +export const projectSelectionLabel = state => { + if (state.token) { + return s__( + "ErrorTracking|Click 'Connect' to re-establish the connection to Sentry and activate the dropdown.", + ); + } + return s__('ErrorTracking|To enable project selection, enter a valid Auth Token'); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/error_tracking_settings/store/index.js b/app/assets/javascripts/error_tracking_settings/store/index.js new file mode 100644 index 00000000000..560f265a2ea --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + state: createState(), + actions, + getters, + mutations, + }); diff --git a/app/assets/javascripts/error_tracking_settings/store/mutation_types.js b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js new file mode 100644 index 00000000000..b4f8a237947 --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js @@ -0,0 +1,11 @@ +export const CLEAR_PROJECTS = 'CLEAR_PROJECTS'; +export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; +export const RECEIVE_PROJECTS = 'RECEIVE_PROJECTS'; +export const RESET_CONNECT = 'RESET_CONNECT'; +export const UPDATE_API_HOST = 'UPDATE_API_HOST'; +export const UPDATE_CONNECT_ERROR = 'UPDATE_CONNECT_ERROR'; +export const UPDATE_CONNECT_SUCCESS = 'UPDATE_CONNECT_SUCCESS'; +export const UPDATE_ENABLED = 'UPDATE_ENABLED'; +export const UPDATE_SELECTED_PROJECT = 'UPDATE_SELECTED_PROJECT'; +export const UPDATE_SETTINGS_LOADING = 'UPDATE_SETTINGS_LOADING'; +export const UPDATE_TOKEN = 'UPDATE_TOKEN'; diff --git a/app/assets/javascripts/error_tracking_settings/store/mutations.js b/app/assets/javascripts/error_tracking_settings/store/mutations.js new file mode 100644 index 00000000000..4089d1ee94e --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/store/mutations.js @@ -0,0 +1,61 @@ +import _ from 'underscore'; +import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; +import * as types from './mutation_types'; +import { projectKeys } from '../utils'; + +export default { + [types.CLEAR_PROJECTS](state) { + state.projects = []; + }, + [types.RECEIVE_PROJECTS](state, projects) { + state.projects = projects + .map(convertObjectPropsToCamelCase) + // The `pick` strips out extra properties returned from Sentry. + // Such properties could be problematic later, e.g. when checking whether `projects` contains `selectedProject` + .map(project => _.pick(project, projectKeys)); + }, + [types.RESET_CONNECT](state) { + state.connectSuccessful = false; + state.connectError = false; + }, + [types.SET_INITIAL_STATE]( + state, + { apiHost, enabled, project, token, listProjectsEndpoint, operationsSettingsEndpoint }, + ) { + state.enabled = parseBoolean(enabled); + state.apiHost = apiHost; + state.token = token; + state.listProjectsEndpoint = listProjectsEndpoint; + state.operationsSettingsEndpoint = operationsSettingsEndpoint; + + if (project) { + state.selectedProject = _.pick( + convertObjectPropsToCamelCase(JSON.parse(project)), + projectKeys, + ); + } + }, + [types.UPDATE_API_HOST](state, apiHost) { + state.apiHost = apiHost; + }, + [types.UPDATE_ENABLED](state, enabled) { + state.enabled = enabled; + }, + [types.UPDATE_TOKEN](state, token) { + state.token = token; + }, + [types.UPDATE_SELECTED_PROJECT](state, selectedProject) { + state.selectedProject = selectedProject; + }, + [types.UPDATE_SETTINGS_LOADING](state, settingsLoading) { + state.settingsLoading = settingsLoading; + }, + [types.UPDATE_CONNECT_SUCCESS](state) { + state.connectSuccessful = true; + state.connectError = false; + }, + [types.UPDATE_CONNECT_ERROR](state) { + state.connectSuccessful = false; + state.connectError = true; + }, +}; diff --git a/app/assets/javascripts/error_tracking_settings/store/state.js b/app/assets/javascripts/error_tracking_settings/store/state.js new file mode 100644 index 00000000000..98219d33f4d --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/store/state.js @@ -0,0 +1,12 @@ +export default () => ({ + apiHost: '', + enabled: false, + token: '', + projects: [], + selectedProject: null, + settingsLoading: false, + connectSuccessful: false, + connectError: false, + listProjectsEndpoint: '', + operationsSettingsEndpoint: '', +}); diff --git a/app/assets/javascripts/error_tracking_settings/utils.js b/app/assets/javascripts/error_tracking_settings/utils.js new file mode 100644 index 00000000000..6613e04ee0e --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/utils.js @@ -0,0 +1,18 @@ +export const projectKeys = ['name', 'organizationName', 'organizationSlug', 'slug']; + +export const transformFrontendSettings = ({ apiHost, enabled, token, selectedProject }) => { + const project = selectedProject + ? { + slug: selectedProject.slug, + name: selectedProject.name, + organization_name: selectedProject.organizationName, + organization_slug: selectedProject.organizationSlug, + } + : null; + + return { api_host: apiHost || null, enabled, token: token || null, project }; +}; + +export const getDisplayName = project => `${project.organizationName} | ${project.name}`; + +export default () => {}; diff --git a/app/assets/javascripts/event_tracking/notes.js b/app/assets/javascripts/event_tracking/notes.js new file mode 100644 index 00000000000..2d1ec238274 --- /dev/null +++ b/app/assets/javascripts/event_tracking/notes.js @@ -0,0 +1 @@ +export default () => {}; diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js new file mode 100644 index 00000000000..e020628a473 --- /dev/null +++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js @@ -0,0 +1,30 @@ +import { __ } from '~/locale'; + +export default IssuableTokenKeys => { + const wipToken = { + key: 'wip', + type: 'string', + param: '', + symbol: '', + icon: 'admin', + tag: __('Yes or No'), + lowercaseValueOnSubmit: true, + uppercaseTokenName: true, + capitalizeTokenValue: true, + }; + + IssuableTokenKeys.tokenKeys.push(wipToken); + IssuableTokenKeys.tokenKeysWithAlternative.push(wipToken); + + const targetBranchToken = { + key: 'target-branch', + type: 'string', + param: '', + symbol: '', + icon: 'arrow-right', + tag: 'branch', + }; + + IssuableTokenKeys.tokenKeys.push(targetBranchToken); + IssuableTokenKeys.tokenKeysWithAlternative.push(targetBranchToken); +}; diff --git a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js index 934375023ba..691d165c585 100644 --- a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js @@ -17,6 +17,14 @@ const tokenKeys = [ icon: 'cube', tag: 'type', }, + { + key: 'tag', + type: 'array', + param: 'name[]', + symbol: '~', + icon: 'tag', + tag: '~tag', + }, ]; const AdminRunnersFilteredSearchTokenKeys = new FilteredSearchTokenKeys(tokenKeys); diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js new file mode 100644 index 00000000000..be867a3838d --- /dev/null +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -0,0 +1,164 @@ +import DropdownHint from './dropdown_hint'; +import DropdownUser from './dropdown_user'; +import DropdownNonUser from './dropdown_non_user'; +import DropdownEmoji from './dropdown_emoji'; +import NullDropdown from './null_dropdown'; +import DropdownAjaxFilter from './dropdown_ajax_filter'; +import DropdownUtils from './dropdown_utils'; +import { mergeUrlParams } from '../lib/utils/url_utility'; + +export default class AvailableDropdownMappings { + constructor(container, baseEndpoint, groupsOnly, includeAncestorGroups, includeDescendantGroups) { + this.container = container; + this.baseEndpoint = baseEndpoint; + this.groupsOnly = groupsOnly; + this.includeAncestorGroups = includeAncestorGroups; + this.includeDescendantGroups = includeDescendantGroups; + this.filteredSearchInput = this.container.querySelector('.filtered-search'); + } + + getAllowedMappings(supportedTokens) { + return this.buildMappings(supportedTokens, this.getMappings()); + } + + buildMappings(supportedTokens, availableMappings) { + const allowedMappings = { + hint: { + reference: null, + gl: DropdownHint, + element: this.container.querySelector('#js-dropdown-hint'), + }, + }; + + supportedTokens.forEach(type => { + if (availableMappings[type]) { + allowedMappings[type] = availableMappings[type]; + } + }); + + return allowedMappings; + } + + getMappings() { + return { + author: { + reference: null, + gl: DropdownUser, + element: this.container.querySelector('#js-dropdown-author'), + }, + assignee: { + reference: null, + gl: DropdownUser, + element: this.container.querySelector('#js-dropdown-assignee'), + }, + milestone: { + reference: null, + gl: DropdownNonUser, + extraArguments: { + endpoint: this.getMilestoneEndpoint(), + symbol: '%', + }, + element: this.container.querySelector('#js-dropdown-milestone'), + }, + label: { + reference: null, + gl: DropdownNonUser, + extraArguments: { + endpoint: this.getLabelsEndpoint(), + symbol: '~', + preprocessing: DropdownUtils.duplicateLabelPreprocessing, + }, + element: this.container.querySelector('#js-dropdown-label'), + }, + 'my-reaction': { + reference: null, + gl: DropdownEmoji, + element: this.container.querySelector('#js-dropdown-my-reaction'), + }, + wip: { + reference: null, + gl: DropdownNonUser, + element: this.container.querySelector('#js-dropdown-wip'), + }, + confidential: { + reference: null, + gl: DropdownNonUser, + element: this.container.querySelector('#js-dropdown-confidential'), + }, + status: { + reference: null, + gl: NullDropdown, + element: this.container.querySelector('#js-dropdown-admin-runner-status'), + }, + type: { + reference: null, + gl: NullDropdown, + element: this.container.querySelector('#js-dropdown-admin-runner-type'), + }, + tag: { + reference: null, + gl: DropdownAjaxFilter, + extraArguments: { + endpoint: this.getRunnerTagsEndpoint(), + symbol: '~', + }, + element: this.container.querySelector('#js-dropdown-runner-tag'), + }, + 'target-branch': { + reference: null, + gl: DropdownNonUser, + extraArguments: { + endpoint: this.getMergeRequestTargetBranchesEndpoint(), + symbol: '', + }, + element: this.container.querySelector('#js-dropdown-target-branch'), + }, + }; + } + + getMilestoneEndpoint() { + return `${this.baseEndpoint}/milestones.json`; + } + + getLabelsEndpoint() { + let endpoint = `${this.baseEndpoint}/labels.json?`; + + if (this.groupsOnly) { + endpoint = `${endpoint}only_group_labels=true&`; + } + + if (this.includeAncestorGroups) { + endpoint = `${endpoint}include_ancestor_groups=true&`; + } + + if (this.includeDescendantGroups) { + endpoint = `${endpoint}include_descendant_groups=true`; + } + + return endpoint; + } + + getRunnerTagsEndpoint() { + return `${this.baseEndpoint}/admin/runners/tag_list.json`; + } + + getMergeRequestTargetBranchesEndpoint() { + const endpoint = `${gon.relative_url_root || + ''}/autocomplete/merge_request_target_branches.json`; + + const params = { + group_id: this.getGroupId(), + project_id: this.getProjectId(), + }; + + return mergeUrlParams(params, endpoint); + } + + getGroupId() { + return this.filteredSearchInput.getAttribute('data-group-id') || ''; + } + + getProjectId() { + return this.filteredSearchInput.getAttribute('data-project-id') || ''; + } +} diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js new file mode 100644 index 00000000000..b27bb63c220 --- /dev/null +++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js @@ -0,0 +1,68 @@ +import createFlash from '../flash'; +import AjaxFilter from '../droplab/plugins/ajax_filter'; +import FilteredSearchDropdown from './filtered_search_dropdown'; +import DropdownUtils from './dropdown_utils'; +import FilteredSearchTokenizer from './filtered_search_tokenizer'; +import { __ } from '~/locale'; + +export default class DropdownAjaxFilter extends FilteredSearchDropdown { + constructor(options = {}) { + const { tokenKeys, endpoint, symbol } = options; + + super(options); + + this.tokenKeys = tokenKeys; + this.endpoint = endpoint; + this.symbol = symbol; + + this.config = { + AjaxFilter: this.ajaxFilterConfig(), + }; + } + + ajaxFilterConfig() { + return { + endpoint: `${gon.relative_url_root || ''}${this.endpoint}`, + searchKey: 'search', + searchValueFunction: this.getSearchInput.bind(this), + loadingTemplate: this.loadingTemplate, + onError() { + createFlash(__('An error occurred fetching the dropdown data.')); + }, + }; + } + + itemClicked(e) { + super.itemClicked(e, selected => + selected.querySelector('.dropdown-light-content').innerText.trim(), + ); + } + + renderContent(forceShowList = false) { + this.droplab.changeHookList(this.hookId, this.dropdown, [AjaxFilter], this.config); + super.renderContent(forceShowList); + } + + getSearchInput() { + const query = DropdownUtils.getSearchInput(this.input); + const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get()); + + let value = lastToken || ''; + + if (value[0] === this.symbol) { + value = value.slice(1); + } + + // Removes the first character if it is a quotation so that we can search + // with multiple words + if (value[0] === '"' || value[0] === "'") { + value = value.slice(1); + } + + return value; + } + + init() { + this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init(); + } +} diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js index d9a4d06b549..dad188f6f98 100644 --- a/app/assets/javascripts/filtered_search/dropdown_emoji.js +++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js @@ -3,6 +3,7 @@ import Ajax from '../droplab/plugins/ajax'; import Filter from '../droplab/plugins/filter'; import FilteredSearchDropdown from './filtered_search_dropdown'; import DropdownUtils from './dropdown_utils'; +import { __ } from '~/locale'; export default class DropdownEmoji extends FilteredSearchDropdown { constructor(options = {}) { @@ -14,7 +15,7 @@ export default class DropdownEmoji extends FilteredSearchDropdown { loadingTemplate: this.loadingTemplate, onError() { /* eslint-disable no-new */ - new Flash('An error occurred fetching the dropdown data.'); + new Flash(__('An error occurred fetching the dropdown data.')); /* eslint-enable no-new */ }, }, diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js index 0264f934914..a2312de289d 100644 --- a/app/assets/javascripts/filtered_search/dropdown_non_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js @@ -3,6 +3,7 @@ import Ajax from '../droplab/plugins/ajax'; import Filter from '../droplab/plugins/filter'; import FilteredSearchDropdown from './filtered_search_dropdown'; import DropdownUtils from './dropdown_utils'; +import { __ } from '~/locale'; export default class DropdownNonUser extends FilteredSearchDropdown { constructor(options = {}) { @@ -17,7 +18,7 @@ export default class DropdownNonUser extends FilteredSearchDropdown { preprocessing, onError() { /* eslint-disable no-new */ - new Flash('An error occurred fetching the dropdown data.'); + new Flash(__('An error occurred fetching the dropdown data.')); /* eslint-enable no-new */ }, }, diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js index d5027590bb7..a65c0012b4d 100644 --- a/app/assets/javascripts/filtered_search/dropdown_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_user.js @@ -1,54 +1,35 @@ -import Flash from '../flash'; -import AjaxFilter from '../droplab/plugins/ajax_filter'; -import FilteredSearchDropdown from './filtered_search_dropdown'; import { addClassIfElementExists } from '../lib/utils/dom_utils'; -import DropdownUtils from './dropdown_utils'; -import FilteredSearchTokenizer from './filtered_search_tokenizer'; +import DropdownAjaxFilter from './dropdown_ajax_filter'; -export default class DropdownUser extends FilteredSearchDropdown { +export default class DropdownUser extends DropdownAjaxFilter { constructor(options = {}) { - const { tokenKeys } = options; - super(options); - this.config = { - AjaxFilter: { - endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`, - searchKey: 'search', - params: { - active: true, - group_id: this.getGroupId(), - project_id: this.getProjectId(), - current_user: true, - }, - searchValueFunction: this.getSearchInput.bind(this), - loadingTemplate: this.loadingTemplate, - onLoadingFinished: () => { - this.hideCurrentUser(); - }, - onError() { - /* eslint-disable no-new */ - new Flash('An error occurred fetching the dropdown data.'); - /* eslint-enable no-new */ - }, + super({ + ...options, + endpoint: '/autocomplete/users.json', + symbol: '@', + }); + } + + ajaxFilterConfig() { + return { + ...super.ajaxFilterConfig(), + params: { + active: true, + group_id: this.getGroupId(), + project_id: this.getProjectId(), + current_user: true, + ...this.projectOrGroupId(), + }, + onLoadingFinished: () => { + this.hideCurrentUser(); }, }; - this.tokenKeys = tokenKeys; } hideCurrentUser() { addClassIfElementExists(this.dropdown.querySelector('.js-current-user'), 'hidden'); } - itemClicked(e) { - super.itemClicked(e, selected => - selected.querySelector('.dropdown-light-content').innerText.trim(), - ); - } - - renderContent(forceShowList = false) { - this.droplab.changeHookList(this.hookId, this.dropdown, [AjaxFilter], this.config); - super.renderContent(forceShowList); - } - getGroupId() { return this.input.getAttribute('data-group-id'); } @@ -57,26 +38,16 @@ export default class DropdownUser extends FilteredSearchDropdown { return this.input.getAttribute('data-project-id'); } - getSearchInput() { - const query = DropdownUtils.getSearchInput(this.input); - const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get()); - - let value = lastToken || ''; - - if (value[0] === '@') { - value = value.slice(1); + projectOrGroupId() { + const projectId = this.getProjectId(); + const groupId = this.getGroupId(); + if (groupId) { + return { + group_id: groupId, + }; } - - // Removes the first character if it is a quotation so that we can search - // with multiple words - if (value[0] === '"' || value[0] === "'") { - value = value.slice(1); - } - - return value; - } - - init() { - this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init(); + return { + project_id: projectId, + }; } } diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index 4d05f46ed17..cb0a84b490b 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -1,13 +1,9 @@ +import AvailableDropdownMappings from 'ee_else_ce/filtered_search/available_dropdown_mappings'; import _ from 'underscore'; import DropLab from '~/droplab/drop_lab'; import FilteredSearchContainer from './container'; import FilteredSearchTokenKeys from './filtered_search_token_keys'; import DropdownUtils from './dropdown_utils'; -import DropdownHint from './dropdown_hint'; -import DropdownEmoji from './dropdown_emoji'; -import DropdownNonUser from './dropdown_non_user'; -import DropdownUser from './dropdown_user'; -import NullDropdown from './null_dropdown'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; export default class FilteredSearchDropdownManager { @@ -49,101 +45,15 @@ export default class FilteredSearchDropdownManager { setupMapping() { const supportedTokens = this.filteredSearchTokenKeys.getKeys(); - const allowedMappings = { - hint: { - reference: null, - gl: DropdownHint, - element: this.container.querySelector('#js-dropdown-hint'), - }, - }; - const availableMappings = { - author: { - reference: null, - gl: DropdownUser, - element: this.container.querySelector('#js-dropdown-author'), - }, - assignee: { - reference: null, - gl: DropdownUser, - element: this.container.querySelector('#js-dropdown-assignee'), - }, - milestone: { - reference: null, - gl: DropdownNonUser, - extraArguments: { - endpoint: this.getMilestoneEndpoint(), - symbol: '%', - }, - element: this.container.querySelector('#js-dropdown-milestone'), - }, - label: { - reference: null, - gl: DropdownNonUser, - extraArguments: { - endpoint: this.getLabelsEndpoint(), - symbol: '~', - preprocessing: DropdownUtils.duplicateLabelPreprocessing, - }, - element: this.container.querySelector('#js-dropdown-label'), - }, - 'my-reaction': { - reference: null, - gl: DropdownEmoji, - element: this.container.querySelector('#js-dropdown-my-reaction'), - }, - wip: { - reference: null, - gl: DropdownNonUser, - element: this.container.querySelector('#js-dropdown-wip'), - }, - confidential: { - reference: null, - gl: DropdownNonUser, - element: this.container.querySelector('#js-dropdown-confidential'), - }, - status: { - reference: null, - gl: NullDropdown, - element: this.container.querySelector('#js-dropdown-admin-runner-status'), - }, - type: { - reference: null, - gl: NullDropdown, - element: this.container.querySelector('#js-dropdown-admin-runner-type'), - }, - }; - - supportedTokens.forEach(type => { - if (availableMappings[type]) { - allowedMappings[type] = availableMappings[type]; - } - }); - - this.mapping = allowedMappings; - } - - getMilestoneEndpoint() { - const endpoint = `${this.baseEndpoint}/milestones.json`; - - return endpoint; - } - - getLabelsEndpoint() { - let endpoint = `${this.baseEndpoint}/labels.json?`; - - if (this.groupsOnly) { - endpoint = `${endpoint}only_group_labels=true&`; - } - - if (this.includeAncestorGroups) { - endpoint = `${endpoint}include_ancestor_groups=true&`; - } - - if (this.includeDescendantGroups) { - endpoint = `${endpoint}include_descendant_groups=true`; - } + const availableMappings = new AvailableDropdownMappings( + this.container, + this.baseEndpoint, + this.groupsOnly, + this.includeAncestorGroups, + this.includeDescendantGroups, + ); - return endpoint; + this.mapping = availableMappings.getAllowedMappings(supportedTokens); } static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) { diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 33c82778c79..78fbb3696cc 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -1,6 +1,7 @@ import _ from 'underscore'; import { getParameterByName, getUrlParamsArray } from '~/lib/utils/common_utils'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; import { visitUrl } from '../lib/utils/url_utility'; import Flash from '../flash'; import FilteredSearchContainer from './container'; @@ -13,6 +14,7 @@ import FilteredSearchTokenizer from './filtered_search_tokenizer'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; import DropdownUtils from './dropdown_utils'; +import { __ } from '~/locale'; export default class FilteredSearchManager { constructor({ @@ -36,10 +38,11 @@ export default class FilteredSearchManager { this.tokensContainer = this.container.querySelector('.tokens-container'); this.filteredSearchTokenKeys = filteredSearchTokenKeys; this.stateFiltersSelector = stateFiltersSelector; - this.recentsStorageKeyNames = { - issues: 'issue-recent-searches', - merge_requests: 'merge-request-recent-searches', - }; + + const { multipleAssignees } = this.filteredSearchInput.dataset; + if (multipleAssignees && this.filteredSearchTokenKeys.enableMultipleAssignees) { + this.filteredSearchTokenKeys.enableMultipleAssignees(); + } this.recentSearchesStore = new RecentSearchesStore({ isLocalStorageAvailable: RecentSearchesService.isAvailable(), @@ -51,7 +54,7 @@ export default class FilteredSearchManager { const fullPath = this.searchHistoryDropdownElement ? this.searchHistoryDropdownElement.dataset.fullPath : 'project'; - const recentSearchesKey = `${fullPath}-${this.recentsStorageKeyNames[this.page]}`; + const recentSearchesKey = `${fullPath}-${recentSearchesStorageKeys[this.page]}`; this.recentSearchesService = new RecentSearchesService(recentSearchesKey); } @@ -62,7 +65,7 @@ export default class FilteredSearchManager { .catch(error => { if (error.name === 'RecentSearchesServiceError') return undefined; // eslint-disable-next-line no-new - new Flash('An error occurred while parsing recent searches'); + new Flash(__('An error occurred while parsing recent searches')); // Gracefully fail to empty array return []; }) @@ -338,7 +341,7 @@ export default class FilteredSearchManager { handleInputPlaceholder() { const query = DropdownUtils.getSearchQuery(); - const placeholder = 'Search or filter results...'; + const placeholder = __('Search or filter results...'); const currentPlaceholder = this.filteredSearchInput.placeholder; if (query.length === 0 && currentPlaceholder !== placeholder) { @@ -504,14 +507,7 @@ export default class FilteredSearchManager { const match = this.filteredSearchTokenKeys.searchByKeyParam(keyParam); if (match) { - // Use lastIndexOf because the token key is allowed to contain underscore - // e.g. 'my_reaction' is the token key of 'my_reaction_emoji' - const lastIndexOf = keyParam.lastIndexOf('_'); - let sanitizedKey = lastIndexOf !== -1 ? keyParam.slice(0, lastIndexOf) : keyParam; - // Replace underscore with hyphen in the sanitizedkey. - // e.g. 'my_reaction' => 'my-reaction' - sanitizedKey = sanitizedKey.replace('_', '-'); - const { symbol } = match; + const { key, symbol } = match; let quotationsToUse = ''; if (sanitizedValue.indexOf(' ') !== -1) { @@ -520,10 +516,10 @@ export default class FilteredSearchManager { } hasFilteredSearch = true; - const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue); + const canEdit = this.canEdit && this.canEdit(key, sanitizedValue); const { uppercaseTokenName, capitalizeTokenValue } = match; FilteredSearchVisualTokens.addFilterVisualToken( - sanitizedKey, + key, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`, { canEdit, diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js index 48534bdf815..0a9579bf491 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export default class FilteredSearchTokenKeys { constructor(tokenKeys = [], alternativeTokenKeys = [], conditions = []) { this.tokenKeys = tokenKeys; @@ -79,7 +81,7 @@ export default class FilteredSearchTokenKeys { param: '', symbol: '', icon: 'eye-slash', - tag: 'Yes or No', + tag: __('Yes or No'), lowercaseValueOnSubmit: true, uppercaseTokenName: false, capitalizeTokenValue: true, @@ -88,21 +90,4 @@ export default class FilteredSearchTokenKeys { this.tokenKeys.push(confidentialToken); this.tokenKeysWithAlternative.push(confidentialToken); } - - addExtraTokensForMergeRequests() { - const wipToken = { - key: 'wip', - type: 'string', - param: '', - symbol: '', - icon: 'admin', - tag: 'Yes or No', - lowercaseValueOnSubmit: true, - uppercaseTokenName: true, - capitalizeTokenValue: true, - }; - - this.tokenKeys.push(wipToken); - this.tokenKeysWithAlternative.push(wipToken); - } } diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 5090b0bdc3c..315cd6f64da 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -1,10 +1,6 @@ -import _ from 'underscore'; -import AjaxCache from '~/lib/utils/ajax_cache'; +import VisualTokenValue from 'ee_else_ce/filtered_search/visual_token_value'; import { objectToQueryString } from '~/lib/utils/common_utils'; -import Flash from '../flash'; import FilteredSearchContainer from './container'; -import UsersCache from '../lib/utils/users_cache'; -import DropdownUtils from './dropdown_utils'; export default class FilteredSearchVisualTokens { static getLastVisualTokenBeforeInput() { @@ -20,21 +16,6 @@ export default class FilteredSearchVisualTokens { }; } - /** - * Returns a computed API endpoint - * and query string composed of values from endpointQueryParams - * @param {String} endpoint - * @param {String} endpointQueryParams - */ - static getEndpointWithQueryParams(endpoint, endpointQueryParams) { - if (!endpointQueryParams) { - return endpoint; - } - - const queryString = objectToQueryString(JSON.parse(endpointQueryParams)); - return `${endpoint}?${queryString}`; - } - static unselectTokens() { const otherTokens = FilteredSearchContainer.container.querySelectorAll( '.js-visual-token .selectable.selected', @@ -76,122 +57,33 @@ export default class FilteredSearchVisualTokens { `; } - static setTokenStyle(tokenContainer, backgroundColor, textColor) { - const token = tokenContainer; - - token.style.backgroundColor = backgroundColor; - token.style.color = textColor; - - if (textColor === '#FFFFFF') { - const removeToken = token.querySelector('.remove-token'); - removeToken.classList.add('inverted'); - } - - return token; - } - - static updateLabelTokenColor(tokenValueContainer, tokenValue) { - const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search'); - const { baseEndpoint } = filteredSearchInput.dataset; - const labelsEndpoint = FilteredSearchVisualTokens.getEndpointWithQueryParams( - `${baseEndpoint}/labels.json`, - filteredSearchInput.dataset.endpointQueryParams, - ); - - return AjaxCache.retrieve(labelsEndpoint) - .then(labels => { - const matchingLabel = (labels || []).find( - label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue, - ); - - if (!matchingLabel) { - return; - } - - FilteredSearchVisualTokens.setTokenStyle( - tokenValueContainer, - matchingLabel.color, - matchingLabel.text_color, - ); - }) - .catch(() => new Flash('An error occurred while fetching label colors.')); - } - - static updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) { - const username = tokenValue.replace(/^@/, ''); - return ( - UsersCache.retrieve(username) - .then(user => { - if (!user) { - return; - } - - /* eslint-disable no-param-reassign */ - tokenValueContainer.dataset.originalValue = tokenValue; - tokenValueElement.innerHTML = ` - <img class="avatar s20" src="${user.avatar_url}" alt=""> - ${_.escape(user.name)} - `; - /* eslint-enable no-param-reassign */ - }) - // ignore error and leave username in the search bar - .catch(() => {}) - ); - } - - static updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) { - const container = tokenValueContainer; - const element = tokenValueElement; - - return ( - import(/* webpackChunkName: 'emoji' */ '../emoji') - .then(Emoji => { - if (!Emoji.isEmojiNameValid(tokenValue)) { - return; - } - - container.dataset.originalValue = tokenValue; - element.innerHTML = Emoji.glEmojiTag(tokenValue); - }) - // ignore error and leave emoji name in the search bar - .catch(() => {}) - ); - } - static renderVisualTokenValue(parentElement, tokenName, tokenValue) { + const tokenType = tokenName.toLowerCase(); const tokenValueContainer = parentElement.querySelector('.value-container'); const tokenValueElement = tokenValueContainer.querySelector('.value'); tokenValueElement.innerText = tokenValue; - if (['none', 'any'].includes(tokenValue.toLowerCase())) { - return; - } - - const tokenType = tokenName.toLowerCase(); + const visualTokenValue = new VisualTokenValue(tokenValue, tokenType); - if (tokenType === 'label') { - FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue); - } else if (tokenType === 'author' || tokenType === 'assignee') { - FilteredSearchVisualTokens.updateUserTokenAppearance( - tokenValueContainer, - tokenValueElement, - tokenValue, - ); - } else if (tokenType === 'my-reaction') { - FilteredSearchVisualTokens.updateEmojiTokenAppearance( - tokenValueContainer, - tokenValueElement, - tokenValue, - ); - } + visualTokenValue.render(tokenValueContainer, tokenValueElement); } static addVisualTokenElement(name, value, options = {}) { - const { isSearchTerm = false, canEdit, uppercaseTokenName, capitalizeTokenValue } = options; + const { + isSearchTerm = false, + canEdit, + uppercaseTokenName, + capitalizeTokenValue, + tokenClass = `search-token-${name.toLowerCase()}`, + } = options; const li = document.createElement('li'); li.classList.add('js-visual-token'); li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token'); + if (!isSearchTerm) { + li.classList.add(tokenClass); + } + if (value) { li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({ canEdit, @@ -318,6 +210,21 @@ export default class FilteredSearchVisualTokens { } } + /** + * Returns a computed API endpoint + * and query string composed of values from endpointQueryParams + * @param {String} endpoint + * @param {String} endpointQueryParams + */ + static getEndpointWithQueryParams(endpoint, endpointQueryParams) { + if (!endpointQueryParams) { + return endpoint; + } + + const queryString = objectToQueryString(JSON.parse(endpointQueryParams)); + return `${endpoint}?${queryString}`; + } + static editToken(token) { const input = FilteredSearchContainer.container.querySelector('.filtered-search'); diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js index fd61030eb13..6c3d9e33420 100644 --- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js @@ -1,4 +1,5 @@ import FilteredSearchTokenKeys from './filtered_search_token_keys'; +import { __ } from '~/locale'; export const tokenKeys = [ { @@ -60,52 +61,52 @@ export const conditions = [ { url: 'assignee_id=None', tokenKey: 'assignee', - value: 'None', + value: __('None'), }, { url: 'assignee_id=Any', tokenKey: 'assignee', - value: 'Any', + value: __('Any'), }, { url: 'milestone_title=None', tokenKey: 'milestone', - value: 'None', + value: __('None'), }, { url: 'milestone_title=Any', tokenKey: 'milestone', - value: 'Any', + value: __('Any'), }, { url: 'milestone_title=%23upcoming', tokenKey: 'milestone', - value: 'Upcoming', + value: __('Upcoming'), }, { url: 'milestone_title=%23started', tokenKey: 'milestone', - value: 'Started', + value: __('Started'), }, { url: 'label_name[]=None', tokenKey: 'label', - value: 'None', + value: __('None'), }, { url: 'label_name[]=Any', tokenKey: 'label', - value: 'Any', + value: __('Any'), }, { url: 'my_reaction_emoji=None', tokenKey: 'my-reaction', - value: 'None', + value: __('None'), }, { url: 'my_reaction_emoji=Any', tokenKey: 'my-reaction', - value: 'Any', + value: __('Any'), }, ]; diff --git a/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js new file mode 100644 index 00000000000..7e9b809e9b2 --- /dev/null +++ b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js @@ -0,0 +1,4 @@ +export default { + issues: 'issue-recent-searches', + merge_requests: 'merge-request-recent-searches', +}; diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js index 5917b223d63..011b37e218d 100644 --- a/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js +++ b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js @@ -1,7 +1,9 @@ +import { __ } from '~/locale'; + class RecentSearchesServiceError { constructor(message) { this.name = 'RecentSearchesServiceError'; - this.message = message || 'Recent Searches Service is unavailable'; + this.message = message || __('Recent Searches Service is unavailable'); } } diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js new file mode 100644 index 00000000000..38327472cb3 --- /dev/null +++ b/app/assets/javascripts/filtered_search/visual_token_value.js @@ -0,0 +1,117 @@ +import _ from 'underscore'; +import FilteredSearchContainer from '~/filtered_search/container'; +import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens'; +import AjaxCache from '~/lib/utils/ajax_cache'; +import DropdownUtils from '~/filtered_search/dropdown_utils'; +import Flash from '~/flash'; +import UsersCache from '~/lib/utils/users_cache'; +import { __ } from '~/locale'; + +export default class VisualTokenValue { + constructor(tokenValue, tokenType) { + this.tokenValue = tokenValue; + this.tokenType = tokenType; + } + + render(tokenValueContainer, tokenValueElement) { + const { tokenType, tokenValue } = this; + + if (['none', 'any'].includes(tokenValue.toLowerCase())) { + return; + } + + if (tokenType === 'label') { + this.updateLabelTokenColor(tokenValueContainer); + } else if (tokenType === 'author' || tokenType === 'assignee') { + this.updateUserTokenAppearance(tokenValueContainer, tokenValueElement); + } else if (tokenType === 'my-reaction') { + this.updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement); + } + } + + updateUserTokenAppearance(tokenValueContainer, tokenValueElement) { + const { tokenValue } = this; + const username = this.tokenValue.replace(/^@/, ''); + + return ( + UsersCache.retrieve(username) + .then(user => { + if (!user) { + return; + } + + /* eslint-disable no-param-reassign */ + tokenValueContainer.dataset.originalValue = tokenValue; + tokenValueElement.innerHTML = ` + <img class="avatar s20" src="${user.avatar_url}" alt=""> + ${_.escape(user.name)} + `; + /* eslint-enable no-param-reassign */ + }) + // ignore error and leave username in the search bar + .catch(() => {}) + ); + } + + updateLabelTokenColor(tokenValueContainer) { + const { tokenValue } = this; + const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search'); + const { baseEndpoint } = filteredSearchInput.dataset; + const labelsEndpoint = FilteredSearchVisualTokens.getEndpointWithQueryParams( + `${baseEndpoint}/labels.json`, + filteredSearchInput.dataset.endpointQueryParams, + ); + + return AjaxCache.retrieve(labelsEndpoint) + .then(labels => { + const matchingLabel = (labels || []).find( + label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue, + ); + + if (!matchingLabel) { + return; + } + + VisualTokenValue.setTokenStyle( + tokenValueContainer, + matchingLabel.color, + matchingLabel.text_color, + ); + }) + .catch(() => new Flash(__('An error occurred while fetching label colors.'))); + } + + static setTokenStyle(tokenValueContainer, backgroundColor, textColor) { + const token = tokenValueContainer; + + token.style.backgroundColor = backgroundColor; + token.style.color = textColor; + + if (textColor === '#FFFFFF') { + const removeToken = token.querySelector('.remove-token'); + removeToken.classList.add('inverted'); + } + + return token; + } + + updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement) { + const container = tokenValueContainer; + const element = tokenValueElement; + const value = this.tokenValue; + + return ( + import(/* webpackChunkName: 'emoji' */ '../emoji') + .then(Emoji => { + if (!Emoji.isEmojiNameValid(value)) { + return; + } + + container.dataset.originalValue = value; + element.innerHTML = Emoji.glEmojiTag(value); + }) + // ignore error and leave emoji name in the search bar + .catch(() => {}) + ); + } +} diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js index 2b6af9060d1..2566ed6b47c 100644 --- a/app/assets/javascripts/fly_out_nav.js +++ b/app/assets/javascripts/fly_out_nav.js @@ -1,4 +1,5 @@ import bp from './breakpoints'; +import { SIDEBAR_COLLAPSED_CLASS } from './contextual_sidebar'; const HIDE_INTERVAL_TIMEOUT = 300; const IS_OVER_CLASS = 'is-over'; @@ -29,7 +30,7 @@ const setHeaderHeight = () => { }; export const isSidebarCollapsed = () => - sidebar && sidebar.classList.contains('sidebar-collapsed-desktop'); + sidebar && sidebar.classList.contains(SIDEBAR_COLLAPSED_CLASS); export const canShowActiveSubItems = el => { if (el.classList.contains('active') && !isSidebarCollapsed()) { diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue index 42d14b65b3a..92c3bcb5012 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -1,6 +1,9 @@ <script> /* eslint-disable vue/require-default-prop */ -import Identicon from '../../vue_shared/components/identicon.vue'; +import _ from 'underscore'; +import Identicon from '~/vue_shared/components/identicon.vue'; +import highlight from '~/lib/utils/highlight'; +import { truncateNamespace } from '~/lib/utils/text_utility'; export default { components: { @@ -36,43 +39,13 @@ export default { }, computed: { hasAvatar() { - return this.avatarUrl !== null; + return _.isString(this.avatarUrl) && !_.isEmpty(this.avatarUrl); }, - highlightedItemName() { - if (this.matcher) { - const matcherRegEx = new RegExp(this.matcher, 'gi'); - const matches = this.itemName.match(matcherRegEx); - - if (matches && matches.length > 0) { - return this.itemName.replace(matches[0], `<b>${matches[0]}</b>`); - } - } - return this.itemName; - }, - /** - * Smartly truncates item namespace by doing two things; - * 1. Only include Group names in path by removing item name - * 2. Only include first and last group names in the path - * when namespace has more than 2 groups present - * - * First part (removal of item name from namespace) can be - * done from backend but doing so involves migration of - * existing item namespaces which is not wise thing to do. - */ truncatedNamespace() { - if (!this.namespace) { - return null; - } - const namespaceArr = this.namespace.split(' / '); - - namespaceArr.splice(-1, 1); - let namespace = namespaceArr.join(' / '); - - if (namespaceArr.length > 2) { - namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`; - } - - return namespace; + return truncateNamespace(this.namespace); + }, + highlightedItemName() { + return highlight(this.itemName, this.matcher); }, }, }; @@ -92,8 +65,16 @@ export default { /> </div> <div class="frequent-items-item-metadata-container"> - <div :title="itemName" class="frequent-items-item-title" v-html="highlightedItemName"></div> - <div v-if="truncatedNamespace" :title="namespace" class="frequent-items-item-namespace"> + <div + :title="itemName" + class="frequent-items-item-title js-frequent-items-item-title" + v-html="highlightedItemName" + ></div> + <div + v-if="namespace" + :title="namespace" + class="frequent-items-item-namespace js-frequent-items-item-namespace" + > {{ truncatedNamespace }} </div> </div> diff --git a/app/assets/javascripts/frequent_items/store/actions.js b/app/assets/javascripts/frequent_items/store/actions.js index 3dd89a82a42..ba62ab67e50 100644 --- a/app/assets/javascripts/frequent_items/store/actions.js +++ b/app/assets/javascripts/frequent_items/store/actions.js @@ -51,7 +51,7 @@ export const fetchSearchedItems = ({ state, dispatch }, searchQuery) => { const params = { simple: true, per_page: 20, - membership: !!gon.current_user_id, + membership: Boolean(gon.current_user_id), }; if (state.namespace === 'projects') { diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index c81e754df4c..0af9aabd8cf 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import 'at.js'; import _ from 'underscore'; import glRegexp from './lib/utils/regexp'; import AjaxCache from './lib/utils/ajax_cache'; @@ -461,7 +462,10 @@ class GfmAutoComplete { // We can ignore this for quick actions because they are processed // before Markdown. if (!this.setting.skipMarkdownCharacterTest) { - withoutAt = withoutAt.replace(/([~\-_*`])/g, '\\$&'); + withoutAt = withoutAt + .replace(/(~~|`|\*)/g, '\\$1') + .replace(/(\b)(_+)/g, '$1\\$2') // only escape underscores at the start + .replace(/(_+)(\b)/g, '\\$1$2'); // or end of words } return `${at}${withoutAt}`; @@ -474,6 +478,16 @@ class GfmAutoComplete { } return null; }, + highlighter(li, query) { + // override default behaviour to escape dot character + // see https://github.com/ichord/At.js/pull/576 + if (!query) { + return li; + } + const escapedQuery = query.replace(/[.+]/, '\\$&'); + const regexp = new RegExp(`>\\s*([^<]*?)(${escapedQuery})([^<]*)\\s*<`, 'ig'); + return li.replace(regexp, (str, $1, $2, $3) => `> ${$1}<strong>${$2}</strong>${$3} <`); + }, }; } diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index a8ac2f510a4..05f34391323 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -307,8 +307,8 @@ GitLabDropdown = (function() { // Set Defaults this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT); this.noFilterInput = this.options.noFilterInput || this.getElement(NO_FILTER_INPUT); - this.highlight = !!this.options.highlight; - this.icon = !!this.options.icon; + this.highlight = Boolean(this.options.highlight); + this.icon = Boolean(this.options.icon); this.filterInputBlur = this.options.filterInputBlur != null ? this.options.filterInputBlur : true; // If no input is passed create a default one @@ -335,6 +335,10 @@ GitLabDropdown = (function() { _this.fullData = data; _this.parseData(_this.fullData); _this.focusTextInput(); + + // Update dropdown position since remote data may have changed dropdown size + _this.dropdown.find('.dropdown-menu-toggle').dropdown('update'); + if ( _this.options.filterable && _this.filter && @@ -561,10 +565,14 @@ GitLabDropdown = (function() { !$target.data('isLink') ) { e.stopPropagation(); - return false; - } else { - return true; + + // This prevents automatic scrolling to the top + if ($target.is('a')) { + return false; + } } + + return true; } }; @@ -656,23 +664,7 @@ GitLabDropdown = (function() { if (this.options.renderMenu) { return this.options.renderMenu(html); } else { - var ul = document.createElement('ul'); - - for (var i = 0; i < html.length; i += 1) { - var el = html[i]; - - if (el instanceof $) { - el = el.get(0); - } - - if (typeof el === 'string') { - ul.innerHTML += el; - } else { - ul.appendChild(el); - } - } - - return ul; + return $('<ul>').append(html); } }; @@ -719,6 +711,10 @@ GitLabDropdown = (function() { } html = document.createElement('li'); + if (rowHidden) { + html.style.display = 'none'; + } + if (data === 'divider' || data === 'separator') { html.className = data; return html; diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js index a5b8c357e8a..04301c9ce12 100644 --- a/app/assets/javascripts/gl_field_error.js +++ b/app/assets/javascripts/gl_field_error.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { __ } from '~/locale'; /** * This class overrides the browser's validation error bubbles, displaying custom @@ -61,7 +62,7 @@ export default class GlFieldError { this.inputElement = $(input); this.inputDomElement = this.inputElement.get(0); this.form = formErrors; - this.errorMessage = this.inputElement.attr('title') || 'This field is required.'; + this.errorMessage = this.inputElement.attr('title') || __('This field is required.'); this.fieldErrorElement = $(`<p class='${errorMessageClass} hidden'>${this.errorMessage}</p>`); this.state = { diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js index d5d5954ce6a..c4fd719c8d0 100644 --- a/app/assets/javascripts/gl_field_errors.js +++ b/app/assets/javascripts/gl_field_errors.js @@ -15,7 +15,7 @@ export default class GlFieldErrors { initValidators() { // register selectors here as needed - const validateSelectors = [':text', ':password', '[type=email]'] + const validateSelectors = [':text', ':password', '[type=email]', '[type=url]', '[type=number]'] .map(selector => `input${selector}`) .join(','); diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index f5e2e46237f..a66555838ba 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import autosize from 'autosize'; -import GfmAutoComplete, * as GFMConfig from './gfm_auto_complete'; +import GfmAutoComplete, { defaultAutocompleteConfig } from 'ee_else_ce/gfm_auto_complete'; import dropzoneInput from './dropzone_input'; import { addMarkdownListeners, removeMarkdownListeners } from './lib/utils/text_markdown'; @@ -8,12 +8,12 @@ export default class GLForm { constructor(form, enableGFM = {}) { this.form = form; this.textarea = this.form.find('textarea.js-gfm-input'); - this.enableGFM = Object.assign({}, GFMConfig.defaultAutocompleteConfig, enableGFM); + this.enableGFM = Object.assign({}, defaultAutocompleteConfig, enableGFM); // Disable autocomplete for keywords which do not have dataSources available const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {}; Object.keys(this.enableGFM).forEach(item => { if (item !== 'emojis') { - this.enableGFM[item] = !!dataSources[item]; + this.enableGFM[item] = Boolean(dataSources[item]); } }); // Before we start, we should clean up any previous data for this form diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js index efba6fc1aff..96051b612b5 100644 --- a/app/assets/javascripts/gpg_badges.js +++ b/app/assets/javascripts/gpg_badges.js @@ -20,7 +20,7 @@ export default class GpgBadges { const endpoint = tag.data('signaturesPath'); if (!endpoint) { displayError(); - return Promise.reject(new Error('Missing commit signatures endpoint!')); + return Promise.reject(new Error(__('Missing commit signatures endpoint!'))); } const params = parseQueryStringIntoObject(tag.serialize()); diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index 29dc2d6a8a3..aa50fd8ff62 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -244,7 +244,7 @@ export default { <gl-loading-icon v-if="isLoading" :label="s__('GroupsTree|Loading groups')" - :size="2" + size="md" class="loading-animation prepend-top-20" /> <groups-component diff --git a/app/assets/javascripts/groups/transfer_dropdown.js b/app/assets/javascripts/groups/transfer_dropdown.js index 26510fcdb2a..ce0c9256148 100644 --- a/app/assets/javascripts/groups/transfer_dropdown.js +++ b/app/assets/javascripts/groups/transfer_dropdown.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { __ } from '~/locale'; export default class TransferDropdown { constructor() { @@ -13,7 +14,7 @@ export default class TransferDropdown { } buildDropdown() { - const extraOptions = [{ id: '', text: 'No parent group' }, 'divider']; + const extraOptions = [{ id: '', text: __('No parent group') }, 'divider']; this.groupDropdown.glDropdown({ selectable: true, diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index bdadbb1bb2a..a1263d1cdab 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; import Api from './api'; import { normalizeHeaders } from './lib/utils/common_utils'; +import { __ } from '~/locale'; export default function groupsSelect() { import(/* webpackChunkName: 'select2' */ 'select2/select2') @@ -18,7 +19,7 @@ export default function groupsSelect() { : Api.groupsPath; $select.select2({ - placeholder: 'Search for a group', + placeholder: __('Search for a group'), allowClear: $select.hasClass('allowClear'), multiple: $select.hasClass('multiselect'), minimumInputLength: 0, diff --git a/app/assets/javascripts/helpers/monitor_helper.js b/app/assets/javascripts/helpers/monitor_helper.js new file mode 100644 index 00000000000..2c2a04d5b5e --- /dev/null +++ b/app/assets/javascripts/helpers/monitor_helper.js @@ -0,0 +1,17 @@ +/* eslint-disable import/prefer-default-export */ + +export const makeDataSeries = (queryResults, defaultConfig) => + queryResults.reduce((acc, result) => { + const data = result.values.filter(([, value]) => !Number.isNaN(value)); + if (!data.length) { + return acc; + } + const relevantMetric = defaultConfig.name.toLowerCase().replace(' ', '_'); + const name = result.metric[relevantMetric]; + const series = { data }; + if (name) { + series.name = `${defaultConfig.name}: ${name}`; + } + + return acc.concat({ ...defaultConfig, ...series }); + }, []); diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue index 7c769ab7fa0..7b4e03be8eb 100644 --- a/app/assets/javascripts/ide/components/activity_bar.vue +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -78,7 +78,7 @@ export default { data-container="body" data-placement="right" type="button" - class="ide-sidebar-link js-ide-commit-mode" + class="ide-sidebar-link js-ide-commit-mode qa-commit-mode-tab" @click.prevent="changedActivityView($event, $options.activityBarViews.commit)" > <icon name="commit" /> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index d360dc42cd3..685d8a6b245 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -1,17 +1,24 @@ <script> import _ from 'underscore'; -import { mapActions, mapState, mapGetters } from 'vuex'; +import { mapState, mapGetters, createNamespacedHelpers } from 'vuex'; import { sprintf, __ } from '~/locale'; -import * as consts from '../../stores/modules/commit/constants'; +import consts from '../../stores/modules/commit/constants'; import RadioGroup from './radio_group.vue'; +import NewMergeRequestOption from './new_merge_request_option.vue'; + +const { mapState: mapCommitState, mapActions: mapCommitActions } = createNamespacedHelpers( + 'commit', +); export default { components: { RadioGroup, + NewMergeRequestOption, }, computed: { ...mapState(['currentBranchId', 'changedFiles', 'stagedFiles']), - ...mapGetters(['currentProject', 'currentBranch']), + ...mapCommitState(['commitAction']), + ...mapGetters(['currentBranch']), commitToCurrentBranchText() { return sprintf( __('Commit to %{branchName} branch'), @@ -19,12 +26,12 @@ export default { false, ); }, - disableMergeRequestRadio() { + containsStagedChanges() { return this.changedFiles.length > 0 && this.stagedFiles.length > 0; }, }, watch: { - disableMergeRequestRadio() { + containsStagedChanges() { this.updateSelectedCommitAction(); }, }, @@ -32,18 +39,17 @@ export default { this.updateSelectedCommitAction(); }, methods: { - ...mapActions('commit', ['updateCommitAction']), + ...mapCommitActions(['updateCommitAction']), updateSelectedCommitAction() { if (this.currentBranch && !this.currentBranch.can_push) { this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH); - } else if (this.disableMergeRequestRadio) { + } else if (this.containsStagedChanges) { this.updateCommitAction(consts.COMMIT_TO_CURRENT_BRANCH); } }, }, commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH, commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH, - commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR, currentBranchPermissionsTooltip: __( "This option is disabled as you don't have write permissions for the current branch", ), @@ -51,7 +57,7 @@ export default { </script> <template> - <div class="append-bottom-15 ide-commit-radios"> + <div class="append-bottom-15 ide-commit-options"> <radio-group :value="$options.commitToCurrentBranch" :disabled="currentBranch && !currentBranch.can_push" @@ -64,13 +70,6 @@ export default { :label="__('Create a new branch')" :show-input="true" /> - <radio-group - v-if="currentProject.merge_requests_enabled" - :value="$options.commitToNewBranchMR" - :label="__('Create a new branch and merge request')" - :title="__('This option is disabled while you still have unstaged changes')" - :show-input="true" - :disabled="disableMergeRequestRadio" - /> + <new-merge-request-option /> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue index 00b2d236da3..6b0aa5b2b2b 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue @@ -108,6 +108,7 @@ export default { :placeholder="placeholder" :value="text" class="note-textarea ide-commit-message-textarea" + dir="auto" name="commit-message" @scroll="handleScroll" @input="onInput" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue new file mode 100644 index 00000000000..b2e7b15089c --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue @@ -0,0 +1,43 @@ +<script> +import { mapGetters, createNamespacedHelpers } from 'vuex'; + +const { + mapState: mapCommitState, + mapGetters: mapCommitGetters, + mapActions: mapCommitActions, +} = createNamespacedHelpers('commit'); + +export default { + computed: { + ...mapCommitState(['shouldCreateMR']), + ...mapCommitGetters(['isCommittingToCurrentBranch', 'isCommittingToDefaultBranch']), + ...mapGetters(['hasMergeRequest', 'isOnDefaultBranch']), + currentBranchHasMr() { + return this.hasMergeRequest && this.isCommittingToCurrentBranch; + }, + showNewMrOption() { + return ( + this.isCommittingToDefaultBranch || !this.currentBranchHasMr || this.isCommittingToNewBranch + ); + }, + }, + mounted() { + this.setShouldCreateMR(); + }, + methods: { + ...mapCommitActions(['toggleShouldCreateMR', 'setShouldCreateMR']), + }, +}; +</script> + +<template> + <div v-if="showNewMrOption"> + <hr class="my-2" /> + <label class="mb-0"> + <input :checked="shouldCreateMR" type="checkbox" @change="toggleShouldCreateMR" /> + <span class="prepend-left-10"> + {{ __('Start a new merge request') }} + </span> + </label> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue index 2b44438f849..9161eb3d9b1 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue @@ -38,8 +38,8 @@ export default { }, }, computed: { - ...mapState('commit', ['commitAction']), - ...mapGetters('commit', ['newBranchName']), + ...mapState('commit', ['commitAction', 'newBranchName']), + ...mapGetters('commit', ['placeholderBranchName']), tooltipTitle() { return this.disabled ? this.title : ''; }, @@ -73,7 +73,8 @@ export default { </label> <div v-if="commitAction === value && showInput" class="ide-commit-new-branch"> <input - :placeholder="newBranchName" + :placeholder="placeholderBranchName" + :value="newBranchName" type="text" class="form-control monospace" @input="updateBranchName($event.target.value)" diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index d6673cf0421..80a6ab9598a 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -23,7 +23,7 @@ export default { type: Object, required: true, }, - mouseOver: { + dropdownOpen: { type: Boolean, required: true, }, @@ -92,8 +92,9 @@ export default { <new-dropdown :type="file.type" :path="file.path" - :mouse-over="mouseOver" + :is-open="dropdownOpen" class="prepend-left-8" + v-on="$listeners" /> </div> </template> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 9894ebb0624..e41b1530226 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,6 +1,7 @@ <script> import Vue from 'vue'; import { mapActions, mapState, mapGetters } from 'vuex'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import FindFile from '~/vue_shared/components/file_finder/index.vue'; import NewModal from './new_dropdown/modal.vue'; @@ -22,6 +23,8 @@ export default { FindFile, ErrorMessage, CommitEditorHeader, + GlButton, + GlLoadingIcon, }, props: { rightPaneComponent: { @@ -47,13 +50,15 @@ export default { 'someUncommittedChanges', 'isCommitModeActive', 'allBlobs', + 'emptyRepo', + 'currentTree', ]), }, mounted() { window.onbeforeunload = e => this.onBeforeUnload(e); }, methods: { - ...mapActions(['toggleFileFinder']), + ...mapActions(['toggleFileFinder', 'openNewEntryModal']), onBeforeUnload(e = {}) { const returnValue = __('Are you sure you want to lose unsaved changes?'); @@ -98,17 +103,40 @@ export default { <repo-editor :file="activeFile" class="multi-file-edit-pane-content" /> </template> <template v-else> - <div v-once class="ide-empty-state"> + <div class="ide-empty-state"> <div class="row js-empty-state"> <div class="col-12"> <div class="svg-content svg-250"><img :src="emptyStateSvgPath" /></div> </div> <div class="col-12"> <div class="text-content text-center"> - <h4>Welcome to the GitLab IDE</h4> - <p> - Select a file from the left sidebar to begin editing. Afterwards, you'll be able - to commit your changes. + <h4> + {{ __('Make and review changes in the browser with the Web IDE') }} + </h4> + <template v-if="emptyRepo"> + <p> + {{ + __( + "Create a new file as there are no files yet. Afterwards, you'll be able to commit your changes.", + ) + }} + </p> + <gl-button + variant="success" + :title="__('New file')" + :aria-label="__('New file')" + @click="openNewEntryModal({ type: 'blob' })" + > + {{ __('New file') }} + </gl-button> + </template> + <gl-loading-icon v-else-if="!currentTree || currentTree.loading" size="md" /> + <p v-else> + {{ + __( + "Select a file from the left sidebar to begin editing. Afterwards, you'll be able to commit your changes.", + ) + }} </p> </div> </div> diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index 81374f26645..95782b2c88a 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -54,14 +54,17 @@ export default { <slot name="header"></slot> </header> <div class="ide-tree-body h-100"> - <file-row - v-for="file in currentTree.tree" - :key="file.key" - :file="file" - :level="0" - :extra-component="$options.FileRowExtra" - @toggleTreeOpen="toggleTreeOpen" - /> + <template v-if="currentTree.tree.length"> + <file-row + v-for="file in currentTree.tree" + :key="file.key" + :file="file" + :level="0" + :extra-component="$options.FileRowExtra" + @toggleTreeOpen="toggleTreeOpen" + /> + </template> + <div v-else class="file-row">{{ __('No files') }}</div> </div> </template> </div> diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index d7a7b1b4d78..27d24fa5e1d 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -1,7 +1,6 @@ <script> import { mapActions } from 'vuex'; import icon from '~/vue_shared/components/icon.vue'; -import newModal from './modal.vue'; import upload from './upload.vue'; import ItemButton from './button.vue'; import { modalTypes } from '../../constants'; @@ -9,7 +8,6 @@ import { modalTypes } from '../../constants'; export default { components: { icon, - newModal, upload, ItemButton, }, @@ -23,38 +21,29 @@ export default { required: false, default: '', }, - mouseOver: { + isOpen: { type: Boolean, - required: true, + required: false, + default: false, }, }, - data() { - return { - dropdownOpen: false, - }; - }, watch: { - dropdownOpen() { + isOpen() { this.$nextTick(() => { this.$refs.dropdownMenu.scrollIntoView({ block: 'nearest', }); }); }, - mouseOver() { - if (!this.mouseOver) { - this.dropdownOpen = false; - } - }, }, methods: { ...mapActions(['createTempEntry', 'openNewEntryModal', 'deleteEntry']), createNewItem(type) { this.openNewEntryModal({ type, path: this.path }); - this.dropdownOpen = false; + this.$emit('toggle', false); }, openDropdown() { - this.dropdownOpen = !this.dropdownOpen; + this.$emit('toggle', !this.isOpen); }, }, modalTypes, @@ -65,7 +54,7 @@ export default { <div class="ide-new-btn"> <div :class="{ - show: dropdownOpen, + show: isOpen, }" class="dropdown d-flex" > diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index c9c4e9e86f8..f67666f1fbf 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -1,6 +1,7 @@ <script> import $ from 'jquery'; -import { __ } from '~/locale'; +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 { modalTypes } from '../../constants'; @@ -15,18 +16,20 @@ export default { }; }, computed: { - ...mapState(['entryModal']), + ...mapState(['entries', 'entryModal']), ...mapGetters('fileTemplates', ['templateTypes']), entryName: { get() { + const entryPath = this.entryModal.entry.path; + if (this.entryModal.type === modalTypes.rename) { - return this.name || this.entryModal.entry.name; + return this.name || entryPath; } - return this.name || (this.entryModal.path !== '' ? `${this.entryModal.path}/` : ''); + return this.name || (entryPath ? `${entryPath}/` : ''); }, set(val) { - this.name = val; + this.name = val.trim(); }, }, modalTitle() { @@ -62,10 +65,40 @@ export default { ...mapActions(['createTempEntry', 'renameEntry']), submitForm() { if (this.entryModal.type === modalTypes.rename) { - this.renameEntry({ - path: this.entryModal.entry.path, - name: this.entryName, - }); + if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) { + flash( + sprintf(s__('The name %{entryName} is already taken in this directory.'), { + entryName: this.entryName, + }), + 'alert', + document, + null, + false, + true, + ); + } else { + let parentPath = this.entryName.split('/'); + const entryName = parentPath.pop(); + parentPath = parentPath.join('/'); + + const createPromise = + parentPath && !this.entries[parentPath] + ? this.createTempEntry({ name: parentPath, type: 'tree' }) + : Promise.resolve(); + + createPromise + .then(() => + this.renameEntry({ + path: this.entryModal.entry.path, + name: entryName, + entryPath: null, + parentPath, + }), + ) + .catch(() => + flash(__('Error creating a new path'), 'alert', document, null, false, true), + ); + } } else { this.createTempEntry({ name: this.name, @@ -82,7 +115,14 @@ export default { $('#ide-new-entry').modal('toggle'); }, focusInput() { + const name = this.entries[this.entryName] ? this.entries[this.entryName].name : null; + const inputValue = this.$refs.fieldName.value; + this.$refs.fieldName.focus(); + + if (name) { + this.$refs.fieldName.setSelectionRange(inputValue.indexOf(name), inputValue.length); + } }, closedModal() { this.name = ''; @@ -94,6 +134,7 @@ export default { <template> <gl-modal id="ide-new-entry" + class="qa-new-file-modal" :header-title-text="modalTitle" :footer-primary-button-text="buttonLabel" footer-primary-button-variant="success" diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index ec759043efc..188518dd419 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -57,6 +57,8 @@ export default { type: 'blob', content: result, base64: !isText, + binary: !isText, + rawPath: !isText ? target.result : '', }); }, readFile(file) { diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 451c8030e16..5ae73b2fc9c 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -24,7 +24,13 @@ export default { ...mapState(['pipelinesEmptyStateSvgPath', 'links']), ...mapGetters(['currentProject']), ...mapGetters('pipelines', ['jobsCount', 'failedJobsCount', 'failedStages', 'pipelineFailed']), - ...mapState('pipelines', ['isLoadingPipeline', 'latestPipeline', 'stages', 'isLoadingJobs']), + ...mapState('pipelines', [ + 'isLoadingPipeline', + 'hasLoadedPipeline', + 'latestPipeline', + 'stages', + 'isLoadingJobs', + ]), ciLintText() { return sprintf( __('You can test your .gitlab-ci.yml in %{linkStart}CI Lint%{linkEnd}.'), @@ -36,7 +42,7 @@ export default { ); }, showLoadingIcon() { - return this.isLoadingPipeline && this.latestPipeline === null; + return this.isLoadingPipeline && !this.hasLoadedPipeline; }, }, created() { @@ -51,7 +57,7 @@ export default { <template> <div class="ide-pipeline"> <gl-loading-icon v-if="showLoadingIcon" :size="2" class="prepend-top-default" /> - <template v-else-if="latestPipeline !== null"> + <template v-else-if="hasLoadedPipeline"> <header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header"> <ci-icon :status="latestPipeline.details.status" :size="24" /> <span class="prepend-left-8"> @@ -62,7 +68,7 @@ export default { </span> </header> <empty-state - v-if="latestPipeline === false" + v-if="!latestPipeline" :help-page-path="links.ciHelpPagePath" :empty-state-svg-path="pipelinesEmptyStateSvgPath" :can-set-ci="true" diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue index c98dda00817..6999746f115 100644 --- a/app/assets/javascripts/ide/components/preview/clientside.vue +++ b/app/assets/javascripts/ide/components/preview/clientside.vue @@ -105,7 +105,7 @@ export default { .then(() => { this.initManager('#ide-preview', this.sandboxOpts, { fileResolver: { - isFile: p => Promise.resolve(!!this.entries[createPathWithExt(p)]), + isFile: p => Promise.resolve(Boolean(this.entries[createPathWithExt(p)])), readFile: p => this.loadFileContent(createPathWithExt(p)).then(content => content), }, }); diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 8dd88f187d4..5201c33b1b4 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -5,7 +5,7 @@ import Icon from '~/vue_shared/components/icon.vue'; import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import CommitFilesList from './commit_sidebar/list.vue'; import EmptyState from './commit_sidebar/empty_state.vue'; -import * as consts from '../stores/modules/commit/constants'; +import consts from '../stores/modules/commit/constants'; import { activityBarViews, stageKeys } from '../constants'; export default { @@ -30,7 +30,7 @@ export default { ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommittedChanges', 'activeFile']), ...mapGetters('commit', ['discardDraftButtonDisabled']), showStageUnstageArea() { - return !!(this.someUncommittedChanges || this.lastCommitMsg || !this.unusedSeal); + return Boolean(this.someUncommittedChanges || this.lastCommitMsg || !this.unusedSeal); }, activeFileKey() { return this.activeFile ? this.activeFile.key : null; diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 94a9e87369c..b0c4969c5e4 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -1,5 +1,6 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; +import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; import flash from '~/flash'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; @@ -35,7 +36,7 @@ export default { ]), ...mapGetters('fileTemplates', ['showFileTemplatesBar']), shouldHideEditor() { - return this.file && this.file.binary && !this.file.content; + return this.file && this.file.binary; }, showContentViewer() { return ( @@ -56,6 +57,10 @@ export default { active: this.file.viewMode === 'preview', }; }, + fileType() { + const info = viewerInformationForPath(this.file.path); + return (info && info.id) || ''; + }, }, watch: { file(newVal, oldVal) { @@ -120,6 +125,7 @@ export default { 'setFileEOL', 'updateViewer', 'removePendingTab', + 'triggerFilesChange', ]), initEditor() { if (this.shouldHideEditor) return; @@ -251,6 +257,7 @@ export default { 'is-added': file.tempFile, }" class="multi-file-editor-holder" + @focusout="triggerFilesChange" ></div> <content-viewer v-if="showContentViewer" @@ -258,6 +265,7 @@ export default { :path="file.rawPath || file.path" :file-size="file.size" :project-path="file.projectId" + :type="fileType" /> <diff-viewer v-if="showDiffViewer" diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 7c560c89695..e30670e119f 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -72,4 +72,11 @@ export const modalTypes = { tree: 'tree', }; +export const commitActionTypes = { + move: 'move', + delete: 'delete', + create: 'create', + update: 'update', +}; + export const packageJsonPath = 'package.json'; diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index 229ef168926..8c84b98a108 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -1,8 +1,9 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; -import { join as joinPath } from 'path'; +import { joinPaths } from '~/lib/utils/url_utility'; import flash from '~/flash'; import store from './stores'; +import { __ } from '~/locale'; Vue.use(VueRouter); @@ -34,7 +35,7 @@ const EmptyRouterComponent = { const router = new VueRouter({ mode: 'history', - base: `${gon.relative_url_root}/-/ide/`, + base: joinPaths(gon.relative_url_root || '', '/-/ide/'), routes: [ { path: '/project/:namespace+/:project', @@ -46,11 +47,11 @@ const router = new VueRouter({ }, { path: ':targetmode(edit|tree|blob)/:branchid+/', - redirect: to => joinPath(to.path, '/-/'), + redirect: to => joinPaths(to.path, '/-/'), }, { path: ':targetmode(edit|tree|blob)', - redirect: to => joinPath(to.path, '/master/-/'), + redirect: to => joinPaths(to.path, '/master/-/'), }, { path: 'merge_requests/:mrid', @@ -58,7 +59,7 @@ const router = new VueRouter({ }, { path: '', - redirect: to => joinPath(to.path, '/edit/master/-/'), + redirect: to => joinPaths(to.path, '/edit/master/-/'), }, ], }, @@ -94,7 +95,7 @@ router.beforeEach((to, from, next) => { }) .catch(e => { flash( - 'Error while loading the project data. Please try again.', + __('Error while loading the project data. Please try again.'), 'alert', document, null, diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js index e35595ab1fd..dac2a8e8b51 100644 --- a/app/assets/javascripts/ide/lib/editor_options.js +++ b/app/assets/javascripts/ide/lib/editor_options.js @@ -11,7 +11,7 @@ export const defaultEditorOptions = { export default [ { - readOnly: model => !!model.file.file_lock, + readOnly: model => Boolean(model.file.file_lock), quickSuggestions: model => !(model.language === 'markdown'), }, ]; diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js new file mode 100644 index 00000000000..b8abaa41f23 --- /dev/null +++ b/app/assets/javascripts/ide/lib/files.js @@ -0,0 +1,119 @@ +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, '/'); + +export const splitParent = path => { + const idx = path.lastIndexOf('/'); + + return { + parent: idx >= 0 ? path.substring(0, idx) : null, + name: idx >= 0 ? path.substring(idx + 1) : path, + }; +}; + +/** + * Create file objects from a list of file paths. + */ +export const decorateFiles = ({ + data, + projectId, + branchId, + tempFile = false, + content = '', + base64 = false, + binary = false, + rawPath = '', +}) => { + const treeList = []; + const entries = {}; + + // These mutable variable references end up being exported and used by `createTempEntry` + let file; + let parentPath; + + const insertParent = path => { + if (!path) { + return null; + } else if (entries[path]) { + return entries[path]; + } + + const { parent, name } = splitParent(path); + const parentFolder = parent && insertParent(parent); + parentPath = parentFolder && parentFolder.path; + + const tree = decorateData({ + projectId, + branchId, + id: path, + name, + path, + url: `/${projectId}/tree/${branchId}/-/${escapeFileUrl(path)}/`, + type: 'tree', + parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`, + tempFile, + changed: tempFile, + opened: tempFile, + parentPath, + }); + + Object.assign(entries, { + [path]: tree, + }); + + if (parentFolder) { + parentFolder.tree.push(tree); + } else { + treeList.push(tree); + } + + return tree; + }; + + data.forEach(path => { + const { parent, name } = splitParent(path); + + const fileFolder = parent && insertParent(parent); + + if (name) { + parentPath = fileFolder && fileFolder.path; + + file = decorateData({ + projectId, + branchId, + id: path, + name, + path, + url: `/${projectId}/blob/${branchId}/-/${escapeFileUrl(path)}`, + type: 'blob', + parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`, + tempFile, + changed: tempFile, + content, + base64, + binary, + rawPath, + previewMode: viewerInformationForPath(name), + parentPath, + }); + + Object.assign(entries, { + [path]: file, + }); + + if (fileFolder) { + fileFolder.tree.push(file); + } else { + treeList.push(file); + } + } + }); + + return { + entries, + treeList: sortTree(treeList), + file, + parentPath, + }; +}; diff --git a/app/assets/javascripts/ide/lib/keymap.json b/app/assets/javascripts/ide/lib/keymap.json index 131abfebbed..2db87c07dde 100644 --- a/app/assets/javascripts/ide/lib/keymap.json +++ b/app/assets/javascripts/ide/lib/keymap.json @@ -7,5 +7,13 @@ "name": "toggleFileFinder", "params": true } + }, + { + "id": "save-files", + "label": "Save files", + "bindings": ["CtrlCmd+KEY_S"], + "action": { + "name": "triggerFilesChange" + } } ] diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index 13449592e62..ba33b6826d6 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -40,6 +40,9 @@ export default { getProjectData(namespace, project) { return Api.project(`${namespace}/${project}`); }, + getProjectMergeRequests(projectId, params = {}) { + return Api.projectMergeRequests(projectId, params); + }, getProjectMergeRequestData(projectId, mergeRequestId, params = {}) { return Api.projectMergeRequest(projectId, mergeRequestId, params); }, diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index e10a132ab4b..5429b834708 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -1,12 +1,15 @@ import $ from 'jquery'; import Vue from 'vue'; +import { __, sprintf } from '~/locale'; import { visitUrl } from '~/lib/utils/url_utility'; import flash from '~/flash'; +import _ from 'underscore'; import * as types from './mutation_types'; -import FilesDecoratorWorker from './workers/files_decorator_worker'; +import { decorateFiles } from '../lib/files'; import { stageKeys } from '../constants'; +import service from '../services'; -export const redirectToUrl = (_, url) => visitUrl(url); +export const redirectToUrl = (self, url) => visitUrl(url); export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); @@ -53,10 +56,9 @@ export const setResizingStatus = ({ commit }, resizing) => { export const createTempEntry = ( { state, commit, dispatch }, - { name, type, content = '', base64 = false }, + { name, type, content = '', base64 = false, binary = false, rawPath = '' }, ) => new Promise(resolve => { - const worker = new FilesDecoratorWorker(); const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; if (state.entries[name]) { @@ -74,40 +76,38 @@ export const createTempEntry = ( return null; } - worker.addEventListener('message', ({ data }) => { - const { file, parentPath } = data; - - worker.terminate(); - - commit(types.CREATE_TMP_ENTRY, { - data, - projectId: state.currentProjectId, - branchId: state.currentBranchId, - }); - - if (type === 'blob') { - commit(types.TOGGLE_FILE_OPEN, file.path); - commit(types.ADD_FILE_TO_CHANGED, file.path); - dispatch('setFileActive', file.path); - } - - if (parentPath && !state.entries[parentPath].opened) { - commit(types.TOGGLE_TREE_OPEN, parentPath); - } - - resolve(file); - }); - - worker.postMessage({ + const data = decorateFiles({ data: [fullName], projectId: state.currentProjectId, branchId: state.currentBranchId, type, tempFile: true, - base64, content, + base64, + binary, + rawPath, + }); + const { file, parentPath } = data; + + commit(types.CREATE_TMP_ENTRY, { + data, + projectId: state.currentProjectId, + branchId: state.currentBranchId, }); + if (type === 'blob') { + commit(types.TOGGLE_FILE_OPEN, file.path); + commit(types.ADD_FILE_TO_CHANGED, file.path); + dispatch('setFileActive', file.path); + dispatch('triggerFilesChange'); + } + + if (parentPath && !state.entries[parentPath].opened) { + commit(types.TOGGLE_TREE_OPEN, parentPath); + } + + resolve(file); + return null; }); @@ -211,26 +211,89 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => { if (entry.parentPath && state.entries[entry.parentPath].tree.length === 0) { dispatch('deleteEntry', entry.parentPath); } + + dispatch('triggerFilesChange'); }; export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES); -export const renameEntry = ({ dispatch, commit, state }, { path, name, entryPath = null }) => { +export const renameEntry = ( + { dispatch, commit, state }, + { path, name, entryPath = null, parentPath }, +) => { const entry = state.entries[entryPath || path]; - commit(types.RENAME_ENTRY, { path, name, entryPath }); + commit(types.RENAME_ENTRY, { path, name, entryPath, parentPath }); if (entry.type === 'tree') { - state.entries[entryPath || path].tree.forEach(f => - dispatch('renameEntry', { path, name, entryPath: f.path }), - ); + const slashedParentPath = parentPath ? `${parentPath}/` : ''; + const targetEntry = entryPath ? entryPath.split('/').pop() : name; + const newParentPath = `${slashedParentPath}${targetEntry}`; + + state.entries[entryPath || path].tree.forEach(f => { + dispatch('renameEntry', { + path, + name, + entryPath: f.path, + parentPath: newParentPath, + }); + }); } if (!entryPath && !entry.tempFile) { dispatch('deleteEntry', path); } + + dispatch('triggerFilesChange'); }; +export const getBranchData = ({ commit, state }, { projectId, branchId, force = false } = {}) => + new Promise((resolve, reject) => { + const currentProject = state.projects[projectId]; + if (!currentProject || !currentProject.branches[branchId] || force) { + service + .getBranchData(projectId, branchId) + .then(({ data }) => { + const { id } = data.commit; + commit(types.SET_BRANCH, { + projectPath: projectId, + branchName: branchId, + branch: data, + }); + commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id }); + resolve(data); + }) + .catch(e => { + if (e.response.status === 404) { + reject(e); + } else { + flash( + __('Error loading branch data. Please try again.'), + 'alert', + document, + null, + false, + true, + ); + + reject( + new Error( + sprintf( + __('Branch not loaded - %{branchId}'), + { + branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`, + }, + false, + ), + ), + ); + } + }); + } else { + resolve(currentProject.branches[branchId]); + } + }); + export * from './actions/tree'; export * from './actions/file'; export * from './actions/project'; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index e74b880e02c..dc40a1fa6a2 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -1,5 +1,6 @@ -import { __ } from '../../../locale'; -import { normalizeHeaders } from '../../../lib/utils/common_utils'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { normalizeHeaders } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; import eventHub from '../../eventhub'; import service from '../../services'; import * as types from '../mutation_types'; @@ -69,7 +70,7 @@ export const getFileData = ( const url = file.prevPath ? file.url.replace(file.path, file.prevPath) : file.url; return service - .getFileData(`${gon.relative_url_root ? gon.relative_url_root : ''}${url.replace('/-/', '/')}`) + .getFileData(joinPaths(gon.relative_url_root || '', url.replace('/-/', '/'))) .then(({ data, headers }) => { const normalizedHeaders = normalizeHeaders(headers); setPageTitle(decodeURI(normalizedHeaders['PAGE-TITLE'])); @@ -264,3 +265,8 @@ export const removePendingTab = ({ commit }, file) => { eventHub.$emit(`editor.update.model.dispose.${file.key}`); }; + +export const triggerFilesChange = () => { + // Used in EE for file mirroring + eventHub.$emit('ide.files.change'); +}; diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 18c24369996..1273e375859 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -4,6 +4,39 @@ import service from '../../services'; import * as types from '../mutation_types'; import { activityBarViews } from '../../constants'; +export const getMergeRequestsForBranch = ({ commit, state }, { projectId, branchId } = {}) => + service + .getProjectMergeRequests(`${projectId}`, { + source_branch: branchId, + source_project_id: state.projects[projectId].id, + order_by: 'created_at', + per_page: 1, + }) + .then(({ data }) => { + if (data.length > 0) { + const currentMR = data[0]; + + commit(types.SET_MERGE_REQUEST, { + projectPath: projectId, + mergeRequestId: currentMR.iid, + mergeRequest: currentMR, + }); + + commit(types.SET_CURRENT_MERGE_REQUEST, `${currentMR.iid}`); + } + }) + .catch(e => { + flash( + __(`Error fetching merge requests for ${branchId}`), + 'alert', + document, + null, + false, + true, + ); + throw e; + }); + export const getMergeRequestData = ( { commit, dispatch, state }, { projectId, mergeRequestId, targetProjectId = null, force = false } = {}, diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index b65f631c99c..dd8f17e4f3a 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -35,48 +35,6 @@ export const getProjectData = ({ commit, state }, { namespace, projectId, force } }); -export const getBranchData = ( - { commit, dispatch, state }, - { projectId, branchId, force = false } = {}, -) => - new Promise((resolve, reject) => { - if ( - typeof state.projects[`${projectId}`] === 'undefined' || - !state.projects[`${projectId}`].branches[branchId] || - force - ) { - service - .getBranchData(`${projectId}`, branchId) - .then(({ data }) => { - const { id } = data.commit; - commit(types.SET_BRANCH, { - projectPath: `${projectId}`, - branchName: branchId, - branch: data, - }); - commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id }); - resolve(data); - }) - .catch(e => { - if (e.response.status === 404) { - dispatch('showBranchNotFoundError', branchId); - } else { - flash( - __('Error loading branch data. Please try again.'), - 'alert', - document, - null, - false, - true, - ); - } - reject(new Error(`Branch not loaded - ${projectId}/${branchId}`)); - }); - } else { - resolve(state.projects[`${projectId}`].branches[branchId]); - } - }); - export const refreshLastCommitData = ({ commit }, { projectId, branchId } = {}) => service .getBranchData(projectId, branchId) @@ -125,28 +83,66 @@ export const showBranchNotFoundError = ({ dispatch }, branchId) => { }); }; -export const openBranch = ({ dispatch, state }, { projectId, branchId, basePath }) => { - dispatch('setCurrentBranchId', branchId); - - dispatch('getBranchData', { - projectId, - branchId, +export const showEmptyState = ({ commit, state }, { projectId, branchId }) => { + const treePath = `${projectId}/${branchId}`; + commit(types.CREATE_TREE, { treePath }); + commit(types.TOGGLE_LOADING, { + entry: state.trees[treePath], + forceValue: false, }); +}; - return dispatch('getFiles', { +export const openBranch = ({ dispatch, state, getters }, { projectId, branchId, basePath }) => { + dispatch('setCurrentBranchId', branchId); + + if (getters.emptyRepo) { + return dispatch('showEmptyState', { projectId, branchId }); + } + return dispatch('getBranchData', { projectId, branchId, - }).then(() => { - if (basePath) { - const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath; - const treeEntryKey = Object.keys(state.entries).find( - key => key === path && !state.entries[key].pending, - ); - const treeEntry = state.entries[treeEntryKey]; + }) + .then(() => { + dispatch('getMergeRequestsForBranch', { + projectId, + branchId, + }); + dispatch('getFiles', { + projectId, + branchId, + }) + .then(() => { + if (basePath) { + const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath; + const treeEntryKey = Object.keys(state.entries).find( + key => key === path && !state.entries[key].pending, + ); + const treeEntry = state.entries[treeEntryKey]; - if (treeEntry) { - dispatch('handleTreeEntryAction', treeEntry); - } - } - }); + if (treeEntry) { + dispatch('handleTreeEntryAction', treeEntry); + } else { + dispatch('createTempEntry', { + name: path, + type: 'blob', + }); + } + } + }) + .catch( + () => + new Error( + sprintf( + __('An error occurred whilst getting files for - %{branchId}'), + { + branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`, + }, + false, + ), + ), + ); + }) + .catch(() => { + dispatch('showBranchNotFoundError', branchId); + }); }; diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index de5f6050074..75511574d3e 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -1,7 +1,8 @@ +import _ from 'underscore'; import { __ } from '../../../locale'; import service from '../../services'; import * as types from '../mutation_types'; -import FilesDecoratorWorker from '../workers/files_decorator_worker'; +import { decorateFiles } from '../../lib/files'; export const toggleTreeOpen = ({ commit }, path) => { commit(types.TOGGLE_TREE_OPEN, path); @@ -32,6 +33,19 @@ export const handleTreeEntryAction = ({ commit, dispatch }, row) => { dispatch('showTreeEntry', row.path); }; +export const setDirectoryData = ({ state, commit }, { projectId, branchId, treeList }) => { + const selectedTree = state.trees[`${projectId}/${branchId}`]; + + commit(types.SET_DIRECTORY_DATA, { + treePath: `${projectId}/${branchId}`, + data: treeList, + }); + commit(types.TOGGLE_LOADING, { + entry: selectedTree, + forceValue: false, + }); +}; + export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = {}) => new Promise((resolve, reject) => { if ( @@ -45,44 +59,28 @@ export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = service .getFiles(selectedProject.web_url, branchId) .then(({ data }) => { - const worker = new FilesDecoratorWorker(); - worker.addEventListener('message', e => { - const { entries, treeList } = e.data; - const selectedTree = state.trees[`${projectId}/${branchId}`]; - - commit(types.SET_ENTRIES, entries); - commit(types.SET_DIRECTORY_DATA, { - treePath: `${projectId}/${branchId}`, - data: treeList, - }); - commit(types.TOGGLE_LOADING, { - entry: selectedTree, - forceValue: false, - }); - - worker.terminate(); - - resolve(); - }); - - worker.postMessage({ + const { entries, treeList } = decorateFiles({ data, projectId, branchId, }); + + commit(types.SET_ENTRIES, entries); + + // Defer setting the directory data because this triggers some intense rendering. + // The entries is all we need to load the file editor. + _.defer(() => dispatch('setDirectoryData', { projectId, branchId, treeList })); + + resolve(); }) .catch(e => { - if (e.response.status === 404) { - dispatch('showBranchNotFoundError', branchId); - } else { - dispatch('setErrorMessage', { - text: __('An error occurred whilst loading all the files.'), - action: payload => - dispatch('getFiles', payload).then(() => dispatch('setErrorMessage', null)), - actionText: __('Please try again'), - actionPayload: { projectId, branchId }, - }); - } + dispatch('setErrorMessage', { + text: __('An error occurred whilst loading all the files.'), + action: payload => + dispatch('getFiles', payload).then(() => dispatch('setErrorMessage', null)), + actionText: __('Please try again'), + actionPayload: { projectId, branchId }, + }); reject(e); }); } else { diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 8ad85074d6b..406903129db 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -25,7 +25,10 @@ export const projectsWithTrees = state => }); export const currentMergeRequest = state => { - if (state.projects[state.currentProjectId]) { + if ( + state.projects[state.currentProjectId] && + state.projects[state.currentProjectId].mergeRequests + ) { return state.projects[state.currentProjectId].mergeRequests[state.currentMergeRequestId]; } return null; @@ -33,12 +36,16 @@ export const currentMergeRequest = state => { export const currentProject = state => state.projects[state.currentProjectId]; +export const emptyRepo = state => + state.projects[state.currentProjectId] && state.projects[state.currentProjectId].empty_repo; + export const currentTree = state => state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; -export const hasChanges = state => !!state.changedFiles.length || !!state.stagedFiles.length; +export const hasChanges = state => + Boolean(state.changedFiles.length) || Boolean(state.stagedFiles.length); -export const hasMergeRequest = state => !!state.currentMergeRequestId; +export const hasMergeRequest = state => Boolean(state.currentMergeRequestId); export const allBlobs = state => Object.keys(state.entries) @@ -64,7 +71,7 @@ export const isCommitModeActive = state => state.currentActivityView === activit export const isReviewModeActive = state => state.currentActivityView === activityBarViews.review; export const someUncommittedChanges = state => - !!(state.changedFiles.length || state.stagedFiles.length); + Boolean(state.changedFiles.length || state.stagedFiles.length); export const getChangesInFolder = state => path => { const changedFilesCount = state.changedFiles.filter(f => filePathMatches(f.path, path)).length; @@ -90,7 +97,12 @@ export const lastCommit = (state, getters) => { export const currentBranch = (state, getters) => getters.currentProject && getters.currentProject.branches[state.currentBranchId]; +export const branchName = (_state, getters) => getters.currentBranch && getters.currentBranch.name; + export const packageJson = state => state.entries[packageJsonPath]; +export const isOnDefaultBranch = (_state, getters) => + getters.currentProject && getters.currentProject.default_branch === getters.branchName; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 24c2f71ae2b..51062f092ad 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -6,7 +6,7 @@ import { createCommitPayload, createNewMergeRequestUrl } from '../../utils'; import router from '../../../ide_router'; import service from '../../../services'; import * as types from './mutation_types'; -import * as consts from './constants'; +import consts from './constants'; import { activityBarViews } from '../../../constants'; import eventHub from '../../../eventhub'; @@ -18,16 +18,42 @@ export const discardDraft = ({ commit }) => { commit(types.UPDATE_COMMIT_MESSAGE, ''); }; -export const updateCommitAction = ({ commit }, commitAction) => { - commit(types.UPDATE_COMMIT_ACTION, commitAction); +export const updateCommitAction = ({ commit, dispatch }, commitAction) => { + commit(types.UPDATE_COMMIT_ACTION, { + commitAction, + }); + dispatch('setShouldCreateMR'); +}; + +export const toggleShouldCreateMR = ({ commit }) => { + commit(types.TOGGLE_SHOULD_CREATE_MR); + commit(types.INTERACT_WITH_NEW_MR); +}; + +export const setShouldCreateMR = ({ + commit, + getters, + rootGetters, + state: { interactedWithNewMR }, +}) => { + const committingToExistingMR = + getters.isCommittingToCurrentBranch && + rootGetters.hasMergeRequest && + !rootGetters.isOnDefaultBranch; + + if ((getters.isCommittingToDefaultBranch && !interactedWithNewMR) || committingToExistingMR) { + commit(types.TOGGLE_SHOULD_CREATE_MR, false); + } else if (!interactedWithNewMR) { + commit(types.TOGGLE_SHOULD_CREATE_MR, true); + } }; export const updateBranchName = ({ commit }, branchName) => { commit(types.UPDATE_NEW_BRANCH_NAME, branchName); }; -export const setLastCommitMessage = ({ rootState, commit }, data) => { - const currentProject = rootState.projects[rootState.currentProjectId]; +export const setLastCommitMessage = ({ commit, rootGetters }, data) => { + const { currentProject } = rootGetters; const commitStats = data.stats ? sprintf(__('with %{additions} additions, %{deletions} deletions.'), { additions: data.stats.additions, @@ -48,8 +74,8 @@ export const setLastCommitMessage = ({ rootState, commit }, data) => { commit(rootTypes.SET_LAST_COMMIT_MSG, commitMsg, { root: true }); }; -export const updateFilesAfterCommit = ({ commit, dispatch, rootState }, { data }) => { - const selectedProject = rootState.projects[rootState.currentProjectId]; +export const updateFilesAfterCommit = ({ commit, dispatch, rootState, rootGetters }, { data }) => { + const selectedProject = rootGetters.currentProject; const lastCommit = { commit_path: `${selectedProject.web_url}/commit/${data.id}`, commit: { @@ -95,7 +121,7 @@ export const updateFilesAfterCommit = ({ commit, dispatch, rootState }, { data } eventHub.$emit(`editor.update.model.content.${file.key}`, { content: file.content, - changed: !!changedFile, + changed: Boolean(changedFile), }); }); }; @@ -128,6 +154,17 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo return null; } + if (!data.parent_ids.length) { + commit( + rootTypes.TOGGLE_EMPTY_STATE, + { + projectPath: rootState.currentProjectId, + value: false, + }, + { root: true }, + ); + } + dispatch('setLastCommitMessage', data); dispatch('updateCommitMessage', ''); return dispatch('updateFilesAfterCommit', { @@ -135,14 +172,15 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo branch: getters.branchName, }) .then(() => { - if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR) { + if (state.shouldCreateMR) { + const { currentProject } = rootGetters; + const targetBranch = getters.isCreatingNewBranch + ? rootState.currentBranchId + : currentProject.default_branch; + dispatch( 'redirectToUrl', - createNewMergeRequestUrl( - rootState.projects[rootState.currentProjectId].web_url, - getters.branchName, - rootState.currentBranchId, - ), + createNewMergeRequestUrl(currentProject.web_url, getters.branchName, targetBranch), { root: true }, ); } diff --git a/app/assets/javascripts/ide/stores/modules/commit/constants.js b/app/assets/javascripts/ide/stores/modules/commit/constants.js index 230b0a3d9b5..c6c3701effe 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/constants.js +++ b/app/assets/javascripts/ide/stores/modules/commit/constants.js @@ -1,3 +1,7 @@ -export const COMMIT_TO_CURRENT_BRANCH = '1'; -export const COMMIT_TO_NEW_BRANCH = '2'; -export const COMMIT_TO_NEW_BRANCH_MR = '3'; +const COMMIT_TO_CURRENT_BRANCH = '1'; +const COMMIT_TO_NEW_BRANCH = '2'; + +export default { + COMMIT_TO_CURRENT_BRANCH, + COMMIT_TO_NEW_BRANCH, +}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js index 03777e6c10b..64779e9e4df 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/getters.js +++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js @@ -1,5 +1,5 @@ import { sprintf, n__, __ } from '../../../../locale'; -import * as consts from './constants'; +import consts from './constants'; const BRANCH_SUFFIX_COUNT = 5; const createTranslatedTextForFiles = (files, text) => { @@ -14,18 +14,15 @@ const createTranslatedTextForFiles = (files, text) => { export const discardDraftButtonDisabled = state => state.commitMessage === '' || state.submitCommitLoading; -export const newBranchName = (state, _, rootState) => +export const placeholderBranchName = (state, _, rootState) => `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr( -BRANCH_SUFFIX_COUNT, )}`; export const branchName = (state, getters, rootState) => { - if ( - state.commitAction === consts.COMMIT_TO_NEW_BRANCH || - state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR - ) { + if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) { if (state.newBranchName === '') { - return getters.newBranchName; + return getters.placeholderBranchName; } return state.newBranchName; @@ -49,5 +46,13 @@ export const preBuiltCommitMessage = (state, _, rootState) => { .join('\n'); }; +export const isCreatingNewBranch = state => state.commitAction === consts.COMMIT_TO_NEW_BRANCH; + +export const isCommittingToCurrentBranch = state => + state.commitAction === consts.COMMIT_TO_CURRENT_BRANCH; + +export const isCommittingToDefaultBranch = (_state, getters, _rootState, rootGetters) => + getters.isCommittingToCurrentBranch && rootGetters.isOnDefaultBranch; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js index 9221f054e9f..b81918156b0 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js +++ b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js @@ -2,3 +2,5 @@ export const UPDATE_COMMIT_MESSAGE = 'UPDATE_COMMIT_MESSAGE'; export const UPDATE_COMMIT_ACTION = 'UPDATE_COMMIT_ACTION'; export const UPDATE_NEW_BRANCH_NAME = 'UPDATE_NEW_BRANCH_NAME'; export const UPDATE_LOADING = 'UPDATE_LOADING'; +export const TOGGLE_SHOULD_CREATE_MR = 'TOGGLE_SHOULD_CREATE_MR'; +export const INTERACT_WITH_NEW_MR = 'INTERACT_WITH_NEW_MR'; diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutations.js b/app/assets/javascripts/ide/stores/modules/commit/mutations.js index 797357e3df9..14957d283bb 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/commit/mutations.js @@ -6,10 +6,8 @@ export default { commitMessage, }); }, - [types.UPDATE_COMMIT_ACTION](state, commitAction) { - Object.assign(state, { - commitAction, - }); + [types.UPDATE_COMMIT_ACTION](state, { commitAction }) { + Object.assign(state, { commitAction }); }, [types.UPDATE_NEW_BRANCH_NAME](state, newBranchName) { Object.assign(state, { @@ -21,4 +19,12 @@ export default { submitCommitLoading, }); }, + [types.TOGGLE_SHOULD_CREATE_MR](state, shouldCreateMR) { + Object.assign(state, { + shouldCreateMR: shouldCreateMR === undefined ? !state.shouldCreateMR : shouldCreateMR, + }); + }, + [types.INTERACT_WITH_NEW_MR](state) { + Object.assign(state, { interactedWithNewMR: true }); + }, }; diff --git a/app/assets/javascripts/ide/stores/modules/commit/state.js b/app/assets/javascripts/ide/stores/modules/commit/state.js index 8dae50961b0..53647a7e3e3 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/state.js +++ b/app/assets/javascripts/ide/stores/modules/commit/state.js @@ -3,4 +3,6 @@ export default () => ({ commitAction: '1', newBranchName: '', submitCommitLoading: false, + shouldCreateMR: false, + interactedWithNewMR: false, }); diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/actions.js b/app/assets/javascripts/ide/stores/modules/file_templates/actions.js index b7090e09daf..59ead8a3dcf 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/actions.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/actions.js @@ -23,22 +23,27 @@ export const receiveTemplateTypesError = ({ commit, dispatch }) => { export const receiveTemplateTypesSuccess = ({ commit }, templates) => commit(types.RECEIVE_TEMPLATE_TYPES_SUCCESS, templates); -export const fetchTemplateTypes = ({ dispatch, state, rootState }, page = 1) => { +export const fetchTemplateTypes = ({ dispatch, state, rootState }) => { if (!Object.keys(state.selectedTemplateType).length) return Promise.reject(); dispatch('requestTemplateTypes'); - return Api.projectTemplates(rootState.currentProjectId, state.selectedTemplateType.key, { page }) - .then(({ data, headers }) => { - const nextPage = parseInt(normalizeHeaders(headers)['X-NEXT-PAGE'], 10); + const fetchPages = (page = 1, prev = []) => + Api.projectTemplates(rootState.currentProjectId, state.selectedTemplateType.key, { + page, + per_page: 100, + }) + .then(({ data, headers }) => { + const nextPage = parseInt(normalizeHeaders(headers)['X-NEXT-PAGE'], 10); + const nextData = prev.concat(data); - dispatch('receiveTemplateTypesSuccess', data); + dispatch('receiveTemplateTypesSuccess', nextData); - if (nextPage) { - dispatch('fetchTemplateTypes', nextPage); - } - }) - .catch(() => dispatch('receiveTemplateTypesError')); + return nextPage ? fetchPages(nextPage, nextData) : nextData; + }) + .catch(() => dispatch('receiveTemplateTypesError')); + + return fetchPages(); }; export const setSelectedTemplateType = ({ commit, dispatch, rootGetters }, type) => { diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js index 628babe6a01..f10891a8e5b 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js @@ -1,4 +1,5 @@ import { activityBarViews } from '../../../constants'; +import { __ } from '~/locale'; export const templateTypes = () => [ { @@ -10,11 +11,11 @@ export const templateTypes = () => [ key: 'gitignores', }, { - name: 'LICENSE', + name: __('LICENSE'), key: 'licenses', }, { - name: 'Dockerfile', + name: __('Dockerfile'), key: 'dockerfiles', }, ]; diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/mutations.js b/app/assets/javascripts/ide/stores/modules/file_templates/mutations.js index 25a65b047f1..7fc1c9134a7 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/mutations.js @@ -3,13 +3,14 @@ import * as types from './mutation_types'; export default { [types.REQUEST_TEMPLATE_TYPES](state) { state.isLoading = true; + state.templates = []; }, [types.RECEIVE_TEMPLATE_TYPES_ERROR](state) { state.isLoading = false; }, [types.RECEIVE_TEMPLATE_TYPES_SUCCESS](state, templates) { state.isLoading = false; - state.templates = state.templates.concat(templates); + state.templates = templates; }, [types.SET_SELECTED_TEMPLATE_TYPE](state, type) { state.selectedTemplateType = type; diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/getters.js b/app/assets/javascripts/ide/stores/modules/pipelines/getters.js index ef7cd4ff8e8..1d127d915d7 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/getters.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/getters.js @@ -1,6 +1,6 @@ import { states } from './constants'; -export const hasLatestPipeline = state => !state.isLoadingPipeline && !!state.latestPipeline; +export const hasLatestPipeline = state => !state.isLoadingPipeline && Boolean(state.latestPipeline); export const pipelineFailed = state => state.latestPipeline && state.latestPipeline.details.status.text === states.failed; diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js index b4be100cb07..eaaa82cb339 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js @@ -10,6 +10,7 @@ export default { }, [types.RECEIVE_LASTEST_PIPELINE_SUCCESS](state, pipeline) { state.isLoadingPipeline = false; + state.hasLoadedPipeline = true; if (pipeline) { state.latestPipeline = { @@ -34,7 +35,7 @@ export default { }; }); } else { - state.latestPipeline = false; + state.latestPipeline = null; } }, [types.REQUEST_JOBS](state, id) { diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/state.js b/app/assets/javascripts/ide/stores/modules/pipelines/state.js index 8651e267b53..8dfa0ec491f 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/state.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/state.js @@ -1,5 +1,6 @@ export default () => ({ isLoadingPipeline: true, + hasLoadedPipeline: false, isLoadingJobs: false, latestPipeline: null, stages: [], diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index a5f8098dc17..86ab76136df 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -12,6 +12,7 @@ export const SET_LINKS = 'SET_LINKS'; export const SET_PROJECT = 'SET_PROJECT'; export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT'; export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN'; +export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; // Merge Request Mutation Types export const SET_MERGE_REQUEST = 'SET_MERGE_REQUEST'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index 78cdfda74f0..ae42b87c9a7 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -142,7 +142,7 @@ export default { Object.assign(state.entries[file.path], { raw: file.content, - changed: !!changedFile, + changed: Boolean(changedFile), staged: false, prevPath: '', moved: false, @@ -206,19 +206,17 @@ export default { } } }, - [types.RENAME_ENTRY](state, { path, name, entryPath = null }) { + [types.RENAME_ENTRY](state, { path, name, entryPath = null, parentPath }) { const oldEntry = state.entries[entryPath || path]; - const nameRegex = - !entryPath && oldEntry.type === 'blob' - ? new RegExp(`${oldEntry.name}$`) - : new RegExp(`^${path}`); - const newPath = oldEntry.path.replace(nameRegex, name); - const parentPath = oldEntry.parentPath ? oldEntry.parentPath.replace(nameRegex, name) : ''; + const slashedParentPath = parentPath ? `${parentPath}/` : ''; + const newPath = entryPath + ? `${slashedParentPath}${oldEntry.name}` + : `${slashedParentPath}${name}`; - state.entries[newPath] = { + Vue.set(state.entries, newPath, { ...oldEntry, id: newPath, - key: `${name}-${oldEntry.type}-${oldEntry.id}`, + key: `${newPath}-${oldEntry.type}-${oldEntry.id}`, path: newPath, name: entryPath ? oldEntry.name : name, tempFile: true, @@ -227,7 +225,8 @@ export default { tree: [], parentPath, raw: '', - }; + }); + oldEntry.moved = true; oldEntry.movedPath = newPath; @@ -256,6 +255,7 @@ export default { Vue.delete(state.entries, oldEntry.path); } }, + ...projectMutations, ...mergeRequestMutation, ...fileMutations, diff --git a/app/assets/javascripts/ide/stores/mutations/branch.js b/app/assets/javascripts/ide/stores/mutations/branch.js index e09f88878f4..6afd8de2aa4 100644 --- a/app/assets/javascripts/ide/stores/mutations/branch.js +++ b/app/assets/javascripts/ide/stores/mutations/branch.js @@ -19,6 +19,12 @@ export default { }); }, [types.SET_BRANCH_WORKING_REFERENCE](state, { projectId, branchId, reference }) { + if (!state.projects[projectId].branches[branchId]) { + Object.assign(state.projects[projectId].branches, { + [branchId]: {}, + }); + } + Object.assign(state.projects[projectId].branches[branchId], { workingReference: reference, }); diff --git a/app/assets/javascripts/ide/stores/mutations/merge_request.js b/app/assets/javascripts/ide/stores/mutations/merge_request.js index 334819fe702..e5b5107bc93 100644 --- a/app/assets/javascripts/ide/stores/mutations/merge_request.js +++ b/app/assets/javascripts/ide/stores/mutations/merge_request.js @@ -7,6 +7,8 @@ export default { }); }, [types.SET_MERGE_REQUEST](state, { projectPath, mergeRequestId, mergeRequest }) { + const existingMergeRequest = state.projects[projectPath].mergeRequests[mergeRequestId] || {}; + Object.assign(state.projects[projectPath], { mergeRequests: { [mergeRequestId]: { @@ -15,6 +17,7 @@ export default { changes: [], versions: [], baseCommitSha: null, + ...existingMergeRequest, }, }, }); diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js index 284b39a2c72..9230f3839c1 100644 --- a/app/assets/javascripts/ide/stores/mutations/project.js +++ b/app/assets/javascripts/ide/stores/mutations/project.js @@ -21,4 +21,9 @@ export default { }), }); }, + [types.TOGGLE_EMPTY_STATE](state, { projectPath, value }) { + Object.assign(state.projects[projectPath], { + empty_repo: value, + }); + }, }; diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js index eac7441ee54..359943b4ab7 100644 --- a/app/assets/javascripts/ide/stores/mutations/tree.js +++ b/app/assets/javascripts/ide/stores/mutations/tree.js @@ -1,5 +1,5 @@ import * as types from '../mutation_types'; -import { sortTree } from '../utils'; +import { sortTree, mergeTrees } from '../utils'; export default { [types.TOGGLE_TREE_OPEN](state, path) { @@ -23,9 +23,15 @@ export default { }); }, [types.SET_DIRECTORY_DATA](state, { data, treePath }) { - Object.assign(state.trees[treePath], { - tree: data, - }); + const selectedTree = state.trees[treePath]; + + // If we opened files while loading the tree, we need to merge them + // Otherwise, simply overwrite the tree + const tree = !selectedTree.tree.length + ? data + : selectedTree.loading && mergeTrees(selectedTree.tree, data); + + Object.assign(selectedTree, { tree }); }, [types.SET_LAST_COMMIT_URL](state, { tree = state, url }) { Object.assign(tree, { diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 0ede76fd1e0..bcc9ca60d9b 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -1,3 +1,5 @@ +import { commitActionTypes } from '../constants'; + export const dataStructure = () => ({ id: '', // Key will contain a mixture of ID and path @@ -69,14 +71,15 @@ export const decorateData = entity => { changed = false, parentTreeUrl = '', base64 = false, + binary = false, + rawPath = '', previewMode, file_lock, html, parentPath = '', } = entity; - return { - ...dataStructure(), + return Object.assign(dataStructure(), { id, projectId, branchId, @@ -93,11 +96,13 @@ export const decorateData = entity => { renderError, content, base64, + binary, + rawPath, previewMode, file_lock, html, parentPath, - }; + }); }; export const findEntry = (tree, type, name, prop = 'name') => @@ -111,14 +116,14 @@ export const setPageTitle = title => { export const commitActionForFile = file => { if (file.prevPath) { - return 'move'; + return commitActionTypes.move; } else if (file.deleted) { - return 'delete'; + return commitActionTypes.delete; } else if (file.tempFile) { - return 'create'; + return commitActionTypes.create; } - return 'update'; + return commitActionTypes.update; }; export const getCommitFiles = stagedFiles => @@ -171,3 +176,31 @@ export const filePathMatches = (filePath, path) => filePath.indexOf(`${path}/`) export const getChangesCountForFiles = (files, path) => files.filter(f => filePathMatches(f.path, path)).length; + +export const mergeTrees = (fromTree, toTree) => { + if (!fromTree || !fromTree.length) { + return toTree; + } + + const recurseTree = (n, t) => { + if (!n) { + return t; + } + const existingTreeNode = t.find(el => el.path === n.path); + + if (existingTreeNode && n.tree.length > 0) { + existingTreeNode.opened = true; + recurseTree(n.tree[0], existingTreeNode.tree); + } else if (!existingTreeNode) { + const sorted = sortTree(t.concat(n)); + t.splice(0, t.length + 1, ...sorted); + } + return t; + }; + + for (let i = 0, l = fromTree.length; i < l; i += 1) { + recurseTree(fromTree[i], toTree); + } + + return toTree; +}; diff --git a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js deleted file mode 100644 index fa35c215880..00000000000 --- a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js +++ /dev/null @@ -1,100 +0,0 @@ -import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; -import { decorateData, sortTree } from '../utils'; - -// eslint-disable-next-line no-restricted-globals -self.addEventListener('message', e => { - const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data; - - const treeList = []; - let file; - let parentPath; - const entries = data.reduce((acc, path) => { - const pathSplit = path.split('/'); - const blobName = pathSplit.pop().trim(); - - if (pathSplit.length > 0) { - pathSplit.reduce((pathAcc, folderName) => { - const parentFolder = acc[pathAcc[pathAcc.length - 1]]; - const folderPath = `${parentFolder ? `${parentFolder.path}/` : ''}${folderName}`; - const foundEntry = acc[folderPath]; - - if (!foundEntry) { - parentPath = parentFolder ? parentFolder.path : null; - - const tree = decorateData({ - projectId, - branchId, - id: folderPath, - name: folderName, - path: folderPath, - url: `/${projectId}/tree/${branchId}/-/${folderPath}/`, - type: 'tree', - parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`, - tempFile, - changed: tempFile, - opened: tempFile, - parentPath, - }); - - Object.assign(acc, { - [folderPath]: tree, - }); - - if (parentFolder) { - parentFolder.tree.push(tree); - } else { - treeList.push(tree); - } - - pathAcc.push(tree.path); - } else { - pathAcc.push(foundEntry.path); - } - - return pathAcc; - }, []); - } - - if (blobName !== '') { - const fileFolder = acc[pathSplit.join('/')]; - parentPath = fileFolder ? fileFolder.path : null; - - file = decorateData({ - projectId, - branchId, - id: path, - name: blobName, - path, - url: `/${projectId}/blob/${branchId}/-/${path}`, - type: 'blob', - parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`, - tempFile, - changed: tempFile, - content, - base64, - previewMode: viewerInformationForPath(blobName), - parentPath, - }); - - Object.assign(acc, { - [path]: file, - }); - - if (fileFolder) { - fileFolder.tree.push(file); - } else { - treeList.push(file); - } - } - - return acc; - }, {}); - - // eslint-disable-next-line no-restricted-globals - self.postMessage({ - entries, - treeList: sortTree(treeList), - file, - parentPath, - }); -}); diff --git a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js index 05000c73052..7051a968dac 100644 --- a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js +++ b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js @@ -14,7 +14,7 @@ export function addCommentIndicator(containerEl, { x, y }) { export function removeCommentIndicator(imageFrameEl) { const commentIndicatorEl = imageFrameEl.querySelector('.comment-indicator'); const imageEl = imageFrameEl.querySelector('img'); - const willRemove = !!commentIndicatorEl; + const willRemove = Boolean(commentIndicatorEl); let meta = {}; if (willRemove) { diff --git a/app/assets/javascripts/image_diff/image_diff.js b/app/assets/javascripts/image_diff/image_diff.js index 3587f073a00..26c1b0ec7be 100644 --- a/app/assets/javascripts/image_diff/image_diff.js +++ b/app/assets/javascripts/image_diff/image_diff.js @@ -6,8 +6,8 @@ import { isImageLoaded } from '../lib/utils/image_utility'; export default class ImageDiff { constructor(el, options) { this.el = el; - this.canCreateNote = !!(options && options.canCreateNote); - this.renderCommentBadge = !!(options && options.renderCommentBadge); + this.canCreateNote = Boolean(options && options.canCreateNote); + this.renderCommentBadge = Boolean(options && options.renderCommentBadge); this.$noteContainer = $('.note-container', this.el); this.imageBadges = []; } diff --git a/app/assets/javascripts/image_diff/view_types.js b/app/assets/javascripts/image_diff/view_types.js index ab0a595571f..1a5123de220 100644 --- a/app/assets/javascripts/image_diff/view_types.js +++ b/app/assets/javascripts/image_diff/view_types.js @@ -5,5 +5,5 @@ export const viewTypes = { }; export function isValidViewType(validate) { - return !!Object.getOwnPropertyNames(viewTypes).find(viewType => viewType === validate); + return Boolean(Object.getOwnPropertyNames(viewTypes).find(viewType => viewType === validate)); } diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_projects/components/import_projects_table.vue index 777f8fa6691..00eb0afb3bf 100644 --- a/app/assets/javascripts/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_projects/components/import_projects_table.vue @@ -74,7 +74,7 @@ export default { <gl-loading-icon v-if="isLoadingRepos" class="js-loading-button-icon import-projects-loading-icon" - :size="4" + size="md" /> <div v-else-if="hasProviderRepos || hasImportedProjects" class="table-responsive"> <table class="table import-table"> diff --git a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue index 7cc29fa1b91..3c6c9c71b8c 100644 --- a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue @@ -41,7 +41,7 @@ export default { return { data: this.namespaceSelectOptions, containerCssClass: - 'import-namespace-select js-namespace-select qa-project-namespace-select', + 'import-namespace-select js-namespace-select qa-project-namespace-select w-auto', }; }, diff --git a/app/assets/javascripts/import_projects/index.js b/app/assets/javascripts/import_projects/index.js index 5c77484aee1..2d99d716609 100644 --- a/app/assets/javascripts/import_projects/index.js +++ b/app/assets/javascripts/import_projects/index.js @@ -3,7 +3,7 @@ import { mapActions } from 'vuex'; import Translate from '../vue_shared/translate'; import ImportProjectsTable from './components/import_projects_table.vue'; import { parseBoolean } from '../lib/utils/common_utils'; -import store from './store'; +import createStore from './store'; Vue.use(Translate); @@ -20,6 +20,7 @@ export default function mountImportProjectsTable(mountElement) { ciCdOnly, } = mountElement.dataset; + const store = createStore(); return new Vue({ el: mountElement, store, diff --git a/app/assets/javascripts/import_projects/store/getters.js b/app/assets/javascripts/import_projects/store/getters.js index f03474a8404..727b80765bd 100644 --- a/app/assets/javascripts/import_projects/store/getters.js +++ b/app/assets/javascripts/import_projects/store/getters.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export const namespaceSelectOptions = state => { const serializedNamespaces = state.namespaces.map(({ fullPath }) => ({ id: fullPath, @@ -5,9 +7,9 @@ export const namespaceSelectOptions = state => { })); return [ - { text: 'Groups', children: serializedNamespaces }, + { text: __('Groups'), children: serializedNamespaces }, { - text: 'Users', + text: __('Users'), children: [{ id: state.defaultTargetNamespace, text: state.defaultTargetNamespace }], }, ]; diff --git a/app/assets/javascripts/import_projects/store/index.js b/app/assets/javascripts/import_projects/store/index.js index 6ac9bfd8189..ff1fd1e598e 100644 --- a/app/assets/javascripts/import_projects/store/index.js +++ b/app/assets/javascripts/import_projects/store/index.js @@ -7,9 +7,12 @@ import mutations from './mutations'; Vue.use(Vuex); -export default new Vuex.Store({ - state: state(), - actions, - mutations, - getters, -}); +export { state, actions, getters, mutations }; + +export default () => + new Vuex.Store({ + state: state(), + actions, + mutations, + getters, + }); diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js index 08b858305ab..a7746bb3a0b 100644 --- a/app/assets/javascripts/integrations/integration_settings_form.js +++ b/app/assets/javascripts/integrations/integration_settings_form.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import axios from '../lib/utils/axios_utils'; import flash from '../flash'; +import { __ } from '~/locale'; export default class IntegrationSettingsForm { constructor(formSelector) { @@ -65,10 +66,10 @@ export default class IntegrationSettingsForm { * Toggle Submit button label based on Integration status and ability to test service */ toggleSubmitBtnLabel(serviceActive) { - let btnLabel = 'Save changes'; + let btnLabel = __('Save changes'); if (serviceActive && this.canTestService) { - btnLabel = 'Test settings and save changes'; + btnLabel = __('Test settings and save changes'); } this.$submitBtnLabel.text(btnLabel); @@ -105,7 +106,7 @@ export default class IntegrationSettingsForm { if (data.test_failed) { flashActions = { - title: 'Save anyway', + title: __('Save anyway'), clickHandler: e => { e.preventDefault(); this.$form.submit(); @@ -121,7 +122,7 @@ export default class IntegrationSettingsForm { this.toggleSubmitBtnState(false); }) .catch(() => { - flash('Something went wrong on our end.'); + flash(__('Something went wrong on our end.')); this.toggleSubmitBtnState(false); }); } diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js index b844e4c5e5b..bc9d7fcf30d 100644 --- a/app/assets/javascripts/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -4,6 +4,7 @@ import $ from 'jquery'; import _ from 'underscore'; import axios from './lib/utils/axios_utils'; import Flash from './flash'; +import { __ } from './locale'; export default { init({ container, form, issues, prefixId } = {}) { @@ -32,7 +33,7 @@ export default { onFormSubmitFailure() { this.form.find('[type="submit"]').enable(); - return new Flash('Issue update failed'); + return new Flash(__('Issue update failed')); }, getSelectedIssues() { @@ -81,9 +82,6 @@ export default { const formData = { update: { state_event: this.form.find('input[name="update[state_event]"]').val(), - // For Merge Requests - assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), - // For Issues assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()], milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 9336b71cfd7..7576d36f27d 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -1,8 +1,8 @@ import $ from 'jquery'; import Pikaday from 'pikaday'; +import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import Autosave from './autosave'; import UsersSelect from './users_select'; -import GfmAutoComplete from './gfm_auto_complete'; import ZenMode from './zen_mode'; import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility'; diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js index ffcbd7cf28c..16f88cddce3 100644 --- a/app/assets/javascripts/issuable_index.js +++ b/app/assets/javascripts/issuable_index.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; import flash from './flash'; -import { __ } from './locale'; +import { s__, __ } from './locale'; import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; @@ -12,7 +12,7 @@ export default class IssuableIndex { } initBulkUpdate(pagePrefix) { const userCanBulkUpdate = $('.issues-bulk-update').length > 0; - const alreadyInitialized = !!this.bulkUpdateSidebar; + const alreadyInitialized = Boolean(this.bulkUpdateSidebar); if (userCanBulkUpdate && !alreadyInitialized) { IssuableBulkUpdateActions.init({ @@ -29,7 +29,7 @@ export default class IssuableIndex { $resetToken.on('click', e => { e.preventDefault(); - $resetToken.text('resetting...'); + $resetToken.text(s__('EmailToken|resetting...')); axios .put($resetToken.attr('href')) @@ -38,12 +38,12 @@ export default class IssuableIndex { .val(data.new_address) .focus(); - $resetToken.text('reset it'); + $resetToken.text(s__('EmailToken|reset it')); }) .catch(() => { flash(__('There was an error when reseting email token.')); - $resetToken.text('reset it'); + $resetToken.text(s__('EmailToken|reset it')); }); }); } diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 94b78907d9a..db4607ca58d 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -7,6 +7,7 @@ import flash from './flash'; import TaskList from './task_list'; import CreateMergeRequestDropdown from './create_merge_request_dropdown'; import IssuablesHelper from './helpers/issuables_helper'; +import { __ } from './locale'; export default class Issue { constructor() { @@ -15,8 +16,9 @@ export default class Issue { Issue.$btnNewBranch = $('#new-branch'); Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap'); - Issue.initMergeRequests(); - Issue.initRelatedBranches(); + if (document.querySelector('#related-branches')) { + Issue.initRelatedBranches(); + } this.closeButtons = $('a.btn-close'); this.reopenButtons = $('a.btn-reopen'); @@ -43,7 +45,11 @@ export default class Issue { * @param {Array} data * @param {String} issueFailMessage */ - updateTopState(isClosed, data, issueFailMessage = 'Unable to update this issue at this time.') { + updateTopState( + isClosed, + data, + issueFailMessage = __('Unable to update this issue at this time.'), + ) { if ('id' in data) { const isClosedBadge = $('div.status-box-issue-closed'); const isOpenBadge = $('div.status-box-open'); @@ -80,7 +86,7 @@ export default class Issue { } initIssueBtnEventListeners() { - const issueFailMessage = 'Unable to update this issue at this time.'; + const issueFailMessage = __('Unable to update this issue at this time.'); return $(document).on( 'click', @@ -141,19 +147,6 @@ export default class Issue { } } - static initMergeRequests() { - var $container; - $container = $('#merge-requests'); - return axios - .get($container.data('url')) - .then(({ data }) => { - if ('html' in data) { - $container.html(data.html); - } - }) - .catch(() => flash('Failed to load referenced merge requests')); - } - static initRelatedBranches() { var $container; $container = $('#related-branches'); @@ -164,6 +157,6 @@ export default class Issue { $container.html(data.html); } }) - .catch(() => flash('Failed to load related branches')); + .catch(() => flash(__('Failed to load related branches'))); } } diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index bd757a76ee7..e88ca4747c5 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -156,12 +156,26 @@ export default { return this.store.formState; }, hasUpdated() { - return !!this.state.updatedAt; + return Boolean(this.state.updatedAt); }, issueChanged() { - const descriptionChanged = this.initialDescriptionText !== this.store.formState.description; - const titleChanged = this.initialTitleText !== this.store.formState.title; - return descriptionChanged || titleChanged; + const { + store: { + formState: { description, title }, + }, + initialDescriptionText, + initialTitleText, + } = this; + + if (initialDescriptionText || description) { + return initialDescriptionText !== description; + } + + if (initialTitleText || title) { + return initialTitleText !== title; + } + + return false; }, defaultErrorMessage() { return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType }); diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 58f14bac8c8..f2462e50093 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -140,14 +140,16 @@ export default { 'issue-realtime-pre-pulse': preAnimation, 'issue-realtime-trigger-pulse': pulseAnimation, }" - class="wiki" + class="md" v-html="descriptionHtml" ></div> <textarea v-if="descriptionText" + ref="textarea" v-model="descriptionText" :data-update-url="updateUrl" class="hidden js-task-list-field" + dir="auto" > </textarea> diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index 299130e56ae..d27dd873125 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -53,6 +53,7 @@ export default { v-model="formState.description" class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" + dir="auto" data-supports-quick-actions="false" aria-label="Description" placeholder="Write a comment or drag your files here…" diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issue_show/components/fields/title.vue index c3d7ba4907f..ce4baf17d09 100644 --- a/app/assets/javascripts/issue_show/components/fields/title.vue +++ b/app/assets/javascripts/issue_show/components/fields/title.vue @@ -17,8 +17,10 @@ export default { <label class="sr-only" for="issuable-title"> Title </label> <input id="issuable-title" + ref="input" v-model="formState.title" class="form-control qa-title-input" + dir="auto" type="text" placeholder="Title" aria-label="Title" diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index eade31f1d14..528ccb77efc 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -1,9 +1,12 @@ <script> +import $ from 'jquery'; import lockedWarning from './locked_warning.vue'; import titleField from './fields/title.vue'; import descriptionField from './fields/description.vue'; import editActions from './edit_actions.vue'; import descriptionTemplate from './fields/description_template.vue'; +import Autosave from '~/autosave'; +import eventHub from '../event_hub'; export default { components: { @@ -68,6 +71,47 @@ export default { return this.issuableTemplates.length; }, }, + created() { + eventHub.$on('delete.issuable', this.resetAutosave); + eventHub.$on('update.issuable', this.resetAutosave); + eventHub.$on('close.form', this.resetAutosave); + }, + mounted() { + this.initAutosave(); + }, + beforeDestroy() { + eventHub.$off('delete.issuable', this.resetAutosave); + eventHub.$off('update.issuable', this.resetAutosave); + eventHub.$off('close.form', this.resetAutosave); + }, + methods: { + initAutosave() { + const { + description: { + $refs: { textarea }, + }, + title: { + $refs: { input }, + }, + } = this.$refs; + + this.autosaveDescription = new Autosave($(textarea), [ + document.location.pathname, + document.location.search, + 'description', + ]); + + this.autosaveTitle = new Autosave($(input), [ + document.location.pathname, + document.location.search, + 'title', + ]); + }, + resetAutosave() { + this.autosaveDescription.reset(); + this.autosaveTitle.reset(); + }, + }, }; </script> @@ -89,10 +133,11 @@ export default { 'col-12': !hasIssuableTemplates, }" > - <title-field :form-state="formState" :issuable-templates="issuableTemplates" /> + <title-field ref="title" :form-state="formState" :issuable-templates="issuableTemplates" /> </div> </div> <description-field + ref="description" :form-state="formState" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue index 3b5c95ccded..1e1dce5f4fc 100644 --- a/app/assets/javascripts/issue_show/components/title.vue +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -71,7 +71,8 @@ export default { 'issue-realtime-pre-pulse': preAnimation, 'issue-realtime-trigger-pulse': pulseAnimation, }" - class="title" + class="title qa-title" + dir="auto" v-html="titleHtml" ></h2> <button diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index d08e8ba0c4b..529b6386221 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,12 +1,9 @@ import Vue from 'vue'; -import sanitize from 'sanitize-html'; import issuableApp from './components/app.vue'; +import { parseIssuableData } from './utils/parse_data'; import '../vue_shared/vue_resource_interceptor'; export default function initIssueableApp() { - const initialDataEl = document.getElementById('js-issuable-app-initial-data'); - const props = JSON.parse(sanitize(initialDataEl.textContent).replace(/"/g, '"')); - return new Vue({ el: document.getElementById('js-issuable-app'), components: { @@ -14,7 +11,7 @@ export default function initIssueableApp() { }, render(createElement) { return createElement('issuable-app', { - props, + props: parseIssuableData(), }); }, }); diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js new file mode 100644 index 00000000000..05e384adad3 --- /dev/null +++ b/app/assets/javascripts/issue_show/utils/parse_data.js @@ -0,0 +1,15 @@ +import sanitize from 'sanitize-html'; + +export const parseIssuableData = () => { + try { + const initialDataEl = document.getElementById('js-issuable-app-initial-data'); + + return JSON.parse(sanitize(initialDataEl.textContent).replace(/"/g, '"')); + } catch (e) { + console.error(e); // eslint-disable-line no-console + + return {}; + } +}; + +export default {}; diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js index c14803c80e7..75edff41a89 100644 --- a/app/assets/javascripts/issue_status_select.js +++ b/app/assets/javascripts/issue_status_select.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { __ } from './locale'; export default function issueStatusSelect() { $('.js-issue-status').each((i, el) => { @@ -7,7 +8,7 @@ export default function issueStatusSelect() { selectable: true, fieldName, toggleLabel(selected, element, instance) { - let label = 'Author'; + let label = __('Author'); const $item = instance.dropdown.find('.is-active'); if ($item.length) { label = $item.text(); diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue index 7076a79dd5d..b651a6e4bfb 100644 --- a/app/assets/javascripts/jobs/components/commit_block.vue +++ b/app/assets/javascripts/jobs/components/commit_block.vue @@ -39,7 +39,7 @@ export default { </gl-link> <clipboard-button - :text="commit.short_id" + :text="commit.id" :title="__('Copy commit SHA to clipboard')" css-class="btn btn-clipboard btn-transparent" /> diff --git a/app/assets/javascripts/jobs/components/empty_state.vue b/app/assets/javascripts/jobs/components/empty_state.vue index 668fcf3d673..04f910b6b80 100644 --- a/app/assets/javascripts/jobs/components/empty_state.vue +++ b/app/assets/javascripts/jobs/components/empty_state.vue @@ -49,7 +49,7 @@ export default { <div class="text-content"> <h4 class="js-job-empty-state-title text-center">{{ title }}</h4> - <p v-if="content" class="js-job-empty-state-content">{{ content }}</p> + <p v-if="content" class="js-job-empty-state-content text-center">{{ content }}</p> <div v-if="action" class="text-center"> <gl-link diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index d473d6a482d..79fb67d38cd 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -15,6 +15,7 @@ import ErasedBlock from './erased_block.vue'; import Log from './job_log.vue'; import LogTopBar from './job_log_controllers.vue'; import StuckBlock from './stuck_block.vue'; +import UnmetPrerequisitesBlock from './unmet_prerequisites_block.vue'; import Sidebar from './sidebar.vue'; import { sprintf } from '~/locale'; import delayedJobMixin from '../mixins/delayed_job_mixin'; @@ -32,8 +33,10 @@ export default { Log, LogTopBar, StuckBlock, + UnmetPrerequisitesBlock, Sidebar, GlLoadingIcon, + SharedRunner: () => import('ee_component/jobs/components/shared_runner_limit_block.vue'), }, mixins: [delayedJobMixin], props: { @@ -47,6 +50,11 @@ export default { required: false, default: null, }, + deploymentHelpUrl: { + type: String, + required: false, + default: null, + }, endpoint: { type: String, required: true, @@ -78,12 +86,15 @@ export default { 'isScrollTopDisabled', 'isScrolledToBottomBeforeReceivingTrace', 'hasError', + 'selectedStage', ]), ...mapGetters([ 'headerTime', + 'hasUnmetPrerequisitesFailure', 'shouldRenderCalloutMessage', 'shouldRenderTriggeredLabel', 'hasEnvironment', + 'shouldRenderSharedRunnerLimitWarning', 'hasTrace', 'emptyStateIllustration', 'isScrollingDown', @@ -111,7 +122,13 @@ export default { // fetch the stages for the dropdown on the sidebar job(newVal, oldVal) { if (_.isEmpty(oldVal) && !_.isEmpty(newVal.pipeline)) { - this.fetchStages(); + const stages = this.job.pipeline.details.stages || []; + + const defaultStage = stages.find(stage => stage && stage.name === this.selectedStage); + + if (defaultStage) { + this.fetchJobsForStage(defaultStage); + } } if (newVal.archived) { @@ -150,7 +167,7 @@ export default { 'setJobEndpoint', 'setTraceOptions', 'fetchJob', - 'fetchStages', + 'fetchJobsForStage', 'hideSidebar', 'showSidebar', 'toggleSidebar', @@ -208,7 +225,10 @@ export default { /> </div> - <callout v-if="shouldRenderCalloutMessage" :message="job.callout_message" /> + <callout + v-if="shouldRenderCalloutMessage && !hasUnmetPrerequisitesFailure" + :message="job.callout_message" + /> </header> <!-- EO Header Section --> @@ -221,6 +241,20 @@ export default { :runners-path="runnerSettingsUrl" /> + <unmet-prerequisites-block + v-if="hasUnmetPrerequisitesFailure" + class="js-job-failed" + :help-path="deploymentHelpUrl" + /> + + <shared-runner + v-if="shouldRenderSharedRunnerLimitWarning" + class="js-shared-runner-limit" + :quota-used="job.runners.quota.used" + :quota-limit="job.runners.quota.limit" + :runners-path="runnerHelpUrl" + /> + <environments-block v-if="hasEnvironment" class="js-job-environment" @@ -242,13 +276,12 @@ export default { :class="{ 'sticky-top border-bottom-0': hasTrace }" > <icon name="lock" class="align-text-bottom" /> - {{ __('This job is archived. Only the complete pipeline can be retried.') }} </div> <!-- job log --> <div v-if="hasTrace" - class="build-trace-container" + class="build-trace-container position-relative" :class="{ 'prepend-top-default': !job.archived }" > <log-top-bar diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue index 845699a90b5..a55dffbe488 100644 --- a/app/assets/javascripts/jobs/components/job_container_item.vue +++ b/app/assets/javascripts/jobs/components/job_container_item.vue @@ -43,7 +43,7 @@ export default { <template> <div - class="build-job" + class="build-job position-relative" :class="{ retried: job.retried, active: isActive, @@ -56,7 +56,11 @@ export default { data-boundary="viewport" class="js-job-link" > - <icon v-if="isActive" name="arrow-right" class="js-arrow-right icon-arrow-right" /> + <icon + v-if="isActive" + name="arrow-right" + class="js-arrow-right icon-arrow-right position-absolute d-block" + /> <ci-icon :status="job.status" /> diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index 52e14f954ee..607b2bd1c74 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -75,7 +75,11 @@ export default { <template v-if="isTraceSizeVisible"> {{ jobLogSize }} - <gl-link v-if="rawPath" :href="rawPath" class="js-raw-link raw-link"> + <gl-link + v-if="rawPath" + :href="rawPath" + class="js-raw-link text-plain text-underline prepend-left-5" + > {{ s__('Job|Complete Raw') }} </gl-link> </template> diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index 1691ac62100..24276c06486 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -34,7 +34,7 @@ export default { }, }, computed: { - ...mapState(['job', 'stages', 'jobs', 'selectedStage', 'isLoadingStages']), + ...mapState(['job', 'stages', 'jobs', 'selectedStage']), coverage() { return `${this.job.coverage}%`; }, @@ -208,7 +208,6 @@ export default { /> <stages-dropdown - v-if="!isLoadingStages" :stages="stages" :pipeline="job.pipeline" :selected-stage="selectedStage" diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue index c5076d65ff9..cb073a9b04d 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue @@ -1,12 +1,16 @@ <script> import _ from 'underscore'; +import { GlLink } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import PipelineLink from '~/vue_shared/components/ci_pipeline_link.vue'; import Icon from '~/vue_shared/components/icon.vue'; export default { components: { CiIcon, Icon, + GlLink, + PipelineLink, }, props: { pipeline: { @@ -26,6 +30,12 @@ export default { hasRef() { return !_.isEmpty(this.pipeline.ref); }, + isTriggeredByMergeRequest() { + return Boolean(this.pipeline.merge_request); + }, + isMergeRequestPipeline() { + return Boolean(this.pipeline.flags && this.pipeline.flags.merge_request_pipeline); + }, }, methods: { onStageClick(stage) { @@ -36,16 +46,44 @@ export default { </script> <template> <div class="block-last dropdown"> - <ci-icon :status="pipeline.details.status" class="vertical-align-middle" /> + <div class="js-pipeline-info"> + <ci-icon :status="pipeline.details.status" class="vertical-align-middle" /> - <span class="font-weight-bold">{{ __('Pipeline') }}</span> - <a :href="pipeline.path" class="js-pipeline-path link-commit qa-pipeline-path" - >#{{ pipeline.id }}</a - > - <template v-if="hasRef"> - {{ __('from') }} - <a :href="pipeline.ref.path" class="link-commit ref-name">{{ pipeline.ref.name }}</a> - </template> + <span class="font-weight-bold">{{ s__('Job|Pipeline') }}</span> + <pipeline-link + :href="pipeline.path" + :pipeline-id="pipeline.id" + :pipeline-iid="pipeline.iid" + class="js-pipeline-path link-commit qa-pipeline-path" + /> + <template v-if="hasRef"> + {{ s__('Job|for') }} + + <template v-if="isTriggeredByMergeRequest"> + <gl-link :href="pipeline.merge_request.path" class="link-commit ref-name js-mr-link" + >!{{ pipeline.merge_request.iid }}</gl-link + > + {{ s__('Job|with') }} + <gl-link + :href="pipeline.merge_request.source_branch_path" + class="link-commit ref-name js-source-branch-link" + >{{ pipeline.merge_request.source_branch }}</gl-link + > + + <template v-if="isMergeRequestPipeline"> + {{ s__('Job|into') }} + <gl-link + :href="pipeline.merge_request.target_branch_path" + class="link-commit ref-name js-target-branch-link" + >{{ pipeline.merge_request.target_branch }}</gl-link + > + </template> + </template> + <gl-link v-else :href="pipeline.ref.path" class="link-commit ref-name">{{ + pipeline.ref.name + }}</gl-link> + </template> + </div> <button type="button" diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue index 997737b3e23..922f64d93fe 100644 --- a/app/assets/javascripts/jobs/components/trigger_block.vue +++ b/app/assets/javascripts/jobs/components/trigger_block.vue @@ -52,7 +52,7 @@ export default { </p> <template v-if="hasVariables"> - <p class="trigger-variables-btn-container"> + <p class="trigger-variables-btn-container d-flex"> <span class="font-weight-bold">{{ __('Trigger variables:') }}</span> <gl-button diff --git a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue new file mode 100644 index 00000000000..25a8da84873 --- /dev/null +++ b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue @@ -0,0 +1,30 @@ +<script> +import { GlLink } from '@gitlab/ui'; +/** + * Renders Unmet Prerequisites block for job's view. + */ +export default { + components: { + GlLink, + }, + props: { + helpPath: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div class="bs-callout bs-callout-danger"> + <p class="js-failed-unmet-prerequisites append-bottom-0"> + {{ + s__(`Job|This job failed because the necessary resources were not successfully created.`) + }} + + <gl-link :href="helpPath" class="js-help-path"> + <strong> {{ __('More information') }} </strong> + </gl-link> + </p> + </div> +</template> diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index a32e945627c..25132449458 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -12,6 +12,7 @@ export default () => { render(createElement) { return createElement('job-app', { props: { + deploymentHelpUrl: element.dataset.deploymentHelpUrl, runnerHelpUrl: element.dataset.runnerHelpUrl, runnerSettingsUrl: element.dataset.runnerSettingsUrl, endpoint: element.dataset.endpoint, diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index 8045f6dc3ff..12d67a43599 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -179,37 +179,13 @@ export const receiveTraceError = ({ commit }) => { }; /** - * Stages dropdown on sidebar - */ -export const requestStages = ({ commit }) => commit(types.REQUEST_STAGES); -export const fetchStages = ({ state, dispatch }) => { - dispatch('requestStages'); - - axios - .get(`${state.job.pipeline.path}.json`) - .then(({ data }) => { - // Set selected stage - dispatch('receiveStagesSuccess', data.details.stages); - const selectedStage = data.details.stages.find(stage => stage.name === state.selectedStage); - dispatch('fetchJobsForStage', selectedStage); - }) - .catch(() => dispatch('receiveStagesError')); -}; -export const receiveStagesSuccess = ({ commit }, data) => - commit(types.RECEIVE_STAGES_SUCCESS, data); -export const receiveStagesError = ({ commit }) => { - commit(types.RECEIVE_STAGES_ERROR); - flash(__('An error occurred while fetching stages.')); -}; - -/** * Jobs list on sidebar - depend on stages dropdown */ export const requestJobsForStage = ({ commit }, stage) => commit(types.REQUEST_JOBS_FOR_STAGE, stage); // On stage click, set selected stage + fetch job -export const fetchJobsForStage = ({ dispatch }, stage) => { +export const fetchJobsForStage = ({ dispatch }, stage = {}) => { dispatch('requestJobsForStage', stage); axios diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js index 98911717381..406b1a2e375 100644 --- a/app/assets/javascripts/jobs/store/getters.js +++ b/app/assets/javascripts/jobs/store/getters.js @@ -3,6 +3,9 @@ import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; export const headerTime = state => (state.job.started ? state.job.started : state.job.created_at); +export const hasUnmetPrerequisitesFailure = state => + state.job && state.job.failure_reason && state.job.failure_reason === 'unmet_prerequisites'; + export const shouldRenderCalloutMessage = state => !_.isEmpty(state.job.status) && !_.isEmpty(state.job.callout_message); @@ -28,6 +31,17 @@ export const emptyStateIllustration = state => export const emptyStateAction = state => (state.job && state.job.status && state.job.status.action) || null; +/** + * Shared runners limit is only rendered when + * used quota is bigger or equal than the limit + * + * @returns {Boolean} + */ +export const shouldRenderSharedRunnerLimitWarning = state => + !_.isEmpty(state.job.runners) && + !_.isEmpty(state.job.runners.quota) && + state.job.runners.quota.used >= state.job.runners.quota.limit; + export const isScrollingDown = state => isScrolledToBottom() && !state.isTraceComplete; export const hasRunnersForProject = state => diff --git a/app/assets/javascripts/jobs/store/mutation_types.js b/app/assets/javascripts/jobs/store/mutation_types.js index fd098f13e90..39146b2eefd 100644 --- a/app/assets/javascripts/jobs/store/mutation_types.js +++ b/app/assets/javascripts/jobs/store/mutation_types.js @@ -24,10 +24,6 @@ export const STOP_POLLING_TRACE = 'STOP_POLLING_TRACE'; export const RECEIVE_TRACE_SUCCESS = 'RECEIVE_TRACE_SUCCESS'; export const RECEIVE_TRACE_ERROR = 'RECEIVE_TRACE_ERROR'; -export const REQUEST_STAGES = 'REQUEST_STAGES'; -export const RECEIVE_STAGES_SUCCESS = 'RECEIVE_STAGES_SUCCESS'; -export const RECEIVE_STAGES_ERROR = 'RECEIVE_STAGES_ERROR'; - export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE'; export const REQUEST_JOBS_FOR_STAGE = 'REQUEST_JOBS_FOR_STAGE'; export const RECEIVE_JOBS_FOR_STAGE_SUCCESS = 'RECEIVE_JOBS_FOR_STAGE_SUCCESS'; diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js index cd440d21c1f..ad08f27b147 100644 --- a/app/assets/javascripts/jobs/store/mutations.js +++ b/app/assets/javascripts/jobs/store/mutations.js @@ -65,6 +65,11 @@ export default { state.isLoading = false; state.job = job; + state.stages = + job.pipeline && job.pipeline.details && job.pipeline.details.stages + ? job.pipeline.details.stages + : []; + /** * We only update it on the first request * The dropdown can be changed by the user @@ -101,19 +106,7 @@ export default { state.isScrolledToBottomBeforeReceivingTrace = toggle; }, - [types.REQUEST_STAGES](state) { - state.isLoadingStages = true; - }, - [types.RECEIVE_STAGES_SUCCESS](state, stages) { - state.isLoadingStages = false; - state.stages = stages; - }, - [types.RECEIVE_STAGES_ERROR](state) { - state.isLoadingStages = false; - state.stages = []; - }, - - [types.REQUEST_JOBS_FOR_STAGE](state, stage) { + [types.REQUEST_JOBS_FOR_STAGE](state, stage = {}) { state.isLoadingJobs = true; state.selectedStage = stage.name; }, diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js index 04825187c99..6019214e62c 100644 --- a/app/assets/javascripts/jobs/store/state.js +++ b/app/assets/javascripts/jobs/store/state.js @@ -25,7 +25,6 @@ export default () => ({ traceState: null, // sidebar dropdown & list of jobs - isLoadingStages: false, isLoadingJobs: false, selectedStage: '', stages: [], diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js index f134a54dd53..7064731a5ea 100644 --- a/app/assets/javascripts/label_manager.js +++ b/app/assets/javascripts/label_manager.js @@ -5,22 +5,26 @@ import Sortable from 'sortablejs'; import flash from './flash'; import axios from './lib/utils/axios_utils'; +import { __ } from './locale'; export default class LabelManager { constructor({ togglePriorityButton, prioritizedLabels, otherLabels } = {}) { this.togglePriorityButton = togglePriorityButton || $('.js-toggle-priority'); this.prioritizedLabels = prioritizedLabels || $('.js-prioritized-labels'); this.otherLabels = otherLabels || $('.js-other-labels'); - this.errorMessage = 'Unable to update label prioritization at this time'; + this.errorMessage = __('Unable to update label prioritization at this time'); this.emptyState = document.querySelector('#js-priority-labels-empty-state'); this.$badgeItemTemplate = $('#js-badge-item-template'); - this.sortable = Sortable.create(this.prioritizedLabels.get(0), { - filter: '.empty-message', - forceFallback: true, - fallbackClass: 'is-dragging', - dataIdAttr: 'data-id', - onUpdate: this.onPrioritySortUpdate.bind(this), - }); + + if ('sortable' in this.prioritizedLabels.data()) { + Sortable.create(this.prioritizedLabels.get(0), { + filter: '.empty-message', + forceFallback: true, + fallbackClass: 'is-dragging', + dataIdAttr: 'data-id', + onUpdate: this.onPrioritySortUpdate.bind(this), + }); + } this.bindEvents(); } @@ -49,7 +53,7 @@ export default class LabelManager { toggleEmptyState($label, $btn, action) { this.emptyState.classList.toggle( 'hidden', - !!this.prioritizedLabels[0].querySelector(':scope > li'), + Boolean(this.prioritizedLabels[0].querySelector(':scope > li')), ); } diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index f7a611fbca0..3f954b43ee3 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -4,13 +4,14 @@ import $ from 'jquery'; import _ from 'underscore'; -import { sprintf, __ } from './locale'; +import { sprintf, s__, __ } from './locale'; import axios from './lib/utils/axios_utils'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import CreateLabelDropdown from './create_label'; import flash from './flash'; import ModalStore from './boards/stores/modal_store'; import boardsStore from './boards/stores/boards_store'; +import { isEE, isScopedLabel } from '~/lib/utils/common_utils'; export default class LabelsSelect { constructor(els, options = {}) { @@ -86,8 +87,9 @@ export default class LabelsSelect { return this.value; }) .get(); + const scopedLabels = $dropdown.data('scopedLabels'); + const scopedLabelsDocumentationLink = $dropdown.data('scopedLabelsDocumentationLink'); const { handleClick } = options; - $sidebarLabelTooltip.tooltip(); if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) { @@ -132,10 +134,51 @@ export default class LabelsSelect { template = LabelsSelect.getLabelTemplate({ labels: data.labels, issueUpdateURL, + enableScopedLabels: scopedLabels, + scopedLabelsDocumentationLink, }); labelCount = data.labels.length; + + // EE Specific + if (isEE) { + /** + * For Scoped labels, the last label selected with the + * same key will be applied to the current issueable. + * + * If these are the labels - priority::1, priority::2; and if + * we apply them in the same order, only priority::2 will stick + * with the issuable. + * + * In the current dropdown implementation, we keep track of all + * the labels selected via a hidden DOM element. Since a User + * can select priority::1 and priority::2 at the same time, the + * DOM will have 2 hidden input and the dropdown will show both + * the items selected but in reality server only applied + * priority::2. + * + * We find all the labels then find all the labels server accepted + * and then remove the excess ones. + */ + const toRemoveIds = Array.from( + $form.find(`input[type="hidden"][name="${fieldName}"]`), + ) + .map(el => el.value) + .map(Number); + + data.labels.forEach(label => { + const index = toRemoveIds.indexOf(label.id); + toRemoveIds.splice(index, 1); + }); + + toRemoveIds.forEach(id => { + $form + .find(`input[type="hidden"][name="${fieldName}"][value="${id}"]`) + .last() + .remove(); + }); + } } else { - template = '<span class="no-value">None</span>'; + template = `<span class="no-value">${__('None')}</span>`; } $value.removeAttr('style').html(template); $sidebarCollapsedValue.text(labelCount); @@ -147,7 +190,9 @@ export default class LabelsSelect { if (labelTitles.length > 5) { labelTitles = labelTitles.slice(0, 5); - labelTitles.push('and ' + (data.labels.length - 5) + ' more'); + labelTitles.push( + sprintf(s__('Labels|and %{count} more'), { count: data.labels.length - 5 }), + ); } labelTooltipTitle = labelTitles.join(', '); @@ -176,13 +221,13 @@ export default class LabelsSelect { if (showNo) { extraData.unshift({ id: 0, - title: 'No Label', + title: __('No Label'), }); } if (showAny) { extraData.unshift({ isAny: true, - title: 'Any Label', + title: __('Any Label'), }); } if (extraData.length) { @@ -199,8 +244,8 @@ export default class LabelsSelect { .catch(() => flash(__('Error fetching labels.'))); }, renderRow: function(label, instance) { - var $a, - $li, + var linkEl, + listItemEl, color, colorEl, indeterminate, @@ -209,12 +254,11 @@ export default class LabelsSelect { spacing, i, marked, - dropdownName, dropdownValue; - $li = $('<li>'); - $a = $('<a href="#">'); + selectedClass = []; removesAll = label.id <= 0 || label.id == null; + if ($dropdown.hasClass('js-filter-bulk-update')) { indeterminate = $dropdown.data('indeterminate') || []; marked = $dropdown.data('marked') || []; @@ -233,7 +277,6 @@ export default class LabelsSelect { } } else { if (this.id(label)) { - dropdownName = $dropdown.data('fieldName'); dropdownValue = this.id(label) .toString() .replace(/'/g, "\\'"); @@ -241,7 +284,7 @@ export default class LabelsSelect { if ( $form.find( "input[type='hidden'][name='" + - dropdownName + + this.fieldName + "'][value='" + dropdownValue + "']", @@ -251,24 +294,34 @@ export default class LabelsSelect { } } - if ($dropdown.hasClass('js-multiselect') && removesAll) { + if (this.multiSelect && removesAll) { selectedClass.push('dropdown-clear-active'); } } + if (label.color) { colorEl = "<span class='dropdown-label-box' style='background: " + label.color + "'></span>"; } else { colorEl = ''; } + + linkEl = document.createElement('a'); + linkEl.href = '#'; + // We need to identify which items are actually labels if (label.id) { selectedClass.push('label-item'); - $a.attr('data-label-id', label.id); + linkEl.dataset.labelId = label.id; } - $a.addClass(selectedClass.join(' ')).html(`${colorEl} ${_.escape(label.title)}`); - // Return generated html - return $li.html($a).prop('outerHTML'); + + linkEl.className = selectedClass.join(' '); + linkEl.innerHTML = `${colorEl} ${_.escape(label.title)}`; + + listItemEl = document.createElement('li'); + listItemEl.appendChild(linkEl); + + return listItemEl; }, search: { fields: ['title'], @@ -290,7 +343,7 @@ export default class LabelsSelect { if (selected && selected.id === 0) { this.selected = []; - return 'No Label'; + return __('No Label'); } else if (isSelected) { this.selected.push(title); } else if (!isSelected && title) { @@ -350,6 +403,7 @@ export default class LabelsSelect { } else { if (!$dropdown.hasClass('js-filter-bulk-update')) { saveLabelData(); + $dropdown.data('glDropdown').clearMenu(); } } } @@ -463,19 +517,60 @@ export default class LabelsSelect { // so best approach is to use traditional way of // concatenation // see: http://2ality.com/2016/05/template-literal-whitespace.html#joining-arrays - const tpl = _.template( + + const labelTemplate = _.template( [ - '<% _.each(labels, function(label){ %>', '<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>">', - '<span class="badge label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;">', + '<span class="badge label has-tooltip color-label" <%= linkAttrs %> title="<%= tooltipTitleTemplate({ label, isScopedLabel, enableScopedLabels, escapeStr }) %>" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;">', '<%- label.title %>', '</span>', '</a>', + ].join(''), + ); + + const infoIconTemplate = _.template( + [ + '<a href="<%= scopedLabelsDocumentationLink %>" class="label scoped-label" target="_blank" rel="noopener">', + '<i class="fa fa-question-circle" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;"></i>', + '</a>', + ].join(''), + ); + + const tooltipTitleTemplate = _.template( + [ + '<% if (isScopedLabel(label) && enableScopedLabels) { %>', + "<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span>", + '<br />', + '<%= escapeStr(label.description) %>', + '<% } else { %>', + '<%= escapeStr(label.description) %>', + '<% } %>', + ].join(''), + ); + + const tpl = _.template( + [ + '<% _.each(labels, function(label){ %>', + '<% if (isScopedLabel(label) && enableScopedLabels) { %>', + '<span class="d-inline-block position-relative scoped-label-wrapper">', + '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>', + '<%= infoIconTemplate({ label, scopedLabelsDocumentationLink, escapeStr }) %>', + '</span>', + '<% } else { %>', + '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: "" }) %>', + '<% } %>', '<% }); %>', ].join(''), ); - return tpl(tplData); + return tpl({ + ...tplData, + labelTemplate, + infoIconTemplate, + tooltipTitleTemplate, + isScopedLabel, + escapeStr: _.escape, + }); } bindEvents() { @@ -486,7 +581,7 @@ export default class LabelsSelect { if ($('.selected-issuable:checked').length) { return; } - return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text('Label'); + return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text(__('Label')); } // eslint-disable-next-line class-methods-use-this enableBulkLabelDropdown() { diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index 64e4e899f44..5857f9e22ae 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -1,11 +1,32 @@ -import ApolloClient from 'apollo-boost'; +import { ApolloClient } from 'apollo-client'; +import { InMemoryCache } from 'apollo-cache-inmemory'; +import { createUploadLink } from 'apollo-upload-client'; +import { ApolloLink } from 'apollo-link'; +import { BatchHttpLink } from 'apollo-link-batch-http'; import csrf from '~/lib/utils/csrf'; -export default (clientState = {}) => - new ApolloClient({ - uri: `${gon.relative_url_root}/api/graphql`, +export default (resolvers = {}, config = {}) => { + let uri = `${gon.relative_url_root}/api/graphql`; + + if (config.baseUrl) { + // Prepend baseUrl and ensure that `///` are replaced with `/` + uri = `${config.baseUrl}${uri}`.replace(/\/{3,}/g, '/'); + } + + const httpOptions = { + uri, headers: { [csrf.headerKey]: csrf.token, }, - clientState, + }; + + return new ApolloClient({ + link: ApolloLink.split( + operation => operation.getContext().hasUpload, + createUploadLink(httpOptions), + new BatchHttpLink(httpOptions), + ), + cache: new InMemoryCache(config.cacheConfig), + resolvers, }); +}; diff --git a/app/assets/javascripts/lib/utils/accessor.js b/app/assets/javascripts/lib/utils/accessor.js index 1d18992af63..39cffedcac6 100644 --- a/app/assets/javascripts/lib/utils/accessor.js +++ b/app/assets/javascripts/lib/utils/accessor.js @@ -2,7 +2,7 @@ function isPropertyAccessSafe(base, property) { let safe; try { - safe = !!base[property]; + safe = Boolean(base[property]); } catch (error) { safe = false; } diff --git a/app/assets/javascripts/lib/utils/autosave.js b/app/assets/javascripts/lib/utils/autosave.js new file mode 100644 index 00000000000..023c336db02 --- /dev/null +++ b/app/assets/javascripts/lib/utils/autosave.js @@ -0,0 +1,32 @@ +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; + +export const clearDraft = autosaveKey => { + try { + window.localStorage.removeItem(`autosave/${autosaveKey}`); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } +}; + +export const getDraft = autosaveKey => { + try { + return window.localStorage.getItem(`autosave/${autosaveKey}`); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + return null; + } +}; + +export const updateDraft = (autosaveKey, text) => { + try { + window.localStorage.setItem(`autosave/${autosaveKey}`, text); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } +}; + +export const getDiscussionReplyKey = (noteableType, discussionId) => + ['Note', capitalizeFirstCharacter(noteableType), discussionId, 'Reply'].join('/'); diff --git a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js index a24c71aeab1..28a7ebfdc69 100644 --- a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js +++ b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js @@ -51,6 +51,7 @@ export default class LinkedTabs { this.defaultAction = this.options.defaultAction; this.action = this.options.action || this.defaultAction; + this.hashedTabs = this.options.hashedTabs || false; if (this.action === 'show') { this.action = this.defaultAction; @@ -58,6 +59,10 @@ export default class LinkedTabs { this.currentLocation = window.location; + if (this.hashedTabs) { + this.action = this.currentLocation.hash || this.action; + } + const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`; // since this is a custom event we need jQuery :( @@ -91,7 +96,9 @@ export default class LinkedTabs { copySource.replace(/\/+$/, ''); - const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`; + const newState = this.hashedTabs + ? copySource + : `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`; window.history.replaceState( { diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index a73cdb73690..cc5e12aa467 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -7,6 +7,7 @@ import axios from './axios_utils'; import { getLocationHash } from './url_utility'; import { convertToCamelCase } from './text_utility'; import { isObject } from './type_utility'; +import breakpointInstance from '../../breakpoints'; export const getPagePath = (index = 0) => { const page = $('body').attr('data-page') || ''; @@ -93,6 +94,8 @@ export const handleLocationHash = () => { const fixedNav = document.querySelector('.navbar-gitlab'); const performanceBar = document.querySelector('#js-peek'); const topPadding = 8; + const diffFileHeader = document.querySelector('.js-file-title'); + const versionMenusContainer = document.querySelector('.mr-version-menus-container'); let adjustment = 0; if (fixedNav) adjustment -= fixedNav.offsetHeight; @@ -113,6 +116,14 @@ export const handleLocationHash = () => { adjustment -= performanceBar.offsetHeight; } + if (diffFileHeader) { + adjustment -= diffFileHeader.offsetHeight; + } + + if (versionMenusContainer) { + adjustment -= versionMenusContainer.offsetHeight; + } + if (isInMRPage()) { adjustment -= topPadding; } @@ -193,16 +204,23 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2; export const contentTop = () => { - const perfBar = $('#js-peek').height() || 0; - const mrTabsHeight = $('.merge-request-tabs').height() || 0; - const headerHeight = $('.navbar-gitlab').height() || 0; - const diffFilesChanged = $('.js-diff-files-changed').height() || 0; - const diffFileLargeEnoughScreen = - 'matchMedia' in window ? window.matchMedia('min-width: 768') : true; + const perfBar = $('#js-peek').outerHeight() || 0; + const mrTabsHeight = $('.merge-request-tabs').outerHeight() || 0; + const headerHeight = $('.navbar-gitlab').outerHeight() || 0; + const diffFilesChanged = $('.js-diff-files-changed').outerHeight() || 0; + const isDesktop = breakpointInstance.isDesktop(); const diffFileTitleBar = - (diffFileLargeEnoughScreen && $('.diff-file .file-title-flex-parent:visible').height()) || 0; + (isDesktop && $('.diff-file .file-title-flex-parent:visible').outerHeight()) || 0; + const compareVersionsHeaderHeight = (isDesktop && $('.mr-version-controls').outerHeight()) || 0; - return perfBar + mrTabsHeight + headerHeight + diffFilesChanged + diffFileTitleBar; + return ( + perfBar + + mrTabsHeight + + headerHeight + + diffFilesChanged + + diffFileTitleBar + + compareVersionsHeaderHeight + ); }; export const scrollToElement = element => { @@ -708,6 +726,26 @@ export const NavigationType = { TYPE_RESERVED: 255, }; +/** + * Returns the value of `gon.ee` + * Used to check if it's the EE codebase or the CE one. + * + * @returns Boolean + */ +export const isEE = () => window.gon && window.gon.ee; + +/** + * Checks if the given Label has a special syntax `::` in + * it's title. + * + * Expected Label to be an Object with `title` as a key: + * { title: 'LabelTitle', ...otherProperties }; + * + * @param {Object} label + * @returns Boolean + */ +export const isScopedLabel = ({ title = '' }) => title.indexOf('::') !== -1; + window.gl = window.gl || {}; window.gl.utils = { ...(window.gl.utils || {}), diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index d3fe8f77bd4..d521c462ad8 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -3,7 +3,7 @@ import _ from 'underscore'; import timeago from 'timeago.js'; import dateFormat from 'dateformat'; import { pluralize } from './text_utility'; -import { languageCode, s__ } from '../../locale'; +import { languageCode, s__, __ } from '../../locale'; window.timeago = timeago; @@ -63,7 +63,15 @@ export const pad = (val, len = 2) => `0${val}`.slice(-len); * @returns {String} */ export const getDayName = date => - ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][date.getDay()]; + [ + __('Sunday'), + __('Monday'), + __('Tuesday'), + __('Wednesday'), + __('Thursday'), + __('Friday'), + __('Saturday'), + ][date.getDay()]; /** * @example @@ -71,7 +79,12 @@ export const getDayName = date => * @param {date} datetime * @returns {String} */ -export const formatDate = datetime => dateFormat(datetime, 'mmm d, yyyy h:MMtt Z'); +export const formatDate = datetime => { + if (_.isString(datetime) && datetime.match(/\d+-\d+\d+ /)) { + throw new Error(__('Invalid date')); + } + return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z'); +}; /** * Timeago uses underscores instead of dashes to separate language from country code. @@ -92,7 +105,7 @@ export const getTimeago = () => { const timeAgoLocaleRemaining = [ () => [s__('Timeago|just now'), s__('Timeago|right now')], - () => [s__('Timeago|%s seconds ago'), s__('Timeago|%s seconds remaining')], + () => [s__('Timeago|just now'), s__('Timeago|%s seconds remaining')], () => [s__('Timeago|1 minute ago'), s__('Timeago|1 minute remaining')], () => [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')], () => [s__('Timeago|1 hour ago'), s__('Timeago|1 hour remaining')], @@ -121,7 +134,7 @@ export const getTimeago = () => { const timeAgoLocale = [ () => [s__('Timeago|just now'), s__('Timeago|right now')], - () => [s__('Timeago|%s seconds ago'), s__('Timeago|in %s seconds')], + () => [s__('Timeago|just now'), s__('Timeago|in %s seconds')], () => [s__('Timeago|1 minute ago'), s__('Timeago|in 1 minute')], () => [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')], () => [s__('Timeago|1 hour ago'), s__('Timeago|in 1 hour')], @@ -160,7 +173,11 @@ export const getTimeago = () => { * @param {Boolean} setTimeago */ export const localTimeAgo = ($timeagoEls, setTimeago = true) => { - getTimeago().render($timeagoEls, timeagoLanguageCode); + getTimeago(); + + $timeagoEls.each((i, el) => { + $(el).text(timeagoInstance.format($(el).attr('datetime'), timeagoLanguageCode)); + }); if (!setTimeago) { return; @@ -316,13 +333,13 @@ export const getSundays = date => { } const daysToSunday = [ - 'Saturday', - 'Friday', - 'Thursday', - 'Wednesday', - 'Tuesday', - 'Monday', - 'Sunday', + __('Saturday'), + __('Friday'), + __('Thursday'), + __('Wednesday'), + __('Tuesday'), + __('Monday'), + __('Sunday'), ]; const month = date.getMonth(); @@ -332,7 +349,7 @@ export const getSundays = date => { while (dateOfMonth.getMonth() === month) { const dayName = getDayName(dateOfMonth); - if (dayName === 'Sunday') { + if (dayName === __('Sunday')) { sundays.push(new Date(dateOfMonth.getTime())); } @@ -496,7 +513,7 @@ export const stringifyTime = (timeObject, fullNameFormat = false) => { const reducedTime = _.reduce( timeObject, (memo, unitValue, unitName) => { - const isNonZero = !!unitValue; + const isNonZero = Boolean(unitValue); if (fullNameFormat && isNonZero) { // Remove traling 's' if unit value is singular diff --git a/app/assets/javascripts/lib/utils/highlight.js b/app/assets/javascripts/lib/utils/highlight.js new file mode 100644 index 00000000000..8f0afa3467d --- /dev/null +++ b/app/assets/javascripts/lib/utils/highlight.js @@ -0,0 +1,44 @@ +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import _ from 'underscore'; +import sanitize from 'sanitize-html'; + +/** + * Wraps substring matches with HTML `<span>` elements. + * Inputs are sanitized before highlighting, so this + * filter is safe to use with `v-html` (as long as `matchPrefix` + * and `matchSuffix` are not being dynamically generated). + * + * Note that this function can't be used inside `v-html` as a filter + * (Vue filters cannot be used inside `v-html`). + * + * @param {String} string The string to highlight + * @param {String} match The substring match to highlight in the string + * @param {String} matchPrefix The string to insert at the beginning of a match + * @param {String} matchSuffix The string to insert at the end of a match + */ +export default function highlight(string, match = '', matchPrefix = '<b>', matchSuffix = '</b>') { + if (_.isUndefined(string) || _.isNull(string)) { + return ''; + } + + if (_.isUndefined(match) || _.isNull(match) || match === '') { + return string; + } + + const sanitizedValue = sanitize(string.toString(), { allowedTags: [] }); + + // occurrences is an array of character indices that should be + // highlighted in the original string, i.e. [3, 4, 5, 7] + const occurrences = fuzzaldrinPlus.match(sanitizedValue, match.toString()); + + return sanitizedValue + .split('') + .map((character, i) => { + if (_.contains(occurrences, i)) { + return `${matchPrefix}${character}${matchSuffix}`; + } + + return character; + }) + .join(''); +} diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index 14c02218990..37ad1676f7a 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -16,6 +16,7 @@ const httpStatusCodes = { IM_USED: 226, MULTIPLE_CHOICES: 300, BAD_REQUEST: 400, + UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, UNPROCESSABLE_ENTITY: 422, diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index 2ccc51c35f7..61c8b8803d7 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -1,4 +1,5 @@ import { BYTES_IN_KIB } from './constants'; +import { sprintf, __ } from '~/locale'; /** * Function that allows a number with an X amount of decimals @@ -72,11 +73,36 @@ export function bytesToGiB(number) { */ export function numberToHumanSize(size) { if (size < BYTES_IN_KIB) { - return `${size} bytes`; + return sprintf(__('%{size} bytes'), { size }); } else if (size < BYTES_IN_KIB * BYTES_IN_KIB) { - return `${bytesToKiB(size).toFixed(2)} KiB`; + return sprintf(__('%{size} KiB'), { size: bytesToKiB(size).toFixed(2) }); } else if (size < BYTES_IN_KIB * BYTES_IN_KIB * BYTES_IN_KIB) { - return `${bytesToMiB(size).toFixed(2)} MiB`; + return sprintf(__('%{size} MiB'), { size: bytesToMiB(size).toFixed(2) }); } - return `${bytesToGiB(size).toFixed(2)} GiB`; + return sprintf(__('%{size} GiB'), { size: bytesToGiB(size).toFixed(2) }); } + +/** + * A simple method that returns the value of a + b + * It seems unessesary, but when combined with a reducer it + * adds up all the values in an array. + * + * e.g. `[1, 2, 3, 4, 5].reduce(sum) // => 15` + * + * @param {Float} a + * @param {Float} b + * @example + * // return 15 + * [1, 2, 3, 4, 5].reduce(sum); + * + * // returns 6 + * Object.values([{a: 1, b: 2, c: 3].reduce(sum); + * @returns {Float} The summed value + */ +export const sum = (a = 0, b = 0) => a + b; + +/** + * Checks if the provided number is odd + * @param {Int} number + */ +export const isOdd = (number = 0) => number % 2; diff --git a/app/assets/javascripts/lib/utils/simple_poll.js b/app/assets/javascripts/lib/utils/simple_poll.js index 473f179ad86..576a9ec880c 100644 --- a/app/assets/javascripts/lib/utils/simple_poll.js +++ b/app/assets/javascripts/lib/utils/simple_poll.js @@ -1,10 +1,10 @@ -export default (fn, interval = 2000, timeout = 60000) => { +export default (fn, { interval = 2000, timeout = 60000 } = {}) => { const startTime = Date.now(); return new Promise((resolve, reject) => { const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg)); const next = () => { - if (Date.now() - startTime < timeout) { + if (timeout === 0 || Date.now() - startTime < timeout) { setTimeout(fn.bind(null, next, stop), interval); } else { reject(new Error('SIMPLE_POLL_TIMEOUT')); diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 84a617acb42..b7922e29bb0 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -223,9 +223,9 @@ export function insertMarkdownText({ return tag.replace(textPlaceholder, val); } if (val.indexOf(tag) === 0) { - return '' + val.replace(tag, ''); + return String(val.replace(tag, '')); } else { - return '' + tag + val; + return String(tag) + val; } }) .join('\n'); @@ -233,7 +233,7 @@ export function insertMarkdownText({ } else if (tag.indexOf(textPlaceholder) > -1) { textToInsert = tag.replace(textPlaceholder, selected); } else { - textToInsert = '' + startChar + tag + selected + (wrap ? tag : ' '); + textToInsert = String(startChar) + tag + selected + (wrap ? tag : ' '); } if (removedFirstNewLine) { diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index c49b1bb5a2f..cc1d85fd97d 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -1,3 +1,5 @@ +import _ from 'underscore'; + /** * Adds a , to a string composed by numbers, at every 3 chars. * @@ -42,18 +44,18 @@ export const pluralize = (str, count) => str + (count > 1 || count === 0 ? 's' : export const dasherize = str => str.replace(/[_\s]+/g, '-'); /** - * Removes accents and converts to lower case + * Replaces whitespaces with hyphens and converts to lower case * @param {String} str * @returns {String} */ -export const slugify = str => str.trim().toLowerCase(); +export const slugifyWithHyphens = str => str.toLowerCase().replace(/\s+/g, '-'); /** - * Replaces whitespaces with hyphens and converts to lower case + * Replaces whitespaces with underscore and converts to lower case * @param {String} str * @returns {String} */ -export const slugifyWithHyphens = str => str.toLowerCase().replace(/\s+/g, '-'); +export const slugifyWithUnderscore = str => str.toLowerCase().replace(/\s+/g, '_'); /** * Truncates given text @@ -160,3 +162,33 @@ export const splitCamelCase = string => .replace(/([A-Z]+)([A-Z][a-z])/g, ' $1 $2') .replace(/([a-z\d])([A-Z])/g, '$1 $2') .trim(); + +/** + * Intelligently truncates an item's namespace by doing two things: + * 1. Only include group names in path by removing the item name + * 2. Only include the first and last group names in the path + * when the namespace includes more than 2 groups + * + * @param {String} string A string namespace, + * i.e. "My Group / My Subgroup / My Project" + */ +export const truncateNamespace = (string = '') => { + if (_.isNull(string) || !_.isString(string)) { + return ''; + } + + const namespaceArray = string.split(' / '); + + if (namespaceArray.length === 1) { + return string; + } + + namespaceArray.splice(-1, 1); + let namespace = namespaceArray.join(' / '); + + if (namespaceArray.length > 2) { + namespace = `${namespaceArray[0]} / ... / ${namespaceArray.pop()}`; + } + + return namespace; +}; diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 4ba84589705..b5474fc5c71 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -120,3 +120,41 @@ export function webIDEUrl(route = undefined) { } return returnUrl; } + +/** + * Returns current base URL + */ +export function getBaseURL() { + const { protocol, host } = window.location; + return `${protocol}//${host}`; +} + +/** + * Returns true if url is an absolute or root-relative URL + * + * @param {String} url + */ +export function isAbsoluteOrRootRelative(url) { + return /^(https?:)?\//.test(url); +} + +/** + * Checks if the provided URL is a safe URL (absolute http(s) or root-relative URL) + * + * @param {String} url that will be checked + * @returns {Boolean} + */ +export function isSafeURL(url) { + if (!isAbsoluteOrRootRelative(url)) { + return false; + } + + try { + const parsedUrl = new URL(url, getBaseURL()); + return ['http:', 'https:'].includes(parsedUrl.protocol); + } catch (e) { + return false; + } +} + +export { join as joinPaths } from 'path'; diff --git a/app/assets/javascripts/lib/utils/webpack.js b/app/assets/javascripts/lib/utils/webpack.js index 308ad9784e4..37b17f0fe23 100644 --- a/app/assets/javascripts/lib/utils/webpack.js +++ b/app/assets/javascripts/lib/utils/webpack.js @@ -1,3 +1,5 @@ +import { joinPaths } from '~/lib/utils/url_utility'; + // tell webpack to load assets from origin so that web workers don't break // eslint-disable-next-line import/prefer-default-export export function resetServiceWorkersPublicPath() { @@ -5,6 +7,12 @@ export function resetServiceWorkersPublicPath() { // the webpack publicPath setting at runtime. // see: https://webpack.js.org/guides/public-path/ const relativeRootPath = (gon && gon.relative_url_root) || ''; - const webpackAssetPath = `${relativeRootPath}/assets/webpack/`; + const webpackAssetPath = joinPaths(relativeRootPath, '/assets/webpack/'); __webpack_public_path__ = webpackAssetPath; // eslint-disable-line camelcase + + // monaco-editor-webpack-plugin currently (incorrectly) references the + // public path as a property of `window`. Once this is fixed upstream we + // can remove this line + // see: https://github.com/Microsoft/monaco-editor-webpack-plugin/pull/63 + window.__webpack_public_path__ = webpackAssetPath; // eslint-disable-line } diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js index 1ae3362c4bc..41aa0f4ddb9 100644 --- a/app/assets/javascripts/locale/index.js +++ b/app/assets/javascripts/locale/index.js @@ -11,7 +11,7 @@ delete window.translations; @param text The text to be translated @returns {String} The translated text */ -const gettext = text => locale.gettext.bind(locale)(ensureSingleLine(text)); +const gettext = text => locale.gettext(ensureSingleLine(text)); /** Translate the text with a number diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 1b722c0505a..9f30a989295 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -31,6 +31,7 @@ import initPerformanceBar from './performance_bar'; import initSearchAutocomplete from './search_autocomplete'; import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; +import { __ } from './locale'; // expose jQuery as global (TODO: remove these) window.jQuery = jQuery; @@ -135,6 +136,24 @@ function deferredInitialisation() { }); loadAwardsHandler(); + + /** + * Toggle Canary Badge + * + * For GitLab.com only, when the user is using canary + * we render a Next badge and hide the option to switch + * to canay + */ + if (Cookies.get('gitlab_canary') && Cookies.get('gitlab_canary') === 'true') { + const canaryBadge = document.querySelector('.js-canary-badge'); + const canaryLink = document.querySelector('.js-canary-link'); + if (canaryBadge) { + canaryBadge.classList.remove('hidden'); + } + if (canaryLink) { + canaryLink.classList.add('hidden'); + } + } } document.addEventListener('DOMContentLoaded', () => { @@ -201,9 +220,9 @@ document.addEventListener('DOMContentLoaded', () => { const ref = xhrObj.status; if (ref === 401) { - Flash('You need to be logged in.'); + Flash(__('You need to be logged in.')); } else if (ref === 404 || ref === 500) { - Flash('Something went wrong on our end.'); + Flash(__('Something went wrong on our end.')); } }); diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index bd263c75a3d..af2697444f2 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -16,25 +16,33 @@ export default class Members { gl.utils.disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change'); } + dropdownClicked(options) { + this.formSubmit(null, options.$el); + } + + // eslint-disable-next-line class-methods-use-this + dropdownToggleLabel(selected, $el) { + return $el.text(); + } + + // eslint-disable-next-line class-methods-use-this + dropdownIsSelectable(selected, $el) { + return !$el.hasClass('is-active'); + } + initGLDropdown() { $('.js-member-permissions-dropdown').each((i, btn) => { const $btn = $(btn); $btn.glDropdown({ selectable: true, - isSelectable(selected, $el) { - return !$el.hasClass('is-active'); - }, + isSelectable: (selected, $el) => this.dropdownIsSelectable(selected, $el), fieldName: $btn.data('fieldName'), id(selected, $el) { return $el.data('id'); }, - toggleLabel(selected, $el) { - return $el.text(); - }, - clicked: options => { - this.formSubmit(null, options.$el); - }, + toggleLabel: (selected, $el) => this.dropdownToggleLabel(selected, $el, $btn), + clicked: options => this.dropdownClicked(options), }); }); } @@ -55,6 +63,7 @@ export default class Members { $toggle.enable(); $dateInput.enable(); } + // eslint-disable-next-line class-methods-use-this getMemberListItems($el) { const $memberListItem = $el.is('.member') ? $el : $(`#${$el.data('elId')}`); diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js index c2de0379d23..3cb406b819d 100644 --- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js +++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js @@ -16,7 +16,7 @@ import utilsMixin from '../mixins/line_conflict_utils'; }, }, template: ` - <table> + <table class="diff-wrap-lines code js-syntax-highlight"> <tr class="line_holder parallel" v-for="section in file.parallelLines"> <template v-for="line in section"> <td class="diff-line-num header" :class="lineCssClass(line)" v-if="line.isHeader"></td> diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js index 0333335de06..88bc0940741 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js @@ -3,15 +3,16 @@ import $ from 'jquery'; import Vue from 'vue'; import Cookies from 'js-cookie'; +import { s__ } from '~/locale'; (global => { global.mergeConflicts = global.mergeConflicts || {}; const diffViewType = Cookies.get('diff_view'); - const HEAD_HEADER_TEXT = 'HEAD//our changes'; - const ORIGIN_HEADER_TEXT = 'origin//their changes'; - const HEAD_BUTTON_TITLE = 'Use ours'; - const ORIGIN_BUTTON_TITLE = 'Use theirs'; + const HEAD_HEADER_TEXT = s__('MergeConflict|HEAD//our changes'); + const ORIGIN_HEADER_TEXT = s__('MergeConflict|origin//their changes'); + const HEAD_BUTTON_TITLE = s__('MergeConflict|Use ours'); + const ORIGIN_BUTTON_TITLE = s__('MergeConflict|Use theirs'); const INTERACTIVE_RESOLVE_MODE = 'interactive'; const EDIT_RESOLVE_MODE = 'edit'; const DEFAULT_RESOLVE_MODE = INTERACTIVE_RESOLVE_MODE; @@ -173,7 +174,7 @@ import Cookies from 'js-cookie'; getConflictsCountText() { const count = this.getConflictsCount(); - const text = count > 1 ? 'conflicts' : 'conflict'; + const text = count > 1 ? s__('MergeConflict|conflicts') : s__('MergeConflict|conflict'); return `${count} ${text}`; }, @@ -348,8 +349,8 @@ import Cookies from 'js-cookie'; }, getCommitButtonText() { - const initial = 'Commit to source branch'; - const inProgress = 'Committing...'; + const initial = s__('MergeConflict|Commit to source branch'); + const inProgress = s__('MergeConflict|Committing...'); return this.state ? (this.state.isSubmitting ? inProgress : initial) : initial; }, diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js index 7badd68089c..d8d203e0616 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -8,6 +8,7 @@ import './components/diff_file_editor'; import './components/inline_conflict_lines'; import './components/parallel_conflict_lines'; import syntaxHighlight from '../syntax_highlight'; +import { __ } from '~/locale'; export default function initMergeConflicts() { const INTERACTIVE_RESOLVE_MODE = 'interactive'; @@ -92,7 +93,7 @@ export default function initMergeConflicts() { }) .catch(() => { mergeConflictsStore.setSubmitState(false); - createFlash('Failed to save merge conflicts resolutions. Please try again!'); + createFlash(__('Failed to save merge conflicts resolutions. Please try again!')); }); }, }, diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 2f15da42271..e5cf43e8289 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -21,6 +21,7 @@ import { localTimeAgo } from './lib/utils/datetime_utility'; import syntaxHighlight from './syntax_highlight'; import Notes from './notes'; import { polyfillSticky } from './lib/utils/sticky'; +import { __ } from './locale'; // MergeRequestTabs // @@ -326,7 +327,7 @@ export default class MergeRequestTabs { }) .catch(() => { this.toggleLoading(false); - flash('An error occurred while fetching this tab.'); + flash(__('An error occurred while fetching this tab.')); }); } @@ -398,7 +399,7 @@ export default class MergeRequestTabs { const hash = getLocationHash(); const anchor = hash && $container.find(`.note[id="${hash}"]`); if (anchor && anchor.length > 0) { - const notesContent = anchor.closest('.notes_content'); + const notesContent = anchor.closest('.notes-content'); const lineType = notesContent.hasClass('new') ? 'new' : 'old'; Notes.instance.toggleDiffNote({ target: anchor, @@ -416,7 +417,7 @@ export default class MergeRequestTabs { }) .catch(() => { this.toggleLoading(false); - flash('An error occurred while fetching this tab.'); + flash(__('An error occurred while fetching this tab.')); }); } diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js index f211632cf24..6aaba4e7c74 100644 --- a/app/assets/javascripts/milestone.js +++ b/app/assets/javascripts/milestone.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; import flash from './flash'; import { mouseenter, debouncedMouseleave, togglePopover } from './shared/popover'; +import { __ } from './locale'; export default class Milestone { constructor() { @@ -42,7 +43,7 @@ export default class Milestone { $(tabElId).html(data.html); $target.addClass('is-loaded'); }) - .catch(() => flash('Error loading milestone tab')); + .catch(() => flash(__('Error loading milestone tab'))); } } diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index 75c18a9b6a0..43949d5cc86 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -56,14 +56,15 @@ export default class MilestoneSelect { const $value = $block.find('.value'); const $loading = $block.find('.block-loading').fadeOut(); selectedMilestoneDefault = showAny ? '' : null; - selectedMilestoneDefault = showNo && defaultNo ? 'No Milestone' : selectedMilestoneDefault; + selectedMilestoneDefault = + showNo && defaultNo ? __('No Milestone') : selectedMilestoneDefault; selectedMilestone = $dropdown.data('selected') || selectedMilestoneDefault; if (issueUpdateURL) { milestoneLinkTemplate = _.template( '<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>', ); - milestoneLinkNoneTemplate = '<span class="no-value">None</span>'; + milestoneLinkNoneTemplate = `<span class="no-value">${__('None')}</span>`; } return $dropdown.glDropdown({ showMenuAbove: showMenuAbove, @@ -74,28 +75,28 @@ export default class MilestoneSelect { extraOptions.push({ id: null, name: null, - title: 'Any Milestone', + title: __('Any Milestone'), }); } if (showNo) { extraOptions.push({ id: -1, - name: 'No Milestone', - title: 'No Milestone', + name: __('No Milestone'), + title: __('No Milestone'), }); } if (showUpcoming) { extraOptions.push({ id: -2, name: '#upcoming', - title: 'Upcoming', + title: __('Upcoming'), }); } if (showStarted) { extraOptions.push({ id: -3, name: '#started', - title: 'Started', + title: __('Started'), }); } if (extraOptions.length) { diff --git a/app/assets/javascripts/mini_pipeline_graph_dropdown.js b/app/assets/javascripts/mini_pipeline_graph_dropdown.js index 81ab9d8be4b..b39ad764f01 100644 --- a/app/assets/javascripts/mini_pipeline_graph_dropdown.js +++ b/app/assets/javascripts/mini_pipeline_graph_dropdown.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import flash from './flash'; import axios from './lib/utils/axios_utils'; +import { __ } from './locale'; /** * In each pipelines table we have a mini pipeline graph for each pipeline. @@ -98,7 +99,7 @@ export default class MiniPipelineGraph { ) { $(button).dropdown('toggle'); } - flash('An error occurred while fetching the builds.', 'alert'); + flash(__('An error occurred while fetching the builds.'), 'alert'); }); } diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js index 196b84621b6..33e9b1c4e46 100644 --- a/app/assets/javascripts/mirrors/mirror_repos.js +++ b/app/assets/javascripts/mirrors/mirror_repos.js @@ -87,7 +87,7 @@ export default class MirrorRepos { project: { remote_mirrors_attributes: { id: $target.data('mirrorId'), - enabled: 0, + _destroy: 1, }, }, }; diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js index 5bdf5d6277a..bb5ae6ce2d1 100644 --- a/app/assets/javascripts/mirrors/ssh_mirror.js +++ b/app/assets/javascripts/mirrors/ssh_mirror.js @@ -20,15 +20,10 @@ export default class SSHMirror { this.$btnDetectHostKeys = this.$form.find('.js-detect-host-keys'); this.$btnSSHHostsShowAdvanced = this.$form.find('.btn-show-advanced'); this.$dropdownAuthType = this.$form.find('.js-mirror-auth-type'); + this.$hiddenAuthType = this.$form.find('.js-hidden-mirror-auth-type'); this.$wellAuthTypeChanging = this.$form.find('.js-well-changing-auth'); this.$wellPasswordAuth = this.$form.find('.js-well-password-auth'); - this.$wellSSHAuth = this.$form.find('.js-well-ssh-auth'); - this.$sshPublicKeyWrap = this.$form.find('.js-ssh-public-key-wrap'); - this.$regeneratePublicSshKeyButton = this.$wellSSHAuth.find('.js-btn-regenerate-ssh-key'); - this.$regeneratePublicSshKeyModal = this.$wellSSHAuth.find( - '.js-regenerate-public-ssh-key-confirm-modal', - ); } init() { @@ -39,15 +34,6 @@ export default class SSHMirror { this.$dropdownAuthType.on('change', e => this.handleAuthTypeChange(e)); this.$btnDetectHostKeys.on('click', e => this.handleDetectHostKeys(e)); this.$btnSSHHostsShowAdvanced.on('click', e => this.handleSSHHostsAdvanced(e)); - this.$regeneratePublicSshKeyButton.on('click', () => - this.$regeneratePublicSshKeyModal.toggle(true), - ); - $('.js-confirm', this.$regeneratePublicSshKeyModal).on('click', e => - this.regeneratePublicSshKey(e), - ); - $('.js-cancel', this.$regeneratePublicSshKeyModal).on('click', () => - this.$regeneratePublicSshKeyModal.toggle(false), - ); } /** @@ -161,53 +147,11 @@ export default class SSHMirror { * Authentication method dropdown change event listener */ handleAuthTypeChange() { - const projectMirrorAuthTypeEndpoint = `${this.$form.attr('action')}.json`; - const $sshPublicKey = this.$sshPublicKeyWrap.find('.ssh-public-key'); const selectedAuthType = this.$dropdownAuthType.val(); this.$wellPasswordAuth.collapse('hide'); - this.$wellSSHAuth.collapse('hide'); - - // This request should happen only if selected Auth type was SSH - // and SSH Public key was not present on page load - if (selectedAuthType === AUTH_METHOD.SSH && !$sshPublicKey.text().trim()) { - if (!this.$wellSSHAuth.length) return; - - // Construct request body - const authTypeData = { - project: { - ...this.$regeneratePublicSshKeyButton.data().projectData, - }, - }; - - this.$wellAuthTypeChanging.collapse('show'); - this.$dropdownAuthType.disable(); - - axios - .put(projectMirrorAuthTypeEndpoint, JSON.stringify(authTypeData), { - headers: { - 'Content-Type': 'application/json; charset=utf-8', - }, - }) - .then(({ data }) => { - // Show SSH public key container and fill in public key - this.toggleAuthWell(selectedAuthType); - this.toggleSSHAuthWellMessage(true); - this.setSSHPublicKey(data.import_data_attributes.ssh_public_key); - - this.$wellAuthTypeChanging.collapse('hide'); - this.$dropdownAuthType.enable(); - }) - .catch(() => { - Flash(__('Something went wrong on our end.')); - - this.$wellAuthTypeChanging.collapse('hide'); - this.$dropdownAuthType.enable(); - }); - } else { - this.toggleAuthWell(selectedAuthType); - this.$wellSSHAuth.find('.js-ssh-public-key-present').collapse('show'); - } + this.updateHiddenAuthType(selectedAuthType); + this.toggleAuthWell(selectedAuthType); } /** @@ -233,57 +177,12 @@ export default class SSHMirror { */ toggleAuthWell(authType) { this.$wellPasswordAuth.collapse(authType === AUTH_METHOD.PASSWORD ? 'show' : 'hide'); - this.$wellSSHAuth.collapse(authType === AUTH_METHOD.SSH ? 'show' : 'hide'); + this.updateHiddenAuthType(authType); } - /** - * Toggle SSH auth information message - */ - toggleSSHAuthWellMessage(sshKeyPresent) { - this.$sshPublicKeyWrap.collapse(sshKeyPresent ? 'show' : 'hide'); - this.$wellSSHAuth.find('.js-ssh-public-key-present').collapse(sshKeyPresent ? 'show' : 'hide'); - this.$regeneratePublicSshKeyButton.collapse(sshKeyPresent ? 'show' : 'hide'); - this.$wellSSHAuth.find('.js-ssh-public-key-pending').collapse(sshKeyPresent ? 'hide' : 'show'); - } - - /** - * Sets SSH Public key to Clipboard button and shows it on UI. - */ - setSSHPublicKey(sshPublicKey) { - this.$sshPublicKeyWrap.find('.ssh-public-key').text(sshPublicKey); - this.$sshPublicKeyWrap - .find('.btn-copy-ssh-public-key') - .attr('data-clipboard-text', sshPublicKey); - } - - regeneratePublicSshKey(event) { - event.preventDefault(); - - this.$regeneratePublicSshKeyModal.toggle(false); - - const button = this.$regeneratePublicSshKeyButton; - const spinner = $('.js-spinner', button); - const endpoint = button.data('endpoint'); - const authTypeData = { - project: { - ...this.$regeneratePublicSshKeyButton.data().projectData, - }, - }; - - button.attr('disabled', 'disabled'); - spinner.removeClass('d-none'); - - axios - .patch(endpoint, authTypeData) - .then(({ data }) => { - button.removeAttr('disabled'); - spinner.addClass('d-none'); - - this.setSSHPublicKey(data.import_data_attributes.ssh_public_key); - }) - .catch(() => { - Flash(_('Unable to regenerate public ssh key.')); - }); + updateHiddenAuthType(authType) { + this.$hiddenAuthType.val(authType); + this.$hiddenAuthType.prop('disabled', authType === AUTH_METHOD.SSH); } destroy() { @@ -292,8 +191,5 @@ export default class SSHMirror { this.$dropdownAuthType.off('change'); this.$btnDetectHostKeys.off('click'); this.$btnSSHHostsShowAdvanced.off('click'); - this.$regeneratePublicSshKeyButton.off('click'); - $('.js-confirm', this.$regeneratePublicSshKeyModal).off('click'); - $('.js-cancel', this.$regeneratePublicSshKeyModal).off('click'); } } diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index 9e031b03579..c43791f2426 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -1,15 +1,18 @@ <script> -import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { GlAreaChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import Icon from '~/vue_shared/components/icon.vue'; +import { chartHeight, graphTypes, lineTypes } from '../../constants'; +import { makeDataSeries } from '~/helpers/monitor_helper'; let debouncedResize; export default { components: { GlAreaChart, + GlChartSeriesLabel, Icon, }, inheritAttrs: false, @@ -19,7 +22,6 @@ export default { required: true, validator(data) { return ( - data.queries && Array.isArray(data.queries) && data.queries.filter(query => { if (Array.isArray(query.result)) { @@ -41,31 +43,58 @@ export default { required: false, default: () => [], }, - alertData: { - type: Object, + thresholds: { + type: Array, required: false, - default: () => ({}), + default: () => [], }, }, data() { return { tooltip: { title: '', - content: '', + content: [], isDeployment: false, sha: '', }, width: 0, - height: 0, - scatterSymbol: undefined, + height: chartHeight, + svgs: {}, + primaryColor: null, }; }, computed: { chartData() { - return this.graphData.queries.reduce((accumulator, query) => { - accumulator[query.unit] = query.result.reduce((acc, res) => acc.concat(res.values), []); - return accumulator; - }, {}); + // Transforms & supplements query data to render appropriate labels & styles + // Input: [{ queryAttributes1 }, { queryAttributes2 }] + // Output: [{ seriesAttributes1 }, { seriesAttributes2 }] + return this.graphData.queries.reduce((acc, query) => { + const { appearance } = query; + const lineType = + appearance && appearance.line && appearance.line.type + ? appearance.line.type + : lineTypes.default; + const lineWidth = + appearance && appearance.line && appearance.line.width + ? appearance.line.width + : undefined; + + const series = makeDataSeries(query.result, { + name: this.formatLegendLabel(query), + lineStyle: { + type: lineType, + width: lineWidth, + }, + areaStyle: { + opacity: + appearance && appearance.area && typeof appearance.area.opacity === 'number' + ? appearance.area.opacity + : undefined, + }, + }); + + return acc.concat(series); + }, []); }, chartOptions() { return { @@ -78,37 +107,40 @@ export default { axisPointer: { snap: true, }, - nameTextStyle: { - padding: [18, 0, 0, 0], - }, }, yAxis: { name: this.yAxisLabel, axisLabel: { formatter: value => value.toFixed(3), }, - nameTextStyle: { - padding: [0, 0, 36, 0], - }, - }, - legend: { - formatter: this.xAxisLabel, }, series: this.scatterSeries, + dataZoom: this.dataZoomConfig, }; }, + dataZoomConfig() { + const handleIcon = this.svgs['scroll-handle']; + + return handleIcon ? { handleIcon } : {}; + }, earliestDatapoint() { - return Object.values(this.chartData).reduce((acc, data) => { - const [[timestamp]] = data.sort(([a], [b]) => { - if (a < b) { - return -1; - } - return a > b ? 1 : 0; - }); + return this.chartData.reduce((acc, series) => { + const { data } = series; + const { length } = data; + if (!length) { + return acc; + } + + const [first] = data[0]; + const [last] = data[length - 1]; + const seriesEarliest = first < last ? first : last; - return timestamp < acc || acc === null ? timestamp : acc; + return seriesEarliest < acc || acc === null ? seriesEarliest : acc; }, null); }, + isMultiSeries() { + return this.tooltip.content.length > 1; + }, recentDeployments() { return this.deploymentData.reduce((acc, deployment) => { if (deployment.created_at >= this.earliestDatapoint) { @@ -129,15 +161,15 @@ export default { }, scatterSeries() { return { - type: 'scatter', + type: graphTypes.deploymentData, data: this.recentDeployments.map(deployment => [deployment.createdAt, 0]), - symbol: this.scatterSymbol, + symbol: this.svgs.rocket, symbolSize: 14, + itemStyle: { + color: this.primaryColor, + }, }; }, - xAxisLabel() { - return this.graphData.queries.map(query => query.label).join(', '); - }, yAxisLabel() { return `${this.graphData.y_label}`; }, @@ -151,35 +183,52 @@ export default { created() { debouncedResize = debounceByAnimationFrame(this.onResize); window.addEventListener('resize', debouncedResize); - this.getScatterSymbol(); + this.setSvg('rocket'); + this.setSvg('scroll-handle'); }, methods: { + formatLegendLabel(query) { + return `${query.label}`; + }, formatTooltipText(params) { - const [seriesData] = params.seriesData; - this.tooltip.isDeployment = seriesData.componentSubType === 'scatter'; this.tooltip.title = dateFormat(params.value, 'dd mmm yyyy, h:MMTT'); - if (this.tooltip.isDeployment) { - const [deploy] = this.recentDeployments.filter( - deployment => deployment.createdAt === seriesData.value[0], - ); - this.tooltip.sha = deploy.sha.substring(0, 8); - } else { - this.tooltip.content = `${this.yAxisLabel} ${seriesData.value[1].toFixed(3)}`; - } + this.tooltip.content = []; + params.seriesData.forEach(seriesData => { + if (seriesData.componentSubType === graphTypes.deploymentData) { + this.tooltip.isDeployment = true; + const [deploy] = this.recentDeployments.filter( + deployment => deployment.createdAt === seriesData.value[0], + ); + this.tooltip.sha = deploy.sha.substring(0, 8); + } else { + const { seriesName, color } = seriesData; + // seriesData.value contains the chart's [x, y] value pair + // seriesData.value[1] is threfore the chart y value + const value = seriesData.value[1].toFixed(3); + + this.tooltip.content.push({ + name: seriesName, + value, + color, + }); + } + }); }, - getScatterSymbol() { - getSvgIconPathContent('rocket') + setSvg(name) { + getSvgIconPathContent(name) .then(path => { if (path) { - this.scatterSymbol = `path://${path}`; + this.$set(this.svgs, name, `path://${path}`); } }) .catch(() => {}); }, + onChartUpdated(chart) { + [this.primaryColor] = chart.getOption().color; + }, onResize() { - const { width, height } = this.$refs.areaChart.$el.getBoundingClientRect(); + const { width } = this.$refs.areaChart.$el.getBoundingClientRect(); this.width = width; - this.height = height; }, }, }; @@ -197,23 +246,39 @@ export default { :data="chartData" :option="chartOptions" :format-tooltip-text="formatTooltipText" - :thresholds="alertData" + :thresholds="thresholds" :width="width" :height="height" + @updated="onChartUpdated" > - <template slot="tooltipTitle"> - <div v-if="tooltip.isDeployment"> + <template v-if="tooltip.isDeployment"> + <template slot="tooltipTitle"> {{ __('Deployed') }} - </div> - {{ tooltip.title }} - </template> - <template slot="tooltipContent"> - <div v-if="tooltip.isDeployment" class="d-flex align-items-center"> + </template> + <div slot="tooltipContent" class="d-flex align-items-center"> <icon name="commit" class="mr-2" /> {{ tooltip.sha }} </div> - <template v-else> - {{ tooltip.content }} + </template> + <template v-else> + <template slot="tooltipTitle"> + <div class="text-nowrap"> + {{ tooltip.title }} + </div> + </template> + <template slot="tooltipContent"> + <div + v-for="(content, key) in tooltip.content" + :key="key" + class="d-flex justify-content-between" + > + <gl-chart-series-label :color="isMultiSeries ? content.color : ''"> + {{ content.name }} + </gl-chart-series-label> + <div class="prepend-left-32"> + {{ content.value }} + </div> + </div> </template> </template> </gl-area-chart> diff --git a/app/assets/javascripts/monitoring/components/charts/single_stat.vue b/app/assets/javascripts/monitoring/components/charts/single_stat.vue new file mode 100644 index 00000000000..b03a6ca1806 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/single_stat.vue @@ -0,0 +1,37 @@ +<script> +import { GlSingleStat } from '@gitlab/ui/dist/charts'; + +export default { + components: { + GlSingleStat, + }, + inheritAttrs: false, + props: { + title: { + type: String, + required: true, + }, + value: { + type: Number, + required: true, + }, + unit: { + type: String, + required: true, + }, + }, + computed: { + valueWithUnit() { + return `${this.value}${this.unit}`; + }, + }, +}; +</script> +<template> + <div class="prometheus-graph col-12 col-lg-6"> + <div class="prometheus-graph-header"> + <h5 ref="graphTitle" class="prometheus-graph-title">{{ title }}</h5> + </div> + <gl-single-stat :value="valueWithUnit" :title="title" variant="success" /> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 895a57785bc..2314f7b80cf 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,12 +1,23 @@ <script> +import { + GlButton, + GlDropdown, + GlDropdownItem, + GlModal, + GlModalDirective, + GlLink, +} from '@gitlab/ui'; +import _ from 'underscore'; +import { mapActions, mapState } from 'vuex'; import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; -import Flash from '../../flash'; -import MonitoringService from '../services/monitoring_service'; +import '~/vue_shared/mixins/is_ee'; +import { getParameterValues } from '~/lib/utils/url_utility'; import MonitorAreaChart from './charts/area.vue'; import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; -import MonitoringStore from '../stores/monitoring_store'; +import { timeWindows, timeWindowsKeyNames } from '../constants'; +import { getTimeDiff } from '../utils'; const sidebarAnimationDuration = 150; let sidebarMutationObserver; @@ -17,8 +28,21 @@ export default { GraphGroup, EmptyState, Icon, + GlButton, + GlDropdown, + GlDropdownItem, + GlLink, + GlModal, + }, + directives: { + GlModalDirective, }, props: { + externalDashboardUrl: { + type: String, + required: false, + default: '', + }, hasMetrics: { type: Boolean, required: false, @@ -82,21 +106,58 @@ export default { type: String, required: true, }, + customMetricsAvailable: { + type: Boolean, + required: false, + default: false, + }, + customMetricsPath: { + type: String, + required: true, + }, + validateQueryPath: { + type: String, + required: true, + }, }, data() { return { - store: new MonitoringStore(), state: 'gettingStarted', - showEmptyState: true, elWidth: 0, + selectedTimeWindow: '', + selectedTimeWindowKey: '', + formIsValid: null, }; }, + computed: { + canAddMetrics() { + return this.customMetricsAvailable && this.customMetricsPath.length; + }, + ...mapState('monitoringDashboard', [ + 'groups', + 'emptyState', + 'showEmptyState', + 'environments', + 'deploymentData', + ]), + }, created() { - this.service = new MonitoringService({ + this.setEndpoints({ metricsEndpoint: this.metricsEndpoint, - deploymentEndpoint: this.deploymentEndpoint, environmentsEndpoint: this.environmentsEndpoint, + deploymentsEndpoint: this.deploymentEndpoint, }); + + this.timeWindows = timeWindows; + this.selectedTimeWindowKey = + _.escape(getParameterValues('time_window')[0]) || timeWindowsKeyNames.eightHours; + + // Set default time window if the selectedTimeWindowKey is bogus + if (!Object.keys(this.timeWindows).includes(this.selectedTimeWindowKey)) { + this.selectedTimeWindowKey = timeWindowsKeyNames.eightHours; + } + + this.selectedTimeWindow = this.timeWindows[this.selectedTimeWindowKey]; }, beforeDestroy() { if (sidebarMutationObserver) { @@ -105,9 +166,10 @@ export default { }, mounted() { if (!this.hasMetrics) { - this.state = 'gettingStarted'; + this.setGettingStartedEmptyState(); } else { - this.getGraphsData(); + this.fetchData(getTimeDiff(this.selectedTimeWindow)); + sidebarMutationObserver = new MutationObserver(this.onSidebarMutation); sidebarMutationObserver.observe(document.querySelector('.layout-page'), { attributes: true, @@ -117,71 +179,135 @@ export default { } }, methods: { - getGraphAlerts(graphId) { - return this.alertData ? this.alertData[graphId] || {} : {}; - }, - getGraphsData() { - this.state = 'loading'; - Promise.all([ - this.service.getGraphsData().then(data => this.store.storeMetrics(data)), - this.service - .getDeploymentData() - .then(data => this.store.storeDeploymentData(data)) - .catch(() => Flash(s__('Metrics|There was an error getting deployment information.'))), - this.service - .getEnvironmentsData() - .then(data => this.store.storeEnvironmentsData(data)) - .catch(() => Flash(s__('Metrics|There was an error getting environments information.'))), - ]) - .then(() => { - if (this.store.groups.length < 1) { - this.state = 'noData'; - return; - } - - this.showEmptyState = false; - }) - .catch(() => { - this.state = 'unableToConnect'; - }); + ...mapActions('monitoringDashboard', [ + 'fetchData', + 'setGettingStartedEmptyState', + 'setEndpoints', + ]), + getGraphAlerts(queries) { + if (!this.allAlerts) return {}; + const metricIdsForChart = queries.map(q => q.metricId); + return _.pick(this.allAlerts, alert => metricIdsForChart.includes(alert.metricId)); + }, + getGraphAlertValues(queries) { + return Object.values(this.getGraphAlerts(queries)); + }, + hideAddMetricModal() { + this.$refs.addMetricModal.hide(); }, onSidebarMutation() { setTimeout(() => { this.elWidth = this.$el.clientWidth; }, sidebarAnimationDuration); }, + setFormValidity(isValid) { + this.formIsValid = isValid; + }, + submitCustomMetricsForm() { + this.$refs.customMetricsForm.submit(); + }, + activeTimeWindow(key) { + return this.timeWindows[key] === this.selectedTimeWindow; + }, + setTimeWindowParameter(key) { + return `?time_window=${key}`; + }, + }, + addMetric: { + title: s__('Metrics|Add metric'), + modalId: 'add-metric', }, }; </script> <template> - <div v-if="!showEmptyState" class="prometheus-graphs prepend-top-default"> - <div class="environments d-flex align-items-center"> - {{ s__('Metrics|Environment') }} - <div class="dropdown prepend-left-10"> - <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button"> - <span>{{ currentEnvironmentName }}</span> - <icon name="chevron-down" /> - </button> - <div - v-if="store.environmentsData.length > 0" - class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up" - > - <ul> - <li v-for="environment in store.environmentsData" :key="environment.id"> - <a - :href="environment.metrics_path" - :class="{ 'is-active': environment.name == currentEnvironmentName }" - class="dropdown-item" - >{{ environment.name }}</a + <div v-if="!showEmptyState" class="prometheus-graphs"> + <div class="gl-p-3 border-bottom bg-gray-light d-flex justify-content-between"> + <div + v-if="environmentsEndpoint" + class="dropdowns d-flex align-items-center justify-content-between" + > + <div class="d-flex align-items-center"> + <strong>{{ s__('Metrics|Environment') }}</strong> + <gl-dropdown + class="prepend-left-10 js-environments-dropdown" + toggle-class="dropdown-menu-toggle" + :text="currentEnvironmentName" + :disabled="environments.length === 0" + > + <gl-dropdown-item + v-for="environment in environments" + :key="environment.id" + :active="environment.name === currentEnvironmentName" + active-class="is-active" + >{{ environment.name }}</gl-dropdown-item + > + </gl-dropdown> + </div> + <div class="d-flex align-items-center prepend-left-8"> + <strong>{{ s__('Metrics|Show last') }}</strong> + <gl-dropdown + class="prepend-left-10 js-time-window-dropdown" + toggle-class="dropdown-menu-toggle" + :text="selectedTimeWindow" + > + <gl-dropdown-item + v-for="(value, key) in timeWindows" + :key="key" + :active="activeTimeWindow(key)" + ><gl-link :href="setTimeWindowParameter(key)">{{ value }}</gl-link></gl-dropdown-item + > + </gl-dropdown> + </div> + </div> + <div class="d-flex"> + <div v-if="isEE && canAddMetrics"> + <gl-button + v-gl-modal-directive="$options.addMetric.modalId" + class="js-add-metric-button text-success border-success" + > + {{ $options.addMetric.title }} + </gl-button> + <gl-modal + ref="addMetricModal" + :modal-id="$options.addMetric.modalId" + :title="$options.addMetric.title" + > + <form ref="customMetricsForm" :action="customMetricsPath" method="post"> + <custom-metrics-form-fields + :validate-query-path="validateQueryPath" + form-operation="post" + @formValidation="setFormValidity" + /> + </form> + <div slot="modal-footer"> + <gl-button @click="hideAddMetricModal"> + {{ __('Cancel') }} + </gl-button> + <gl-button + :disabled="!formIsValid" + variant="success" + @click="submitCustomMetricsForm" > - </li> - </ul> + {{ __('Save changes') }} + </gl-button> + </div> + </gl-modal> </div> + <gl-button + v-if="externalDashboardUrl.length" + class="js-external-dashboard-link prepend-left-8" + variant="primary" + :href="externalDashboardUrl" + target="_blank" + > + {{ __('View full dashboard') }} + <icon name="external-link" /> + </gl-button> </div> </div> <graph-group - v-for="(groupData, index) in store.groups" + v-for="(groupData, index) in groups" :key="index" :name="groupData.group" :show-panels="showPanels" @@ -190,16 +316,24 @@ export default { v-for="(graphData, graphIndex) in groupData.metrics" :key="graphIndex" :graph-data="graphData" - :deployment-data="store.deploymentData" - :alert-data="getGraphAlerts(graphData.id)" + :deployment-data="deploymentData" + :thresholds="getGraphAlertValues(graphData.queries)" :container-width="elWidth" group-id="monitor-area-chart" - /> + > + <alert-widget + v-if="isEE && prometheusAlertsAvailable && alertsEndpoint && graphData" + :alerts-endpoint="alertsEndpoint" + :relevant-queries="graphData.queries" + :alerts-to-manage="getGraphAlerts(graphData.queries)" + @setAlerts="setAlerts" + /> + </monitor-area-chart> </graph-group> </div> <empty-state v-else - :selected-state="state" + :selected-state="emptyState" :documentation-path="documentationPath" :settings-path="settingsPath" :clusters-path="clustersPath" diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js new file mode 100644 index 00000000000..26f1bf3f68d --- /dev/null +++ b/app/assets/javascripts/monitoring/constants.js @@ -0,0 +1,29 @@ +import { __ } from '~/locale'; + +export const chartHeight = 300; + +export const graphTypes = { + deploymentData: 'scatter', +}; + +export const lineTypes = { + default: 'solid', +}; + +export const timeWindows = { + thirtyMinutes: __('30 minutes'), + threeHours: __('3 hours'), + eightHours: __('8 hours'), + oneDay: __('1 day'), + threeDays: __('3 days'), + oneWeek: __('1 week'), +}; + +export const timeWindowsKeyNames = { + thirtyMinutes: 'thirtyMinutes', + threeHours: 'threeHours', + eightHours: 'eightHours', + oneDay: 'oneDay', + threeDays: 'threeDays', + oneWeek: 'oneWeek', +}; diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index 9d78b5ea110..62c0f44c1e6 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -1,19 +1,22 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; -import Dashboard from './components/dashboard.vue'; +import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue'; +import store from './stores'; -export default () => { +export default (props = {}) => { const el = document.getElementById('prometheus-graphs'); if (el && el.dataset) { // eslint-disable-next-line no-new new Vue({ el, + store, render(createElement) { return createElement(Dashboard, { props: { ...el.dataset, hasMetrics: parseBoolean(el.dataset.hasMetrics), + ...props, }, }); }, diff --git a/app/assets/javascripts/monitoring/services/monitoring_service.js b/app/assets/javascripts/monitoring/services/monitoring_service.js deleted file mode 100644 index 24b4acaf6da..00000000000 --- a/app/assets/javascripts/monitoring/services/monitoring_service.js +++ /dev/null @@ -1,75 +0,0 @@ -import axios from '../../lib/utils/axios_utils'; -import statusCodes from '../../lib/utils/http_status'; -import { backOff } from '../../lib/utils/common_utils'; -import { s__ } from '../../locale'; - -const MAX_REQUESTS = 3; - -function backOffRequest(makeRequestCallback) { - let requestCounter = 0; - return backOff((next, stop) => { - makeRequestCallback() - .then(resp => { - if (resp.status === statusCodes.NO_CONTENT) { - requestCounter += 1; - if (requestCounter < MAX_REQUESTS) { - next(); - } else { - stop(new Error('Failed to connect to the prometheus server')); - } - } else { - stop(resp); - } - }) - .catch(stop); - }); -} - -export default class MonitoringService { - constructor({ metricsEndpoint, deploymentEndpoint, environmentsEndpoint }) { - this.metricsEndpoint = metricsEndpoint; - this.deploymentEndpoint = deploymentEndpoint; - this.environmentsEndpoint = environmentsEndpoint; - } - - getGraphsData() { - return backOffRequest(() => axios.get(this.metricsEndpoint)) - .then(resp => resp.data) - .then(response => { - if (!response || !response.data) { - throw new Error(s__('Metrics|Unexpected metrics data response from prometheus endpoint')); - } - return response.data; - }); - } - - getDeploymentData() { - if (!this.deploymentEndpoint) { - return Promise.resolve([]); - } - return backOffRequest(() => axios.get(this.deploymentEndpoint)) - .then(resp => resp.data) - .then(response => { - if (!response || !response.deployments) { - throw new Error( - s__('Metrics|Unexpected deployment data response from prometheus endpoint'), - ); - } - return response.deployments; - }); - } - - getEnvironmentsData() { - return axios - .get(this.environmentsEndpoint) - .then(resp => resp.data) - .then(response => { - if (!response || !response.environments) { - throw new Error( - s__('Metrics|There was an error fetching the environments data, please try again'), - ); - } - return response.environments; - }); - } -} diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js new file mode 100644 index 00000000000..63c23e8449d --- /dev/null +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -0,0 +1,117 @@ +import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; +import statusCodes from '../../lib/utils/http_status'; +import { backOff } from '../../lib/utils/common_utils'; +import { s__, __ } from '../../locale'; + +const MAX_REQUESTS = 3; + +function backOffRequest(makeRequestCallback) { + let requestCounter = 0; + return backOff((next, stop) => { + makeRequestCallback() + .then(resp => { + if (resp.status === statusCodes.NO_CONTENT) { + requestCounter += 1; + if (requestCounter < MAX_REQUESTS) { + next(); + } else { + stop(new Error(__('Failed to connect to the prometheus server'))); + } + } else { + stop(resp); + } + }) + .catch(stop); + }); +} + +export const setGettingStartedEmptyState = ({ commit }) => { + commit(types.SET_GETTING_STARTED_EMPTY_STATE); +}; + +export const setEndpoints = ({ commit }, endpoints) => { + commit(types.SET_ENDPOINTS, endpoints); +}; + +export const requestMetricsData = ({ commit }) => commit(types.REQUEST_METRICS_DATA); +export const receiveMetricsDataSuccess = ({ commit }, data) => + commit(types.RECEIVE_METRICS_DATA_SUCCESS, data); +export const receiveMetricsDataFailure = ({ commit }, error) => + commit(types.RECEIVE_METRICS_DATA_FAILURE, error); +export const receiveDeploymentsDataSuccess = ({ commit }, data) => + commit(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, data); +export const receiveDeploymentsDataFailure = ({ commit }) => + commit(types.RECEIVE_DEPLOYMENTS_DATA_FAILURE); +export const receiveEnvironmentsDataSuccess = ({ commit }, data) => + commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data); +export const receiveEnvironmentsDataFailure = ({ commit }) => + commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE); + +export const fetchData = ({ dispatch }, params) => { + dispatch('fetchMetricsData', params); + dispatch('fetchDeploymentsData'); + dispatch('fetchEnvironmentsData'); +}; + +export const fetchMetricsData = ({ state, dispatch }, params) => { + dispatch('requestMetricsData'); + + return backOffRequest(() => axios.get(state.metricsEndpoint, { params })) + .then(resp => resp.data) + .then(response => { + if (!response || !response.data || !response.success) { + dispatch('receiveMetricsDataFailure', null); + createFlash(s__('Metrics|Unexpected metrics data response from prometheus endpoint')); + } + dispatch('receiveMetricsDataSuccess', response.data); + }) + .catch(error => { + dispatch('receiveMetricsDataFailure', error); + createFlash(s__('Metrics|There was an error while retrieving metrics')); + }); +}; + +export const fetchDeploymentsData = ({ state, dispatch }) => { + if (!state.deploymentEndpoint) { + return Promise.resolve([]); + } + return backOffRequest(() => axios.get(state.deploymentEndpoint)) + .then(resp => resp.data) + .then(response => { + if (!response || !response.deployments) { + createFlash(s__('Metrics|Unexpected deployment data response from prometheus endpoint')); + } + + dispatch('receiveDeploymentsDataSuccess', response.deployments); + }) + .catch(() => { + dispatch('receiveDeploymentsDataFailure'); + createFlash(s__('Metrics|There was an error getting deployment information.')); + }); +}; + +export const fetchEnvironmentsData = ({ state, dispatch }) => { + if (!state.environmentsEndpoint) { + return Promise.resolve([]); + } + return axios + .get(state.environmentsEndpoint) + .then(resp => resp.data) + .then(response => { + if (!response || !response.environments) { + createFlash( + s__('Metrics|There was an error fetching the environments data, please try again'), + ); + } + dispatch('receiveEnvironmentsDataSuccess', response.environments); + }) + .catch(() => { + dispatch('receiveEnvironmentsDataFailure'); + createFlash(s__('Metrics|There was an error getting environments information.')); + }); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/index.js b/app/assets/javascripts/monitoring/stores/index.js new file mode 100644 index 00000000000..d58398c54ae --- /dev/null +++ b/app/assets/javascripts/monitoring/stores/index.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + modules: { + monitoringDashboard: { + namespaced: true, + actions, + mutations, + state, + }, + }, + }); + +export default createStore(); diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js deleted file mode 100644 index 70635059bd9..00000000000 --- a/app/assets/javascripts/monitoring/stores/monitoring_store.js +++ /dev/null @@ -1,75 +0,0 @@ -import _ from 'underscore'; - -function sortMetrics(metrics) { - return _.chain(metrics) - .sortBy('title') - .sortBy('weight') - .value(); -} - -function checkQueryEmptyData(query) { - return { - ...query, - result: query.result.filter(timeSeries => { - const newTimeSeries = timeSeries; - const hasValue = series => - !Number.isNaN(series[1]) && (series[1] !== null || series[1] !== undefined); - const hasNonNullValue = timeSeries.values.find(hasValue); - - newTimeSeries.values = hasNonNullValue ? newTimeSeries.values : []; - - return newTimeSeries.values.length > 0; - }), - }; -} - -function removeTimeSeriesNoData(queries) { - return queries.reduce((series, query) => series.concat(checkQueryEmptyData(query)), []); -} - -function normalizeMetrics(metrics) { - return metrics.map(metric => { - const queries = metric.queries.map(query => ({ - ...query, - result: query.result.map(result => ({ - ...result, - values: result.values.map(([timestamp, value]) => [ - new Date(timestamp * 1000).toISOString(), - Number(value), - ]), - })), - })); - - return { - ...metric, - queries: removeTimeSeriesNoData(queries), - }; - }); -} - -export default class MonitoringStore { - constructor() { - this.groups = []; - this.deploymentData = []; - this.environmentsData = []; - } - - storeMetrics(groups = []) { - this.groups = groups.map(group => ({ - ...group, - metrics: normalizeMetrics(sortMetrics(group.metrics)), - })); - } - - storeDeploymentData(deploymentData = []) { - this.deploymentData = deploymentData; - } - - storeEnvironmentsData(environmentsData = []) { - this.environmentsData = environmentsData.filter(environment => !!environment.last_deployment); - } - - getMetricsCount() { - return this.groups.reduce((count, group) => count + group.metrics.length, 0); - } -} diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js new file mode 100644 index 00000000000..3fd9e07fa8b --- /dev/null +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -0,0 +1,12 @@ +export const REQUEST_METRICS_DATA = 'REQUEST_METRICS_DATA'; +export const RECEIVE_METRICS_DATA_SUCCESS = 'RECEIVE_METRICS_DATA_SUCCESS'; +export const RECEIVE_METRICS_DATA_FAILURE = 'RECEIVE_METRICS_DATA_FAILURE'; +export const REQUEST_DEPLOYMENTS_DATA = 'REQUEST_DEPLOYMENTS_DATA'; +export const RECEIVE_DEPLOYMENTS_DATA_SUCCESS = 'RECEIVE_DEPLOYMENTS_DATA_SUCCESS'; +export const RECEIVE_DEPLOYMENTS_DATA_FAILURE = 'RECEIVE_DEPLOYMENTS_DATA_FAILURE'; +export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA'; +export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS'; +export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE'; +export const SET_TIME_WINDOW = 'SET_TIME_WINDOW'; +export const SET_ENDPOINTS = 'SET_ENDPOINTS'; +export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js new file mode 100644 index 00000000000..c1779333d75 --- /dev/null +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -0,0 +1,45 @@ +import * as types from './mutation_types'; +import { normalizeMetrics, sortMetrics } from './utils'; + +export default { + [types.REQUEST_METRICS_DATA](state) { + state.emptyState = 'loading'; + state.showEmptyState = true; + }, + [types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) { + state.groups = groupData.map(group => ({ + ...group, + metrics: normalizeMetrics(sortMetrics(group.metrics)), + })); + + if (!state.groups.length) { + state.emptyState = 'noData'; + } else { + state.showEmptyState = false; + } + }, + [types.RECEIVE_METRICS_DATA_FAILURE](state, error) { + state.emptyState = error ? 'unableToConnect' : 'noData'; + state.showEmptyState = true; + }, + [types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](state, deployments) { + state.deploymentData = deployments; + }, + [types.RECEIVE_DEPLOYMENTS_DATA_FAILURE](state) { + state.deploymentData = []; + }, + [types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, environments) { + state.environments = environments; + }, + [types.RECEIVE_ENVIRONMENTS_DATA_FAILURE](state) { + state.environments = []; + }, + [types.SET_ENDPOINTS](state, endpoints) { + state.metricsEndpoint = endpoints.metricsEndpoint; + state.environmentsEndpoint = endpoints.environmentsEndpoint; + state.deploymentsEndpoint = endpoints.deploymentsEndpoint; + }, + [types.SET_GETTING_STARTED_EMPTY_STATE](state) { + state.emptyState = 'gettingStarted'; + }, +}; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js new file mode 100644 index 00000000000..5103122612a --- /dev/null +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -0,0 +1,12 @@ +export default () => ({ + hasMetrics: false, + showPanels: true, + metricsEndpoint: null, + environmentsEndpoint: null, + deploymentsEndpoint: null, + emptyState: 'gettingStarted', + showEmptyState: true, + groups: [], + deploymentData: [], + environments: [], +}); diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js new file mode 100644 index 00000000000..9216554ecbf --- /dev/null +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -0,0 +1,83 @@ +import _ from 'underscore'; + +function checkQueryEmptyData(query) { + return { + ...query, + result: query.result.filter(timeSeries => { + const newTimeSeries = timeSeries; + const hasValue = series => + !Number.isNaN(series[1]) && (series[1] !== null || series[1] !== undefined); + const hasNonNullValue = timeSeries.values.find(hasValue); + + newTimeSeries.values = hasNonNullValue ? newTimeSeries.values : []; + + return newTimeSeries.values.length > 0; + }), + }; +} + +function removeTimeSeriesNoData(queries) { + return queries.reduce((series, query) => series.concat(checkQueryEmptyData(query)), []); +} + +// Metrics and queries are currently stored 1:1, so `queries` is an array of length one. +// We want to group queries onto a single chart by title & y-axis label. +// This function will no longer be required when metrics:queries are 1:many, +// though there is no consequence if the function stays in use. +// @param metrics [Array<Object>] +// Ex) [ +// { id: 1, title: 'title', y_label: 'MB', queries: [{ ...query1Attrs }] }, +// { id: 2, title: 'title', y_label: 'MB', queries: [{ ...query2Attrs }] }, +// { id: 3, title: 'new title', y_label: 'MB', queries: [{ ...query3Attrs }] } +// ] +// @return [Array<Object>] +// Ex) [ +// { title: 'title', y_label: 'MB', queries: [{ metricId: 1, ...query1Attrs }, +// { metricId: 2, ...query2Attrs }] }, +// { title: 'new title', y_label: 'MB', queries: [{ metricId: 3, ...query3Attrs }]} +// ] +function groupQueriesByChartInfo(metrics) { + const metricsByChart = metrics.reduce((accumulator, metric) => { + const { queries, ...chart } = metric; + const metricId = chart.id ? chart.id.toString() : null; + + const chartKey = `${chart.title}|${chart.y_label}`; + accumulator[chartKey] = accumulator[chartKey] || { ...chart, queries: [] }; + + queries.forEach(queryAttrs => accumulator[chartKey].queries.push({ metricId, ...queryAttrs })); + + return accumulator; + }, {}); + + return Object.values(metricsByChart); +} + +export const sortMetrics = metrics => + _.chain(metrics) + .sortBy('title') + .sortBy('weight') + .value(); + +export const normalizeMetrics = metrics => { + const groupedMetrics = groupQueriesByChartInfo(metrics); + + return groupedMetrics.map(metric => { + const queries = metric.queries.map(query => ({ + ...query, + // custom metrics do not require a label, so we should ensure this attribute is defined + label: query.label || metric.y_label, + result: query.result.map(result => ({ + ...result, + values: result.values.map(([timestamp, value]) => [ + new Date(timestamp * 1000).toISOString(), + Number(value), + ]), + })), + })); + + return { + ...metric, + queries: removeTimeSeriesNoData(queries), + }; + }); +}; diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js new file mode 100644 index 00000000000..ef309c8a398 --- /dev/null +++ b/app/assets/javascripts/monitoring/utils.js @@ -0,0 +1,33 @@ +import { timeWindows } from './constants'; + +/** + * method that converts a predetermined time window to minutes + * defaults to 8 hours as the default option + * @param {String} timeWindow - The time window to convert to minutes + * @returns {number} The time window in minutes + */ +const getTimeDifferenceSeconds = timeWindow => { + switch (timeWindow) { + case timeWindows.thirtyMinutes: + return 60 * 30; + case timeWindows.threeHours: + return 60 * 60 * 3; + case timeWindows.oneDay: + return 60 * 60 * 24 * 1; + case timeWindows.threeDays: + return 60 * 60 * 24 * 3; + case timeWindows.oneWeek: + return 60 * 60 * 24 * 7 * 1; + default: + return 60 * 60 * 8; + } +}; + +export const getTimeDiff = selectedTimeWindow => { + const end = Date.now() / 1000; // convert milliseconds to seconds + const start = end - getTimeDifferenceSeconds(selectedTimeWindow); + + return { start, end }; +}; + +export default {}; diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index 9e99aa4f724..8eccba07c38 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -1,11 +1,9 @@ -import $ from 'jquery'; import Vue from 'vue'; -import { mapActions, mapState, mapGetters } from 'vuex'; +import store from 'ee_else_ce/mr_notes/stores'; +import initNotesApp from './init_notes'; import initDiffsApp from '../diffs'; -import notesApp from '../notes/components/notes_app.vue'; import discussionCounter from '../notes/components/discussion_counter.vue'; import initDiscussionFilters from '../notes/discussion_filters'; -import store from './stores'; import MergeRequest from '../merge_request'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; @@ -18,68 +16,7 @@ export default function initMrNotes() { action: mrShowNode.dataset.mrAction, }); - // eslint-disable-next-line no-new - new Vue({ - el: '#js-vue-mr-discussions', - name: 'MergeRequestDiscussions', - components: { - notesApp, - }, - store, - data() { - const notesDataset = document.getElementById('js-vue-mr-discussions').dataset; - const noteableData = JSON.parse(notesDataset.noteableData); - noteableData.noteableType = notesDataset.noteableType; - noteableData.targetType = notesDataset.targetType; - - return { - noteableData, - currentUserData: JSON.parse(notesDataset.currentUserData), - notesData: JSON.parse(notesDataset.notesData), - helpPagePath: notesDataset.helpPagePath, - }; - }, - computed: { - ...mapGetters(['discussionTabCounter']), - ...mapState({ - activeTab: state => state.page.activeTab, - }), - }, - watch: { - discussionTabCounter() { - this.updateDiscussionTabCounter(); - }, - }, - created() { - this.setActiveTab(window.mrTabs.getCurrentAction()); - }, - mounted() { - this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'); - $(document).on('visibilitychange', this.updateDiscussionTabCounter); - window.mrTabs.eventHub.$on('MergeRequestTabChange', this.setActiveTab); - }, - beforeDestroy() { - $(document).off('visibilitychange', this.updateDiscussionTabCounter); - window.mrTabs.eventHub.$off('MergeRequestTabChange', this.setActiveTab); - }, - methods: { - ...mapActions(['setActiveTab']), - updateDiscussionTabCounter() { - this.notesCountBadge.text(this.discussionTabCounter); - }, - }, - render(createElement) { - return createElement('notes-app', { - props: { - noteableData: this.noteableData, - notesData: this.notesData, - userData: this.currentUserData, - shouldShow: this.activeTab === 'show', - helpPagePath: this.helpPagePath, - }, - }); - }, - }); + initNotesApp(); // eslint-disable-next-line no-new new Vue({ diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js new file mode 100644 index 00000000000..842a209a545 --- /dev/null +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -0,0 +1,70 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import store from 'ee_else_ce/mr_notes/stores'; +import notesApp from '../notes/components/notes_app.vue'; + +export default () => { + // eslint-disable-next-line no-new + new Vue({ + el: '#js-vue-mr-discussions', + name: 'MergeRequestDiscussions', + components: { + notesApp, + }, + store, + data() { + const notesDataset = document.getElementById('js-vue-mr-discussions').dataset; + const noteableData = JSON.parse(notesDataset.noteableData); + noteableData.noteableType = notesDataset.noteableType; + noteableData.targetType = notesDataset.targetType; + + return { + noteableData, + currentUserData: JSON.parse(notesDataset.currentUserData), + notesData: JSON.parse(notesDataset.notesData), + helpPagePath: notesDataset.helpPagePath, + }; + }, + computed: { + ...mapGetters(['discussionTabCounter']), + ...mapState({ + activeTab: state => state.page.activeTab, + }), + }, + watch: { + discussionTabCounter() { + this.updateDiscussionTabCounter(); + }, + }, + created() { + this.setActiveTab(window.mrTabs.getCurrentAction()); + }, + mounted() { + this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'); + $(document).on('visibilitychange', this.updateDiscussionTabCounter); + window.mrTabs.eventHub.$on('MergeRequestTabChange', this.setActiveTab); + }, + beforeDestroy() { + $(document).off('visibilitychange', this.updateDiscussionTabCounter); + window.mrTabs.eventHub.$off('MergeRequestTabChange', this.setActiveTab); + }, + methods: { + ...mapActions(['setActiveTab']), + updateDiscussionTabCounter() { + this.notesCountBadge.text(this.discussionTabCounter); + }, + }, + render(createElement) { + return createElement('notes-app', { + props: { + noteableData: this.noteableData, + notesData: this.notesData, + userData: this.currentUserData, + shouldShow: this.activeTab === 'show', + helpPagePath: this.helpPagePath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/mr_notes/stores/getters.js b/app/assets/javascripts/mr_notes/stores/getters.js index b10e9f9f9f1..e48cfcd9564 100644 --- a/app/assets/javascripts/mr_notes/stores/getters.js +++ b/app/assets/javascripts/mr_notes/stores/getters.js @@ -1,5 +1,5 @@ export default { isLoggedIn(state, getters) { - return !!getters.getUserData.id; + return Boolean(getters.getUserData.id); }, }; diff --git a/app/assets/javascripts/mr_popover/components/mr_popover.vue b/app/assets/javascripts/mr_popover/components/mr_popover.vue new file mode 100644 index 00000000000..8e2d8fa816a --- /dev/null +++ b/app/assets/javascripts/mr_popover/components/mr_popover.vue @@ -0,0 +1,110 @@ +<script> +import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; +import Icon from '../../vue_shared/components/icon.vue'; +import CiIcon from '../../vue_shared/components/ci_icon.vue'; +import timeagoMixin from '../../vue_shared/mixins/timeago'; +import query from '../queries/merge_request.graphql'; +import { mrStates, humanMRStates } from '../constants'; + +export default { + name: 'MRPopover', + components: { + GlPopover, + GlSkeletonLoading, + Icon, + CiIcon, + }, + mixins: [timeagoMixin], + props: { + target: { + type: HTMLAnchorElement, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + mergeRequestIID: { + type: String, + required: true, + }, + mergeRequestTitle: { + type: String, + required: true, + }, + }, + data() { + return { + mergeRequest: {}, + }; + }, + computed: { + detailedStatus() { + return this.mergeRequest.headPipeline && this.mergeRequest.headPipeline.detailedStatus; + }, + formattedTime() { + return this.timeFormated(this.mergeRequest.createdAt); + }, + statusBoxClass() { + switch (this.mergeRequest.state) { + case mrStates.merged: + return 'status-box-mr-merged'; + case mrStates.closed: + return 'status-box-closed'; + default: + return 'status-box-open'; + } + }, + stateHumanName() { + switch (this.mergeRequest.state) { + case mrStates.merged: + return humanMRStates.merged; + case mrStates.closed: + return humanMRStates.closed; + default: + return humanMRStates.open; + } + }, + showDetails() { + return Object.keys(this.mergeRequest).length > 0; + }, + }, + apollo: { + mergeRequest: { + query, + update: data => data.project.mergeRequest, + variables() { + const { projectPath, mergeRequestIID } = this; + + return { + projectPath, + mergeRequestIID, + }; + }, + }, + }, +}; +</script> + +<template> + <gl-popover :target="target" boundary="viewport" placement="top" show> + <div class="mr-popover"> + <div v-if="$apollo.loading"> + <gl-skeleton-loading :lines="1" class="animation-container-small mt-1" /> + </div> + <div v-else-if="showDetails" class="d-flex align-items-center justify-content-between"> + <div class="d-inline-flex align-items-center"> + <div :class="`issuable-status-box status-box ${statusBoxClass}`"> + {{ stateHumanName }} + </div> + <span class="text-secondary">Opened <time v-text="formattedTime"></time></span> + </div> + <ci-icon v-if="detailedStatus" :status="detailedStatus" /> + </div> + <h5 class="my-2">{{ mergeRequestTitle }}</h5> + <div class="text-secondary"> + {{ `${projectPath}!${mergeRequestIID}` }} + </div> + </div> + </gl-popover> +</template> diff --git a/app/assets/javascripts/mr_popover/constants.js b/app/assets/javascripts/mr_popover/constants.js new file mode 100644 index 00000000000..c13c417cc18 --- /dev/null +++ b/app/assets/javascripts/mr_popover/constants.js @@ -0,0 +1,12 @@ +import { __ } from '~/locale'; + +export const mrStates = { + merged: 'merged', + closed: 'closed', +}; + +export const humanMRStates = { + merged: __('Merged'), + closed: __('Closed'), + open: __('Open'), +}; diff --git a/app/assets/javascripts/mr_popover/index.js b/app/assets/javascripts/mr_popover/index.js new file mode 100644 index 00000000000..18c0e201300 --- /dev/null +++ b/app/assets/javascripts/mr_popover/index.js @@ -0,0 +1,67 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import MRPopover from './components/mr_popover.vue'; +import createDefaultClient from '~/lib/graphql'; + +let renderedPopover; +let renderFn; + +const handleUserPopoverMouseOut = ({ target }) => { + target.removeEventListener('mouseleave', handleUserPopoverMouseOut); + + if (renderFn) { + clearTimeout(renderFn); + } + if (renderedPopover) { + renderedPopover.$destroy(); + renderedPopover = null; + } +}; + +/** + * Adds a MergeRequestPopover component to the body, hands over as much data as the target element has in data attributes. + * loads based on data-project-path and data-iid more data about an MR from the API and sets it on the popover + */ +const handleMRPopoverMount = ({ apolloProvider, projectPath, mrTitle, iid }) => ({ target }) => { + // Add listener to actually remove it again + target.addEventListener('mouseleave', handleUserPopoverMouseOut); + + renderFn = setTimeout(() => { + const MRPopoverComponent = Vue.extend(MRPopover); + renderedPopover = new MRPopoverComponent({ + propsData: { + target, + projectPath, + mergeRequestIID: iid, + mergeRequestTitle: mrTitle, + }, + apolloProvider, + }); + + renderedPopover.$mount(); + }, 200); // 200ms delay so not every mouseover triggers Popover + API Call +}; + +export default elements => { + const mrLinks = elements || [...document.querySelectorAll('.gfm-merge_request')]; + if (mrLinks.length > 0) { + Vue.use(VueApollo); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + const listenerAddedAttr = 'data-mr-listener-added'; + + mrLinks.forEach(el => { + const { projectPath, mrTitle, iid } = el.dataset; + + if (!el.getAttribute(listenerAddedAttr) && projectPath && mrTitle && iid) { + el.addEventListener( + 'mouseenter', + handleMRPopoverMount({ apolloProvider, projectPath, mrTitle, iid }), + ); + el.setAttribute(listenerAddedAttr, true); + } + }); + } +}; diff --git a/app/assets/javascripts/mr_popover/queries/merge_request.graphql b/app/assets/javascripts/mr_popover/queries/merge_request.graphql new file mode 100644 index 00000000000..0bb9bc03bc7 --- /dev/null +++ b/app/assets/javascripts/mr_popover/queries/merge_request.graphql @@ -0,0 +1,14 @@ +query mergeRequest($projectPath: ID!, $mergeRequestIID: ID!) { + project(fullPath: $projectPath) { + mergeRequest(iid: $mergeRequestIID) { + createdAt + state + headPipeline { + detailedStatus { + icon + group + } + } + } + } +} diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js index ee1a5274ff7..03d349ac714 100644 --- a/app/assets/javascripts/namespace_select.js +++ b/app/assets/javascripts/namespace_select.js @@ -4,6 +4,7 @@ import $ from 'jquery'; import Api from './api'; import { mergeUrlParams } from './lib/utils/url_utility'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { __ } from './locale'; export default class NamespaceSelect { constructor(opts) { @@ -29,7 +30,7 @@ export default class NamespaceSelect { return Api.namespaces(term, function(namespaces) { if (isFilter) { const anyNamespace = { - text: 'Any namespace', + text: __('Any namespace'), id: null, }; namespaces.unshift(anyNamespace); diff --git a/app/assets/javascripts/namespaces/leave_by_url.js b/app/assets/javascripts/namespaces/leave_by_url.js new file mode 100644 index 00000000000..b817d38960c --- /dev/null +++ b/app/assets/javascripts/namespaces/leave_by_url.js @@ -0,0 +1,22 @@ +import Flash from '~/flash'; +import { __, sprintf } from '~/locale'; +import { getParameterByName } from '~/lib/utils/common_utils'; + +const PARAMETER_NAME = 'leave'; +const LEAVE_LINK_SELECTOR = '.js-leave-link'; + +export default function leaveByUrl(namespaceType) { + if (!namespaceType) throw new Error('namespaceType not provided'); + + const param = getParameterByName(PARAMETER_NAME); + if (!param) return; + + const leaveLink = document.querySelector(LEAVE_LINK_SELECTOR); + if (leaveLink) { + leaveLink.click(); + } else { + Flash( + sprintf(__('You do not have permission to leave this %{namespaceType}.'), { namespaceType }), + ); + } +} diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue index c5ae7e7ee10..b59ddd0d57a 100644 --- a/app/assets/javascripts/notebook/cells/output/index.vue +++ b/app/assets/javascripts/notebook/cells/output/index.vue @@ -20,12 +20,20 @@ export default { required: true, }, }, - data() { - return { - outputType: '', - }; - }, methods: { + outputType(output) { + if (output.text) { + return 'text/plain'; + } else if (output.data['image/png']) { + return 'image/png'; + } else if (output.data['text/html']) { + return 'text/html'; + } else if (output.data['image/svg+xml']) { + return 'image/svg+xml'; + } + + return 'text/plain'; + }, dataForType(output, type) { let data = output.data[type]; @@ -39,20 +47,13 @@ export default { if (output.text) { return CodeOutput; } else if (output.data['image/png']) { - this.outputType = 'image/png'; - return ImageOutput; } else if (output.data['text/html']) { - this.outputType = 'text/html'; - return HtmlOutput; } else if (output.data['image/svg+xml']) { - this.outputType = 'image/svg+xml'; - return HtmlOutput; } - this.outputType = 'text/plain'; return CodeOutput; }, rawCode(output) { @@ -60,7 +61,7 @@ export default { return output.text.join(''); } - return this.dataForType(output, this.outputType); + return this.dataForType(output, this.outputType(output)); }, }, }; @@ -73,7 +74,7 @@ export default { v-for="(output, index) in outputs" :key="index" type="output" - :output-type="outputType" + :output-type="outputType(output)" :count="count" :index="index" :raw-code="rawCode(output)" diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index c9c01354333..a7156bd2406 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -7,12 +7,16 @@ no-unused-vars, no-shadow, no-useless-escape, class-methods-use-this */ /* global ResolveService */ /* global mrRefreshWidgetUrl */ +/* +old_notes_spec.js is the spec for the legacy, jQuery notes application. It has nothing to do with the new, fancy Vue notes app. + */ + import $ from 'jquery'; import _ from 'underscore'; import Cookies from 'js-cookie'; import Autosize from 'autosize'; -import 'vendor/jquery.caret'; // required by jquery.atwho -import 'vendor/jquery.atwho'; +import 'jquery.caret'; // required by at.js +import 'at.js'; import AjaxCache from '~/lib/utils/ajax_cache'; import Vue from 'vue'; import syntaxHighlight from '~/syntax_highlight'; @@ -35,6 +39,7 @@ import { } from './lib/utils/common_utils'; import imageDiffHelper from './image_diff/helpers/index'; import { localTimeAgo } from './lib/utils/datetime_utility'; +import { sprintf, s__, __ } from './locale'; window.autosize = Autosize; @@ -253,7 +258,7 @@ export default class Notes { discussionNoteForm = $textarea.closest('.js-discussion-note-form'); if (discussionNoteForm.length) { if ($textarea.val() !== '') { - if (!window.confirm('Are you sure you want to cancel creating this comment?')) { + if (!window.confirm(__('Are you sure you want to cancel creating this comment?'))) { return; } } @@ -265,7 +270,7 @@ export default class Notes { originalText = $textarea.closest('form').data('originalNote'); newText = $textarea.val(); if (originalText !== newText) { - if (!window.confirm('Are you sure you want to cancel editing this comment?')) { + if (!window.confirm(__('Are you sure you want to cancel editing this comment?'))) { return; } } @@ -506,7 +511,7 @@ export default class Notes { var contentContainerClass = '.' + $notes - .closest('.notes_content') + .closest('.notes-content') .attr('class') .split(' ') .join('.'); @@ -636,7 +641,7 @@ export default class Notes { this.glForm = new GLForm(form, enableGFM); textarea = form.find('.js-note-text'); key = [ - 'Note', + s__('NoteForm|Note'), form.find('#note_noteable_type').val(), form.find('#note_noteable_id').val(), form.find('#note_commit_id').val(), @@ -670,7 +675,9 @@ export default class Notes { formParentTimeline = $form.closest('.discussion-notes').find('.notes'); } return this.addFlash( - 'Your comment could not be submitted! Please check your network connection and try again.', + __( + 'Your comment could not be submitted! Please check your network connection and try again.', + ), 'alert', formParentTimeline.get(0), ); @@ -679,7 +686,7 @@ export default class Notes { updateNoteError($parentTimeline) { // eslint-disable-next-line no-new new Flash( - 'Your comment could not be updated! Please check your network connection and try again.', + __('Your comment could not be updated! Please check your network connection and try again.'), ); } @@ -983,6 +990,14 @@ export default class Notes { form.find('#note_position').val(dataHolder.attr('data-position')); form + .prepend( + `<div class="avatar-note-form-holder"><div class="content"><a href="${escape( + gon.current_username, + )}" class="user-avatar-link d-none d-sm-block"><img class="avatar s40" src="${encodeURI( + gon.current_user_avatar_url, + )}" alt="${escape(gon.current_user_fullname)}" /></a></div></div>`, + ) + .append('</div>') .find('.js-close-discussion-note-form') .show() .removeClass('hide'); @@ -1018,6 +1033,9 @@ export default class Notes { target: $link, lineType: link.dataset.lineType, showReplyInput, + currentUsername: gon.current_username, + currentUserAvatar: gon.current_user_avatar_url, + currentUserFullname: gon.current_user_fullname, }); } @@ -1046,7 +1064,15 @@ export default class Notes { this.setupDiscussionNoteForm($link, newForm); } - toggleDiffNote({ target, lineType, forceShow, showReplyInput = false }) { + toggleDiffNote({ + target, + lineType, + forceShow, + showReplyInput = false, + currentUsername, + currentUserAvatar, + currentUserFullname, + }) { var $link, addForm, hasNotes, @@ -1069,14 +1095,14 @@ export default class Notes { addForm = false; let lineTypeSelector = ''; rowCssToAdd = - '<tr class="notes_holder js-temp-notes-holder"><td class="notes_content" colspan="3"><div class="content"></div></td></tr>'; + '<tr class="notes_holder js-temp-notes-holder"><td class="notes-content" colspan="3"><div class="content"></div></td></tr>'; // In parallel view, look inside the correct left/right pane if (this.isParallelView()) { lineTypeSelector = `.${lineType}`; rowCssToAdd = - '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content"></div></td></tr>'; + '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes-content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes-content parallel new"><div class="content"></div></td></tr>'; } - const notesContentSelector = `.notes_content${lineTypeSelector} .content`; + const notesContentSelector = `.notes-content${lineTypeSelector} .content`; let notesContent = targetRow.find(notesContentSelector); if (hasNotes && showReplyInput) { @@ -1258,12 +1284,19 @@ export default class Notes { putConflictEditWarningInPlace(noteEntity, $note) { if ($note.find('.js-conflict-edit-warning').length === 0) { + const open_link = `<a href="#note_${ + noteEntity.id + }" target="_blank" rel="noopener noreferrer">`; const $alert = $(`<div class="js-conflict-edit-warning alert alert-danger"> - This comment has changed since you started editing, please review the - <a href="#note_${noteEntity.id}" target="_blank" rel="noopener noreferrer"> - updated comment - </a> - to ensure information is not lost + ${sprintf( + s__( + 'Notes|This comment has changed since you started editing, please review the %{open_link}updated comment%{close_link} to ensure information is not lost', + ), + { + open_link, + close_link: '</a>', + }, + )} </div>`); $alert.insertAfter($note.find('.note-text')); } @@ -1491,13 +1524,15 @@ export default class Notes { if (executedCommands && executedCommands.length) { if (executedCommands.length > 1) { - tempFormContent = 'Applying multiple commands'; + tempFormContent = __('Applying multiple commands'); } else { const commandDescription = executedCommands[0].description.toLowerCase(); - tempFormContent = `Applying command to ${commandDescription}`; + tempFormContent = sprintf(__('Applying command to %{commandDescription}'), { + commandDescription, + }); } } else { - tempFormContent = 'Applying command'; + tempFormContent = __('Applying command'); } return tempFormContent; @@ -1530,7 +1565,9 @@ export default class Notes { <div class="note-header"> <div class="note-header-info"> <a href="/${_.escape(currentUsername)}"> - <span class="d-none d-sm-inline-block">${_.escape(currentUsername)}</span> + <span class="d-none d-sm-inline-block bold">${_.escape( + currentUsername, + )}</span> <span class="note-headline-light">${_.escape(currentUsername)}</span> </a> </div> @@ -1817,7 +1854,9 @@ export default class Notes { $editingNote .find('.note-headline-meta a') .html( - '<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>', + `<i class="fa fa-spinner fa-spin" aria-label="${__( + 'Comment is being updated', + )}" aria-hidden="true"></i>`, ); // Make request to update comment on server diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 1d6cb9485f7..075c28e8d07 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -11,6 +11,7 @@ import { capitalizeFirstCharacter, convertToCamelCase, splitCamelCase, + slugifyWithUnderscore, } from '../../lib/utils/text_utility'; import * as constants from '../constants'; import eventHub from '../event_hub'; @@ -115,8 +116,11 @@ export default { author() { return this.getUserData; }, - canUpdateIssue() { - return this.getNoteableData.current_user.can_update; + canToggleIssueState() { + return ( + this.getNoteableData.current_user.can_update && + this.getNoteableData.state !== constants.MERGED + ); }, endpoint() { return this.getNoteableData.create_note_path; @@ -126,6 +130,9 @@ export default { ? 'merge request' : 'issue'; }, + trackingLabel() { + return slugifyWithUnderscore(`${this.commentButtonTitle} button`); + }, }, watch: { note(newNote) { @@ -330,6 +337,8 @@ Please check your network connection and try again.`; v-if="hasWarning(getNoteableData)" :is-locked="isLocked(getNoteableData)" :is-confidential="isConfidential(getNoteableData)" + :locked-issue-docs-path="lockedIssueDocsPath" + :confidential-issue-docs-path="confidentialIssueDocsPath" /> <markdown-field @@ -344,6 +353,7 @@ Please check your network connection and try again.`; ref="textarea" slot="textarea" v-model="note" + dir="auto" :disabled="isSubmitting" name="note[note]" class="note-textarea js-vue-comment-form js-note-text @@ -367,6 +377,8 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" class="btn btn-success js-comment-button js-comment-submit-button qa-comment-button" type="submit" + :data-track-label="trackingLabel" + data-track-event="click_button" @click.prevent="handleSave()" > {{ __(commentButtonTitle) }} @@ -415,7 +427,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" </div> <loading-button - v-if="canUpdateIssue" + v-if="canToggleIssueState" :loading="isToggleStateButtonLoading" :container-class="[ actionButtonClassNames, diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index d8947e8ca50..b95835ed10a 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -72,8 +72,8 @@ export default { :can-current-user-fork="false" :expanded="!discussion.diff_file.viewer.collapsed" /> - <div v-if="isTextFile" :class="$options.userColorSchemeClass" class="diff-content code"> - <table> + <div v-if="isTextFile" class="diff-content"> + <table class="code js-syntax-highlight" :class="$options.userColorSchemeClass"> <template v-if="hasTruncatedDiffLines"> <tr v-for="line in discussion.truncated_diff_lines" @@ -81,8 +81,8 @@ export default { :key="line.line_code" class="line_holder" > - <td class="diff-line-num old_line">{{ line.old_line }}</td> - <td class="diff-line-num new_line">{{ line.new_line }}</td> + <td :class="line.type" class="diff-line-num old_line">{{ line.old_line }}</td> + <td :class="line.type" class="diff-line-num new_line">{{ line.new_line }}</td> <td :class="line.type" class="line_content" v-html="line.rich_text"></td> </tr> </template> @@ -105,7 +105,7 @@ export default { </td> </tr> <tr class="notes_holder"> - <td class="notes_content" colspan="3"><slot></slot></td> + <td class="notes-content" colspan="3"><slot></slot></td> </tr> </table> </div> diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue new file mode 100644 index 00000000000..22cca756ef6 --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_actions.vue @@ -0,0 +1,58 @@ +<script> +import ReplyPlaceholder from './discussion_reply_placeholder.vue'; +import ResolveDiscussionButton from './discussion_resolve_button.vue'; +import ResolveWithIssueButton from './discussion_resolve_with_issue_button.vue'; +import JumpToNextDiscussionButton from './discussion_jump_to_next_button.vue'; + +export default { + name: 'DiscussionActions', + components: { + ReplyPlaceholder, + ResolveDiscussionButton, + ResolveWithIssueButton, + JumpToNextDiscussionButton, + }, + props: { + discussion: { + type: Object, + required: true, + }, + isResolving: { + type: Boolean, + required: true, + }, + resolveButtonTitle: { + type: String, + required: true, + }, + resolveWithIssuePath: { + type: String, + required: false, + default: '', + }, + shouldShowJumpToNextDiscussion: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <div class="discussion-with-resolve-btn"> + <reply-placeholder class="qa-discussion-reply" @onClick="$emit('showReplyForm')" /> + <resolve-discussion-button + v-if="discussion.resolvable" + :is-resolving="isResolving" + :button-title="resolveButtonTitle" + @onClick="$emit('resolve')" + /> + <div v-if="discussion.resolvable" class="btn-group discussion-actions ml-sm-2" role="group"> + <resolve-with-issue-button v-if="resolveWithIssuePath" :url="resolveWithIssuePath" /> + <jump-to-next-discussion-button + v-if="shouldShowJumpToNextDiscussion" + @onClick="$emit('jumpToNextDiscussion')" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index c7cfc0f0f3b..efd84f5722c 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -49,22 +49,26 @@ export default { </script> <template> - <div v-if="resolvableDiscussionsCount > 0" class="line-resolve-all-container prepend-top-8"> - <div> + <div v-if="resolvableDiscussionsCount > 0" class="line-resolve-all-container full-width-mobile"> + <div class="full-width-mobile d-flex d-sm-block"> <div :class="{ 'has-next-btn': hasNextButton }" class="line-resolve-all"> <span :class="{ 'is-active': allResolved }" class="line-resolve-btn is-disabled" type="button" > - <icon name="check-circle" /> + <icon :name="allResolved ? 'check-circle-filled' : 'check-circle'" /> </span> <span class="line-resolve-text"> {{ resolvedDiscussionsCount }}/{{ resolvableDiscussionsCount }} {{ n__('discussion resolved', 'discussions resolved', resolvableDiscussionsCount) }} </span> </div> - <div v-if="resolveAllDiscussionsIssuePath && !allResolved" class="btn-group" role="group"> + <div + v-if="resolveAllDiscussionsIssuePath && !allResolved" + class="btn-group btn-group-sm" + role="group" + > <a v-gl-tooltip :href="resolveAllDiscussionsIssuePath" @@ -74,7 +78,7 @@ export default { <icon name="issue-new" /> </a> </div> - <div v-if="isLoggedIn && !allResolved" class="btn-group" role="group"> + <div v-if="isLoggedIn && !allResolved" class="btn-group btn-group-sm" role="group"> <button v-gl-tooltip title="Jump to first unresolved discussion" diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index e03d6e9cd02..eb3fbbe1385 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -7,7 +7,9 @@ import { DISCUSSION_FILTERS_DEFAULT_VALUE, HISTORY_ONLY_FILTER_VALUE, DISCUSSION_TAB_LABEL, + DISCUSSION_FILTER_TYPES, } from '../constants'; +import notesEventHub from '../event_hub'; export default { components: { @@ -20,7 +22,7 @@ export default { }, selectedValue: { type: Number, - default: null, + default: DISCUSSION_FILTERS_DEFAULT_VALUE, required: false, }, }, @@ -46,6 +48,7 @@ export default { this.toggleFilters(currentTab); } + notesEventHub.$on('dropdownSelect', this.selectFilter); window.addEventListener('hashchange', this.handleLocationHash); this.handleLocationHash(); }, @@ -53,6 +56,7 @@ export default { this.toggleCommentsForm(); }, destroyed() { + notesEventHub.$off('dropdownSelect', this.selectFilter); window.removeEventListener('hashchange', this.handleLocationHash); }, methods: { @@ -86,28 +90,44 @@ export default { this.setTargetNoteHash(hash); } }, + filterType(value) { + if (value === 0) { + return DISCUSSION_FILTER_TYPES.ALL; + } else if (value === 1) { + return DISCUSSION_FILTER_TYPES.COMMENTS; + } + return DISCUSSION_FILTER_TYPES.HISTORY; + }, }, }; </script> <template> - <div v-if="displayFilters" class="discussion-filter-container d-inline-block align-bottom"> + <div + v-if="displayFilters" + class="discussion-filter-container js-discussion-filter-container d-inline-block align-bottom full-width-mobile" + > <button id="discussion-filter-dropdown" ref="dropdownToggle" - class="btn btn-default qa-discussion-filter" + class="btn btn-sm qa-discussion-filter" data-toggle="dropdown" aria-expanded="false" > {{ currentFilter.title }} <icon name="chevron-down" /> </button> <div + ref="dropdownMenu" class="dropdown-menu dropdown-menu-selectable dropdown-menu-right" aria-labelledby="discussion-filter-dropdown" > <div class="dropdown-content"> <ul> - <li v-for="filter in filters" :key="filter.value"> + <li + v-for="filter in filters" + :key="filter.value" + :data-filter-type="filterType(filter.value)" + > <button :class="{ 'is-active': filter.value === currentValue }" class="qa-filter-options" diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue new file mode 100644 index 00000000000..889731df180 --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue @@ -0,0 +1,52 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { __, sprintf } from '~/locale'; + +import notesEventHub from '../event_hub'; + +export default { + components: { + GlButton, + Icon, + }, + computed: { + timelineContent() { + return sprintf( + __( + "You're only seeing %{startTag}other activity%{endTag} in the feed. To add a comment, switch to one of the following options.", + ), + { + startTag: `<b>`, + endTag: `</b>`, + }, + false, + ); + }, + }, + methods: { + selectFilter(value) { + notesEventHub.$emit('dropdownSelect', value); + }, + }, +}; +</script> + +<template> + <li class="timeline-entry note note-wrapper discussion-filter-note js-discussion-filter-note"> + <div class="timeline-icon d-none d-lg-flex"> + <icon name="comment" /> + </div> + <div class="timeline-content"> + <div v-html="timelineContent"></div> + <div class="discussion-filter-actions mt-2"> + <gl-button variant="default" @click="selectFilter(0)"> + {{ __('Show all activity') }} + </gl-button> + <gl-button variant="default" @click="selectFilter(1)"> + {{ __('Show comments only') }} + </gl-button> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/components/discussion_locked_widget.vue b/app/assets/javascripts/notes/components/discussion_locked_widget.vue index c469a6b7bcd..53f509185a8 100644 --- a/app/assets/javascripts/notes/components/discussion_locked_widget.vue +++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue @@ -1,12 +1,24 @@ <script> +import { GlLink } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; +import { __, sprintf } from '~/locale'; import Issuable from '~/vue_shared/mixins/issuable'; +import issuableStateMixin from '../mixins/issuable_state'; export default { components: { Icon, + GlLink, + }, + mixins: [Issuable, issuableStateMixin], + computed: { + lockedIssueWarning() { + return sprintf( + __('This %{issuableDisplayName} is locked. Only project members can comment.'), + { issuableDisplayName: this.issuableDisplayName }, + ); + }, }, - mixins: [Issuable], }; </script> @@ -15,7 +27,11 @@ export default { <span class="issuable-note-warning inline"> <icon :size="16" name="lock" class="icon" /> <span> - This {{ issuableDisplayName }} is locked. Only <b>project members</b> can comment. + {{ lockedIssueWarning }} + + <gl-link :href="lockedIssueDocsPath" target="_blank" class="learn-more"> + {{ __('Learn more') }} + </gl-link> </span> </span> </div> diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue new file mode 100644 index 00000000000..228bb652597 --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -0,0 +1,155 @@ +<script> +import { mapGetters } from 'vuex'; +import { SYSTEM_NOTE } from '../constants'; +import { __ } from '~/locale'; +import NoteableNote from './noteable_note.vue'; +import PlaceholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; +import PlaceholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; +import SystemNote from '~/vue_shared/components/notes/system_note.vue'; +import ToggleRepliesWidget from './toggle_replies_widget.vue'; +import NoteEditedText from './note_edited_text.vue'; + +export default { + name: 'DiscussionNotes', + components: { + ToggleRepliesWidget, + NoteEditedText, + }, + props: { + discussion: { + type: Object, + required: true, + }, + isExpanded: { + type: Boolean, + required: false, + default: false, + }, + diffLine: { + type: Object, + required: false, + default: null, + }, + line: { + type: Object, + required: false, + default: null, + }, + shouldGroupReplies: { + type: Boolean, + required: false, + default: false, + }, + helpPagePath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + ...mapGetters(['userCanReply']), + hasReplies() { + return Boolean(this.replies.length); + }, + replies() { + return this.discussion.notes.slice(1); + }, + firstNote() { + return this.discussion.notes.slice(0, 1)[0]; + }, + resolvedText() { + return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved'); + }, + commit() { + if (!this.discussion.for_commit) { + return null; + } + + return { + id: this.discussion.commit_id, + url: this.discussion.discussion_path, + }; + }, + }, + methods: { + componentName(note) { + if (note.isPlaceholderNote) { + if (note.placeholderType === SYSTEM_NOTE) { + return PlaceholderSystemNote; + } + + return PlaceholderNote; + } + + if (note.system) { + return SystemNote; + } + + return NoteableNote; + }, + componentData(note) { + return note.isPlaceholderNote ? note.notes[0] : note; + }, + }, +}; +</script> + +<template> + <div class="discussion-notes"> + <ul class="notes"> + <template v-if="shouldGroupReplies"> + <component + :is="componentName(firstNote)" + :note="componentData(firstNote)" + :line="line" + :commit="commit" + :help-page-path="helpPagePath" + :show-reply-button="userCanReply" + @handle-delete-note="$emit('deleteNote')" + @start-replying="$emit('startReplying')" + > + <note-edited-text + v-if="discussion.resolved" + slot="discussion-resolved-text" + :edited-at="discussion.resolved_at" + :edited-by="discussion.resolved_by" + :action-text="resolvedText" + class-name="discussion-headline-light js-discussion-headline discussion-resolved-text" + /> + <slot slot="avatar-badge" name="avatar-badge"></slot> + </component> + <toggle-replies-widget + v-if="hasReplies" + :collapsed="!isExpanded" + :replies="replies" + @toggle="$emit('toggleDiscussion')" + /> + <template v-if="isExpanded"> + <component + :is="componentName(note)" + v-for="note in replies" + :key="note.id" + :note="componentData(note)" + :help-page-path="helpPagePath" + :line="line" + @handle-delete-note="$emit('deleteNote')" + /> + </template> + </template> + <template v-else> + <component + :is="componentName(note)" + v-for="(note, index) in discussion.notes" + :key="note.id" + :note="componentData(note)" + :help-page-path="helpPagePath" + :line="diffLine" + @handle-delete-note="$emit('deleteNote')" + > + <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot> + </component> + </template> + </ul> + <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index de1ea0f58d6..844d0c3e376 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -2,6 +2,7 @@ import { mapGetters } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import resolvedStatusMixin from 'ee_else_ce/batch_comments/mixins/resolved_status'; import ReplyButton from './note_actions/reply_button.vue'; export default { @@ -14,6 +15,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [resolvedStatusMixin], props: { authorId: { type: Number, @@ -86,9 +88,6 @@ export default { }, computed: { ...mapGetters(['getUserDataByProp']), - showReplyButton() { - return gon.features && gon.features.replyToIndividualNotes && this.showReply; - }, shouldShowActionsDropdown() { return this.currentUserId && (this.canEdit || this.canReportAsAbuse); }, @@ -101,15 +100,6 @@ export default { currentUserId() { return this.getUserDataByProp('id'); }, - resolveButtonTitle() { - let title = 'Mark as resolved'; - - if (this.resolvedBy) { - title = `Resolved by ${this.resolvedBy.name}`; - } - - return title; - }, }, methods: { onEdit() { @@ -145,7 +135,7 @@ export default { @click="onResolve" > <template v-if="!isResolving"> - <icon name="check-circle" /> + <icon :name="isResolved ? 'check-circle-filled' : 'check-circle'" /> </template> <gl-loading-icon v-else inline /> </button> @@ -157,18 +147,15 @@ export default { class="note-action-button note-emoji-button js-add-award js-note-emoji" href="#" title="Add reaction" + data-position="right" > - <gl-loading-icon inline /> - <icon - css-classes="link-highlight award-control-icon-neutral" - name="emoji_slightly_smiling_face" - /> - <icon css-classes="link-highlight award-control-icon-positive" name="emoji_smiley" /> - <icon css-classes="link-highlight award-control-icon-super-positive" name="emoji_smiley" /> + <icon css-classes="link-highlight award-control-icon-neutral" name="slight-smile" /> + <icon css-classes="link-highlight award-control-icon-positive" name="smiley" /> + <icon css-classes="link-highlight award-control-icon-super-positive" name="smiley" /> </a> </div> <reply-button - v-if="showReplyButton" + v-if="showReply" ref="replyButton" class="js-reply-button" @startReplying="$emit('startReplying')" @@ -208,7 +195,7 @@ export default { </button> <ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> <li v-if="canReportAsAbuse"> - <a :href="reportAbusePath">{{ __('Report abuse to GitLab') }}</a> + <a :href="reportAbusePath">{{ __('Report abuse to admin') }}</a> </li> <li v-if="noteUrl"> <button 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 f50cab81efe..be8e42af9ea 100644 --- a/app/assets/javascripts/notes/components/note_actions/reply_button.vue +++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue @@ -18,7 +18,7 @@ export default { <div class="note-actions-item"> <gl-button ref="button" - v-gl-tooltip.bottom + v-gl-tooltip class="note-action-button" variant="transparent" :title="__('Reply to comment')" diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 17e5fcab5b7..941b6d5cab3 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -189,13 +189,13 @@ export default { type="button" > <span class="award-control-icon award-control-icon-neutral"> - <icon name="emoji_slightly_smiling_face" /> + <icon name="slight-smile" /> </span> <span class="award-control-icon award-control-icon-positive"> - <icon name="emoji_smiley" /> + <icon name="smiley" /> </span> <span class="award-control-icon award-control-icon-super-positive"> - <icon name="emoji_smiley" /> + <icon name="smiley" /> </span> <i aria-hidden="true" diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index fb1d98355b3..88454c3fb4c 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -1,6 +1,7 @@ <script> import { mapActions } from 'vuex'; import $ from 'jquery'; +import getDiscussion from 'ee_else_ce/notes/mixins/get_discussion'; import noteEditedText from './note_edited_text.vue'; import noteAwardsList from './note_awards_list.vue'; import noteAttachment from './note_attachment.vue'; @@ -16,7 +17,7 @@ export default { noteForm, Suggestions, }, - mixins: [autosave], + mixins: [autosave, getDiscussion], props: { note: { type: Object, @@ -76,16 +77,18 @@ export default { renderGFM() { $(this.$refs['note-body']).renderGFM(); }, - handleFormUpdate(note, parentElement, callback) { - this.$emit('handleFormUpdate', note, parentElement, callback); + handleFormUpdate(note, parentElement, callback, resolveDiscussion) { + this.$emit('handleFormUpdate', note, parentElement, callback, resolveDiscussion); }, formCancelHandler(shouldConfirm, isDirty) { this.$emit('cancelForm', shouldConfirm, isDirty); }, - applySuggestion({ suggestionId, flashContainer, callback }) { + applySuggestion({ suggestionId, flashContainer, callback = () => {} }) { const { discussion_id: discussionId, id: noteId } = this.note; - this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer, callback }); + return this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer }).then( + callback, + ); }, }, }; @@ -95,7 +98,6 @@ export default { <div ref="note-body" :class="{ 'js-task-list-container': canEdit }" class="note-body"> <suggestions v-if="hasSuggestion && !isEditing" - class="note-text md" :suggestions="note.suggestions" :note-html="note.note_html" :line-type="lineType" @@ -112,6 +114,8 @@ export default { :line="line" :note="note" :help-page-path="helpPagePath" + :discussion="discussion" + :resolve-discussion="note.resolve_discussion" @handleFormUpdate="handleFormUpdate" @cancelForm="formCancelHandler" /> @@ -120,6 +124,7 @@ export default { v-model="note.note" :data-update-url="note.path" class="hidden js-task-list-field" + dir="auto" ></textarea> <note-edited-text v-if="note.last_edited_at" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 92258a25438..09ecb695214 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -7,6 +7,8 @@ import markdownField from '../../vue_shared/components/markdown/field.vue'; import issuableStateMixin from '../mixins/issuable_state'; import resolvable from '../mixins/resolvable'; import { __ } from '~/locale'; +import { getDraft, updateDraft } from '~/lib/utils/autosave'; +import noteFormMixin from 'ee_else_ce/notes/mixins/note_form'; export default { name: 'NoteForm', @@ -14,7 +16,7 @@ export default { issueWarning, markdownField, }, - mixins: [issuableStateMixin, resolvable], + mixins: [issuableStateMixin, resolvable, noteFormMixin], props: { noteBody: { type: String, @@ -60,15 +62,31 @@ export default { required: false, default: null, }, + diffFile: { + type: Object, + required: false, + default: null, + }, helpPagePath: { type: String, required: false, default: '', }, + autosaveKey: { + type: String, + required: false, + default: '', + }, }, data() { + let updatedNoteBody = this.noteBody; + + if (!updatedNoteBody && this.autosaveKey) { + updatedNoteBody = getDraft(this.autosaveKey) || ''; + } + return { - updatedNoteBody: this.noteBody, + updatedNoteBody, conflictWhileEditing: false, isSubmitting: false, isResolving: this.resolveDiscussion, @@ -90,9 +108,42 @@ export default { } return '#'; }, + diffParams() { + if (this.diffFile) { + return { + filePath: this.diffFile.file_path, + refs: this.diffFile.diff_refs, + }; + } else if (this.note && this.note.position) { + return { + filePath: this.note.position.new_path, + refs: this.note.position, + }; + } else if (this.discussion && this.discussion.diff_file) { + return { + filePath: this.discussion.diff_file.file_path, + refs: this.discussion.diff_file.diff_refs, + }; + } + + return null; + }, markdownPreviewPath() { const notable = this.getNoteableDataByProp('preview_note_path'); - return mergeUrlParams({ preview_suggestions: true }, notable); + + const previewSuggestions = this.line && this.diffParams; + const params = previewSuggestions + ? { + preview_suggestions: previewSuggestions, + line: this.line.new_line, + file_path: this.diffParams.filePath, + base_sha: this.diffParams.refs.base_sha, + start_sha: this.diffParams.refs.start_sha, + head_sha: this.diffParams.refs.head_sha, + } + : {}; + + return mergeUrlParams(params, notable); }, markdownDocsPath() { return this.getNotesDataByProp('markdownDocsPath'); @@ -145,21 +196,6 @@ export default { return shouldResolve || shouldToggleState; }, - handleKeySubmit() { - this.handleUpdate(); - }, - handleUpdate(shouldResolve) { - const beforeSubmitDiscussionState = this.discussionResolved; - this.isSubmitting = true; - - this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => { - this.isSubmitting = false; - - if (this.shouldToggleResolved(shouldResolve, beforeSubmitDiscussionState)) { - this.resolveHandler(beforeSubmitDiscussionState); - } - }); - }, editMyLastNote() { if (this.updatedNoteBody === '') { const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion); @@ -175,6 +211,12 @@ export default { // Sends information about confirm message and if the textarea has changed this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody); }, + onInput() { + if (this.autosaveKey) { + const { autosaveKey, updatedNoteBody: text } = this; + updateDraft(autosaveKey, text); + } + }, }, }; </script> @@ -192,6 +234,8 @@ export default { v-if="hasWarning(getNoteableData)" :is-locked="isLocked(getNoteableData)" :is-confidential="isConfidential(getNoteableData)" + :locked-issue-docs-path="lockedIssueDocsPath" + :confidential-issue-docs-path="confidentialIssueDocsPath" /> <markdown-field @@ -212,37 +256,85 @@ export default { :data-supports-quick-actions="!isEditing" name="note[note]" class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form js-vue-textarea qa-reply-input" + dir="auto" aria-label="Description" placeholder="Write a comment or drag your files here…" @keydown.meta.enter="handleKeySubmit()" @keydown.ctrl.enter="handleKeySubmit()" - @keydown.up="editMyLastNote()" - @keydown.esc="cancelHandler(true)" + @keydown.exact.up="editMyLastNote()" + @keydown.exact.esc="cancelHandler(true)" + @input="onInput" ></textarea> </markdown-field> <div class="note-form-actions clearfix"> - <button - :disabled="isDisabled" - type="button" - class="js-vue-issue-save btn btn-success js-comment-button qa-reply-comment-button" - @click="handleUpdate()" - > - {{ saveButtonTitle }} - </button> - <button - v-if="discussion.resolvable" - class="btn btn-nr btn-default append-right-10 js-comment-resolve-button" - @click.prevent="handleUpdate(true)" - > - {{ resolveButtonTitle }} - </button> - <button - class="btn btn-cancel note-edit-cancel js-close-discussion-note-form" - type="button" - @click="cancelHandler()" - > - Cancel - </button> + <template v-if="showBatchCommentsActions"> + <p v-if="showResolveDiscussionToggle"> + <label> + <template v-if="discussionResolved"> + <input + v-model="isUnresolving" + type="checkbox" + class="qa-unresolve-review-discussion" + /> + {{ __('Unresolve discussion') }} + </template> + <template v-else> + <input v-model="isResolving" type="checkbox" class="qa-resolve-review-discussion" /> + {{ __('Resolve discussion') }} + </template> + </label> + </p> + <div> + <button + :disabled="isDisabled" + type="button" + class="btn btn-success qa-start-review" + @click="handleAddToReview" + > + <template v-if="hasDrafts">{{ __('Add to review') }}</template> + <template v-else>{{ __('Start a review') }}</template> + </button> + <button + :disabled="isDisabled" + type="button" + class="btn qa-comment-now" + @click="handleUpdate()" + > + {{ __('Add comment now') }} + </button> + <button + class="btn btn-cancel note-edit-cancel js-close-discussion-note-form" + type="button" + @click="cancelHandler()" + > + {{ __('Cancel') }} + </button> + </div> + </template> + <template v-else> + <button + :disabled="isDisabled" + type="button" + class="js-vue-issue-save btn btn-success js-comment-button qa-reply-comment-button" + @click="handleUpdate()" + > + {{ saveButtonTitle }} + </button> + <button + v-if="discussion.resolvable" + class="btn btn-nr btn-default append-right-10 js-comment-resolve-button" + @click.prevent="handleUpdate(true)" + > + {{ resolveButtonTitle }} + </button> + <button + class="btn btn-cancel note-edit-cancel js-close-discussion-note-form" + type="button" + @click="cancelHandler()" + > + Cancel + </button> + </template> </div> </form> </div> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 7b39901024d..fbf82fab9e9 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -69,7 +69,7 @@ export default { type="button" @click="handleToggle" > - <i :class="toggleChevronClass" class="fa" aria-hidden="true"> </i> + <i :class="toggleChevronClass" class="fa" aria-hidden="true"></i> {{ __('Toggle discussion') }} </button> </div> @@ -81,35 +81,31 @@ export default { :data-user-id="author.id" :data-username="author.username" > - <span class="note-header-author-name">{{ author.name }}</span> + <slot name="note-header-info"></slot> + <span class="note-header-author-name bold">{{ author.name }}</span> <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span> - <span class="note-headline-light"> @{{ author.username }} </span> + <span class="note-headline-light">@{{ author.username }}</span> </a> - <span v-else> {{ __('A deleted user') }} </span> - <span class="note-headline-light"> - <span class="note-headline-meta"> - <span class="system-note-message"> <slot></slot> </span> - <template v-if="createdAt"> - <span class="system-note-separator"> - <template v-if="actionText"> - {{ actionText }} - </template> - </span> - <a - :href="noteTimestampLink" - class="note-timestamp system-note-separator" - @click="updateTargetNoteHash" - > - <time-ago-tooltip :time="createdAt" tooltip-placement="bottom" /> - </a> - </template> - <i - class="fa fa-spinner fa-spin editing-spinner" - aria-label="Comment is being updated" - aria-hidden="true" + <span v-else>{{ __('A deleted user') }}</span> + <span class="note-headline-light note-headline-meta"> + <span class="system-note-message"> <slot></slot> </span> + <template v-if="createdAt"> + <span class="system-note-separator"> + <template v-if="actionText">{{ actionText }}</template> + </span> + <a + :href="noteTimestampLink" + class="note-timestamp system-note-separator" + @click="updateTargetNoteHash" > - </i> - </span> + <time-ago-tooltip :time="createdAt" tooltip-placement="bottom" /> + </a> + </template> + <i + class="fa fa-spinner fa-spin editing-spinner" + aria-label="Comment is being updated" + aria-hidden="true" + ></i> </span> </div> </template> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 3894dc8c677..eb6a4a67fff 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -4,55 +4,42 @@ import { mapActions, mapGetters } from 'vuex'; import { GlTooltipDirective } from '@gitlab/ui'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__, __, sprintf } from '~/locale'; -import systemNote from '~/vue_shared/components/notes/system_note.vue'; +import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave'; import icon from '~/vue_shared/components/icon.vue'; +import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; -import { SYSTEM_NOTE } from '../constants'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; -import noteableNote from './noteable_note.vue'; import noteHeader from './note_header.vue'; -import resolveDiscussionButton from './discussion_resolve_button.vue'; -import toggleRepliesWidget from './toggle_replies_widget.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue'; import noteEditedText from './note_edited_text.vue'; import noteForm from './note_form.vue'; import diffWithNote from './diff_with_note.vue'; -import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; -import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; -import autosave from '../mixins/autosave'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; import discussionNavigation from '../mixins/discussion_navigation'; -import ReplyPlaceholder from './discussion_reply_placeholder.vue'; -import ResolveWithIssueButton from './discussion_resolve_with_issue_button.vue'; -import jumpToNextDiscussionButton from './discussion_jump_to_next_button.vue'; import eventHub from '../event_hub'; +import DiscussionNotes from './discussion_notes.vue'; +import DiscussionActions from './discussion_actions.vue'; export default { name: 'NoteableDiscussion', components: { icon, - noteableNote, userAvatarLink, noteHeader, noteSignedOutWidget, noteEditedText, noteForm, - resolveDiscussionButton, - jumpToNextDiscussionButton, - toggleRepliesWidget, - ReplyPlaceholder, - placeholderNote, - placeholderSystemNote, - ResolveWithIssueButton, - systemNote, + DraftNote: () => import('ee_component/batch_comments/components/draft_note.vue'), TimelineEntryItem, + DiscussionNotes, + DiscussionActions, }, directives: { GlTooltip: GlTooltipDirective, }, - mixins: [autosave, noteable, resolvable, discussionNavigation], + mixins: [noteable, resolvable, discussionNavigation, diffLineNoteFormMixin], props: { discussion: { type: Object, @@ -85,42 +72,38 @@ export default { }, }, data() { - const { diff_discussion: isDiffDiscussion, resolved } = this.discussion; - return { isReplying: false, isResolving: false, resolveAsThread: true, - isRepliesCollapsed: Boolean(!isDiffDiscussion && resolved), }; }, computed: { ...mapGetters([ 'convertedDisscussionIds', 'getNoteableData', + 'userCanReply', 'nextUnresolvedDiscussionId', 'unresolvedDiscussionsCount', 'hasUnresolvedDiscussions', 'showJumpToNextDiscussion', + 'getUserData', ]), + currentUser() { + return this.getUserData; + }, author() { - return this.initialDiscussion.author; + return this.firstNote.author; }, - canReply() { - return this.getNoteableData.current_user.can_create_note; + autosaveKey() { + return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id); }, newNotePath() { return this.getNoteableData.create_note_path; }, - hasReplies() { - return this.discussion.notes.length > 1; - }, - initialDiscussion() { + firstNote() { return this.discussion.notes.slice(0, 1)[0]; }, - replies() { - return this.discussion.notes.slice(1); - }, lastUpdatedBy() { const { notes } = this.discussion; @@ -173,11 +156,11 @@ export default { return ''; }, - shouldShowDiscussions() { - const { expanded, resolved } = this.discussion; - const isResolvedNonDiffDiscussion = !this.discussion.diff_discussion && resolved; - - return expanded || this.alwaysExpanded || isResolvedNonDiffDiscussion; + isExpanded() { + return this.discussion.expanded || this.alwaysExpanded; + }, + shouldHideDiscussionBody() { + return this.shouldRenderDiffs && !this.isExpanded; }, actionText() { const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`; @@ -226,30 +209,8 @@ export default { return null; }, - commit() { - if (!this.discussion.for_commit) { - return null; - } - - return { - id: this.discussion.commit_id, - url: this.discussion.discussion_path, - }; - }, resolveWithIssuePath() { - return !this.discussionResolved && this.discussion.resolve_with_issue_path; - }, - }, - watch: { - isReplying() { - if (this.isReplying) { - this.$nextTick(() => { - // Pass an extra key to separate reply and note edit forms - this.initAutoSave({ ...this.initialDiscussion, ...this.discussion }, ['Reply']); - }); - } else { - this.disposeAutoSave(); - } + return !this.discussionResolved ? this.discussion.resolve_with_issue_path : ''; }, }, created() { @@ -268,30 +229,9 @@ export default { 'removeConvertedDiscussion', ]), truncateSha, - componentName(note) { - if (note.isPlaceholderNote) { - if (note.placeholderType === SYSTEM_NOTE) { - return placeholderSystemNote; - } - - return placeholderNote; - } - - if (note.system) { - return systemNote; - } - - return noteableNote; - }, - componentData(note) { - return note.isPlaceholderNote ? note.notes[0] : note; - }, toggleDiscussionHandler() { this.toggleDiscussion({ discussionId: this.discussion.id }); }, - toggleReplies() { - this.isRepliesCollapsed = !this.isRepliesCollapsed; - }, showReplyForm() { this.isReplying = true; }, @@ -310,7 +250,7 @@ export default { } this.isReplying = false; - this.resetAutoSave(); + clearDraft(this.autosaveKey); }, saveReply(noteText, form, callback) { const postData = { @@ -336,7 +276,7 @@ export default { this.isReplying = false; this.saveNote(replyData) .then(() => { - this.resetAutoSave(); + clearDraft(this.autosaveKey); callback(); }) .catch(err => { @@ -388,8 +328,8 @@ Please check your network connection and try again.`; <div class="timeline-content"> <note-header :author="author" - :created-at="initialDiscussion.created_at" - :note-id="initialDiscussion.id" + :created-at="firstNote.created_at" + :note-id="firstNote.id" :include-toggle="true" :expanded="discussion.expanded" @toggleHandler="toggleDiscussionHandler" @@ -412,110 +352,79 @@ Please check your network connection and try again.`; /> </div> </div> - <div v-if="shouldShowDiscussions" class="discussion-body"> + <div v-if="!shouldHideDiscussionBody" class="discussion-body"> <component :is="wrapperComponent" v-bind="wrapperComponentProps" class="card discussion-wrapper" > - <div class="discussion-notes"> - <ul class="notes"> - <template v-if="shouldGroupReplies"> - <component - :is="componentName(initialDiscussion)" - :note="componentData(initialDiscussion)" - :line="line" - :commit="commit" - :help-page-path="helpPagePath" - :show-reply-button="canReply" - @handleDeleteNote="deleteNoteHandler" - @startReplying="showReplyForm" - > - <note-edited-text - v-if="discussion.resolved" - slot="discussion-resolved-text" - :edited-at="discussion.resolved_at" - :edited-by="discussion.resolved_by" - :action-text="resolvedText" - class-name="discussion-headline-light js-discussion-headline discussion-resolved-text" - /> - <slot slot="avatar-badge" name="avatar-badge"></slot> - </component> - <toggle-replies-widget - v-if="hasReplies" - :collapsed="isRepliesCollapsed" - :replies="replies" - @toggle="toggleReplies" + <discussion-notes + :discussion="discussion" + :diff-line="diffLine" + :help-page-path="helpPagePath" + :is-expanded="isExpanded" + :line="line" + :should-group-replies="shouldGroupReplies" + @startReplying="showReplyForm" + @toggleDiscussion="toggleDiscussionHandler" + @deleteNote="deleteNoteHandler" + > + <slot slot="avatar-badge" name="avatar-badge"></slot> + <template #footer="{ showReplies }"> + <draft-note + v-if="showDraft(discussion.reply_id)" + :key="`draft_${discussion.id}`" + :draft="draftForDiscussion(discussion.reply_id)" + /> + <div + v-else-if="showReplies" + :class="{ 'is-replying': isReplying }" + class="discussion-reply-holder" + > + <user-avatar-link + v-if="!isReplying && currentUser" + :link-href="currentUser.path" + :img-src="currentUser.avatar_url" + :img-alt="currentUser.name" + :img-size="40" + class="d-none d-sm-block" + /> + <discussion-actions + v-if="!isReplying && userCanReply" + :discussion="discussion" + :is-resolving="isResolving" + :resolve-button-title="resolveButtonTitle" + :resolve-with-issue-path="resolveWithIssuePath" + :should-show-jump-to-next-discussion="shouldShowJumpToNextDiscussion" + @showReplyForm="showReplyForm" + @resolve="resolveHandler" + @jumpToNextDiscussion="jumpToNextDiscussion" /> - <template v-if="!isRepliesCollapsed"> - <component - :is="componentName(note)" - v-for="note in replies" - :key="note.id" - :note="componentData(note)" - :help-page-path="helpPagePath" - :line="line" - @handleDeleteNote="deleteNoteHandler" + <div v-if="isReplying" class="avatar-note-form-holder"> + <user-avatar-link + v-if="currentUser" + :link-href="currentUser.path" + :img-src="currentUser.avatar_url" + :img-alt="currentUser.name" + :img-size="40" + class="d-none d-sm-block" /> - </template> - </template> - <template v-else> - <component - :is="componentName(note)" - v-for="(note, index) in discussion.notes" - :key="note.id" - :note="componentData(note)" - :help-page-path="helpPagePath" - :line="diffLine" - @handleDeleteNote="deleteNoteHandler" - > - <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot> - </component> - </template> - </ul> - <div - v-if="!isRepliesCollapsed || !hasReplies" - :class="{ 'is-replying': isReplying }" - class="discussion-reply-holder" - > - <template v-if="!isReplying && canReply"> - <div class="discussion-with-resolve-btn"> - <reply-placeholder class="qa-discussion-reply" @onClick="showReplyForm" /> - <resolve-discussion-button - v-if="discussion.resolvable" - :is-resolving="isResolving" - :button-title="resolveButtonTitle" - @onClick="resolveHandler" + <note-form + ref="noteForm" + :discussion="discussion" + :is-editing="false" + :line="diffLine" + save-button-title="Comment" + :autosave-key="autosaveKey" + @handleFormUpdateAddToReview="addReplyToReview" + @handleFormUpdate="saveReply" + @cancelForm="cancelReplyForm" /> - <div - v-if="discussion.resolvable" - class="btn-group discussion-actions ml-sm-2" - role="group" - > - <resolve-with-issue-button - v-if="resolveWithIssuePath" - :url="resolveWithIssuePath" - /> - <jump-to-next-discussion-button - v-if="shouldShowJumpToNextDiscussion" - @onClick="jumpToNextDiscussion" - /> - </div> </div> - </template> - <note-form - v-if="isReplying" - ref="noteForm" - :discussion="discussion" - :is-editing="false" - :line="diffLine" - save-button-title="Comment" - @handleFormUpdate="saveReply" - @cancelForm="cancelReplyForm" - /> - <note-signed-out-widget v-if="!canReply" /> - </div> - </div> + <note-signed-out-widget v-if="!userCanReply" /> + </div> + </template> + </discussion-notes> </component> </div> </div> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 04e74a43acc..aa80e25a3e0 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -4,12 +4,13 @@ import { mapGetters, mapActions } from 'vuex'; import { escape } from 'underscore'; import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import draftMixin from 'ee_else_ce/notes/mixins/draft'; import { s__, sprintf } from '../../locale'; import Flash from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import noteHeader from './note_header.vue'; import noteActions from './note_actions.vue'; -import noteBody from './note_body.vue'; +import NoteBody from './note_body.vue'; import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; @@ -20,10 +21,10 @@ export default { userAvatarLink, noteHeader, noteActions, - noteBody, + NoteBody, TimelineEntryItem, }, - mixins: [noteable, resolvable], + mixins: [noteable, resolvable, draftMixin], props: { note: { type: Object, @@ -73,11 +74,8 @@ export default { 'is-editable': this.note.current_user.can_edit, }; }, - canResolve() { - return this.note.resolvable && !!this.getUserData.id; - }, canReportAsAbuse() { - return !!this.note.report_abuse_path && this.author.id !== this.getUserData.id; + return Boolean(this.note.report_abuse_path) && this.author.id !== this.getUserData.id; }, noteAnchorId() { return `note_${this.note.id}`; @@ -96,7 +94,7 @@ export default { return ''; } - // We need to do this to ensure we have the currect sentence order + // We need to do this to ensure we have the correct sentence order // when translating this as the sentence order may change from one // language to the next. See: // https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24427#note_133713771 @@ -156,12 +154,16 @@ export default { this.$refs.noteBody.resetAutoSave(); this.$emit('updateSuccess'); }, - formUpdateHandler(noteText, parentElement, callback) { + formUpdateHandler(noteText, parentElement, callback, resolveDiscussion) { this.$emit('handleUpdateNote', { note: this.note, noteText, + resolveDiscussion, callback: () => this.updateSuccess(), }); + + if (this.isDraft) return; + const data = { endpoint: this.note.path, note: { @@ -207,7 +209,10 @@ export default { // we need to do this to prevent noteForm inconsistent content warning // this is something we intentionally do so we need to recover the content this.note.note = noteText; - this.$refs.noteBody.note.note = noteText; + const { noteBody } = this.$refs; + if (noteBody) { + noteBody.note.note = noteText; + } }, }, }; @@ -219,7 +224,7 @@ export default { :class="classNameBindings" :data-award-url="note.toggle_award_path" :data-note-id="note.id" - class="note note-wrapper" + class="note note-wrapper qa-noteable-note-item" > <div v-once class="timeline-icon"> <user-avatar-link @@ -234,6 +239,7 @@ export default { <div class="timeline-content"> <div class="note-header"> <note-header v-once :author="author" :created-at="note.created_at" :note-id="note.id"> + <slot slot="note-header-info" name="note-header-info"></slot> <span v-if="commit" v-html="actionText"></span> <span v-else class="d-none d-sm-inline">·</span> </note-header> @@ -247,12 +253,15 @@ export default { :can-award-emoji="note.current_user.can_award_emoji" :can-delete="note.current_user.can_edit" :can-report-as-abuse="canReportAsAbuse" - :can-resolve="note.current_user.can_resolve" + :can-resolve="canResolve" :report-abuse-path="note.report_abuse_path" - :resolvable="note.resolvable" - :is-resolved="note.resolved" + :resolvable="note.resolvable || note.isDraft" + :is-resolved="note.resolved || note.resolve_discussion" :is-resolving="isResolving" :resolved-by="note.resolved_by" + :is-draft="note.isDraft" + :resolve-discussion="note.isDraft && note.resolve_discussion" + :discussion-id="discussionId" @handleEdit="editHandler" @handleDelete="deleteHandler" @handleResolve="resolveHandler" diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 8d3f6d902f8..4d00e957973 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -6,6 +6,7 @@ import * as constants from '../constants'; import eventHub from '../event_hub'; import noteableNote from './noteable_note.vue'; import noteableDiscussion from './noteable_discussion.vue'; +import discussionFilterNote from './discussion_filter_note.vue'; import systemNote from '../../vue_shared/components/notes/system_note.vue'; import commentForm from './comment_form.vue'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; @@ -24,6 +25,7 @@ export default { placeholderNote, placeholderSystemNote, skeletonLoadingContainer, + discussionFilterNote, }, props: { noteableData: { @@ -65,6 +67,7 @@ export default { 'isLoading', 'commentsDisabled', 'getNoteableData', + 'userCanReply', ]), noteableType() { return this.noteableData.noteableType; @@ -81,7 +84,7 @@ export default { return this.discussions; }, canReply() { - return this.getNoteableData.current_user.can_create_note && !this.commentsDisabled; + return this.userCanReply && !this.commentsDisabled; }, }, watch: { @@ -124,6 +127,9 @@ export default { initUserPopovers(this.$el.querySelectorAll('.js-user-link')); }); }, + beforeDestroy() { + this.stopPolling(); + }, methods: { ...mapActions([ 'setLoadingState', @@ -141,6 +147,7 @@ export default { 'expandDiscussion', 'startTaskList', 'convertToDiscussion', + 'stopPolling', ]), fetchNotes() { if (this.isFetching) return null; @@ -235,6 +242,7 @@ export default { :help-page-path="helpPagePath" /> </template> + <discussion-filter-note v-show="commentsDisabled" /> </ul> <comment-form v-if="!commentsDisabled" :noteable-type="noteableType" /> diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index 78d365fe94b..bdfb6b8f105 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -7,6 +7,7 @@ export const COMMENT = 'comment'; 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'; @@ -24,3 +25,9 @@ export const NOTEABLE_TYPE_MAPPING = { MergeRequest: MERGE_REQUEST_NOTEABLE_TYPE, Epic: EPIC_NOTEABLE_TYPE, }; + +export const DISCUSSION_FILTER_TYPES = { + ALL: 'all', + COMMENTS: 'comments', + HISTORY: 'history', +}; diff --git a/app/assets/javascripts/notes/discussion_filters.js b/app/assets/javascripts/notes/discussion_filters.js index 5c5f38a3fb0..cdf9a46c5aa 100644 --- a/app/assets/javascripts/notes/discussion_filters.js +++ b/app/assets/javascripts/notes/discussion_filters.js @@ -6,12 +6,16 @@ export default store => { if (discussionFilterEl) { const { defaultFilter, notesFilters } = discussionFilterEl.dataset; - const selectedValue = defaultFilter ? parseInt(defaultFilter, 10) : null; const filterValues = notesFilters ? JSON.parse(notesFilters) : {}; const filters = Object.keys(filterValues).map(entry => ({ title: entry, value: filterValues[entry], })); + const props = { filters }; + + if (defaultFilter) { + props.selectedValue = parseInt(defaultFilter, 10); + } return new Vue({ el: discussionFilterEl, @@ -21,12 +25,7 @@ export default store => { }, store, render(createElement) { - return createElement('discussion-filter', { - props: { - filters, - selectedValue, - }, - }); + return createElement('discussion-filter', { props }); }, }); } diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 4883266dae5..57dd1c5cab2 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -1,4 +1,6 @@ import Vue from 'vue'; +import { isEE } from '~/lib/utils/common_utils'; +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'; @@ -6,9 +8,8 @@ import createStore from './stores'; document.addEventListener('DOMContentLoaded', () => { const store = createStore(); - initDiscussionFilters(store); - - return new Vue({ + // eslint-disable-next-line no-new + new Vue({ el: '#js-vue-notes', components: { notesApp, @@ -39,6 +40,11 @@ document.addEventListener('DOMContentLoaded', () => { notesData: JSON.parse(notesDataset.notesData), }; }, + mounted() { + if (isEE) { + initNoteStats(); + } + }, render(createElement) { return createElement('notes-app', { props: { @@ -49,4 +55,6 @@ document.addEventListener('DOMContentLoaded', () => { }); }, }); + + initDiscussionFilters(store); }); diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js index 4f45f912479..b161773f5f1 100644 --- a/app/assets/javascripts/notes/mixins/autosave.js +++ b/app/assets/javascripts/notes/mixins/autosave.js @@ -1,12 +1,13 @@ import $ from 'jquery'; import Autosave from '../../autosave'; import { capitalizeFirstCharacter } from '../../lib/utils/text_utility'; +import { s__ } from '~/locale'; export default { methods: { initAutoSave(noteable, extraKeys = []) { let keys = [ - 'Note', + s__('Autosave|Note'), capitalizeFirstCharacter(noteable.noteable_type || noteable.noteableType), noteable.id, ]; diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js new file mode 100644 index 00000000000..188556e8921 --- /dev/null +++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js @@ -0,0 +1,10 @@ +export default { + computed: { + draftForDiscussion: () => () => ({}), + }, + methods: { + showDraft: () => false, + addReplyToReview: () => {}, + addToReview: () => {}, + }, +}; diff --git a/app/assets/javascripts/notes/mixins/draft.js b/app/assets/javascripts/notes/mixins/draft.js new file mode 100644 index 00000000000..1370f3978df --- /dev/null +++ b/app/assets/javascripts/notes/mixins/draft.js @@ -0,0 +1,8 @@ +export default { + computed: { + isDraft: () => false, + canResolve() { + return this.note.current_user.can_resolve; + }, + }, +}; diff --git a/app/assets/javascripts/notes/mixins/get_discussion.js b/app/assets/javascripts/notes/mixins/get_discussion.js new file mode 100644 index 00000000000..b5d820fe083 --- /dev/null +++ b/app/assets/javascripts/notes/mixins/get_discussion.js @@ -0,0 +1,7 @@ +export default { + computed: { + discussion() { + return {}; + }, + }, +}; diff --git a/app/assets/javascripts/notes/mixins/issuable_state.js b/app/assets/javascripts/notes/mixins/issuable_state.js index 97f3ea0d5de..d97d9f6850a 100644 --- a/app/assets/javascripts/notes/mixins/issuable_state.js +++ b/app/assets/javascripts/notes/mixins/issuable_state.js @@ -1,11 +1,22 @@ +import { mapGetters } from 'vuex'; + export default { + computed: { + ...mapGetters(['getNoteableDataByProp']), + lockedIssueDocsPath() { + return this.getNoteableDataByProp('locked_discussion_docs_path'); + }, + confidentialIssueDocsPath() { + return this.getNoteableDataByProp('confidential_issues_docs_path'); + }, + }, methods: { isConfidential(issue) { - return !!issue.confidential; + return Boolean(issue.confidential); }, isLocked(issue) { - return !!issue.discussion_locked; + return Boolean(issue.discussion_locked); }, hasWarning(issue) { diff --git a/app/assets/javascripts/notes/mixins/note_form.js b/app/assets/javascripts/notes/mixins/note_form.js new file mode 100644 index 00000000000..b74879f2256 --- /dev/null +++ b/app/assets/javascripts/notes/mixins/note_form.js @@ -0,0 +1,24 @@ +export default { + data() { + return { + showBatchCommentsActions: false, + }; + }, + methods: { + handleKeySubmit() { + this.handleUpdate(); + }, + handleUpdate(shouldResolve) { + const beforeSubmitDiscussionState = this.discussionResolved; + this.isSubmitting = true; + + this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => { + this.isSubmitting = false; + + if (this.shouldToggleResolved(shouldResolve, beforeSubmitDiscussionState)) { + this.resolveHandler(beforeSubmitDiscussionState); + } + }); + }, + }, +}; diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js index 8edf3d088bb..2329727bca2 100644 --- a/app/assets/javascripts/notes/mixins/resolvable.js +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -31,6 +31,10 @@ export default { }, methods: { resolveHandler(resolvedState = false) { + if (this.note && this.note.isDraft) { + return this.$emit('toggleResolveStatus'); + } + this.isResolving = true; const isResolved = this.discussionResolved || resolvedState; const discussion = this.resolveAsThread; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 1a0dba69a7c..63658d49a05 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -142,6 +142,23 @@ export const createNewNote = ({ commit, dispatch }, { endpoint, data }) => export const removePlaceholderNotes = ({ commit }) => commit(types.REMOVE_PLACEHOLDER_NOTES); +export const resolveDiscussion = ({ state, dispatch, getters }, { discussionId }) => { + const discussion = utils.findNoteObjectById(state.discussions, discussionId); + const isResolved = getters.isDiscussionResolved(discussionId); + + if (!discussion) { + return Promise.reject(); + } else if (isResolved) { + return Promise.resolve(); + } + + return dispatch('toggleResolveNote', { + endpoint: discussion.resolve_path, + isResolved, + discussion: true, + }); +}; + export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, discussion }) => service .toggleResolveNote(endpoint, isResolved) @@ -251,11 +268,20 @@ export const saveNote = ({ commit, dispatch }, noteData) => { const { errors } = res; const commandsChanges = res.commands_changes; - if (hasQuickActions && errors && Object.keys(errors).length) { - eTagPoll.makeRequest(); + if (errors && Object.keys(errors).length) { + /* + The following reply means that quick actions have been successfully applied: + + {"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); + $('.js-gfm-input').trigger('clear-commands-cache.atwho'); + Flash(__('Commands applied'), 'notice', noteData.flashContainer); + } else { + throw new Error(__('Failed to save comment!')); + } } if (commandsChanges) { @@ -269,7 +295,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => { }) .catch(() => { Flash( - 'Something went wrong while adding your award. Please try again.', + __('Something went wrong while adding your award. Please try again.'), 'alert', noteData.flashContainer, ); @@ -311,7 +337,7 @@ export const poll = ({ commit, state, getters, dispatch }) => { data: state, successCallback: resp => resp.json().then(data => pollSuccessCallBack(data, commit, state, getters, dispatch)), - errorCallback: () => Flash('Something went wrong while fetching latest comments.'), + errorCallback: () => Flash(__('Something went wrong while fetching latest comments.')), }); if (!Visibility.hidden()) { @@ -347,7 +373,7 @@ export const fetchData = ({ commit, state, getters }) => { .poll(requestData) .then(resp => resp.json) .then(data => pollSuccessCallBack(data, commit, state, getters)) - .catch(() => Flash('Something went wrong while fetching latest comments.')); + .catch(() => Flash(__('Something went wrong while fetching latest comments.'))); }; export const toggleAward = ({ commit, getters }, { awardName, noteId }) => { @@ -420,15 +446,13 @@ export const updateResolvableDiscussonsCounts = ({ commit }) => commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS); export const submitSuggestion = ( - { commit }, - { discussionId, noteId, suggestionId, flashContainer, callback }, -) => { + { commit, dispatch }, + { discussionId, noteId, suggestionId, flashContainer }, +) => service .applySuggestion(suggestionId) - .then(() => { - commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }); - callback(); - }) + .then(() => commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId })) + .then(() => dispatch('resolveDiscussion', { discussionId }).catch(() => {})) .catch(err => { const defaultMessage = __( 'Something went wrong while applying the suggestion. Please try again.', @@ -436,9 +460,7 @@ export const submitSuggestion = ( const flashMessage = err.response.data ? `${err.response.data.message}.` : defaultMessage; Flash(__(flashMessage), 'alert', flashContainer); - callback(); }); -}; export const convertToDiscussion = ({ commit }, noteId) => commit(types.CONVERT_TO_DISCUSSION, noteId); diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 5026c13dab5..d7982be3e4b 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -20,6 +20,8 @@ export const getNoteableData = state => state.noteableData; export const getNoteableDataByProp = state => prop => state.noteableData[prop]; +export const userCanReply = state => Boolean(state.noteableData.current_user.can_create_note); + export const openState = state => state.noteableData.state; export const getUserData = state => state.userData || {}; @@ -191,6 +193,9 @@ export const firstUnresolvedDiscussionId = (state, getters) => diffOrder => { return getters.unresolvedDiscussionsIdsByDate[0]; }; +export const getDiscussion = state => discussionId => + state.discussions.find(discussion => discussion.id === discussionId); + export const commentsDisabled = state => state.commentsDisabled; // prevent babel-plugin-rewire from generating an invalid default during karma tests diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index ae6f8b7790a..fa44ef2d057 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -193,6 +193,10 @@ export default { const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id); if (noteObj.individual_note) { + if (note.type === constants.DISCUSSION_NOTE) { + noteObj.individual_note = false; + } + noteObj.notes.splice(0, 1, note); } else { const comment = utils.findNoteObjectById(noteObj.notes, note.id); diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js index 4b0feb0f94d..ed4cef4a917 100644 --- a/app/assets/javascripts/notes/stores/utils.js +++ b/app/assets/javascripts/notes/stores/utils.js @@ -1,12 +1,14 @@ import AjaxCache from '~/lib/utils/ajax_cache'; import { trimFirstCharOfLineContent } from '~/diffs/store/utils'; +import { sprintf, __ } from '~/locale'; -const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; +// factory function because global flag makes RegExp stateful +const createQuickActionsRegex = () => /^\/\w+.*$/gm; export const findNoteObjectById = (notes, id) => notes.filter(n => n.id === id)[0]; export const getQuickActionText = note => { - let text = 'Applying command'; + let text = __('Applying command'); const quickActions = AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || []; const executedCommands = quickActions.filter(command => { @@ -16,19 +18,19 @@ export const getQuickActionText = note => { if (executedCommands && executedCommands.length) { if (executedCommands.length > 1) { - text = 'Applying multiple commands'; + text = __('Applying multiple commands'); } else { const commandDescription = executedCommands[0].description.toLowerCase(); - text = `Applying command to ${commandDescription}`; + text = sprintf(__('Applying command to %{commandDescription}', { commandDescription })); } } return text; }; -export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note); +export const hasQuickActions = note => createQuickActionsRegex().test(note); -export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim(); +export const stripQuickActions = note => note.replace(createQuickActionsRegex(), '').trim(); export const prepareDiffLines = diffLines => diffLines.map(line => ({ ...trimFirstCharOfLineContent(line) })); diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js index e7fa05faa8a..08545dcea46 100644 --- a/app/assets/javascripts/notifications_dropdown.js +++ b/app/assets/javascripts/notifications_dropdown.js @@ -1,9 +1,11 @@ import $ from 'jquery'; import Flash from './flash'; +import { __ } from '~/locale'; export default function notificationsDropdown() { $(document).on('click', '.update-notification', function updateNotificationCallback(e) { e.preventDefault(); + if ($(this).is('.is-active') && $(this).data('notificationLevel') === 'custom') { return; } @@ -26,7 +28,7 @@ export default function notificationsDropdown() { .closest('.js-notification-dropdown') .replaceWith(data.html); } else { - Flash('Failed to save new settings', 'alert'); + Flash(__('Failed to save new settings'), 'alert'); } }); } diff --git a/app/assets/javascripts/operation_settings/components/external_dashboard.vue b/app/assets/javascripts/operation_settings/components/external_dashboard.vue new file mode 100644 index 00000000000..ed518611d0b --- /dev/null +++ b/app/assets/javascripts/operation_settings/components/external_dashboard.vue @@ -0,0 +1,67 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlButton, GlFormGroup, GlFormInput, GlLink } from '@gitlab/ui'; + +export default { + components: { + GlButton, + GlFormGroup, + GlFormInput, + GlLink, + }, + computed: { + ...mapState([ + 'externalDashboardHelpPagePath', + 'externalDashboardUrl', + 'operationsSettingsEndpoint', + ]), + userDashboardUrl: { + get() { + return this.externalDashboardUrl; + }, + set(url) { + this.setExternalDashboardUrl(url); + }, + }, + }, + methods: { + ...mapActions(['setExternalDashboardUrl', 'updateExternalDashboardUrl']), + }, +}; +</script> + +<template> + <section class="settings no-animate"> + <div class="settings-header"> + <h4 class="js-section-header"> + {{ s__('ExternalMetrics|External Dashboard') }} + </h4> + <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> + <p class="js-section-sub-header"> + {{ + s__( + 'ExternalMetrics|Add a button to the metrics dashboard linking directly to your existing external dashboards.', + ) + }} + <gl-link :href="externalDashboardHelpPagePath">{{ __('Learn more') }}</gl-link> + </p> + </div> + <div class="settings-content"> + <form> + <gl-form-group + :label="s__('ExternalMetrics|Full dashboard URL')" + :description="s__('ExternalMetrics|Enter the URL of the dashboard you want to link to')" + > + <gl-form-input + v-model="userDashboardUrl" + placeholder="https://my-org.gitlab.io/my-dashboards" + @keydown.enter.native.prevent="updateExternalDashboardUrl" + /> + </gl-form-group> + <gl-button variant="success" @click="updateExternalDashboardUrl"> + {{ __('Save Changes') }} + </gl-button> + </form> + </div> + </section> +</template> diff --git a/app/assets/javascripts/operation_settings/index.js b/app/assets/javascripts/operation_settings/index.js new file mode 100644 index 00000000000..6946578e6d2 --- /dev/null +++ b/app/assets/javascripts/operation_settings/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import store from './store'; +import ExternalDashboardForm from './components/external_dashboard.vue'; + +export default () => { + /** + * This check can be removed when we remove + * the :grafana_dashboard_link feature flag + */ + if (!gon.features.grafanaDashboardLink) { + return null; + } + + const el = document.querySelector('.js-operation-settings'); + + return new Vue({ + el, + store: store(el.dataset), + render(createElement) { + return createElement(ExternalDashboardForm); + }, + }); +}; diff --git a/app/assets/javascripts/operation_settings/store/actions.js b/app/assets/javascripts/operation_settings/store/actions.js new file mode 100644 index 00000000000..ec05b0c76cf --- /dev/null +++ b/app/assets/javascripts/operation_settings/store/actions.js @@ -0,0 +1,38 @@ +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import * as mutationTypes from './mutation_types'; + +export const setExternalDashboardUrl = ({ commit }, url) => + commit(mutationTypes.SET_EXTERNAL_DASHBOARD_URL, url); + +export const updateExternalDashboardUrl = ({ state, dispatch }) => + axios + .patch(state.operationsSettingsEndpoint, { + project: { + metrics_setting_attributes: { + external_dashboard_url: state.externalDashboardUrl, + }, + }, + }) + .then(() => dispatch('receiveExternalDashboardUpdateSuccess')) + .catch(error => dispatch('receiveExternalDashboardUpdateError', error)); + +export const receiveExternalDashboardUpdateSuccess = () => { + /** + * The operations_controller currently handles successful requests + * by creating a flash banner messsage to notify the user. + */ + refreshCurrentPage(); +}; + +export const receiveExternalDashboardUpdateError = (_, error) => { + const { response } = error; + const message = response.data && response.data.message ? response.data.message : ''; + + createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert'); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/operation_settings/store/index.js b/app/assets/javascripts/operation_settings/store/index.js new file mode 100644 index 00000000000..e96bb1e8aad --- /dev/null +++ b/app/assets/javascripts/operation_settings/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export const createStore = initialState => + new Vuex.Store({ + state: createState(initialState), + actions, + mutations, + }); + +export default createStore; diff --git a/app/assets/javascripts/operation_settings/store/mutation_types.js b/app/assets/javascripts/operation_settings/store/mutation_types.js new file mode 100644 index 00000000000..237d2b6122f --- /dev/null +++ b/app/assets/javascripts/operation_settings/store/mutation_types.js @@ -0,0 +1,3 @@ +/* eslint-disable import/prefer-default-export */ + +export const SET_EXTERNAL_DASHBOARD_URL = 'SET_EXTERNAL_DASHBOARD_URL'; diff --git a/app/assets/javascripts/operation_settings/store/mutations.js b/app/assets/javascripts/operation_settings/store/mutations.js new file mode 100644 index 00000000000..64bb33bb89f --- /dev/null +++ b/app/assets/javascripts/operation_settings/store/mutations.js @@ -0,0 +1,7 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_EXTERNAL_DASHBOARD_URL](state, url) { + state.externalDashboardUrl = url; + }, +}; diff --git a/app/assets/javascripts/operation_settings/store/state.js b/app/assets/javascripts/operation_settings/store/state.js new file mode 100644 index 00000000000..72167141c48 --- /dev/null +++ b/app/assets/javascripts/operation_settings/store/state.js @@ -0,0 +1,5 @@ +export default (initialState = {}) => ({ + externalDashboardUrl: initialState.externalDashboardUrl || '', + operationsSettingsEndpoint: initialState.operationsSettingsEndpoint, + externalDashboardHelpPagePath: initialState.externalDashboardHelpPagePath, +}); diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js index d5ded3f9a79..6e00e31b828 100644 --- a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js +++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js @@ -22,7 +22,7 @@ export default () => { _.debounce(function onMessageInput() { const message = $(this).val(); if (message === '') { - $('.js-broadcast-message-preview').text('Your message here'); + $('.js-broadcast-message-preview').text(__('Your message here')); } else { axios .post(previewPath, { diff --git a/app/assets/javascripts/pages/admin/clusters/destroy/index.js b/app/assets/javascripts/pages/admin/clusters/destroy/index.js new file mode 100644 index 00000000000..8001d2dd1da --- /dev/null +++ b/app/assets/javascripts/pages/admin/clusters/destroy/index.js @@ -0,0 +1,5 @@ +import ClustersBundle from '~/clusters/clusters_bundle'; + +document.addEventListener('DOMContentLoaded', () => { + new ClustersBundle(); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/admin/clusters/edit/index.js b/app/assets/javascripts/pages/admin/clusters/edit/index.js new file mode 100644 index 00000000000..8001d2dd1da --- /dev/null +++ b/app/assets/javascripts/pages/admin/clusters/edit/index.js @@ -0,0 +1,5 @@ +import ClustersBundle from '~/clusters/clusters_bundle'; + +document.addEventListener('DOMContentLoaded', () => { + new ClustersBundle(); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/admin/clusters/index.js b/app/assets/javascripts/pages/admin/clusters/index.js new file mode 100644 index 00000000000..d0c9ae66c6a --- /dev/null +++ b/app/assets/javascripts/pages/admin/clusters/index.js @@ -0,0 +1,21 @@ +import PersistentUserCallout from '~/persistent_user_callout'; +import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; + +function initGcpSignupCallout() { + const callout = document.querySelector('.gcp-signup-offer'); + PersistentUserCallout.factory(callout); +} + +document.addEventListener('DOMContentLoaded', () => { + const { page } = document.body.dataset; + const newClusterViews = [ + 'admin:clusters:new', + 'admin:clusters:create_gcp', + 'admin:clusters:create_user', + ]; + + if (newClusterViews.indexOf(page) > -1) { + initGcpSignupCallout(); + initGkeDropdowns(); + } +}); diff --git a/app/assets/javascripts/pages/admin/clusters/index/index.js b/app/assets/javascripts/pages/admin/clusters/index/index.js new file mode 100644 index 00000000000..30d519d0e37 --- /dev/null +++ b/app/assets/javascripts/pages/admin/clusters/index/index.js @@ -0,0 +1,6 @@ +import PersistentUserCallout from '~/persistent_user_callout'; + +document.addEventListener('DOMContentLoaded', () => { + const callout = document.querySelector('.gcp-signup-offer'); + PersistentUserCallout.factory(callout); +}); diff --git a/app/assets/javascripts/pages/admin/clusters/show/index.js b/app/assets/javascripts/pages/admin/clusters/show/index.js new file mode 100644 index 00000000000..8001d2dd1da --- /dev/null +++ b/app/assets/javascripts/pages/admin/clusters/show/index.js @@ -0,0 +1,5 @@ +import ClustersBundle from '~/clusters/clusters_bundle'; + +document.addEventListener('DOMContentLoaded', () => { + new ClustersBundle(); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/admin/groups/edit/index.js b/app/assets/javascripts/pages/admin/groups/edit/index.js index d3d125a1859..ad7276132b9 100644 --- a/app/assets/javascripts/pages/admin/groups/edit/index.js +++ b/app/assets/javascripts/pages/admin/groups/edit/index.js @@ -1,3 +1,3 @@ -import groupAvatar from '~/group_avatar'; +import initAvatarPicker from '~/avatar_picker'; -document.addEventListener('DOMContentLoaded', groupAvatar); +document.addEventListener('DOMContentLoaded', initAvatarPicker); diff --git a/app/assets/javascripts/pages/admin/groups/new/index.js b/app/assets/javascripts/pages/admin/groups/new/index.js index 21f1ce222ac..6de740ee9ce 100644 --- a/app/assets/javascripts/pages/admin/groups/new/index.js +++ b/app/assets/javascripts/pages/admin/groups/new/index.js @@ -1,9 +1,9 @@ import BindInOut from '../../../../behaviors/bind_in_out'; import Group from '../../../../group'; -import groupAvatar from '../../../../group_avatar'; +import initAvatarPicker from '~/avatar_picker'; document.addEventListener('DOMContentLoaded', () => { BindInOut.initAll(); new Group(); // eslint-disable-line no-new - groupAvatar(); + initAvatarPicker(); }); diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js index 260484726f3..ff758fcb4fe 100644 --- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js +++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js @@ -1,10 +1,11 @@ import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; +import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/pages/constants'; document.addEventListener('DOMContentLoaded', () => { - IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests(); + addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, diff --git a/app/assets/javascripts/pages/groups/clusters/index/index.js b/app/assets/javascripts/pages/groups/clusters/index/index.js index 21efc4f6d00..30d519d0e37 100644 --- a/app/assets/javascripts/pages/groups/clusters/index/index.js +++ b/app/assets/javascripts/pages/groups/clusters/index/index.js @@ -2,6 +2,5 @@ import PersistentUserCallout from '~/persistent_user_callout'; document.addEventListener('DOMContentLoaded', () => { const callout = document.querySelector('.gcp-signup-offer'); - - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new + PersistentUserCallout.factory(callout); }); diff --git a/app/assets/javascripts/pages/groups/details/index.js b/app/assets/javascripts/pages/groups/details/index.js new file mode 100644 index 00000000000..3bcaa0f0232 --- /dev/null +++ b/app/assets/javascripts/pages/groups/details/index.js @@ -0,0 +1,5 @@ +import initGroupDetails from '../shared/group_details'; + +document.addEventListener('DOMContentLoaded', () => { + initGroupDetails('details'); +}); diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index 01ef445c901..d036ff07d89 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -1,4 +1,4 @@ -import groupAvatar from '~/group_avatar'; +import initAvatarPicker from '~/avatar_picker'; import TransferDropdown from '~/groups/transfer_dropdown'; import initConfirmDangerModal from '~/confirm_danger_modal'; import initSettingsPanels from '~/settings_panels'; @@ -9,7 +9,7 @@ import groupsSelect from '~/groups_select'; import projectSelect from '~/project_select'; document.addEventListener('DOMContentLoaded', () => { - groupAvatar(); + initAvatarPicker(); new TransferDropdown(); // eslint-disable-line no-new initConfirmDangerModal(); initSettingsPanels(); diff --git a/app/assets/javascripts/pages/groups/group_members/index/index.js b/app/assets/javascripts/pages/groups/group_members/index/index.js index c22a164cd4e..e4f4c3b574e 100644 --- a/app/assets/javascripts/pages/groups/group_members/index/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index/index.js @@ -1,7 +1,7 @@ /* eslint-disable no-new */ import memberExpirationDate from '~/member_expiration_date'; -import Members from '~/members'; +import Members from 'ee_else_ce/members'; import UsersSelect from '~/users_select'; document.addEventListener('DOMContentLoaded', () => { diff --git a/app/assets/javascripts/pages/groups/index.js b/app/assets/javascripts/pages/groups/index.js index a63a0dbc6b1..451be6497de 100644 --- a/app/assets/javascripts/pages/groups/index.js +++ b/app/assets/javascripts/pages/groups/index.js @@ -3,8 +3,7 @@ import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; function initGcpSignupCallout() { const callout = document.querySelector('.gcp-signup-offer'); - - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new + PersistentUserCallout.factory(callout); } document.addEventListener('DOMContentLoaded', () => { diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index 21ec3f9f9ba..35d4b034654 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -1,7 +1,7 @@ import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import { FILTERED_SEARCH } from '~/pages/constants'; -import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; document.addEventListener('DOMContentLoaded', () => { IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); diff --git a/app/assets/javascripts/pages/groups/labels/edit/index.js b/app/assets/javascripts/pages/groups/labels/edit/index.js index fa81ad914ba..83d6ac9fd14 100644 --- a/app/assets/javascripts/pages/groups/labels/edit/index.js +++ b/app/assets/javascripts/pages/groups/labels/edit/index.js @@ -1,3 +1,3 @@ -import Labels from '~/labels'; +import Labels from 'ee_else_ce/labels'; document.addEventListener('DOMContentLoaded', () => new Labels()); diff --git a/app/assets/javascripts/pages/groups/labels/new/index.js b/app/assets/javascripts/pages/groups/labels/new/index.js index fa81ad914ba..83d6ac9fd14 100644 --- a/app/assets/javascripts/pages/groups/labels/new/index.js +++ b/app/assets/javascripts/pages/groups/labels/new/index.js @@ -1,3 +1,3 @@ -import Labels from '~/labels'; +import Labels from 'ee_else_ce/labels'; document.addEventListener('DOMContentLoaded', () => new Labels()); diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index 339ce67438a..12a26fd88fa 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -1,10 +1,11 @@ import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import { FILTERED_SEARCH } from '~/pages/constants'; document.addEventListener('DOMContentLoaded', () => { - IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests(); + addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index b2f275dc5ea..57b53eb9e5d 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -1,9 +1,9 @@ import BindInOut from '~/behaviors/bind_in_out'; import Group from '~/group'; -import groupAvatar from '~/group_avatar'; +import initAvatarPicker from '~/avatar_picker'; document.addEventListener('DOMContentLoaded', () => { BindInOut.initAll(); new Group(); // eslint-disable-line no-new - groupAvatar(); + initAvatarPicker(); }); diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index ae0a8c74964..8a5300c9266 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -12,5 +12,6 @@ document.addEventListener('DOMContentLoaded', () => { saveButton: variableListEl.querySelector('.js-ci-variables-save-button'), errorBox: variableListEl.querySelector('.js-ci-variable-error-box'), saveEndpoint: variableListEl.dataset.saveEndpoint, + maskableRegex: variableListEl.dataset.maskableRegex, }); }); diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js new file mode 100644 index 00000000000..01ef3f1db2b --- /dev/null +++ b/app/assets/javascripts/pages/groups/shared/group_details.js @@ -0,0 +1,31 @@ +/* eslint-disable no-new */ + +import { getPagePath } 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'; +import NotificationsForm from '~/notifications_form'; +import ProjectsList from '~/projects_list'; +import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; +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); + if (actionName && action === actionName) { + action = 'show'; // 'show' resets GroupTabs to default action through base class + } + + new GroupTabs({ parentEl: '.groups-listing', action }); + new ShortcutsNavigation(); + new NotificationsForm(); + notificationsDropdown(); + new ProjectsList(); + + if (newGroupChildWrapper) { + new NewGroupChild(newGroupChildWrapper); + } +} diff --git a/app/assets/javascripts/pages/groups/show/group_tabs.js b/app/assets/javascripts/pages/groups/shared/group_tabs.js index c6fe61d2bd9..c6fe61d2bd9 100644 --- a/app/assets/javascripts/pages/groups/show/group_tabs.js +++ b/app/assets/javascripts/pages/groups/shared/group_tabs.js diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js index 3a45fd70d02..82ee5ead83d 100644 --- a/app/assets/javascripts/pages/groups/show/index.js +++ b/app/assets/javascripts/pages/groups/show/index.js @@ -1,28 +1,7 @@ -/* eslint-disable no-new */ - -import { getPagePath } 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'; -import NotificationsForm from '~/notifications_form'; -import ProjectsList from '~/projects_list'; -import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import GroupTabs from './group_tabs'; +import leaveByUrl from '~/namespaces/leave_by_url'; +import initGroupDetails from '../shared/group_details'; document.addEventListener('DOMContentLoaded', () => { - 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]; - const action = loadableActions.includes(subpath) ? subpath : getPagePath(1); - - new GroupTabs({ parentEl: '.groups-listing', action }); - new ShortcutsNavigation(); - new NotificationsForm(); - notificationsDropdown(); - new ProjectsList(); - - if (newGroupChildWrapper) { - new NewGroupChild(newGroupChildWrapper); - } + leaveByUrl('group'); + initGroupDetails(); }); 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 a79ef07f1c5..c563514d36b 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 @@ -33,8 +33,7 @@ export default { text() { return sprintf( s__(`Milestones|Promoting %{milestoneTitle} will make it available for all projects inside %{groupName}. - Existing project milestones with the same title will be merged. - This action cannot be reversed.`), + Existing project milestones with the same title will be merged.`), { milestoneTitle: this.milestoneTitle, groupName: this.groupName }, ); }, @@ -72,6 +71,9 @@ export default { <template slot="title"> {{ title }} </template> - {{ text }} + <div> + <p>{{ text }}</p> + <p>{{ s__('Milestones|This action cannot be reversed.') }}</p> + </div> </gl-modal> </template> diff --git a/app/assets/javascripts/pages/profiles/keys/index.js b/app/assets/javascripts/pages/profiles/keys/index.js index 1cd3ee1dfdb..d3dcd21f456 100644 --- a/app/assets/javascripts/pages/profiles/keys/index.js +++ b/app/assets/javascripts/pages/profiles/keys/index.js @@ -2,6 +2,8 @@ import AddSshKeyValidation from '~/profile/add_ssh_key_validation'; document.addEventListener('DOMContentLoaded', () => { const input = document.querySelector('.js-add-ssh-key-validation-input'); + if (!input) return; + const warning = document.querySelector('.js-add-ssh-key-validation-warning'); const originalSubmit = input.form.querySelector('.js-add-ssh-key-validation-original-submit'); const confirmSubmit = warning.querySelector('.js-add-ssh-key-validation-confirm-submit'); diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js index 0dd0d5336fc..13cb0d6f74b 100644 --- a/app/assets/javascripts/pages/profiles/show/index.js +++ b/app/assets/javascripts/pages/profiles/show/index.js @@ -1,8 +1,9 @@ import $ from 'jquery'; import createFlash from '~/flash'; -import GfmAutoComplete from '~/gfm_auto_complete'; +import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import emojiRegex from 'emoji-regex'; import EmojiMenu from './emoji_menu'; +import { __ } from '~/locale'; const defaultStatusEmoji = 'speech_balloon'; @@ -48,7 +49,7 @@ document.addEventListener('DOMContentLoaded', () => { const EMOJI_REGEX = emojiRegex(); if (EMOJI_REGEX.test(userNameInput.value)) { // set field to invalid so it gets detected by GlFieldErrors - userNameInput.setCustomValidity('Invalid field'); + userNameInput.setCustomValidity(__('Invalid field')); } else { userNameInput.setCustomValidity(''); } @@ -81,5 +82,5 @@ document.addEventListener('DOMContentLoaded', () => { } }); }) - .catch(() => createFlash('Failed to load emoji list.')); + .catch(() => createFlash(__('Failed to load emoji list.'))); }); diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js index 21efc4f6d00..30d519d0e37 100644 --- a/app/assets/javascripts/pages/projects/clusters/index/index.js +++ b/app/assets/javascripts/pages/projects/clusters/index/index.js @@ -2,6 +2,5 @@ import PersistentUserCallout from '~/persistent_user_callout'; document.addEventListener('DOMContentLoaded', () => { const callout = document.querySelector('.gcp-signup-offer'); - - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new + PersistentUserCallout.factory(callout); }); diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index 899d5925956..92ed6a652d7 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -3,17 +3,24 @@ import initSettingsPanels from '~/settings_panels'; import setupProjectEdit from '~/project_edit'; import initConfirmDangerModal from '~/confirm_danger_modal'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; -import fileUpload from '~/lib/utils/file_upload'; +import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; +import initAvatarPicker from '~/avatar_picker'; import initProjectLoadingSpinner from '../shared/save_project_loader'; import initProjectPermissionsSettings from '../shared/permissions'; document.addEventListener('DOMContentLoaded', () => { - initProjectLoadingSpinner(); - setupProjectEdit(); - // Initialize expandable settings panels - initSettingsPanels(); - fileUpload('.js-choose-project-avatar-button', '.js-project-avatar-input'); - initProjectPermissionsSettings(); + initAvatarPicker(); initConfirmDangerModal(); + initSettingsPanels(); mountBadgeSettings(PROJECT_BADGE); + + initProjectLoadingSpinner(); + initProjectPermissionsSettings(); + setupProjectEdit(); + + dirtySubmitFactory( + document.querySelectorAll( + '.js-general-settings-form, .js-mr-settings-form, .js-mr-approvals-form', + ), + ); }); diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index b0345b4e50d..d4bd02c14e9 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -13,7 +13,7 @@ document.addEventListener('DOMContentLoaded', () => { if (newClusterViews.indexOf(page) > -1) { const callout = document.querySelector('.gcp-signup-offer'); - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new + PersistentUserCallout.factory(callout); initGkeDropdowns(); } diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js index ffc84dc106b..aecc6484b26 100644 --- a/app/assets/javascripts/pages/projects/issues/edit/index.js +++ b/app/assets/javascripts/pages/projects/issues/edit/index.js @@ -1,3 +1,3 @@ -import initForm from '../form'; +import initForm from 'ee_else_ce/pages/projects/issues/form'; document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js index f99023ad8e7..941c4552579 100644 --- a/app/assets/javascripts/pages/projects/issues/form.js +++ b/app/assets/javascripts/pages/projects/issues/form.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import GLForm from '~/gl_form'; -import IssuableForm from '~/issuable_form'; +import IssuableForm from 'ee_else_ce/issuable_form'; import LabelsSelect from '~/labels_select'; import MilestoneSelect from '~/milestone_select'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index bb91e38cb64..c34aff02111 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -4,9 +4,9 @@ import IssuableIndex from '~/issuable_index'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import UsersSelect from '~/users_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; -import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; +import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; document.addEventListener('DOMContentLoaded', () => { IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js index ffc84dc106b..aecc6484b26 100644 --- a/app/assets/javascripts/pages/projects/issues/new/index.js +++ b/app/assets/javascripts/pages/projects/issues/new/index.js @@ -1,3 +1,3 @@ -import initForm from '../form'; +import initForm from 'ee_else_ce/pages/projects/issues/form'; document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 8987c8e3f47..0447d1f79fb 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -4,9 +4,11 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ZenMode from '~/zen_mode'; import '~/notes/index'; import initIssueableApp from '~/issue_show'; +import initRelatedMergeRequestsApp from '~/related_merge_requests'; export default function() { initIssueableApp(); + initRelatedMergeRequestsApp(); new Issue(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/labels/edit/index.js b/app/assets/javascripts/pages/projects/labels/edit/index.js index fa81ad914ba..83d6ac9fd14 100644 --- a/app/assets/javascripts/pages/projects/labels/edit/index.js +++ b/app/assets/javascripts/pages/projects/labels/edit/index.js @@ -1,3 +1,3 @@ -import Labels from '~/labels'; +import Labels from 'ee_else_ce/labels'; document.addEventListener('DOMContentLoaded', () => new Labels()); diff --git a/app/assets/javascripts/pages/projects/labels/new/index.js b/app/assets/javascripts/pages/projects/labels/new/index.js index fa81ad914ba..83d6ac9fd14 100644 --- a/app/assets/javascripts/pages/projects/labels/new/index.js +++ b/app/assets/javascripts/pages/projects/labels/new/index.js @@ -1,3 +1,3 @@ -import Labels from '~/labels'; +import Labels from 'ee_else_ce/labels'; document.addEventListener('DOMContentLoaded', () => new Labels()); diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index ec39db12e74..0bcca22e40f 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -2,12 +2,13 @@ import IssuableIndex from '~/issuable_index'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import UsersSelect from '~/users_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; +import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; document.addEventListener('DOMContentLoaded', () => { - IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests(); + addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, 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 e3971618da5..8f0dc8554e2 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 @@ -4,7 +4,7 @@ import $ from 'jquery'; import Diff from '~/diff'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import GLForm from '~/gl_form'; -import IssuableForm from '~/issuable_form'; +import IssuableForm from 'ee_else_ce/issuable_form'; import LabelsSelect from '~/labels_select'; import MilestoneSelect from '~/milestone_select'; import IssuableTemplateSelectors from '~/templates/issuable_template_selectors'; diff --git a/app/assets/javascripts/pages/projects/pages_domains/edit/index.js b/app/assets/javascripts/pages/projects/pages_domains/edit/index.js new file mode 100644 index 00000000000..27e4433ad4d --- /dev/null +++ b/app/assets/javascripts/pages/projects/pages_domains/edit/index.js @@ -0,0 +1,3 @@ +import initForm from '~/pages/projects/pages_domains/form'; + +document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pages/projects/pages_domains/form.js b/app/assets/javascripts/pages/projects/pages_domains/form.js new file mode 100644 index 00000000000..1d0dbfe0406 --- /dev/null +++ b/app/assets/javascripts/pages/projects/pages_domains/form.js @@ -0,0 +1,43 @@ +import setupToggleButtons from '~/toggle_buttons'; + +export default () => { + const toggleContainer = document.querySelector('.js-auto-ssl-toggle-container'); + + if (toggleContainer) { + const onToggleButtonClicked = isAutoSslEnabled => { + Array.from(document.querySelectorAll('.js-shown-if-auto-ssl')).forEach(el => { + if (isAutoSslEnabled) { + el.classList.remove('d-none'); + } else { + el.classList.add('d-none'); + } + }); + + Array.from(document.querySelectorAll('.js-shown-unless-auto-ssl')).forEach(el => { + if (isAutoSslEnabled) { + el.classList.add('d-none'); + } else { + el.classList.remove('d-none'); + } + }); + + Array.from(document.querySelectorAll('.js-enabled-if-auto-ssl')).forEach(el => { + if (isAutoSslEnabled) { + el.removeAttribute('disabled'); + } else { + el.setAttribute('disabled', 'disabled'); + } + }); + + Array.from(document.querySelectorAll('.js-enabled-unless-auto-ssl')).forEach(el => { + if (isAutoSslEnabled) { + el.setAttribute('disabled', 'disabled'); + } else { + el.removeAttribute('disabled'); + } + }); + }; + + setupToggleButtons(toggleContainer, onToggleButtonClicked); + } +}; diff --git a/app/assets/javascripts/pages/projects/pages_domains/new/index.js b/app/assets/javascripts/pages/projects/pages_domains/new/index.js new file mode 100644 index 00000000000..27e4433ad4d --- /dev/null +++ b/app/assets/javascripts/pages/projects/pages_domains/new/index.js @@ -0,0 +1,3 @@ +import initForm from '~/pages/projects/pages_domains/form'; + +document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index bd4309e47ad..bb490919a9a 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue @@ -29,7 +29,7 @@ export default { // The text input is editable when there's a custom interval, or when it's // a preset interval and the user clicks the 'custom' radio button isEditable() { - return !!(this.customInputEnabled || !this.intervalIsPreset); + return Boolean(this.customInputEnabled || !this.intervalIsPreset); }, }, watch: { diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js index 95b57d5e048..a20a0526f12 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js @@ -1,15 +1,42 @@ -/* eslint-disable class-methods-use-this */ +const defaultTimezone = { name: 'UTC', offset: 0 }; +const defaults = { + $inputEl: null, + $dropdownEl: null, + onSelectTimezone: null, + displayFormat: item => item.name, +}; -import $ from 'jquery'; +export const formatUtcOffset = offset => { + const parsed = parseInt(offset, 10); + if (Number.isNaN(parsed) || parsed === 0) { + return `0`; + } + const prefix = offset > 0 ? '+' : '-'; + return `${prefix} ${Math.abs(offset / 3600)}`; +}; + +export const formatTimezone = item => `[UTC ${formatUtcOffset(item.offset)}] ${item.name}`; -const defaultTimezone = 'UTC'; +export const findTimezoneByIdentifier = (tzList = [], identifier = null) => { + if (tzList && tzList.length && identifier && identifier.length) { + return tzList.find(tz => tz.identifier === identifier) || null; + } + return null; +}; export default class TimezoneDropdown { - constructor() { - this.$dropdown = $('.js-timezone-dropdown'); + constructor({ $dropdownEl, $inputEl, onSelectTimezone, displayFormat } = defaults) { + this.$dropdown = $dropdownEl; this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text'); - this.$input = $('#schedule_cron_timezone'); + this.$input = $inputEl; this.timezoneData = this.$dropdown.data('data'); + + this.onSelectTimezone = onSelectTimezone; + this.displayFormat = displayFormat || defaults.displayFormat; + + this.initialTimezone = + findTimezoneByIdentifier(this.timezoneData, this.$input.val()) || defaultTimezone; + this.initDefaultTimezone(); this.initDropdown(); } @@ -19,50 +46,32 @@ export default class TimezoneDropdown { data: this.timezoneData, filterable: true, selectable: true, - toggleLabel: item => item.name, + toggleLabel: this.displayFormat, search: { fields: ['name'], }, clicked: cfg => this.updateInputValue(cfg), - text: item => this.formatTimezone(item), + text: item => formatTimezone(item), }); - this.setDropdownToggle(); - } - - formatUtcOffset(offset) { - let prefix = ''; - - if (offset > 0) { - prefix = '+'; - } else if (offset < 0) { - prefix = '-'; - } - - return `${prefix} ${Math.abs(offset / 3600)}`; - } - - formatTimezone(item) { - return `[UTC ${this.formatUtcOffset(item.offset)}] ${item.name}`; + this.setDropdownToggle(this.displayFormat(this.initialTimezone)); } initDefaultTimezone() { - const initialValue = this.$input.val(); - - if (!initialValue) { - this.$input.val(defaultTimezone); + if (!this.$input.val()) { + this.$input.val(defaultTimezone.name); } } - setDropdownToggle() { - const initialValue = this.$input.val(); - - this.$dropdownToggle.text(initialValue); + setDropdownToggle(dropdownText) { + this.$dropdownToggle.text(dropdownText); } updateInputValue({ selectedObj, e }) { e.preventDefault(); this.$input.val(selectedObj.identifier); - gl.pipelineScheduleFieldErrors.updateFormValidityState(); + if (this.onSelectTimezone) { + this.onSelectTimezone({ selectedObj, e }); + } } } diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js index 4d494efef6c..dc6df27f1c7 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js @@ -41,7 +41,13 @@ export default () => { const formElement = document.getElementById('new-pipeline-schedule-form'); - gl.timezoneDropdown = new TimezoneDropdown(); + gl.timezoneDropdown = new TimezoneDropdown({ + $dropdownEl: $('.js-timezone-dropdown'), + $inputEl: $('#schedule_cron_timezone'), + onSelectTimezone: () => { + gl.pipelineScheduleFieldErrors.updateFormValidityState(); + }, + }); gl.targetBranchDropdown = new TargetBranchDropdown(); gl.pipelineScheduleFieldErrors = new GlFieldErrors(formElement); diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index b288989b252..f0d529758d5 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -39,6 +39,11 @@ export default class Project { $label.text(activeText); }); + $('#modal-geo-info').data({ + cloneUrlSecondary: $this.attr('href'), + cloneUrlPrimary: $this.data('primaryUrl') || '', + }); + if (mobileCloneField) { mobileCloneField.dataset.clipboardText = url; } else { @@ -67,6 +72,13 @@ export default class Project { .remove(); return e.preventDefault(); }); + $('.hide-shared-runner-limit-message').on('click', function(e) { + var $alert = $(this).parents('.shared-runner-quota-message'); + var scope = $alert.data('scope'); + Cookies.set('hide_shared_runner_quota_message', 'false', { path: scope }); + $alert.remove(); + e.preventDefault(); + }); $('.hide-auto-devops-implicitly-enabled-banner').on('click', function(e) { const projectId = $(this).data('project-id'); const cookieKey = `hide_auto_devops_implicitly_enabled_banner_${projectId}`; diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index adbe744290a..f39765818e7 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -1,7 +1,7 @@ +import Members from 'ee_else_ce/members'; import memberExpirationDate from '../../../member_expiration_date'; import UsersSelect from '../../../users_select'; import groupsSelect from '../../../groups_select'; -import Members from '../../../members'; document.addEventListener('DOMContentLoaded', () => { memberExpirationDate('.js-access-expiration-date-groups'); diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index 15c6fb550c1..885247335a4 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -21,6 +21,7 @@ document.addEventListener('DOMContentLoaded', () => { saveButton: variableListEl.querySelector('.js-ci-variables-save-button'), errorBox: variableListEl.querySelector('.js-ci-variable-error-box'), saveEndpoint: variableListEl.dataset.saveEndpoint, + maskableRegex: variableListEl.dataset.maskableRegex, }); // hide extra auto devops settings based checkbox state diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js new file mode 100644 index 00000000000..98e19705976 --- /dev/null +++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js @@ -0,0 +1,9 @@ +import mountErrorTrackingForm from '~/error_tracking_settings'; +import mountOperationSettings from '~/operation_settings'; +import initSettingsPanels from '~/settings_panels'; + +document.addEventListener('DOMContentLoaded', () => { + mountErrorTrackingForm(); + mountOperationSettings(); + initSettingsPanels(); +}); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index 19d9903c988..dea7c586868 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -175,11 +175,6 @@ export default { if (value === 0) toggleHiddenClassBySelector('.merge-requests-feature', true); else if (oldValue === 0) toggleHiddenClassBySelector('.merge-requests-feature', false); }, - - buildsAccessLevel(value, oldValue) { - if (value === 0) toggleHiddenClassBySelector('.builds-feature', true); - else if (oldValue === 0) toggleHiddenClassBySelector('.builds-feature', false); - }, }, methods: { diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js index bc5c29d12b5..ac0dca31c37 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js +++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export const visibilityOptions = { PRIVATE: 0, INTERNAL: 10, @@ -5,9 +7,11 @@ export const visibilityOptions = { }; export const visibilityLevelDescriptions = { - [visibilityOptions.PRIVATE]: + [visibilityOptions.PRIVATE]: __( 'The project is accessible only by members of the project. Access must be granted explicitly to each user.', - [visibilityOptions.INTERNAL]: 'The project can be accessed by any user who is logged in.', - [visibilityOptions.PUBLIC]: + ), + [visibilityOptions.INTERNAL]: __('The project can be accessed by any user who is logged in.'), + [visibilityOptions.PUBLIC]: __( 'The project can be accessed by anyone, regardless of authentication.', + ), }; diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 7302c1ab202..6aa41d0825b 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -9,6 +9,7 @@ import Activities from '~/activities'; import { ajaxGet } from '~/lib/utils/common_utils'; import GpgBadges from '~/gpg_badges'; import initReadMore from '~/read_more'; +import leaveByUrl from '~/namespaces/leave_by_url'; import Star from '../../../star'; import notificationsDropdown from '../../../notifications_dropdown'; @@ -44,4 +45,13 @@ document.addEventListener('DOMContentLoaded', () => { }); GpgBadges.fetch(); + leaveByUrl('project'); + + if (document.getElementById('js-tree-list')) { + import('~/repository') + .then(m => m.default()) + .catch(e => { + throw e; + }); + } }); diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js index 400aed35e32..7b90a3a4f6e 100644 --- a/app/assets/javascripts/pages/projects/tree/show/index.js +++ b/app/assets/javascripts/pages/projects/tree/show/index.js @@ -40,4 +40,12 @@ document.addEventListener('DOMContentLoaded', () => { } GpgBadges.fetch(); + + if (document.getElementById('js-tree-list')) { + import('~/repository') + .then(m => m.default()) + .catch(e => { + throw e; + }); + } }); diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js index 0c896c8599e..d5a8e712d6b 100644 --- a/app/assets/javascripts/pages/search/show/search.js +++ b/app/assets/javascripts/pages/search/show/search.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import Flash from '~/flash'; import Api from '~/api'; +import { __ } from '~/locale'; export default class Search { constructor() { @@ -24,7 +25,7 @@ export default class Search { data(term, callback) { return Api.groups(term, {}, data => { data.unshift({ - full_name: 'Any', + full_name: __('Any'), }); data.splice(1, 0, 'divider'); return callback(data); @@ -54,14 +55,14 @@ export default class Search { this.getProjectsData(term) .then(data => { data.unshift({ - name_with_namespace: 'Any', + name_with_namespace: __('Any'), }); data.splice(1, 0, 'divider'); return data; }) .then(data => callback(data)) - .catch(() => new Flash('Error fetching projects')); + .catch(() => new Flash(__('Error fetching projects'))); }, id(obj) { return obj.id; diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index e1a3f42a71f..3f5a3e15c2c 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import LengthValidator from './length_validator'; import UsernameValidator from './username_validator'; import NoEmojiValidator from '../../../emoji/no_emoji_validator'; import SigninTabsMemoizer from './signin_tabs_memoizer'; @@ -6,6 +7,7 @@ import OAuthRememberMe from './oauth_remember_me'; import preserveUrlFragment from './preserve_url_fragment'; document.addEventListener('DOMContentLoaded', () => { + new LengthValidator(); // eslint-disable-line no-new new UsernameValidator(); // eslint-disable-line no-new new SigninTabsMemoizer(); // eslint-disable-line no-new new NoEmojiValidator(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/sessions/new/length_validator.js b/app/assets/javascripts/pages/sessions/new/length_validator.js new file mode 100644 index 00000000000..3d687ca08cc --- /dev/null +++ b/app/assets/javascripts/pages/sessions/new/length_validator.js @@ -0,0 +1,32 @@ +import InputValidator from '../../../validators/input_validator'; + +const errorMessageClass = 'gl-field-error'; + +export default class LengthValidator extends InputValidator { + constructor(opts = {}) { + super(); + + const container = opts.container || ''; + const validateLengthElements = document.querySelectorAll(`${container} .js-validate-length`); + + validateLengthElements.forEach(element => + element.addEventListener('input', this.eventHandler.bind(this)), + ); + } + + eventHandler(event) { + this.inputDomElement = event.target; + this.inputErrorMessage = this.inputDomElement.parentElement.querySelector( + `.${errorMessageClass}`, + ); + + const { value } = this.inputDomElement; + const { maxLengthMessage, maxLength } = this.inputDomElement.dataset; + + this.errorMessage = maxLengthMessage; + + this.invalidInput = value.length > parseInt(maxLength, 10); + + this.setValidationStateAndMessage(); + } +} diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index afa099d0e0b..693125f8a38 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -6,10 +6,16 @@ import dateFormat from 'dateformat'; import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility'; import axios from '~/lib/utils/axios_utils'; import flash from '~/flash'; -import { __ } from '~/locale'; +import { n__, s__, __ } from '~/locale'; const d3 = { select, scaleLinear, scaleThreshold }; +const firstDayOfWeekChoices = Object.freeze({ + sunday: 0, + monday: 1, + saturday: 6, +}); + const LOADING_HTML = ` <div class="text-center"> <i class="fa fa-spinner fa-spin user-calendar-activities-loading"></i> @@ -29,9 +35,9 @@ function formatTooltipText({ date, count }) { const dateDayName = getDayName(dateObject); const dateText = dateFormat(dateObject, 'mmm d, yyyy'); - let contribText = 'No contributions'; + let contribText = __('No contributions'); if (count > 0) { - contribText = `${count} contribution${count > 1 ? 's' : ''}`; + contribText = n__('%d contribution', '%d contributions', count); } return `${contribText}<br />${dateDayName} ${dateText}`; } @@ -49,7 +55,7 @@ export default class ActivityCalendar { timestamps, calendarActivitiesPath, utcOffset = 0, - firstDayOfWeek = 0, + firstDayOfWeek = firstDayOfWeekChoices.sunday, monthsAgo = 12, ) { this.calendarActivitiesPath = calendarActivitiesPath; @@ -59,18 +65,18 @@ export default class ActivityCalendar { this.daySize = 15; this.daySizeWithSpace = this.daySize + this.daySpace * 2; this.monthNames = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', + __('Jan'), + __('Feb'), + __('Mar'), + __('Apr'), + __('May'), + __('Jun'), + __('Jul'), + __('Aug'), + __('Sep'), + __('Oct'), + __('Nov'), + __('Dec'), ]; this.months = []; this.firstDayOfWeek = firstDayOfWeek; @@ -193,24 +199,29 @@ export default class ActivityCalendar { renderDayTitles() { const days = [ { - text: 'M', + text: s__('DayTitle|M'), y: 29 + this.dayYPos(1), }, { - text: 'W', + text: s__('DayTitle|W'), y: 29 + this.dayYPos(3), }, { - text: 'F', + text: s__('DayTitle|F'), y: 29 + this.dayYPos(5), }, ]; - if (this.firstDayOfWeek === 1) { + if (this.firstDayOfWeek === firstDayOfWeekChoices.monday) { days.push({ - text: 'S', + text: s__('DayTitle|S'), y: 29 + this.dayYPos(7), }); + } else if (this.firstDayOfWeek === firstDayOfWeekChoices.saturday) { + days.push({ + text: s__('DayTitle|S'), + y: 29 + this.dayYPos(6), + }); } this.svg @@ -242,11 +253,11 @@ export default class ActivityCalendar { renderKey() { const keyValues = [ - 'no contributions', - '1-9 contributions', - '10-19 contributions', - '20-29 contributions', - '30+ contributions', + __('no contributions'), + __('1-9 contributions'), + __('10-19 contributions'), + __('20-29 contributions'), + __('30+ contributions'), ]; const keyColors = [ '#ededed', diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index 636308c5401..7f800d20835 100644 --- a/app/assets/javascripts/pages/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -91,6 +91,7 @@ export default class UserTabs { this.actions = Object.keys(this.loaded); this.bindEvents(); + // TODO: refactor to make this configurable via constructor params with a default value of 'show' if (this.action === 'show') { this.action = this.defaultAction; } diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue index cdf1257b4e3..6d39abd4a1f 100644 --- a/app/assets/javascripts/pdf/index.vue +++ b/app/assets/javascripts/pdf/index.vue @@ -1,6 +1,6 @@ <script> -import pdfjsLib from 'vendor/pdf'; -import workerSrc from 'vendor/pdf.worker.min'; +import pdfjsLib from 'pdfjs-dist/build/pdf'; +import workerSrc from 'pdfjs-dist/build/pdf.worker.min'; import page from './page/index.vue'; @@ -28,7 +28,7 @@ export default { }, watch: { pdf: 'load' }, mounted() { - pdfjsLib.PDFJS.workerSrc = workerSrc; + pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc; if (this.hasPDF) this.load(); }, methods: { diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index c729198c1d3..8f3ba9779fb 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -1,9 +1,11 @@ <script> import GlModal from '~/vue_shared/components/gl_modal.vue'; +import Icon from '~/vue_shared/components/icon.vue'; export default { components: { GlModal, + Icon, }, props: { currentRequest: { @@ -38,7 +40,11 @@ export default { }; </script> <template> - <div v-if="currentRequest.details" :id="`peek-view-${metric}`" class="view"> + <div + v-if="currentRequest.details" + :id="`peek-view-${metric}`" + class="view qa-performance-bar-detailed-metric" + > <button :data-target="`#modal-peek-${metric}-details`" class="btn-blank btn-link bold" @@ -57,9 +63,31 @@ export default { <template v-if="detailsList.length"> <tr v-for="(item, index) in detailsList" :key="index"> <td> - <strong>{{ item.duration }}ms</strong> + <span>{{ item.duration }}ms</span> + </td> + <td> + <div class="js-toggle-container"> + <div + v-for="(key, keyIndex) in keys" + :key="key" + class="break-word" + :class="{ 'mb-3 bold': keyIndex == 0 }" + > + {{ item[key] }} + <button + v-if="keyIndex == 0 && item.backtrace" + class="text-expander js-toggle-button" + type="button" + :aria-label="__('Toggle backtrace')" + > + <icon :size="12" name="ellipsis_h" /> + </button> + </div> + <pre v-if="item.backtrace" class="backtrace-row js-toggle-content mt-2">{{ + item.backtrace + }}</pre> + </div> </td> - <td v-for="key in keys" :key="key" class="break-word">{{ item[key] }}</td> </tr> </template> <template v-else> 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 1ec2784cc5a..48515cf785c 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -92,7 +92,7 @@ export default { </script> <template> <div id="js-peek" :class="env"> - <div v-if="currentRequest" class="d-flex container-fluid container-limited"> + <div v-if="currentRequest" class="d-flex container-fluid container-limited qa-performance-bar"> <div id="peek-view-host" class="view"> <span v-if="hasHost" diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue index fdb5c0d6939..297507b85af 100644 --- a/app/assets/javascripts/performance_bar/components/request_selector.vue +++ b/app/assets/javascripts/performance_bar/components/request_selector.vue @@ -37,7 +37,12 @@ export default { <template> <div id="peek-request-selector"> <select v-model="currentRequestId"> - <option v-for="request in requests" :key="request.id" :value="request.id"> + <option + v-for="request in requests" + :key="request.id" + :value="request.id" + class="qa-performance-bar-request" + > {{ truncatedUrl(request.url) }} </option> </select> diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js index 1e34e74a152..4a08e158f6b 100644 --- a/app/assets/javascripts/persistent_user_callout.js +++ b/app/assets/javascripts/persistent_user_callout.js @@ -31,4 +31,12 @@ export default class PersistentUserCallout { Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.')); }); } + + static factory(container) { + if (!container) { + return undefined; + } + + return new PersistentUserCallout(container); + } } diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index 8ca539351a7..3c85bb61ce8 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective, GlButton } from '@gitlab/ui'; +import { GlTooltipDirective, GlButton, GlLoadingIcon } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import { dasherize } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; @@ -20,6 +20,7 @@ export default { components: { Icon, GlButton, + GlLoadingIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -41,6 +42,7 @@ export default { data() { return { isDisabled: false, + isLoading: false, }; }, computed: { @@ -59,15 +61,19 @@ export default { onClickAction() { this.$root.$emit('bv::hide::tooltip', `js-ci-action-${this.link}`); this.isDisabled = true; + this.isLoading = true; axios .post(`${this.link}.json`) .then(() => { this.isDisabled = false; + this.isLoading = false; + this.$emit('pipelineActionRequestComplete'); }) .catch(() => { this.isDisabled = false; + this.isLoading = false; createFlash(__('An error occurred while making the request.')); }); @@ -82,10 +88,10 @@ export default { :title="tooltipText" :class="cssClass" :disabled="isDisabled" - class="js-ci-action btn btn-blank -btn-transparent ci-action-icon-container ci-action-icon-wrapper" + class="js-ci-action btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper" @click="onClickAction" > - <icon :name="actionIcon" /> + <gl-loading-icon v-if="isLoading" class="js-action-icon-loading" /> + <icon v-else :name="actionIcon" /> </gl-button> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index a49dc311bd0..ba0dea626dc 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -24,6 +24,7 @@ export default { :groups="stage.groups" :stage-connector-class="stageConnectorClass(index, stage)" :is-first-column="isFirstColumn(index)" + :action="stage.status.action" @refreshPipelineGraph="refreshPipelineGraph" /> </ul> diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue index 482898b80c4..ebd7a17040a 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -69,7 +69,9 @@ export default { > <ci-icon :status="group.status" /> - <span class="ci-status-text"> {{ group.name }} </span> + <span class="ci-status-text text-truncate mw-70p gl-pl-1 d-inline-block align-bottom"> + {{ group.name }} + </span> <span class="dropdown-counter-badge"> {{ group.size }} </span> </button> diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 2b32a6e4a98..0d5afe04e8e 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -57,6 +57,9 @@ export default { }, }, computed: { + boundary() { + return this.dropdownLength === 1 ? 'viewport' : 'scrollParent'; + }, status() { return this.job && this.job.status ? this.job.status : {}; }, @@ -104,7 +107,7 @@ export default { <div class="ci-job-component"> <gl-link v-if="status.has_details" - v-gl-tooltip + v-gl-tooltip="{ boundary, placement: 'bottom' }" :href="status.details_path" :title="tooltipText" :class="cssClassJobName" @@ -115,7 +118,7 @@ export default { <div v-else - v-gl-tooltip + v-gl-tooltip="{ boundary, placement: 'bottom' }" :title="tooltipText" :class="cssClassJobName" class="js-job-component-tooltip non-details-job-component" diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue index 1bfab2a7fc0..02451839330 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue @@ -27,7 +27,8 @@ export default { <template> <span class="ci-job-name-component"> <ci-icon :status="status" /> - - <span class="ci-status-text"> {{ name }} </span> + <span class="ci-status-text text-truncate mw-70p gl-pl-1 d-inline-block align-bottom"> + {{ name }} + </span> </span> </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 09a50d25020..d5c124dc0ca 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -1,13 +1,17 @@ <script> import _ from 'underscore'; +import stageColumnMixin from 'ee_else_ce/pipelines/mixins/stage_column_mixin'; import JobItem from './job_item.vue'; import JobGroupDropdown from './job_group_dropdown.vue'; +import ActionComponent from './action_component.vue'; export default { components: { JobItem, JobGroupDropdown, + ActionComponent, }, + mixins: [stageColumnMixin], props: { title: { type: String, @@ -27,14 +31,21 @@ export default { required: false, default: '', }, + action: { + type: Object, + required: false, + default: () => ({}), + }, + }, + computed: { + hasAction() { + return !_.isEmpty(this.action); + }, }, methods: { groupId(group) { return `ci-badge-${_.escape(group.name)}`; }, - buildConnnectorClass(index) { - return index === 0 && !this.isFirstColumn ? 'left-connector' : ''; - }, pipelineActionRequestComplete() { this.$emit('refreshPipelineGraph'); }, @@ -43,7 +54,18 @@ export default { </script> <template> <li :class="stageConnectorClass" class="stage-column"> - <div class="stage-name">{{ title }}</div> + <div class="stage-name position-relative"> + {{ title }} + <action-component + v-if="hasAction" + :action-icon="action.icon" + :tooltip-text="action.title" + :link="action.path" + class="js-stage-action stage-action position-absolute position-top-0 rounded" + @pipelineActionRequestComplete="pipelineActionRequestComplete" + /> + </div> + <div class="builds-container"> <ul> <li diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index b2e365e5cde..f3a71ee434c 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -83,6 +83,8 @@ export default { v-if="shouldRenderContent" :status="status" :item-id="pipeline.id" + :item-iid="pipeline.iid" + :item-id-tooltip="__('Pipeline ID (IID)')" :time="pipeline.created_at" :user="pipeline.user" :actions="actions" diff --git a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue new file mode 100644 index 00000000000..4cafd147511 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue @@ -0,0 +1,97 @@ +<script> +import _ from 'underscore'; +import GlModal from '~/vue_shared/components/gl_modal.vue'; +import { GlLink } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { s__, sprintf } from '~/locale'; + +/** + * Pipeline Stop Modal. + * + * Renders the modal used to confirm stopping a pipeline. + */ +export default { + components: { + GlModal, + GlLink, + ClipboardButton, + CiIcon, + }, + props: { + pipeline: { + type: Object, + required: true, + deep: true, + }, + }, + computed: { + modalTitle() { + return sprintf( + s__('Pipeline|Stop pipeline #%{pipelineId}?'), + { + pipelineId: `${this.pipeline.id}`, + }, + false, + ); + }, + modalText() { + return sprintf( + s__(`Pipeline|You’re about to stop pipeline %{pipelineId}.`), + { + pipelineId: `<strong>#${this.pipeline.id}</strong>`, + }, + false, + ); + }, + hasRef() { + return !_.isEmpty(this.pipeline.ref); + }, + }, + methods: { + emitSubmit(event) { + this.$emit('submit', event); + }, + }, +}; +</script> +<template> + <gl-modal + id="confirmation-modal" + :header-title-text="modalTitle" + :footer-primary-button-text="s__('Pipeline|Stop pipeline')" + footer-primary-button-variant="danger" + @submit="emitSubmit($event)" + > + <p v-html="modalText"></p> + + <p v-if="pipeline"> + <ci-icon + v-if="pipeline.details" + :status="pipeline.details.status" + class="vertical-align-middle" + /> + + <span class="font-weight-bold">{{ __('Pipeline') }}</span> + + <a :href="pipeline.path" class="js-pipeline-path link-commit qa-pipeline-path" + >#{{ pipeline.id }}</a + > + <template v-if="hasRef"> + {{ __('from') }} + <a :href="pipeline.ref.path" class="link-commit ref-name">{{ pipeline.ref.name }}</a> + </template> + </p> + + <template v-if="pipeline.commit"> + <p> + <span class="font-weight-bold">{{ __('Commit') }}</span> + + <gl-link :href="pipeline.commit.commit_path" class="js-commit-sha commit-sha link-commit"> + {{ pipeline.commit.short_id }} + </gl-link> + </p> + <p>{{ pipeline.commit.title }}</p> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipeline_triggerer.vue new file mode 100644 index 00000000000..740b54cd8e0 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipeline_triggerer.vue @@ -0,0 +1,35 @@ +<script> +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; + +export default { + components: { + UserAvatarLink, + }, + props: { + pipeline: { + type: Object, + required: true, + }, + }, + computed: { + user() { + return this.pipeline.user; + }, + }, +}; +</script> +<template> + <div class="table-section section-10 d-none d-sm-none d-md-block pipeline-triggerer"> + <user-avatar-link + v-if="user" + :link-href="user.path" + :img-src="user.avatar_url" + :img-size="26" + :tooltip-text="user.name" + class="prepend-left-default js-pipeline-url-user" + /> + <span v-else class="prepend-left-default js-pipeline-url-api api"> + {{ s__('Pipelines|API') }} + </span> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index 918622ef8dc..00c02e15562 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -2,6 +2,7 @@ import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import _ from 'underscore'; import { __, sprintf } from '~/locale'; +import PipelineLink from '~/vue_shared/components/ci_pipeline_link.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import popover from '~/vue_shared/directives/popover'; @@ -19,6 +20,7 @@ export default { components: { UserAvatarLink, GlLink, + PipelineLink, }, directives: { GlTooltip: GlTooltipDirective, @@ -59,19 +61,13 @@ export default { }; </script> <template> - <div class="table-section section-15 d-none d-sm-none d-md-block pipeline-tags"> - <gl-link :href="pipeline.path" class="js-pipeline-url-link"> - <span class="pipeline-id">#{{ pipeline.id }}</span> - </gl-link> - <span>by</span> - <user-avatar-link - v-if="user" - :link-href="user.path" - :img-src="user.avatar_url" - :tooltip-text="user.name" - class="js-pipeline-url-user" + <div class="table-section section-10 d-none d-sm-none d-md-block pipeline-tags section-wrap"> + <pipeline-link + :href="pipeline.path" + :pipeline-id="pipeline.id" + :pipeline-iid="pipeline.iid" + class="js-pipeline-url-link" /> - <span v-if="!user" class="js-pipeline-url-api api"> API </span> <div class="label-container"> <span v-if="pipeline.flags.latest" @@ -110,12 +106,12 @@ export default { {{ __('stuck') }} </span> <span - v-if="pipeline.flags.merge_request" + v-if="pipeline.flags.detached_merge_request_pipeline" v-gl-tooltip - :title="__('This pipeline is run in a merge request context')" - class="js-pipeline-url-mergerequest badge badge-info" + :title="__('This pipeline is run on the source branch')" + class="js-pipeline-url-detached badge badge-info" > - {{ __('merge request') }} + {{ __('detached') }} </span> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_table.vue index 1c60ae6a152..03d332cd430 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table.vue @@ -1,7 +1,7 @@ <script> -import Modal from '~/vue_shared/components/gl_modal.vue'; -import { s__, sprintf } from '~/locale'; +import { GlTooltipDirective } from '@gitlab/ui'; import PipelinesTableRowComponent from './pipelines_table_row.vue'; +import PipelineStopModal from './pipeline_stop_modal.vue'; import eventHub from '../event_hub'; /** @@ -12,7 +12,10 @@ import eventHub from '../event_hub'; export default { components: { PipelinesTableRowComponent, - Modal, + PipelineStopModal, + }, + directives: { + GlTooltip: GlTooltipDirective, }, props: { pipelines: { @@ -36,30 +39,11 @@ export default { data() { return { pipelineId: 0, + pipeline: {}, endpoint: '', cancelingPipeline: null, }; }, - computed: { - modalTitle() { - return sprintf( - s__('Pipeline|Stop pipeline #%{pipelineId}?'), - { - pipelineId: `${this.pipelineId}`, - }, - false, - ); - }, - modalText() { - return sprintf( - s__('Pipeline|You’re about to stop pipeline %{pipelineId}.'), - { - pipelineId: `<strong>#${this.pipelineId}</strong>`, - }, - false, - ); - }, - }, created() { eventHub.$on('openConfirmationModal', this.setModalData); }, @@ -68,7 +52,8 @@ export default { }, methods: { setModalData(data) { - this.pipelineId = data.pipelineId; + this.pipelineId = data.pipeline.id; + this.pipeline = data.pipeline; this.endpoint = data.endpoint; }, onSubmit() { @@ -81,16 +66,19 @@ export default { <template> <div class="ci-table"> <div class="gl-responsive-table-row table-row-header" role="row"> - <div class="table-section section-10 js-pipeline-status pipeline-status" role="rowheader"> + <div class="table-section section-10 js-pipeline-status" role="rowheader"> {{ s__('Pipeline|Status') }} </div> - <div class="table-section section-15 js-pipeline-info pipeline-info" role="rowheader"> + <div class="table-section section-10 js-pipeline-info pipeline-info" role="rowheader"> {{ s__('Pipeline|Pipeline') }} </div> + <div class="table-section section-10 js-triggerer-info triggerer-info" role="rowheader"> + {{ s__('Pipeline|Triggerer') }} + </div> <div class="table-section section-20 js-pipeline-commit pipeline-commit" role="rowheader"> {{ s__('Pipeline|Commit') }} </div> - <div class="table-section section-20 js-pipeline-stages pipeline-stages" role="rowheader"> + <div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader"> {{ s__('Pipeline|Stages') }} </div> </div> @@ -103,15 +91,6 @@ export default { :view-type="viewType" :canceling-pipeline="cancelingPipeline" /> - - <modal - id="confirmation-modal" - :header-title-text="modalTitle" - :footer-primary-button-text="s__('Pipeline|Stop pipeline')" - footer-primary-button-variant="danger" - @submit="onSubmit" - > - <span v-html="modalText"></span> - </modal> + <pipeline-stop-modal :pipeline="pipeline" @submit="onSubmit" /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue index da42698c255..e32e2f785bd 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue @@ -5,6 +5,7 @@ import PipelinesArtifactsComponent from './pipelines_artifacts.vue'; import CiBadge from '../../vue_shared/components/ci_badge_link.vue'; import PipelineStage from './stage.vue'; import PipelineUrl from './pipeline_url.vue'; +import PipelineTriggerer from './pipeline_triggerer.vue'; import PipelinesTimeago from './time_ago.vue'; import CommitComponent from '../../vue_shared/components/commit.vue'; import LoadingButton from '../../vue_shared/components/loading_button.vue'; @@ -23,6 +24,7 @@ export default { CommitComponent, PipelineStage, PipelineUrl, + PipelineTriggerer, CiBadge, PipelinesTimeago, LoadingButton, @@ -243,7 +245,7 @@ export default { methods: { handleCancelClick() { eventHub.$emit('openConfirmationModal', { - pipelineId: this.pipeline.id, + pipeline: this.pipeline, endpoint: this.pipeline.cancel_path, }); }, @@ -264,23 +266,25 @@ export default { </div> <pipeline-url :pipeline="pipeline" :auto-devops-help-path="autoDevopsHelpPath" /> + <pipeline-triggerer :pipeline="pipeline" /> - <div class="table-section section-20"> + <div class="table-section section-wrap section-20"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Commit') }}</div> <div class="table-mobile-content"> <commit-component :tag="commitTag" :commit-ref="commitRef" :commit-url="commitUrl" + :merge-request-ref="pipeline.merge_request" :short-sha="commitShortSha" :title="commitTitle" :author="commitAuthor" - :show-branch="!isChildView" + :show-ref-info="!isChildView" /> </div> </div> - <div class="table-section section-wrap section-20 stage-cell"> + <div class="table-section section-wrap section-15 stage-cell"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Stages') }}</div> <div class="table-mobile-content"> <template v-if="pipeline.details.stages.length > 0"> diff --git a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js new file mode 100644 index 00000000000..dd79ade5bc9 --- /dev/null +++ b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js @@ -0,0 +1,16 @@ +import Flash from '~/flash'; +import { __ } from '~/locale'; + +export default { + methods: { + clickTriggeredByPipeline() {}, + clickTriggeredPipeline() {}, + 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.'))); + }, + }, +}; diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 74ca3071364..3cc9d0a3a4e 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -27,11 +27,7 @@ export default { }, computed: { shouldRenderPagination() { - return ( - !this.isLoading && - this.state.pipelines.length && - this.state.pageInfo.total > this.state.pageInfo.perPage - ); + return !this.isLoading; }, }, beforeMount() { diff --git a/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js b/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js new file mode 100644 index 00000000000..64283ed0e58 --- /dev/null +++ b/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js @@ -0,0 +1,7 @@ +export default { + methods: { + buildConnnectorClass(index) { + return index === 0 && !this.isFirstColumn ? 'left-connector' : ''; + }, + }, +}; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index dc9befe6349..b8976f77bac 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -2,8 +2,9 @@ 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 PipelinesMediator from './pipeline_details_mediator'; -import pipelineGraph from './components/graph/graph_component.vue'; import pipelineHeader from './components/header_component.vue'; import eventHub from './event_hub'; @@ -22,28 +23,25 @@ export default () => { components: { pipelineGraph, }, + mixins: [GraphEEMixin], data() { return { mediator, }; }, - methods: { - 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.'))); - }, - }, render(createElement) { return createElement('pipeline-graph', { props: { isLoading: this.mediator.state.isLoading, pipeline: this.mediator.store.state.pipeline, + mediator: this.mediator, }, on: { refreshPipelineGraph: this.requestRefreshPipelineGraph, + onClickTriggeredBy: (parentPipeline, pipeline) => + this.clickTriggeredByPipeline(parentPipeline, pipeline), + onClickTriggered: (parentPipeline, pipeline) => + this.clickTriggeredPipeline(parentPipeline, pipeline), }, }); }, diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js index bd1e1895660..d67d88c4dba 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js +++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js @@ -19,6 +19,7 @@ export default class pipelinesMediator { this.poll = new Poll({ resource: this.service, method: 'getPipeline', + data: this.store.state.expandedPipelines ? this.getExpandedParameters() : undefined, successCallback: this.successCallback.bind(this), errorCallback: this.errorCallback.bind(this), }); @@ -56,6 +57,19 @@ export default class pipelinesMediator { .getPipeline() .then(response => this.successCallback(response)) .catch(() => this.errorCallback()) - .finally(() => this.poll.restart()); + .finally(() => + this.poll.restart( + this.store.state.expandedPipelines ? this.getExpandedParameters() : undefined, + ), + ); + } + + /** + * Backend expects paramets in the following format: `expanded[]=id&expanded[]=id` + */ + getExpandedParameters() { + return { + expanded: this.store.state.expandedPipelines, + }; } } diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js index a53a9cc8365..e44eb9cdfd1 100644 --- a/app/assets/javascripts/pipelines/services/pipeline_service.js +++ b/app/assets/javascripts/pipelines/services/pipeline_service.js @@ -5,8 +5,8 @@ export default class PipelineService { this.pipeline = endpoint; } - getPipeline() { - return axios.get(this.pipeline); + getPipeline(params) { + return axios.get(this.pipeline, { params }); } // eslint-disable-next-line class-methods-use-this diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js index 052e34a8aef..259278b6410 100644 --- a/app/assets/javascripts/pipelines/stores/pipeline_store.js +++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js @@ -1,7 +1,6 @@ export default class PipelineStore { constructor() { this.state = {}; - this.state.pipeline = {}; } diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js index 59c13e1a042..f0d9642a2b2 100644 --- a/app/assets/javascripts/profile/account/index.js +++ b/app/assets/javascripts/profile/account/index.js @@ -35,7 +35,7 @@ export default () => { return createElement('delete-account-modal', { props: { actionUrl: deleteAccountModalEl.dataset.actionUrl, - confirmWithPassword: !!deleteAccountModalEl.dataset.confirmWithPassword, + confirmWithPassword: Boolean(deleteAccountModalEl.dataset.confirmWithPassword), username: deleteAccountModalEl.dataset.username, }, }); diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index deacff5abe7..8dd37aee7e1 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -2,6 +2,9 @@ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; import flash from '../flash'; import { parseBoolean } from '~/lib/utils/common_utils'; +import TimezoneDropdown, { + formatTimezone, +} from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown'; export default class Profile { constructor({ form } = {}) { @@ -10,6 +13,14 @@ export default class Profile { this.setRepoRadio(); this.bindEvents(); this.initAvatarGlCrop(); + + this.$inputEl = $('#user_timezone'); + + this.timezoneDropdown = new TimezoneDropdown({ + $inputEl: this.$inputEl, + $dropdownEl: $('.js-timezone-dropdown'), + displayFormat: selectedItem => formatTimezone(selectedItem), + }); } initAvatarGlCrop() { @@ -28,6 +39,7 @@ export default class Profile { bindEvents() { $('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm); + $('.js-group-notification-email').on('change', this.submitForm); $('#user_notification_email').on('change', this.submitForm); $('#user_notified_of_own_activity').on('change', this.submitForm); this.form.on('submit', this.onSubmitForm); diff --git a/app/assets/javascripts/project_label_subscription.js b/app/assets/javascripts/project_label_subscription.js index d3c604dcee1..5395e14cc79 100644 --- a/app/assets/javascripts/project_label_subscription.js +++ b/app/assets/javascripts/project_label_subscription.js @@ -38,9 +38,9 @@ export default class ProjectLabelSubscription { let newAction; if (oldStatus === 'unsubscribed') { - [newStatus, newAction] = ['subscribed', 'Unsubscribe']; + [newStatus, newAction] = ['subscribed', __('Unsubscribe')]; } else { - [newStatus, newAction] = ['unsubscribed', 'Subscribe']; + [newStatus, newAction] = ['unsubscribed', __('Subscribe')]; } $btn.removeClass('disabled'); diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index 5ee510eb11d..dbe354a547b 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -3,6 +3,7 @@ import $ from 'jquery'; import Api from './api'; import ProjectSelectComboButton from './project_select_combo_button'; +import { s__ } from './locale'; export default function projectSelect() { import(/* webpackChunkName: 'select2' */ 'select2/select2') @@ -21,9 +22,9 @@ export default function projectSelect() { this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false; this.allowClear = $(select).data('allowClear') || false; - placeholder = 'Search for project'; + placeholder = s__('ProjectSelect|Search for project'); if (this.includeGroups) { - placeholder += ' or group'; + placeholder += s__('ProjectSelect| or group'); } $(select).select2({ diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue index 5f8a4946f4a..fd5d5f86401 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue @@ -34,7 +34,7 @@ export default { }, errorMessage() { return sprintf( - s__('ClusterIntegration|An error occured while trying to fetch project zones: %{error}'), + s__('ClusterIntegration|An error occurred while trying to fetch project zones: %{error}'), { error: this.gapiError }, ); }, diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js index 4834a856271..f05ad7773a2 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js @@ -57,7 +57,7 @@ export const validateProjectBilling = ({ dispatch, commit, state }) => resp => { const { billingEnabled } = resp.result; - commit(types.SET_PROJECT_BILLING_STATUS, !!billingEnabled); + commit(types.SET_PROJECT_BILLING_STATUS, Boolean(billingEnabled)); dispatch('setIsValidatingProjectBilling', false); resolve(); }, diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js index e39f02d0894..f9e2e2f74fb 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js @@ -1,3 +1,3 @@ -export const hasProject = state => !!state.selectedProject.projectId; -export const hasZone = state => !!state.selectedZone; -export const hasMachineType = state => !!state.selectedMachineType; +export const hasProject = state => Boolean(state.selectedProject.projectId); +export const hasZone = state => Boolean(state.selectedZone); +export const hasMachineType = state => Boolean(state.selectedMachineType); diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 6fb25622a05..ea82ff4e340 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils'; import { slugifyWithHyphens } from '../lib/utils/text_utility'; +import { s__ } from '~/locale'; let hasUserDefinedProjectPath = false; @@ -114,59 +115,71 @@ const bindEvents = () => { const value = $(this).val(); const templates = { rails: { - text: 'Ruby on Rails', + text: s__('ProjectTemplates|Ruby on Rails'), icon: '.template-option .icon-rails', }, express: { - text: 'NodeJS Express', + text: s__('ProjectTemplates|NodeJS Express'), icon: '.template-option .icon-express', }, spring: { - text: 'Spring', + text: s__('ProjectTemplates|Spring'), icon: '.template-option .icon-spring', }, + iosswift: { + text: s__('ProjectTemplates|iOS (Swift)'), + icon: '.template-option svg.icon-gitlab', + }, dotnetcore: { - text: '.NET Core', + text: s__('ProjectTemplates|.NET Core'), icon: '.template-option .icon-dotnet', }, + android: { + text: s__('ProjectTemplates|Android'), + icon: '.template-option svg.icon-android', + }, + gomicro: { + text: s__('ProjectTemplates|Go Micro'), + icon: '.template-option .icon-gomicro', + }, hugo: { - text: 'Pages/Hugo', + text: s__('ProjectTemplates|Pages/Hugo'), icon: '.template-option .icon-hugo', }, jekyll: { - text: 'Pages/Jekyll', + text: s__('ProjectTemplates|Pages/Jekyll'), icon: '.template-option .icon-jekyll', }, plainhtml: { - text: 'Pages/Plain HTML', + text: s__('ProjectTemplates|Pages/Plain HTML'), icon: '.template-option .icon-plainhtml', }, gitbook: { - text: 'Pages/GitBook', + text: s__('ProjectTemplates|Pages/GitBook'), icon: '.template-option .icon-gitbook', }, hexo: { - text: 'Pages/Hexo', + text: s__('ProjectTemplates|Pages/Hexo'), icon: '.template-option .icon-hexo', }, nfhugo: { - text: 'Netlify/Hugo', + text: s__('ProjectTemplates|Netlify/Hugo'), icon: '.template-option .icon-netlify', }, nfjekyll: { - text: 'Netlify/Jekyll', + text: s__('ProjectTemplates|Netlify/Jekyll'), icon: '.template-option .icon-netlify', }, nfplainhtml: { - text: 'Netlify/Plain HTML', + text: s__('ProjectTemplates|Netlify/Plain HTML'), icon: '.template-option .icon-netlify', }, nfgitbook: { - text: 'Netlify/GitBook', + text: s__('ProjectTemplates|Netlify/GitBook'), icon: '.template-option .icon-netlify', }, nfhexo: { - text: 'Netlify/Hexo', + text: s__('ProjectTemplates|Netlify/Hexo'), icon: '.template-option .icon-netlify', }, }; @@ -205,6 +218,12 @@ const bindEvents = () => { $projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl)); + $('.js-import-git-toggle-button').on('click', () => { + const $projectMirror = $('#project_mirror'); + + $projectMirror.attr('disabled', !$projectMirror.attr('disabled')); + }); + $projectName.on('keyup change', () => { onProjectNameChange($projectName, $projectPath); hasUserDefinedProjectPath = $projectPath.val().trim().length > 0; diff --git a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js index 40a873833e1..41e295387ae 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js +++ b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export default class ProtectedBranchAccessDropdown { constructor(options) { this.options = options; @@ -15,7 +17,7 @@ export default class ProtectedBranchAccessDropdown { if ($el.is('.is-active')) { return item.text; } - return 'Select'; + return __('Select'); }, clicked(options) { options.e.preventDefault(); diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index 48343c8ba0a..16ecd5523d6 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown'; import CreateItemDropdown from '../create_item_dropdown'; import AccessorUtilities from '../lib/utils/accessor'; +import { __ } from '~/locale'; export default class ProtectedBranchCreate { constructor() { @@ -35,7 +36,7 @@ export default class ProtectedBranchCreate { this.createItemDropdown = new CreateItemDropdown({ $dropdown: $protectedBranchDropdown, - defaultToggleLabel: 'Protected Branch', + defaultToggleLabel: __('Protected Branch'), fieldName: 'protected_branch[name]', onSelect: this.onSelectCallback, getData: ProtectedBranchCreate.getProtectedBranches, diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js index 5bc08f60d16..08d8c9919dd 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_edit.js +++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js @@ -1,6 +1,7 @@ import flash from '../flash'; import axios from '../lib/utils/axios_utils'; import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown'; +import { __ } from '~/locale'; export default class ProtectedBranchEdit { constructor(options) { @@ -68,7 +69,7 @@ export default class ProtectedBranchEdit { this.$allowedToPushDropdown.enable(); flash( - 'Failed to update branch!', + __('Failed to update branch!'), 'alert', document.querySelector('.js-protected-branches-list'), ); diff --git a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js index b803da798d5..def2f091947 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js +++ b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export default class ProtectedTagAccessDropdown { constructor(options) { this.options = options; @@ -15,7 +17,7 @@ export default class ProtectedTagAccessDropdown { if ($el.is('.is-active')) { return item.text; } - return 'Select'; + return __('Select'); }, clicked(options) { options.e.preventDefault(); diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js index fddf2674cbb..03a5fe6b353 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_create.js +++ b/app/assets/javascripts/protected_tags/protected_tag_create.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import ProtectedTagAccessDropdown from './protected_tag_access_dropdown'; import CreateItemDropdown from '../create_item_dropdown'; +import { __ } from '~/locale'; export default class ProtectedTagCreate { constructor() { @@ -27,7 +28,7 @@ export default class ProtectedTagCreate { // Protected tag dropdown this.createItemDropdown = new CreateItemDropdown({ $dropdown: this.$form.find('.js-protected-tag-select'), - defaultToggleLabel: 'Protected Tag', + defaultToggleLabel: __('Protected Tag'), fieldName: 'protected_tag[name]', onSelect: this.onSelectCallback, getData: ProtectedTagCreate.getProtectedTags, diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js index c52497e62f2..70bfd71abce 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_edit.js +++ b/app/assets/javascripts/protected_tags/protected_tag_edit.js @@ -1,6 +1,7 @@ import flash from '../flash'; import axios from '../lib/utils/axios_utils'; import ProtectedTagAccessDropdown from './protected_tag_access_dropdown'; +import { __ } from '~/locale'; export default class ProtectedTagEdit { constructor(options) { @@ -47,7 +48,11 @@ export default class ProtectedTagEdit { .catch(() => { this.$allowedToCreateDropdownButton.enable(); - flash('Failed to update tag!', 'alert', document.querySelector('.js-protected-tags-list')); + flash( + __('Failed to update tag!'), + 'alert', + document.querySelector('.js-protected-tags-list'), + ); }); } } diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/raven/index.js index edc2293915f..4dd0175e528 100644 --- a/app/assets/javascripts/raven/index.js +++ b/app/assets/javascripts/raven/index.js @@ -4,8 +4,11 @@ const index = function index() { RavenConfig.init({ sentryDsn: gon.sentry_dsn, currentUserId: gon.current_user_id, - whitelistUrls: [gon.gitlab_url], - isProduction: process.env.NODE_ENV, + whitelistUrls: + process.env.NODE_ENV === 'production' + ? [gon.gitlab_url] + : [gon.gitlab_url, 'webpack-internal://'], + environment: gon.sentry_environment, release: gon.revision, tags: { revision: gon.revision, diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js index 338006ce2b9..7259e0df104 100644 --- a/app/assets/javascripts/raven/raven_config.js +++ b/app/assets/javascripts/raven/raven_config.js @@ -1,5 +1,6 @@ import Raven from 'raven-js'; import $ from 'jquery'; +import { __ } from '~/locale'; const IGNORE_ERRORS = [ // Random plugins/extensions @@ -9,9 +10,9 @@ const IGNORE_ERRORS = [ 'canvas.contentDocument', 'MyApp_RemoveAllHighlights', 'http://tt.epicplay.com', - "Can't find variable: ZiteReader", - 'jigsaw is not defined', - 'ComboSearch is not defined', + __("Can't find variable: ZiteReader"), + __('jigsaw is not defined'), + __('ComboSearch is not defined'), 'http://loading.retry.widdit.com/', 'atomicFindClose', // Facebook borked @@ -61,7 +62,7 @@ const RavenConfig = { release: this.options.release, tags: this.options.tags, whitelistUrls: this.options.whitelistUrls, - environment: this.options.isProduction ? 'production' : 'development', + environment: this.options.environment, ignoreErrors: this.IGNORE_ERRORS, ignoreUrls: this.IGNORE_URLS, shouldSendCallback: this.shouldSendSample.bind(this), @@ -80,7 +81,7 @@ const RavenConfig = { handleRavenErrors(event, req, config, err) { const error = err || req.statusText; - const responseText = req.responseText || 'Unknown response text'; + const responseText = req.responseText || __('Unknown response text'); Raven.captureMessage(error, { extra: { diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js index 1ac699c538f..8ace6657ad1 100644 --- a/app/assets/javascripts/registry/stores/mutations.js +++ b/app/assets/javascripts/registry/stores/mutations.js @@ -9,7 +9,7 @@ export default { [types.SET_REPOS_LIST](state, list) { Object.assign(state, { repos: list.map(el => ({ - canDelete: !!el.destroy_path, + canDelete: Boolean(el.destroy_path), destroyPath: el.destroy_path, id: el.id, isLoading: false, @@ -42,7 +42,7 @@ export default { location: element.location, createdAt: element.created_at, destroyPath: element.destroy_path, - canDelete: !!element.destroy_path, + canDelete: Boolean(element.destroy_path), })); }, diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue new file mode 100644 index 00000000000..6d908524da9 --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue @@ -0,0 +1,118 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { sprintf, n__, s__ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; +import { parseIssuableData } from '../../issue_show/utils/parse_data'; + +export default { + name: 'RelatedMergeRequests', + components: { + Icon, + GlLoadingIcon, + RelatedIssuableItem, + }, + props: { + endpoint: { + type: String, + required: true, + }, + projectNamespace: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['isFetchingMergeRequests', 'mergeRequests', 'totalCount']), + closingMergeRequestsText() { + if (!this.hasClosingMergeRequest) { + return ''; + } + + const mrText = n__( + 'When this merge request is accepted', + 'When these merge requests are accepted', + this.totalCount, + ); + + return sprintf(s__('%{mrText}, this issue will be closed automatically.'), { mrText }); + }, + }, + mounted() { + this.setInitialState({ apiEndpoint: this.endpoint }); + this.fetchMergeRequests(); + }, + created() { + this.hasClosingMergeRequest = parseIssuableData().hasClosingMergeRequest; + }, + methods: { + ...mapActions(['setInitialState', 'fetchMergeRequests']), + getAssignees(mr) { + if (mr.assignees) { + return mr.assignees; + } + + return mr.assignee ? [mr.assignee] : []; + }, + }, +}; +</script> + +<template> + <div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)"> + <div id="merge-requests" class="card-slim mt-3"> + <div class="card-header"> + <div class="card-title mt-0 mb-0 h5 merge-requests-title"> + <span class="mr-1"> + {{ __('Related merge requests') }} + </span> + <div v-if="totalCount" class="d-inline-flex lh-100 align-middle"> + <div class="mr-count-badge"> + <div class="mr-count-badge-count"> + <svg class="s16 mr-1 text-secondary"> + <icon name="merge-request" class="mr-1 text-secondary" /> + </svg> + <span class="js-items-count">{{ totalCount }}</span> + </div> + </div> + </div> + </div> + </div> + <div> + <div v-if="isFetchingMergeRequests" class="qa-related-merge-requests-loading-icon"> + <gl-loading-icon label="Fetching related merge requests" class="py-2" /> + </div> + <ul v-else class="content-list related-items-list"> + <li v-for="mr in mergeRequests" :key="mr.id" class="list-item pt-0 pb-0"> + <related-issuable-item + :id-key="mr.id" + :display-reference="mr.reference" + :title="mr.title" + :milestone="mr.milestone" + :assignees="getAssignees(mr)" + :created-at="mr.created_at" + :closed-at="mr.closed_at" + :merged-at="mr.merged_at" + :path="mr.web_url" + :state="mr.state" + :is-merge-request="true" + :pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status" + path-id-separator="!" + /> + </li> + </ul> + </div> + </div> + <div + v-if="hasClosingMergeRequest && !isFetchingMergeRequests" + class="issue-closed-by-widget second-block" + > + {{ closingMergeRequestsText }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/related_merge_requests/index.js b/app/assets/javascripts/related_merge_requests/index.js new file mode 100644 index 00000000000..092ff1df00f --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import RelatedMergeRequests from './components/related_merge_requests.vue'; +import createStore from './store'; + +export default function initRelatedMergeRequests() { + const relatedMergeRequestsElement = document.querySelector('#js-related-merge-requests'); + + if (relatedMergeRequestsElement) { + const { endpoint, projectPath, projectNamespace } = relatedMergeRequestsElement.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el: relatedMergeRequestsElement, + components: { + RelatedMergeRequests, + }, + store: createStore(), + render: createElement => + createElement('related-merge-requests', { + props: { endpoint, projectNamespace, projectPath }, + }), + }); + } +} diff --git a/app/assets/javascripts/related_merge_requests/store/actions.js b/app/assets/javascripts/related_merge_requests/store/actions.js new file mode 100644 index 00000000000..69abeaaf7db --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/store/actions.js @@ -0,0 +1,37 @@ +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import { normalizeHeaders } from '~/lib/utils/common_utils'; +import * as types from './mutation_types'; + +const REQUEST_PAGE_COUNT = 100; + +export const setInitialState = ({ commit }, props) => { + commit(types.SET_INITIAL_STATE, props); +}; + +export const requestData = ({ commit }) => commit(types.REQUEST_DATA); + +export const receiveDataSuccess = ({ commit }, data) => commit(types.RECEIVE_DATA_SUCCESS, data); + +export const receiveDataError = ({ commit }) => commit(types.RECEIVE_DATA_ERROR); + +export const fetchMergeRequests = ({ state, dispatch }) => { + dispatch('requestData'); + + return axios + .get(`${state.apiEndpoint}?per_page=${REQUEST_PAGE_COUNT}`) + .then(res => { + const { headers, data } = res; + const total = Number(normalizeHeaders(headers)['X-TOTAL']) || 0; + + dispatch('receiveDataSuccess', { data, total }); + }) + .catch(() => { + dispatch('receiveDataError'); + createFlash(s__('Something went wrong while fetching related merge requests.')); + }); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/related_merge_requests/store/index.js b/app/assets/javascripts/related_merge_requests/store/index.js new file mode 100644 index 00000000000..dcb70c22bcb --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/store/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + state: createState(), + actions, + mutations, + }); diff --git a/app/assets/javascripts/related_merge_requests/store/mutation_types.js b/app/assets/javascripts/related_merge_requests/store/mutation_types.js new file mode 100644 index 00000000000..31d4fe032e1 --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/store/mutation_types.js @@ -0,0 +1,4 @@ +export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; +export const REQUEST_DATA = 'REQUEST_DATA'; +export const RECEIVE_DATA_SUCCESS = 'RECEIVE_DATA_SUCCESS'; +export const RECEIVE_DATA_ERROR = 'RECEIVE_DATA_ERROR'; diff --git a/app/assets/javascripts/related_merge_requests/store/mutations.js b/app/assets/javascripts/related_merge_requests/store/mutations.js new file mode 100644 index 00000000000..11ca28a5fb9 --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/store/mutations.js @@ -0,0 +1,19 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_INITIAL_STATE](state, { apiEndpoint }) { + state.apiEndpoint = apiEndpoint; + }, + [types.REQUEST_DATA](state) { + state.isFetchingMergeRequests = true; + }, + [types.RECEIVE_DATA_SUCCESS](state, { data, total }) { + state.isFetchingMergeRequests = false; + state.mergeRequests = data; + state.totalCount = total; + }, + [types.RECEIVE_DATA_ERROR](state) { + state.isFetchingMergeRequests = false; + state.hasErrorFetchingMergeRequests = true; + }, +}; diff --git a/app/assets/javascripts/related_merge_requests/store/state.js b/app/assets/javascripts/related_merge_requests/store/state.js new file mode 100644 index 00000000000..bc3468a025b --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/store/state.js @@ -0,0 +1,7 @@ +export default () => ({ + apiEndpoint: '', + isFetchingMergeRequests: false, + hasErrorFetchingMergeRequests: false, + mergeRequests: [], + totalCount: 0, +}); diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue index 7ed1b407ddd..0958b9fa926 100644 --- a/app/assets/javascripts/releases/components/release_block.vue +++ b/app/assets/javascripts/releases/components/release_block.vue @@ -86,7 +86,7 @@ export default { </div> <div - v-if="assets.links.length || assets.sources.length" + v-if="assets.links.length || (assets.sources && assets.sources.length)" class="card-text prepend-top-default" > <b> @@ -103,7 +103,7 @@ export default { </li> </ul> - <div v-if="assets.sources.length" class="dropdown"> + <div v-if="assets.sources && assets.sources.length" class="dropdown"> <button type="button" class="btn btn-link" diff --git a/app/assets/javascripts/releases/store/actions.js b/app/assets/javascripts/releases/store/actions.js index b5c4d54ac33..e0a922d5ef6 100644 --- a/app/assets/javascripts/releases/store/actions.js +++ b/app/assets/javascripts/releases/store/actions.js @@ -30,7 +30,7 @@ export const receiveReleasesSuccess = ({ commit }, data) => export const receiveReleasesError = ({ commit }) => { commit(types.RECEIVE_RELEASES_ERROR); - createFlash(__('An error occured while fetching the releases. Please try again.')); + createFlash(__('An error occurred while fetching the releases. Please try again.')); }; // prevent babel-plugin-rewire from generating an invalid default during karma tests diff --git a/app/assets/javascripts/reports/components/issue_status_icon.vue b/app/assets/javascripts/reports/components/issue_status_icon.vue index 2946fbc6a1f..04fba43b2f3 100644 --- a/app/assets/javascripts/reports/components/issue_status_icon.vue +++ b/app/assets/javascripts/reports/components/issue_status_icon.vue @@ -13,6 +13,11 @@ export default { type: String, required: true, }, + statusIconSize: { + type: Number, + required: false, + default: 32, + }, }, computed: { iconName() { @@ -45,6 +50,6 @@ export default { }" class="report-block-list-icon" > - <icon :name="iconName" :size="32" /> + <icon :name="iconName" :size="statusIconSize" /> </div> </template> diff --git a/app/assets/javascripts/reports/components/issues_list.vue b/app/assets/javascripts/reports/components/issues_list.vue index f4243522ef8..ee07efea3b0 100644 --- a/app/assets/javascripts/reports/components/issues_list.vue +++ b/app/assets/javascripts/reports/components/issues_list.vue @@ -52,6 +52,21 @@ export default { required: false, default: '', }, + showReportSectionStatusIcon: { + type: Boolean, + required: false, + default: true, + }, + issuesUlElementClass: { + type: String, + required: false, + default: '', + }, + issueItemClass: { + type: String, + required: false, + default: null, + }, }, computed: { issuesWithState() { @@ -62,6 +77,9 @@ export default { ...this.resolvedIssues.map(wrapIssueWithState(STATUS_SUCCESS)), ]; }, + wclass() { + return `report-block-list ${this.issuesUlElementClass}`; + }, }, }; </script> @@ -72,7 +90,7 @@ export default { :size="$options.typicalReportItemHeight" class="report-block-container" wtag="ul" - wclass="report-block-list" + :wclass="wclass" > <report-item v-for="(wrapped, index) in issuesWithState" @@ -81,6 +99,8 @@ export default { :status="wrapped.status" :component="component" :is-new="wrapped.isNew" + :show-report-section-status-icon="showReportSectionStatusIcon" + :class="issueItemClass" /> </smart-virtual-list> </template> diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue index 839e86bdf17..01a30809e1a 100644 --- a/app/assets/javascripts/reports/components/report_item.vue +++ b/app/assets/javascripts/reports/components/report_item.vue @@ -24,17 +24,32 @@ export default { type: String, required: true, }, + statusIconSize: { + type: Number, + required: false, + default: 32, + }, isNew: { type: Boolean, required: false, default: false, }, + showReportSectionStatusIcon: { + type: Boolean, + required: false, + default: true, + }, }, }; </script> <template> <li :class="{ 'is-dismissed': issue.isDismissed }" class="report-block-list-issue"> - <issue-status-icon :status="status" class="append-right-5" /> + <issue-status-icon + v-if="showReportSectionStatusIcon" + :status="status" + :status-icon-size="statusIconSize" + class="append-right-5" + /> <component :is="component" v-if="component" :issue="issue" :status="status" :is-new="isNew" /> </li> diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index d6483e95278..3d576caaf8f 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -3,10 +3,7 @@ import { __ } from '~/locale'; import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue'; import Popover from '~/vue_shared/components/help_popover.vue'; import IssuesList from './issues_list.vue'; - -const LOADING = 'LOADING'; -const ERROR = 'ERROR'; -const SUCCESS = 'SUCCESS'; +import { status } from '../constants'; export default { name: 'ReportSection', @@ -42,7 +39,8 @@ export default { }, successText: { type: String, - required: true, + required: false, + default: '', }, unresolvedIssues: { type: Array, @@ -73,6 +71,26 @@ export default { default: () => ({}), required: false, }, + showReportSectionStatusIcon: { + type: Boolean, + required: false, + default: true, + }, + issuesUlElementClass: { + type: String, + required: false, + default: undefined, + }, + issuesListContainerClass: { + type: String, + required: false, + default: undefined, + }, + issueItemClass: { + type: String, + required: false, + default: undefined, + }, }, data() { @@ -86,13 +104,13 @@ export default { return this.isCollapsed ? __('Expand') : __('Collapse'); }, isLoading() { - return this.status === LOADING; + return this.status === status.LOADING; }, loadingFailed() { - return this.status === ERROR; + return this.status === status.ERROR; }, isSuccess() { - return this.status === SUCCESS; + return this.status === status.SUCCESS; }, isCollapsible() { return !this.alwaysOpen && this.hasIssues; @@ -127,6 +145,15 @@ export default { hasPopover() { return Object.keys(this.popoverOptions).length > 0; }, + slotName() { + if (this.isSuccess) { + return 'success'; + } else if (this.isLoading) { + return 'loading'; + } + + return 'error'; + }, }, methods: { toggleCollapsed() { @@ -142,6 +169,7 @@ export default { <div class="media-body d-flex flex-align-self-center"> <span class="js-code-text code-text"> {{ headerText }} + <slot :name="slotName"></slot> <popover v-if="hasPopover" :options="popoverOptions" class="prepend-left-5" /> </span> @@ -151,7 +179,7 @@ export default { <button v-if="isCollapsible" type="button" - class="js-collapse-btn btn float-right btn-sm" + class="js-collapse-btn btn float-right btn-sm qa-expand-report-button" @click="toggleCollapsed" > {{ collapseText }} @@ -166,6 +194,10 @@ export default { :resolved-issues="resolvedIssues" :neutral-issues="neutralIssues" :component="component" + :show-report-section-status-icon="showReportSectionStatusIcon" + :issues-ul-element-class="issuesUlElementClass" + :class="issuesListContainerClass" + :issue-item-class="issueItemClass" /> </slot> </div> diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js index c323dc543f3..66ac1af062b 100644 --- a/app/assets/javascripts/reports/constants.js +++ b/app/assets/javascripts/reports/constants.js @@ -16,3 +16,9 @@ export const STATUS_NEUTRAL = 'neutral'; export const ICON_WARNING = 'warning'; export const ICON_SUCCESS = 'success'; export const ICON_NOTFOUND = 'notfound'; + +export const status = { + LOADING: 'LOADING', + ERROR: 'ERROR', + SUCCESS: 'SUCCESS', +}; diff --git a/app/assets/javascripts/reports/store/state.js b/app/assets/javascripts/reports/store/state.js index 5484900276c..25f9f70d095 100644 --- a/app/assets/javascripts/reports/store/state.js +++ b/app/assets/javascripts/reports/store/state.js @@ -40,6 +40,11 @@ export default () => ({ text: s__('Reports|Class'), type: fieldTypes.link, }, + classname: { + value: null, + text: s__('Reports|Classname'), + type: fieldTypes.text, + }, execution_time: { value: null, text: s__('Reports|Execution time'), diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/store/utils.js index 35632218269..10560d0ae8e 100644 --- a/app/assets/javascripts/reports/store/utils.js +++ b/app/assets/javascripts/reports/store/utils.js @@ -1,4 +1,4 @@ -import { sprintf, n__, s__ } from '~/locale'; +import { sprintf, n__, s__, __ } from '~/locale'; import { STATUS_FAILED, STATUS_SUCCESS, @@ -38,12 +38,12 @@ const textBuilder = results => { export const summaryTextBuilder = (name = '', results = {}) => { const resultsString = textBuilder(results); - return `${name} contained ${resultsString}`; + return sprintf(__('%{name} contained %{resultsString}'), { name, resultsString }); }; export const reportTextBuilder = (name = '', results = {}) => { const resultsString = textBuilder(results); - return `${name} found ${resultsString}`; + return sprintf(__('%{name} found %{resultsString}'), { name, resultsString }); }; export const statusIcon = status => { diff --git a/app/assets/javascripts/repository/components/app.vue b/app/assets/javascripts/repository/components/app.vue new file mode 100644 index 00000000000..98240aef810 --- /dev/null +++ b/app/assets/javascripts/repository/components/app.vue @@ -0,0 +1,3 @@ +<template> + <router-view /> +</template> diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue new file mode 100644 index 00000000000..6eca015036f --- /dev/null +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -0,0 +1,61 @@ +<script> +import getRefMixin from '../mixins/get_ref'; +import getProjectShortPath from '../queries/getProjectShortPath.graphql'; + +export default { + apollo: { + projectShortPath: { + query: getProjectShortPath, + }, + }, + mixins: [getRefMixin], + props: { + currentPath: { + type: String, + required: false, + default: '/', + }, + }, + data() { + return { + projectShortPath: '', + }; + }, + computed: { + pathLinks() { + return this.currentPath + .split('/') + .filter(p => p !== '') + .reduce( + (acc, name, i) => { + const path = `${i > 0 ? acc[i].path : ''}/${name}`; + + return acc.concat({ + name, + path, + to: `/tree/${this.ref}${path}`, + }); + }, + [{ name: this.projectShortPath, path: '/', to: `/tree/${this.ref}` }], + ); + }, + }, + methods: { + isLast(i) { + return i === this.pathLinks.length - 1; + }, + }, +}; +</script> + +<template> + <nav :aria-label="__('Files breadcrumb')"> + <ol class="breadcrumb repo-breadcrumb"> + <li v-for="(link, i) in pathLinks" :key="i" class="breadcrumb-item"> + <router-link :to="link.to" :aria-current="isLast(i) ? 'page' : null"> + {{ link.name }} + </router-link> + </li> + </ol> + </nav> +</template> diff --git a/app/assets/javascripts/repository/components/table/header.vue b/app/assets/javascripts/repository/components/table/header.vue new file mode 100644 index 00000000000..9d30aa88155 --- /dev/null +++ b/app/assets/javascripts/repository/components/table/header.vue @@ -0,0 +1,9 @@ +<template> + <thead> + <tr> + <th id="name" scope="col">{{ s__('ProjectFileTree|Name') }}</th> + <th id="last-commit" scope="col" class="d-none d-sm-table-cell">{{ __('Last commit') }}</th> + <th id="last-update" scope="col" class="text-right">{{ __('Last update') }}</th> + </tr> + </thead> +</template> diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue new file mode 100644 index 00000000000..d2198bcccfe --- /dev/null +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -0,0 +1,145 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { sprintf, __ } from '../../../locale'; +import getRefMixin from '../../mixins/get_ref'; +import getFiles from '../../queries/getFiles.graphql'; +import getProjectPath from '../../queries/getProjectPath.graphql'; +import TableHeader from './header.vue'; +import TableRow from './row.vue'; +import ParentRow from './parent_row.vue'; + +const PAGE_SIZE = 100; + +export default { + components: { + GlLoadingIcon, + TableHeader, + TableRow, + ParentRow, + }, + mixins: [getRefMixin], + apollo: { + projectPath: { + query: getProjectPath, + }, + }, + props: { + path: { + type: String, + required: true, + }, + }, + data() { + return { + projectPath: '', + nextPageCursor: '', + entries: { + trees: [], + submodules: [], + blobs: [], + }, + isLoadingFiles: false, + }; + }, + computed: { + tableCaption() { + return sprintf( + __('Files, directories, and submodules in the path %{path} for commit reference %{ref}'), + { path: this.path, ref: this.ref }, + ); + }, + showParentRow() { + return !this.isLoadingFiles && ['', '/'].indexOf(this.path) === -1; + }, + }, + watch: { + $route: function routeChange() { + this.entries.trees = []; + this.entries.submodules = []; + this.entries.blobs = []; + this.nextPageCursor = ''; + this.fetchFiles(); + }, + }, + mounted() { + // We need to wait for `ref` and `projectPath` to be set + this.$nextTick(() => this.fetchFiles()); + }, + methods: { + fetchFiles() { + this.isLoadingFiles = true; + + return this.$apollo + .query({ + query: getFiles, + variables: { + projectPath: this.projectPath, + ref: this.ref, + path: this.path, + nextPageCursor: this.nextPageCursor, + pageSize: PAGE_SIZE, + }, + }) + .then(({ data }) => { + if (!data) return; + + const pageInfo = this.hasNextPage(data.project.repository.tree); + + this.isLoadingFiles = false; + this.entries = Object.keys(this.entries).reduce( + (acc, key) => ({ + ...acc, + [key]: this.normalizeData(key, data.project.repository.tree[key].edges), + }), + {}, + ); + + if (pageInfo && pageInfo.hasNextPage) { + this.nextPageCursor = pageInfo.endCursor; + this.fetchFiles(); + } + }) + .catch(() => createFlash(__('An error occurred while fetching folder content.'))); + }, + normalizeData(key, data) { + return this.entries[key].concat(data.map(({ node }) => node)); + }, + hasNextPage(data) { + return [] + .concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo) + .find(({ hasNextPage }) => hasNextPage); + }, + }, +}; +</script> + +<template> + <div class="tree-content-holder"> + <div class="table-holder bordered-box"> + <table class="table tree-table qa-file-tree" aria-live="polite"> + <caption class="sr-only"> + {{ + tableCaption + }} + </caption> + <table-header v-once /> + <tbody> + <parent-row v-show="showParentRow" :commit-ref="ref" :path="path" /> + <template v-for="val in entries"> + <table-row + v-for="entry in val" + :id="entry.id" + :key="`${entry.flatPath}-${entry.id}`" + :current-path="path" + :path="entry.flatPath" + :type="entry.type" + :url="entry.webUrl" + /> + </template> + </tbody> + </table> + <gl-loading-icon v-show="isLoadingFiles" class="my-3" size="md" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue new file mode 100644 index 00000000000..3c39f404226 --- /dev/null +++ b/app/assets/javascripts/repository/components/table/parent_row.vue @@ -0,0 +1,37 @@ +<script> +export default { + props: { + commitRef: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + }, + computed: { + parentRoute() { + const splitArray = this.path.split('/'); + splitArray.pop(); + + return { path: `/tree/${this.commitRef}/${splitArray.join('/')}` }; + }, + }, + methods: { + clickRow() { + this.$router.push(this.parentRoute); + }, + }, +}; +</script> + +<template> + <tr class="tree-item"> + <td colspan="3" class="tree-item-file-name" @click.self="clickRow"> + <router-link :to="parentRoute" :aria-label="__('Go to parent')"> + .. + </router-link> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue new file mode 100644 index 00000000000..764882a7936 --- /dev/null +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -0,0 +1,77 @@ +<script> +import { getIconName } from '../../utils/icon'; +import getRefMixin from '../../mixins/get_ref'; + +export default { + mixins: [getRefMixin], + props: { + id: { + type: String, + required: true, + }, + currentPath: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + type: { + type: String, + required: true, + }, + url: { + type: String, + required: false, + default: null, + }, + }, + computed: { + routerLinkTo() { + return this.isFolder ? { path: `/tree/${this.ref}/${this.path}` } : null; + }, + iconName() { + return `fa-${getIconName(this.type, this.path)}`; + }, + isFolder() { + return this.type === 'tree'; + }, + isSubmodule() { + return this.type === 'commit'; + }, + linkComponent() { + return this.isFolder ? 'router-link' : 'a'; + }, + fullPath() { + return this.path.replace(new RegExp(`^${this.currentPath}/`), ''); + }, + shortSha() { + return this.id.slice(0, 8); + }, + }, + methods: { + openRow() { + if (this.isFolder) { + this.$router.push(this.routerLinkTo); + } + }, + }, +}; +</script> + +<template> + <tr v-once :class="`file_${id}`" class="tree-item" @click="openRow"> + <td class="tree-item-file-name"> + <i :aria-label="type" role="img" :class="iconName" class="fa fa-fw"></i> + <component :is="linkComponent" :to="routerLinkTo" :href="url" class="str-truncated"> + {{ fullPath }} + </component> + <template v-if="isSubmodule"> + @ <a href="#" class="commit-sha">{{ shortSha }}</a> + </template> + </td> + <td class="d-none d-sm-table-cell tree-commit"></td> + <td class="tree-time-ago text-right"></td> + </tr> +</template> diff --git a/app/assets/javascripts/repository/fragmentTypes.json b/app/assets/javascripts/repository/fragmentTypes.json new file mode 100644 index 00000000000..949ebca432b --- /dev/null +++ b/app/assets/javascripts/repository/fragmentTypes.json @@ -0,0 +1 @@ +{"__schema":{"types":[{"kind":"INTERFACE","name":"Entry","possibleTypes":[{"name":"Blob"},{"name":"Submodule"},{"name":"TreeEntry"}]}]}} diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js new file mode 100644 index 00000000000..c64d16ef02a --- /dev/null +++ b/app/assets/javascripts/repository/graphql.js @@ -0,0 +1,43 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; +import createDefaultClient from '~/lib/graphql'; +import introspectionQueryResultData from './fragmentTypes.json'; + +Vue.use(VueApollo); + +// We create a fragment matcher so that we can create a fragment from an interface +// Without this, Apollo throws a heuristic fragment matcher warning +const fragmentMatcher = new IntrospectionFragmentMatcher({ + introspectionQueryResultData, +}); + +const defaultClient = createDefaultClient( + {}, + { + cacheConfig: { + fragmentMatcher, + dataIdFromObject: obj => { + // eslint-disable-next-line no-underscore-dangle + switch (obj.__typename) { + // We need to create a dynamic ID for each entry + // Each entry can have the same ID as the ID is a commit ID + // So we create a unique cache ID with the path and the ID + case 'TreeEntry': + case 'Submodule': + case 'Blob': + return `${obj.flatPath}-${obj.id}`; + default: + // If the type doesn't match any of the above we fallback + // to using the default Apollo ID + // eslint-disable-next-line no-underscore-dangle + return obj.id || obj._id; + } + }, + }, + }, +); + +export default new VueApollo({ + defaultClient, +}); diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js new file mode 100644 index 00000000000..52f53be045b --- /dev/null +++ b/app/assets/javascripts/repository/index.js @@ -0,0 +1,59 @@ +import Vue from 'vue'; +import createRouter from './router'; +import App from './components/app.vue'; +import Breadcrumbs from './components/breadcrumbs.vue'; +import apolloProvider from './graphql'; +import { setTitle } from './utils/title'; + +export default function setupVueRepositoryList() { + const el = document.getElementById('js-tree-list'); + const { projectPath, projectShortPath, ref, fullName } = el.dataset; + const router = createRouter(projectPath, ref); + + apolloProvider.clients.defaultClient.cache.writeData({ + data: { + projectPath, + projectShortPath, + ref, + }, + }); + + router.afterEach(({ params: { pathMatch } }) => { + const isRoot = pathMatch === undefined || pathMatch === '/'; + + setTitle(pathMatch, ref, fullName); + + if (!isRoot) { + document + .querySelectorAll('.js-keep-hidden-on-navigation') + .forEach(elem => elem.classList.add('hidden')); + } + + document + .querySelectorAll('.js-hide-on-navigation') + .forEach(elem => elem.classList.toggle('hidden', !isRoot)); + }); + + // eslint-disable-next-line no-new + new Vue({ + el: document.getElementById('js-repo-breadcrumb'), + router, + apolloProvider, + render(h) { + return h(Breadcrumbs, { + props: { + currentPath: this.$route.params.pathMatch, + }, + }); + }, + }); + + return new Vue({ + el, + router, + apolloProvider, + render(h) { + return h(App); + }, + }); +} diff --git a/app/assets/javascripts/repository/mixins/get_ref.js b/app/assets/javascripts/repository/mixins/get_ref.js new file mode 100644 index 00000000000..b06087d6f42 --- /dev/null +++ b/app/assets/javascripts/repository/mixins/get_ref.js @@ -0,0 +1,14 @@ +import getRef from '../queries/getRef.graphql'; + +export default { + apollo: { + ref: { + query: getRef, + }, + }, + data() { + return { + ref: '', + }; + }, +}; diff --git a/app/assets/javascripts/repository/pages/index.vue b/app/assets/javascripts/repository/pages/index.vue new file mode 100644 index 00000000000..2d92e9174ca --- /dev/null +++ b/app/assets/javascripts/repository/pages/index.vue @@ -0,0 +1,18 @@ +<script> +import FileTable from '../components/table/index.vue'; + +export default { + components: { + FileTable, + }, + data() { + return { + ref: '', + }; + }, +}; +</script> + +<template> + <file-table path="/" /> +</template> diff --git a/app/assets/javascripts/repository/pages/tree.vue b/app/assets/javascripts/repository/pages/tree.vue new file mode 100644 index 00000000000..3b898d1aa91 --- /dev/null +++ b/app/assets/javascripts/repository/pages/tree.vue @@ -0,0 +1,20 @@ +<script> +import FileTable from '../components/table/index.vue'; + +export default { + components: { + FileTable, + }, + props: { + path: { + type: String, + required: false, + default: '/', + }, + }, +}; +</script> + +<template> + <file-table :path="path" /> +</template> diff --git a/app/assets/javascripts/repository/queries/getFiles.graphql b/app/assets/javascripts/repository/queries/getFiles.graphql new file mode 100644 index 00000000000..7d92bc46455 --- /dev/null +++ b/app/assets/javascripts/repository/queries/getFiles.graphql @@ -0,0 +1,57 @@ +fragment TreeEntry on Entry { + id + flatPath + type +} + +fragment PageInfo on PageInfo { + hasNextPage + endCursor +} + +query getFiles( + $projectPath: ID! + $path: String + $ref: String! + $pageSize: Int! + $nextPageCursor: String +) { + project(fullPath: $projectPath) { + repository { + tree(path: $path, ref: $ref) { + trees(first: $pageSize, after: $nextPageCursor) { + edges { + node { + ...TreeEntry + webUrl + } + } + pageInfo { + ...PageInfo + } + } + submodules(first: $pageSize, after: $nextPageCursor) { + edges { + node { + ...TreeEntry + } + } + pageInfo { + ...PageInfo + } + } + blobs(first: $pageSize, after: $nextPageCursor) { + edges { + node { + ...TreeEntry + webUrl + } + } + pageInfo { + ...PageInfo + } + } + } + } + } +} diff --git a/app/assets/javascripts/repository/queries/getProjectPath.graphql b/app/assets/javascripts/repository/queries/getProjectPath.graphql new file mode 100644 index 00000000000..74e73e07577 --- /dev/null +++ b/app/assets/javascripts/repository/queries/getProjectPath.graphql @@ -0,0 +1,3 @@ +query getProjectPath { + projectPath +} diff --git a/app/assets/javascripts/repository/queries/getProjectShortPath.graphql b/app/assets/javascripts/repository/queries/getProjectShortPath.graphql new file mode 100644 index 00000000000..34eb26598c2 --- /dev/null +++ b/app/assets/javascripts/repository/queries/getProjectShortPath.graphql @@ -0,0 +1,3 @@ +query getProjectShortPath { + projectShortPath @client +} diff --git a/app/assets/javascripts/repository/queries/getRef.graphql b/app/assets/javascripts/repository/queries/getRef.graphql new file mode 100644 index 00000000000..58c09844c3f --- /dev/null +++ b/app/assets/javascripts/repository/queries/getRef.graphql @@ -0,0 +1,3 @@ +query getRef { + ref @client +} diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js new file mode 100644 index 00000000000..9322c81ab97 --- /dev/null +++ b/app/assets/javascripts/repository/router.js @@ -0,0 +1,29 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import { joinPaths } from '../lib/utils/url_utility'; +import IndexPage from './pages/index.vue'; +import TreePage from './pages/tree.vue'; + +Vue.use(VueRouter); + +export default function createRouter(base, baseRef) { + return new VueRouter({ + mode: 'history', + base: joinPaths(gon.relative_url_root || '', base), + routes: [ + { + path: `/tree/${baseRef}(/.*)?`, + name: 'treePath', + component: TreePage, + props: route => ({ + path: route.params.pathMatch && route.params.pathMatch.replace(/^\//, ''), + }), + }, + { + path: '/', + name: 'projectRoot', + component: IndexPage, + }, + ], + }); +} diff --git a/app/assets/javascripts/repository/utils/icon.js b/app/assets/javascripts/repository/utils/icon.js new file mode 100644 index 00000000000..661ebb6edfc --- /dev/null +++ b/app/assets/javascripts/repository/utils/icon.js @@ -0,0 +1,99 @@ +const entryTypeIcons = { + tree: 'folder', + commit: 'archive', +}; + +const fileTypeIcons = [ + { extensions: ['pdf'], name: 'file-pdf-o' }, + { + extensions: [ + 'jpg', + 'jpeg', + 'jif', + 'jfif', + 'jp2', + 'jpx', + 'j2k', + 'j2c', + 'png', + 'gif', + 'tif', + 'tiff', + 'svg', + 'ico', + 'bmp', + ], + name: 'file-image-o', + }, + { + extensions: ['zip', 'zipx', 'tar', 'gz', 'bz', 'bzip', 'xz', 'rar', '7z'], + name: 'file-archive-o', + }, + { extensions: ['mp3', 'wma', 'ogg', 'oga', 'wav', 'flac', 'aac'], name: 'file-audio-o' }, + { + extensions: [ + 'mp4', + 'm4p', + 'm4v', + 'mpg', + 'mp2', + 'mpeg', + 'mpe', + 'mpv', + 'm2v', + 'avi', + 'mkv', + 'flv', + 'ogv', + 'mov', + '3gp', + '3g2', + ], + name: 'file-video-o', + }, + { extensions: ['doc', 'dot', 'docx', 'docm', 'dotx', 'dotm', 'docb'], name: 'file-word-o' }, + { + extensions: [ + 'xls', + 'xlt', + 'xlm', + 'xlsx', + 'xlsm', + 'xltx', + 'xltm', + 'xlsb', + 'xla', + 'xlam', + 'xll', + 'xlw', + ], + name: 'file-excel-o', + }, + { + extensions: [ + 'ppt', + 'pot', + 'pps', + 'pptx', + 'pptm', + 'potx', + 'potm', + 'ppam', + 'ppsx', + 'ppsm', + 'sldx', + 'sldm', + ], + name: 'file-powerpoint-o', + }, +]; + +// eslint-disable-next-line import/prefer-default-export +export const getIconName = (type, path) => { + if (entryTypeIcons[type]) return entryTypeIcons[type]; + + const extension = path.split('.').pop(); + const file = fileTypeIcons.find(t => t.extensions.some(ext => ext === extension)); + + return file ? file.name : 'file-text-o'; +}; diff --git a/app/assets/javascripts/repository/utils/title.js b/app/assets/javascripts/repository/utils/title.js new file mode 100644 index 00000000000..4e194640e92 --- /dev/null +++ b/app/assets/javascripts/repository/utils/title.js @@ -0,0 +1,9 @@ +// eslint-disable-next-line import/prefer-default-export +export const setTitle = (pathMatch, ref, project) => { + if (!pathMatch) return; + + const path = pathMatch.replace(/^\//, ''); + const isEmpty = path === ''; + + document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project}`; +}; diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 9a0cdc02952..930c0d5e958 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -5,7 +5,7 @@ import _ from 'underscore'; import Cookies from 'js-cookie'; import flash from './flash'; import axios from './lib/utils/axios_utils'; -import { __ } from './locale'; +import { sprintf, s__, __ } from './locale'; function Sidebar(currentUser) { this.toggleTodo = this.toggleTodo.bind(this); @@ -82,9 +82,9 @@ Sidebar.prototype.toggleTodo = function(e) { ajaxType = $this.data('deletePath') ? 'delete' : 'post'; if ($this.data('deletePath')) { - url = '' + $this.data('deletePath'); + url = String($this.data('deletePath')); } else { - url = '' + $this.data('createPath'); + url = String($this.data('createPath')); } $this.tooltip('hide'); @@ -101,7 +101,10 @@ Sidebar.prototype.toggleTodo = function(e) { this.todoUpdateDone(data); }) .catch(() => - flash(`There was an error ${ajaxType === 'post' ? 'adding a' : 'deleting the'} todo.`), + flash(sprintf(__('There was an error %{message} todo.')), { + message: + ajaxType === 'post' ? s__('RightSidebar|adding a') : s__('RightSidebar|deleting the'), + }), ); }; diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 0a4583b5861..6aca4067ba7 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import { escape, throttle } from 'underscore'; -import { s__, sprintf } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper'; import axios from './lib/utils/axios_utils'; import DropdownUtils from './filtered_search/dropdown_utils'; @@ -379,7 +379,7 @@ export class SearchAutocomplete { } } } - this.wrap.toggleClass('has-value', !!e.target.value); + this.wrap.toggleClass('has-value', Boolean(e.target.value)); } onSearchInputFocus() { @@ -396,7 +396,7 @@ export class SearchAutocomplete { onClearInputClick(e) { e.preventDefault(); - this.wrap.toggleClass('has-value', !!e.target.value); + this.wrap.toggleClass('has-value', Boolean(e.target.value)); return this.searchInput.val('').focus(); } @@ -405,8 +405,9 @@ export class SearchAutocomplete { this.wrap.removeClass('search-active'); // If input is blank then restore state if (this.searchInput.val() === '') { - return this.restoreOriginalState(); + this.restoreOriginalState(); } + this.dropdownMenu.removeClass('show'); } restoreOriginalState() { @@ -439,7 +440,7 @@ export class SearchAutocomplete { restoreMenu() { var html; - html = '<ul><li class="dropdown-menu-empty-item"><a>Loading...</a></li></ul>'; + html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`; return this.dropdownContent.html(html); } diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue new file mode 100644 index 00000000000..32c9d6eccb8 --- /dev/null +++ b/app/assets/javascripts/serverless/components/area.vue @@ -0,0 +1,146 @@ +<script> +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; +import dateFormat from 'dateformat'; +import { X_INTERVAL } from '../constants'; +import { validateGraphData } from '../utils'; + +let debouncedResize; + +export default { + components: { + GlAreaChart, + }, + inheritAttrs: false, + props: { + graphData: { + type: Object, + required: true, + validator: validateGraphData, + }, + containerWidth: { + type: Number, + required: true, + }, + }, + data() { + return { + tooltipPopoverTitle: '', + tooltipPopoverContent: '', + width: this.containerWidth, + }; + }, + computed: { + chartData() { + return this.graphData.queries.reduce((accumulator, query) => { + accumulator[query.unit] = query.result.reduce((acc, res) => acc.concat(res.values), []); + return accumulator; + }, {}); + }, + extractTimeData() { + return this.chartData.requests.map(data => data.time); + }, + generateSeries() { + return { + name: 'Invocations', + type: 'line', + data: this.chartData.requests.map(data => [data.time, data.value]), + symbolSize: 0, + }; + }, + getInterval() { + const { result } = this.graphData.queries[0]; + + if (result.length === 0) { + return 1; + } + + const split = result[0].values.reduce( + (acc, pair) => (pair.value > acc ? pair.value : acc), + 1, + ); + + return split < X_INTERVAL ? split : X_INTERVAL; + }, + chartOptions() { + return { + xAxis: { + name: 'time', + type: 'time', + axisLabel: { + formatter: date => dateFormat(date, 'h:MM TT'), + }, + data: this.extractTimeData, + nameTextStyle: { + padding: [18, 0, 0, 0], + }, + }, + yAxis: { + name: this.yAxisLabel, + nameTextStyle: { + padding: [0, 0, 36, 0], + }, + splitNumber: this.getInterval, + }, + legend: { + formatter: this.xAxisLabel, + }, + series: this.generateSeries, + }; + }, + xAxisLabel() { + return this.graphData.queries.map(query => query.label).join(', '); + }, + yAxisLabel() { + const [query] = this.graphData.queries; + return `${this.graphData.y_label} (${query.unit})`; + }, + }, + watch: { + containerWidth: 'onResize', + }, + beforeDestroy() { + window.removeEventListener('resize', debouncedResize); + }, + created() { + debouncedResize = debounceByAnimationFrame(this.onResize); + window.addEventListener('resize', debouncedResize); + }, + methods: { + formatTooltipText(params) { + const [seriesData] = params.seriesData; + this.tooltipPopoverTitle = dateFormat(params.value, 'dd mmm yyyy, h:MMTT'); + this.tooltipPopoverContent = `${this.yAxisLabel}: ${seriesData.value[1]}`; + }, + onResize() { + const { width } = this.$refs.areaChart.$el.getBoundingClientRect(); + this.width = width; + }, + }, +}; +</script> + +<template> + <div class="prometheus-graph"> + <div class="prometheus-graph-header"> + <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5> + <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div> + </div> + <gl-area-chart + ref="areaChart" + v-bind="$attrs" + :data="[]" + :option="chartOptions" + :format-tooltip-text="formatTooltipText" + :width="width" + :include-legend-avg-max="false" + > + <template slot="tooltipTitle"> + {{ tooltipPopoverTitle }} + </template> + <template slot="tooltipContent"> + {{ tooltipPopoverContent }} + </template> + </gl-area-chart> + </div> +</template> diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue index 4f89ad69129..b8906cfca4e 100644 --- a/app/assets/javascripts/serverless/components/function_details.vue +++ b/app/assets/javascripts/serverless/components/function_details.vue @@ -1,39 +1,77 @@ <script> +import _ from 'underscore'; +import { mapState, mapActions, mapGetters } from 'vuex'; import PodBox from './pod_box.vue'; import Url from './url.vue'; +import AreaChart from './area.vue'; +import MissingPrometheus from './missing_prometheus.vue'; export default { components: { PodBox, Url, + AreaChart, + MissingPrometheus, }, props: { func: { type: Object, required: true, }, + hasPrometheus: { + type: Boolean, + required: false, + default: false, + }, + clustersPath: { + type: String, + required: true, + }, + helpPath: { + type: String, + required: true, + }, + }, + data() { + return { + elWidth: 0, + }; }, computed: { name() { return this.func.name; }, description() { - return this.func.description; + return _.isString(this.func.description) ? this.func.description : ''; }, funcUrl() { return this.func.url; }, podCount() { - return this.func.podcount || 0; + return Number(this.func.podcount) || 0; }, + ...mapState(['graphData', 'hasPrometheusData']), + ...mapGetters(['hasPrometheusMissingData']), + }, + created() { + this.fetchMetrics({ + metricsPath: this.func.metricsUrl, + hasPrometheus: this.hasPrometheus, + }); + }, + mounted() { + this.elWidth = this.$el.clientWidth; + }, + methods: { + ...mapActions(['fetchMetrics']), }, }; </script> <template> <section id="serverless-function-details"> - <h3>{{ name }}</h3> - <div class="append-bottom-default"> + <h3 class="serverless-function-name">{{ name }}</h3> + <div class="append-bottom-default serverless-function-description"> <div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div> </div> <url :uri="funcUrl" /> @@ -52,5 +90,13 @@ export default { </p> </div> <div v-else><p>No pods loaded at this time.</p></div> + + <area-chart v-if="hasPrometheusData" :graph-data="graphData" :container-width="elWidth" /> + <missing-prometheus + v-if="!hasPrometheus || hasPrometheusMissingData" + :help-path="helpPath" + :clusters-path="clustersPath" + :missing-data="hasPrometheusMissingData" + /> </section> </template> diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue index 773d18781fd..4b3bb078eae 100644 --- a/app/assets/javascripts/serverless/components/function_row.vue +++ b/app/assets/javascripts/serverless/components/function_row.vue @@ -1,4 +1,5 @@ <script> +import _ from 'underscore'; import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; import Url from './url.vue'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -19,6 +20,10 @@ export default { return this.func.name; }, description() { + if (!_.isString(this.func.description)) { + return ''; + } + const desc = this.func.description.split('\n'); if (desc.length > 1) { return desc[1]; diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index 4bde409f906..94341050b86 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -1,26 +1,19 @@ <script> -import { GlSkeletonLoading } from '@gitlab/ui'; +import { mapState, mapActions, mapGetters } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; import FunctionRow from './function_row.vue'; import EnvironmentRow from './environment_row.vue'; import EmptyState from './empty_state.vue'; +import { CHECKING_INSTALLED } from '../constants'; export default { components: { EnvironmentRow, FunctionRow, EmptyState, - GlSkeletonLoading, + GlLoadingIcon, }, props: { - functions: { - type: Object, - required: true, - default: () => ({}), - }, - installed: { - type: Boolean, - required: true, - }, clustersPath: { type: String, required: true, @@ -29,32 +22,48 @@ export default { type: String, required: true, }, - loadingData: { - type: Boolean, - required: false, - default: true, + statusPath: { + type: String, + required: true, }, - hasFunctionData: { - type: Boolean, - required: false, - default: true, + }, + computed: { + ...mapState(['installed', 'isLoading', 'hasFunctionData']), + ...mapGetters(['getFunctions']), + + checkingInstalled() { + return this.installed === CHECKING_INSTALLED; + }, + isInstalled() { + return this.installed === true; }, }, + created() { + this.fetchFunctions({ + functionsPath: this.statusPath, + }); + }, + methods: { + ...mapActions(['fetchFunctions']), + }, }; </script> <template> <section id="serverless-functions"> - <div v-if="installed"> + <gl-loading-icon + v-if="checkingInstalled" + :size="2" + class="prepend-top-default append-bottom-default" + /> + + <div v-else-if="isInstalled"> <div v-if="hasFunctionData"> - <template v-if="loadingData"> - <div v-for="j in 3" :key="j" class="gl-responsive-table-row"><gl-skeleton-loading /></div> - </template> - <template v-else> - <div class="groups-list-tree-container"> + <template> + <div class="groups-list-tree-container js-functions-wrapper"> <ul class="content-list group-list-tree"> <environment-row - v-for="(env, index) in functions" + v-for="(env, index) in getFunctions" :key="index" :env="env" :env-name="index" @@ -62,6 +71,11 @@ export default { </ul> </div> </template> + <gl-loading-icon + v-if="isLoading" + :size="2" + class="prepend-top-default append-bottom-default js-functions-loader" + /> </div> <div v-else class="empty-state js-empty-state"> <div class="text-content"> diff --git a/app/assets/javascripts/serverless/components/missing_prometheus.vue b/app/assets/javascripts/serverless/components/missing_prometheus.vue new file mode 100644 index 00000000000..6c19434f202 --- /dev/null +++ b/app/assets/javascripts/serverless/components/missing_prometheus.vue @@ -0,0 +1,63 @@ +<script> +import { GlButton, GlLink } from '@gitlab/ui'; +import { s__ } from '../../locale'; + +export default { + components: { + GlButton, + GlLink, + }, + props: { + clustersPath: { + type: String, + required: true, + }, + helpPath: { + type: String, + required: true, + }, + missingData: { + type: Boolean, + required: true, + }, + }, + computed: { + missingStateClass() { + return this.missingData ? 'missing-prometheus-state' : 'empty-prometheus-state'; + }, + prometheusHelpPath() { + return `${this.helpPath}#prometheus-support`; + }, + description() { + return this.missingData + ? s__(`ServerlessDetails|Invocation metrics loading or not available at this time.`) + : s__( + `ServerlessDetails|Function invocation metrics require Prometheus to be installed first.`, + ); + }, + }, +}; +</script> + +<template> + <div class="row" :class="missingStateClass"> + <div class="col-12"> + <div class="text-content"> + <h4 class="state-title text-left">{{ s__(`ServerlessDetails|Invocations`) }}</h4> + <p class="state-description"> + {{ description }} + <gl-link :href="prometheusHelpPath">{{ + s__(`ServerlessDetails|More information`) + }}</gl-link + >. + </p> + + <div v-if="!missingData" class="text-left"> + <gl-button :href="clustersPath" variant="success"> + {{ s__('ServerlessDetails|Install Prometheus') }} + </gl-button> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue index ca53bf6c52a..e47a03f1939 100644 --- a/app/assets/javascripts/serverless/components/url.vue +++ b/app/assets/javascripts/serverless/components/url.vue @@ -20,7 +20,7 @@ export default { <template> <div class="clipboard-group"> - <div class="url-text-field label label-monospace">{{ uri }}</div> + <div class="url-text-field label label-monospace monospace">{{ uri }}</div> <clipboard-button :text="uri" :title="s__('ServerlessURL|Copy URL to clipboard')" diff --git a/app/assets/javascripts/serverless/constants.js b/app/assets/javascripts/serverless/constants.js new file mode 100644 index 00000000000..2fa15e56ccb --- /dev/null +++ b/app/assets/javascripts/serverless/constants.js @@ -0,0 +1,7 @@ +export const MAX_REQUESTS = 3; // max number of times to retry + +export const X_INTERVAL = 5; // Reflects the number of verticle bars on the x-axis + +export const CHECKING_INSTALLED = 'checking'; // The backend is still determining whether or not Knative is installed + +export const TIMEOUT = 'timeout'; diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js index 47a510d5fb5..ed3b633d766 100644 --- a/app/assets/javascripts/serverless/serverless_bundle.js +++ b/app/assets/javascripts/serverless/serverless_bundle.js @@ -1,13 +1,7 @@ -import Visibility from 'visibilityjs'; import Vue from 'vue'; -import { s__ } from '../locale'; -import Flash from '../flash'; -import Poll from '../lib/utils/poll'; -import ServerlessStore from './stores/serverless_store'; -import ServerlessDetailsStore from './stores/serverless_details_store'; -import GetFunctionsService from './services/get_functions_service'; import Functions from './components/functions.vue'; import FunctionDetails from './components/function_details.vue'; +import { createStore } from './store'; export default class Serverless { constructor() { @@ -19,10 +13,12 @@ export default class Serverless { serviceUrl, serviceNamespace, servicePodcount, + serviceMetricsUrl, + prometheus, + clustersPath, + helpPath, } = document.querySelector('.js-serverless-function-details-page').dataset; const el = document.querySelector('#js-serverless-function-details'); - this.store = new ServerlessDetailsStore(); - const { store } = this; const service = { name: serviceName, @@ -31,118 +27,48 @@ export default class Serverless { url: serviceUrl, namespace: serviceNamespace, podcount: servicePodcount, + metricsUrl: serviceMetricsUrl, }; - this.store.updateDetailedFunction(service); this.functionDetails = new Vue({ el, - data() { - return { - state: store.state, - }; - }, + store: createStore(), render(createElement) { return createElement(FunctionDetails, { props: { - func: this.state.functionDetail, + func: service, + hasPrometheus: prometheus !== undefined, + clustersPath, + helpPath, }, }); }, }); } else { - const { statusPath, clustersPath, helpPath, installed } = document.querySelector( + const { statusPath, clustersPath, helpPath } = document.querySelector( '.js-serverless-functions-page', ).dataset; - this.service = new GetFunctionsService(statusPath); - this.knativeInstalled = installed !== undefined; - this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath); - this.initServerless(); - this.functionLoadCount = 0; - - if (statusPath && this.knativeInstalled) { - this.initPolling(); - } - } - } - - initServerless() { - const { store } = this; - const el = document.querySelector('#js-serverless-functions'); - - this.functions = new Vue({ - el, - data() { - return { - state: store.state, - }; - }, - render(createElement) { - return createElement(Functions, { - props: { - functions: this.state.functions, - installed: this.state.installed, - clustersPath: this.state.clustersPath, - helpPath: this.state.helpPath, - loadingData: this.state.loadingData, - hasFunctionData: this.state.hasFunctionData, - }, - }); - }, - }); - } - - initPolling() { - this.poll = new Poll({ - resource: this.service, - method: 'fetchData', - successCallback: data => this.handleSuccess(data), - errorCallback: () => Serverless.handleError(), - }); - - if (!Visibility.hidden()) { - this.poll.makeRequest(); - } else { - this.service - .fetchData() - .then(data => this.handleSuccess(data)) - .catch(() => Serverless.handleError()); - } - - Visibility.change(() => { - if (!Visibility.hidden() && !this.destroyed) { - this.poll.restart(); - } else { - this.poll.stop(); - } - }); - } - - handleSuccess(data) { - if (data.status === 200) { - this.store.updateFunctionsFromServer(data.data); - this.store.updateLoadingState(false); - } else if (data.status === 204) { - /* Time out after 3 attempts to retrieve data */ - this.functionLoadCount += 1; - if (this.functionLoadCount === 3) { - this.poll.stop(); - this.store.toggleNoFunctionData(); - } + const el = document.querySelector('#js-serverless-functions'); + this.functions = new Vue({ + el, + store: createStore(), + render(createElement) { + return createElement(Functions, { + props: { + clustersPath, + helpPath, + statusPath, + }, + }); + }, + }); } } - static handleError() { - Flash(s__('Serverless|An error occurred while retrieving serverless components')); - } - destroy() { this.destroyed = true; - if (this.poll) { - this.poll.stop(); - } - this.functions.$destroy(); this.functionDetails.$destroy(); } diff --git a/app/assets/javascripts/serverless/services/get_functions_service.js b/app/assets/javascripts/serverless/services/get_functions_service.js deleted file mode 100644 index 303b42dc66c..00000000000 --- a/app/assets/javascripts/serverless/services/get_functions_service.js +++ /dev/null @@ -1,11 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; - -export default class GetFunctionsService { - constructor(endpoint) { - this.endpoint = endpoint; - } - - fetchData() { - return axios.get(this.endpoint); - } -} diff --git a/app/assets/javascripts/serverless/store/actions.js b/app/assets/javascripts/serverless/store/actions.js new file mode 100644 index 00000000000..a0a9fdf7ace --- /dev/null +++ b/app/assets/javascripts/serverless/store/actions.js @@ -0,0 +1,128 @@ +import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import statusCodes from '~/lib/utils/http_status'; +import { backOff } from '~/lib/utils/common_utils'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import { MAX_REQUESTS, CHECKING_INSTALLED, TIMEOUT } from '../constants'; + +export const requestFunctionsLoading = ({ commit }) => commit(types.REQUEST_FUNCTIONS_LOADING); +export const receiveFunctionsSuccess = ({ commit }, data) => + commit(types.RECEIVE_FUNCTIONS_SUCCESS, data); +export const receiveFunctionsPartial = ({ commit }, data) => + commit(types.RECEIVE_FUNCTIONS_PARTIAL, data); +export const receiveFunctionsTimeout = ({ commit }, data) => + commit(types.RECEIVE_FUNCTIONS_TIMEOUT, data); +export const receiveFunctionsNoDataSuccess = ({ commit }, data) => + commit(types.RECEIVE_FUNCTIONS_NODATA_SUCCESS, data); +export const receiveFunctionsError = ({ commit }, error) => + commit(types.RECEIVE_FUNCTIONS_ERROR, error); + +export const receiveMetricsSuccess = ({ commit }, data) => + commit(types.RECEIVE_METRICS_SUCCESS, data); +export const receiveMetricsNoPrometheus = ({ commit }) => + commit(types.RECEIVE_METRICS_NO_PROMETHEUS); +export const receiveMetricsNoDataSuccess = ({ commit }, data) => + commit(types.RECEIVE_METRICS_NODATA_SUCCESS, data); +export const receiveMetricsError = ({ commit }, error) => + commit(types.RECEIVE_METRICS_ERROR, error); + +export const fetchFunctions = ({ dispatch }, { functionsPath }) => { + let retryCount = 0; + + const functionsPartiallyFetched = data => { + if (data.functions !== null && data.functions.length) { + dispatch('receiveFunctionsPartial', data); + } + }; + + dispatch('requestFunctionsLoading'); + + backOff((next, stop) => { + axios + .get(functionsPath) + .then(response => { + if (response.data.knative_installed === CHECKING_INSTALLED) { + retryCount += 1; + if (retryCount < MAX_REQUESTS) { + functionsPartiallyFetched(response.data); + next(); + } else { + stop(TIMEOUT); + } + } else { + stop(response.data); + } + }) + .catch(stop); + }) + .then(data => { + if (data === TIMEOUT) { + dispatch('receiveFunctionsTimeout'); + createFlash(__('Loading functions timed out. Please reload the page to try again.')); + } else if (data.functions !== null && data.functions.length) { + dispatch('receiveFunctionsSuccess', data); + } else { + dispatch('receiveFunctionsNoDataSuccess', data); + } + }) + .catch(error => { + dispatch('receiveFunctionsError', error); + createFlash(error); + }); +}; + +export const fetchMetrics = ({ dispatch }, { metricsPath, hasPrometheus }) => { + let retryCount = 0; + + if (!hasPrometheus) { + dispatch('receiveMetricsNoPrometheus'); + return; + } + + backOff((next, stop) => { + axios + .get(metricsPath) + .then(response => { + if (response.status === statusCodes.NO_CONTENT) { + retryCount += 1; + if (retryCount < MAX_REQUESTS) { + next(); + } else { + dispatch('receiveMetricsNoDataSuccess'); + stop(null); + } + } else { + stop(response.data); + } + }) + .catch(stop); + }) + .then(data => { + if (data === null) { + return; + } + + const updatedMetric = data.metrics; + const queries = data.metrics.queries.map(query => ({ + ...query, + result: query.result.map(result => ({ + ...result, + values: result.values.map(([timestamp, value]) => ({ + time: new Date(timestamp * 1000).toISOString(), + value: Number(value), + })), + })), + })); + + updatedMetric.queries = queries; + dispatch('receiveMetricsSuccess', updatedMetric); + }) + .catch(error => { + dispatch('receiveMetricsError', error); + createFlash(error); + }); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/serverless/store/getters.js b/app/assets/javascripts/serverless/store/getters.js new file mode 100644 index 00000000000..071f663d9d2 --- /dev/null +++ b/app/assets/javascripts/serverless/store/getters.js @@ -0,0 +1,10 @@ +import { translate } from '../utils'; + +export const hasPrometheusMissingData = state => state.hasPrometheus && !state.hasPrometheusData; + +// Convert the function list into a k/v grouping based on the environment scope + +export const getFunctions = state => translate(state.functions); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/serverless/store/index.js b/app/assets/javascripts/serverless/store/index.js new file mode 100644 index 00000000000..5f72060633e --- /dev/null +++ b/app/assets/javascripts/serverless/store/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import createState from './state'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + actions, + getters, + mutations, + state: createState(), + }); + +export default createStore(); diff --git a/app/assets/javascripts/serverless/store/mutation_types.js b/app/assets/javascripts/serverless/store/mutation_types.js new file mode 100644 index 00000000000..b8fa9ea1a01 --- /dev/null +++ b/app/assets/javascripts/serverless/store/mutation_types.js @@ -0,0 +1,11 @@ +export const REQUEST_FUNCTIONS_LOADING = 'REQUEST_FUNCTIONS_LOADING'; +export const RECEIVE_FUNCTIONS_SUCCESS = 'RECEIVE_FUNCTIONS_SUCCESS'; +export const RECEIVE_FUNCTIONS_PARTIAL = 'RECEIVE_FUNCTIONS_PARTIAL'; +export const RECEIVE_FUNCTIONS_TIMEOUT = 'RECEIVE_FUNCTIONS_TIMEOUT'; +export const RECEIVE_FUNCTIONS_NODATA_SUCCESS = 'RECEIVE_FUNCTIONS_NODATA_SUCCESS'; +export const RECEIVE_FUNCTIONS_ERROR = 'RECEIVE_FUNCTIONS_ERROR'; + +export const RECEIVE_METRICS_NO_PROMETHEUS = 'RECEIVE_METRICS_NO_PROMETHEUS'; +export const RECEIVE_METRICS_SUCCESS = 'RECEIVE_METRICS_SUCCESS'; +export const RECEIVE_METRICS_NODATA_SUCCESS = 'RECEIVE_METRICS_NODATA_SUCCESS'; +export const RECEIVE_METRICS_ERROR = 'RECEIVE_METRICS_ERROR'; diff --git a/app/assets/javascripts/serverless/store/mutations.js b/app/assets/javascripts/serverless/store/mutations.js new file mode 100644 index 00000000000..2685a5b11ff --- /dev/null +++ b/app/assets/javascripts/serverless/store/mutations.js @@ -0,0 +1,49 @@ +import * as types from './mutation_types'; + +export default { + [types.REQUEST_FUNCTIONS_LOADING](state) { + state.isLoading = true; + }, + [types.RECEIVE_FUNCTIONS_SUCCESS](state, data) { + state.functions = data.functions; + state.installed = data.knative_installed; + state.isLoading = false; + state.hasFunctionData = true; + }, + [types.RECEIVE_FUNCTIONS_PARTIAL](state, data) { + state.functions = data.functions; + state.installed = true; + state.isLoading = true; + state.hasFunctionData = true; + }, + [types.RECEIVE_FUNCTIONS_TIMEOUT](state) { + state.isLoading = false; + }, + [types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state, data) { + state.isLoading = false; + state.installed = data.knative_installed; + state.hasFunctionData = false; + }, + [types.RECEIVE_FUNCTIONS_ERROR](state, error) { + state.error = error; + state.hasFunctionData = false; + state.isLoading = false; + }, + [types.RECEIVE_METRICS_SUCCESS](state, data) { + state.isLoading = false; + state.hasPrometheusData = true; + state.graphData = data; + }, + [types.RECEIVE_METRICS_NODATA_SUCCESS](state) { + state.isLoading = false; + state.hasPrometheusData = false; + }, + [types.RECEIVE_METRICS_ERROR](state, error) { + state.hasPrometheusData = false; + state.error = error; + }, + [types.RECEIVE_METRICS_NO_PROMETHEUS](state) { + state.hasPrometheusData = false; + state.hasPrometheus = false; + }, +}; diff --git a/app/assets/javascripts/serverless/store/state.js b/app/assets/javascripts/serverless/store/state.js new file mode 100644 index 00000000000..fdd29299749 --- /dev/null +++ b/app/assets/javascripts/serverless/store/state.js @@ -0,0 +1,14 @@ +export default () => ({ + error: null, + installed: 'checking', + isLoading: true, + + // functions + functions: [], + hasFunctionData: true, + + // function_details + hasPrometheus: true, + hasPrometheusData: false, + graphData: {}, +}); diff --git a/app/assets/javascripts/serverless/stores/serverless_details_store.js b/app/assets/javascripts/serverless/stores/serverless_details_store.js deleted file mode 100644 index 5394d2cded1..00000000000 --- a/app/assets/javascripts/serverless/stores/serverless_details_store.js +++ /dev/null @@ -1,11 +0,0 @@ -export default class ServerlessDetailsStore { - constructor() { - this.state = { - functionDetail: {}, - }; - } - - updateDetailedFunction(func) { - this.state.functionDetail = func; - } -} diff --git a/app/assets/javascripts/serverless/stores/serverless_store.js b/app/assets/javascripts/serverless/stores/serverless_store.js deleted file mode 100644 index 816d55a03f9..00000000000 --- a/app/assets/javascripts/serverless/stores/serverless_store.js +++ /dev/null @@ -1,29 +0,0 @@ -export default class ServerlessStore { - constructor(knativeInstalled = false, clustersPath, helpPath) { - this.state = { - functions: {}, - hasFunctionData: true, - loadingData: true, - installed: knativeInstalled, - clustersPath, - helpPath, - }; - } - - updateFunctionsFromServer(upstreamFunctions = []) { - this.state.functions = upstreamFunctions.reduce((rv, func) => { - const envs = rv; - envs[func.environment_scope] = (rv[func.environment_scope] || []).concat([func]); - - return envs; - }, {}); - } - - updateLoadingState(loadingData) { - this.state.loadingData = loadingData; - } - - toggleNoFunctionData() { - this.state.hasFunctionData = false; - } -} diff --git a/app/assets/javascripts/serverless/utils.js b/app/assets/javascripts/serverless/utils.js new file mode 100644 index 00000000000..8b9e96ce9aa --- /dev/null +++ b/app/assets/javascripts/serverless/utils.js @@ -0,0 +1,23 @@ +// Validate that the object coming in has valid query details and results +export const validateGraphData = data => + data.queries && + Array.isArray(data.queries) && + data.queries.filter(query => { + if (Array.isArray(query.result)) { + return query.result.filter(res => Array.isArray(res.values)).length === query.result.length; + } + + return false; + }).length === data.queries.length; + +export const translate = functions => + functions.reduce( + (acc, func) => + Object.assign(acc, { + [func.environment_scope]: (acc[func.environment_scope] || []).concat([func]), + }), + {}, + ); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; 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 7f86741ed29..35eba266625 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -2,7 +2,7 @@ import $ from 'jquery'; import createFlash from '~/flash'; import Icon from '~/vue_shared/components/icon.vue'; -import GfmAutoComplete from '~/gfm_auto_complete'; +import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import { __, s__ } from '~/locale'; import Api from '~/api'; import { GlModal, GlTooltipDirective } from '@gitlab/ui'; @@ -178,7 +178,7 @@ export default { /> <div ref="userStatusForm" class="form-group position-relative m-0"> <div class="input-group"> - <span class="input-group-btn"> + <span class="input-group-prepend"> <button ref="toggleEmojiMenuButton" v-gl-tooltip.bottom @@ -194,9 +194,9 @@ export default { v-show="noEmoji" class="js-no-emoji-placeholder no-emoji-placeholder position-relative" > - <icon name="emoji_slightly_smiling_face" css-classes="award-control-icon-neutral" /> - <icon name="emoji_smiley" css-classes="award-control-icon-positive" /> - <icon name="emoji_smile" css-classes="award-control-icon-super-positive" /> + <icon name="slight-smile" css-classes="award-control-icon-neutral" /> + <icon name="smiley" css-classes="award-control-icon-positive" /> + <icon name="smile" css-classes="award-control-icon-super-positive" /> </span> </button> </span> @@ -211,7 +211,7 @@ export default { @keyup.enter.prevent @click="hideEmojiMenu" /> - <span v-show="isDirty" class="input-group-btn"> + <span v-show="isDirty" class="input-group-append"> <button v-gl-tooltip.bottom :title="s__('SetStatusModal|Clear status')" diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index d1a396182b3..0074d7099dc 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -74,8 +74,7 @@ export default { } if (!this.users.length) { - const emptyTooltipLabel = - this.issuableType === 'issue' ? __('Assignee(s)') : __('Assignee'); + const emptyTooltipLabel = __('Assignee(s)'); names.push(emptyTooltipLabel); } @@ -90,6 +89,27 @@ export default { return counter; }, + mergeNotAllowedTooltipMessage() { + const assigneesCount = this.users.length; + + if (this.issuableType !== 'merge_request' || assigneesCount === 0) { + return null; + } + + const cannotMergeCount = this.users.filter(u => u.can_merge === false).length; + const canMergeCount = assigneesCount - cannotMergeCount; + + if (canMergeCount === assigneesCount) { + // Everyone can merge + return null; + } else if (cannotMergeCount === assigneesCount && assigneesCount > 1) { + return 'No one can merge'; + } else if (assigneesCount === 1) { + return 'Cannot merge'; + } + + return `${canMergeCount}/${assigneesCount} can merge`; + }, }, methods: { assignSelf() { @@ -133,7 +153,7 @@ export default { data-placement="left" data-boundary="viewport" > - <i v-if="hasNoUsers" aria-label="No Assignee" class="fa fa-user"> </i> + <i v-if="hasNoUsers" aria-label="None" class="fa fa-user"> </i> <button v-for="(user, index) in users" v-if="shouldRenderCollapsedAssignee(index)" @@ -154,9 +174,18 @@ export default { </button> </div> <div class="value hide-collapsed"> + <span + v-if="mergeNotAllowedTooltipMessage" + v-tooltip + :title="mergeNotAllowedTooltipMessage" + data-placement="left" + class="float-right cannot-be-merged" + > + <i aria-hidden="true" data-hidden="true" class="fa fa-exclamation-triangle"></i> + </span> <template v-if="hasNoUsers"> - <span class="assign-yourself no-value"> - No assignee + <span class="assign-yourself no-value qa-assign-yourself"> + None <template v-if="editable"> - <button type="button" class="btn-link" @click="assignSelf">assign yourself</button> </template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index c03b2a68c78..d84d5344935 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -49,10 +49,10 @@ export default { }, computed: { hasTimeSpent() { - return !!this.timeSpent; + return Boolean(this.timeSpent); }, hasTimeEstimate() { - return !!this.timeEstimate; + return Boolean(this.timeEstimate); }, showComparisonState() { return this.hasTimeEstimate && this.hasTimeSpent; @@ -67,7 +67,7 @@ export default { return !this.hasTimeEstimate && !this.hasTimeSpent; }, showHelpState() { - return !!this.showHelp; + return Boolean(this.showHelp); }, }, created() { diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue index 706e6ca19c3..57125c78cf6 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue @@ -50,6 +50,9 @@ export default { buttonLabel() { return this.isTodo ? MARK_TEXT : TODO_TEXT; }, + buttonTooltip() { + return !this.collapsed ? undefined : this.buttonLabel; + }, collapsedButtonIconClasses() { return this.isTodo ? 'todo-undone' : ''; }, @@ -69,7 +72,7 @@ export default { <button v-tooltip :class="buttonClasses" - :title="buttonLabel" + :title="buttonTooltip" :aria-label="buttonLabel" :data-issuable-id="issuableId" :data-issuable-type="issuableType" diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js index 225ebb61195..110175a6779 100644 --- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js +++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import _ from 'underscore'; +import { __ } from '~/locale'; function isValidProjectId(id) { return id > 0; @@ -40,7 +41,9 @@ class SidebarMoveIssue { this.mediator .fetchAutocompleteProjects(searchTerm) .then(callback) - .catch(() => new window.Flash('An error occurred while fetching projects autocomplete.')); + .catch( + () => new window.Flash(__('An error occurred while fetching projects autocomplete.')), + ); }, renderRow: project => ` <li> @@ -72,7 +75,7 @@ class SidebarMoveIssue { this.$confirmButton.disable().addClass('is-loading'); this.mediator.moveIssue().catch(() => { - window.Flash('An error occurred while moving the issue.'); + window.Flash(__('An error occurred while moving the issue.')); this.$confirmButton.enable().removeClass('is-loading'); }); } diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 3e040ec8428..22ac8df9699 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -2,6 +2,7 @@ import { visitUrl } from '../lib/utils/url_utility'; import Flash from '../flash'; import Service from './services/sidebar_service'; import Store from './stores/sidebar_store'; +import { __ } from '~/locale'; export default class SidebarMediator { constructor(options) { @@ -45,7 +46,7 @@ export default class SidebarMediator { .then(data => { this.processFetchedData(data); }) - .catch(() => new Flash('Error occurred when fetching sidebar data')); + .catch(() => new Flash(__('Error occurred when fetching sidebar data'))); } processFetchedData(data) { diff --git a/app/assets/javascripts/snippet/snippet_embed.js b/app/assets/javascripts/snippet/snippet_embed.js index 873a506a92f..fe08d2c7ebb 100644 --- a/app/assets/javascripts/snippet/snippet_embed.js +++ b/app/assets/javascripts/snippet/snippet_embed.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export default () => { const { protocol, host, pathname } = window.location; const shareBtn = document.querySelector('.js-share-btn'); @@ -10,7 +12,7 @@ export default () => { shareBtn.classList.add('is-active'); embedBtn.classList.remove('is-active'); snippetUrlArea.value = url; - embedAction.innerText = 'Share'; + embedAction.innerText = __('Share'); }); embedBtn.addEventListener('click', () => { @@ -18,6 +20,6 @@ export default () => { shareBtn.classList.remove('is-active'); const scriptTag = `<script src="${url}.js"></script>`; snippetUrlArea.value = scriptTag; - embedAction.innerText = 'Embed'; + embedAction.innerText = __('Embed'); }); }; diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/star.js index 7404dfbf22a..70f89152f70 100644 --- a/app/assets/javascripts/star.js +++ b/app/assets/javascripts/star.js @@ -31,7 +31,7 @@ export default class Star { $this.prepend(spriteIcon('star', iconClasses)); } }) - .catch(() => Flash('Star toggle failed. Try again later.')); + .catch(() => Flash(__('Star toggle failed. Try again later.'))); }); } } diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/subscription_select.js index ebe1c6dd02d..7206bbd7109 100644 --- a/app/assets/javascripts/subscription_select.js +++ b/app/assets/javascripts/subscription_select.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { __ } from './locale'; export default function subscriptionSelect() { $('.js-subscription-event').each((i, element) => { @@ -8,7 +9,7 @@ export default function subscriptionSelect() { selectable: true, fieldName, toggleLabel(selected, el, instance) { - let label = 'Subscription'; + let label = __('Subscription'); const $item = instance.dropdown.find('.is-active'); if ($item.length) { label = $item.text(); diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/templates/issuable_template_selector.js index 6065770e68d..78609ce0610 100644 --- a/app/assets/javascripts/templates/issuable_template_selector.js +++ b/app/assets/javascripts/templates/issuable_template_selector.js @@ -3,6 +3,7 @@ import $ from 'jquery'; import Api from '../api'; import TemplateSelector from '../blob/template_selector'; +import { __ } from '~/locale'; export default class IssuableTemplateSelector extends TemplateSelector { constructor(...args) { @@ -25,7 +26,7 @@ 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'); + $('.dropdown-toggle-text', this.dropdown).text(__('Choose a template')); }); } diff --git a/app/assets/javascripts/terminal/terminal.js b/app/assets/javascripts/terminal/terminal.js index e5dd7a465ea..9c7c10d9864 100644 --- a/app/assets/javascripts/terminal/terminal.js +++ b/app/assets/javascripts/terminal/terminal.js @@ -4,6 +4,7 @@ import { Terminal } from 'xterm'; import * as fit from 'xterm/lib/addons/fit/fit'; import * as webLinks from 'xterm/lib/addons/webLinks/webLinks'; import { canScrollUp, canScrollDown } from '~/lib/utils/dom_utils'; +import { __ } from '~/locale'; const SCROLL_MARGIN = 5; @@ -78,7 +79,8 @@ export default class GLTerminal { } handleSocketFailure() { - this.terminal.write('\r\nConnection failure'); + this.terminal.write('\r\n'); + this.terminal.write(__('Connection failure')); } addScrollListener(onScrollLimit) { diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js index a55a338eea8..1e75ee60671 100644 --- a/app/assets/javascripts/test_utils/index.js +++ b/app/assets/javascripts/test_utils/index.js @@ -1,5 +1,5 @@ -import 'core-js/es6/map'; -import 'core-js/es6/set'; +import 'core-js/es/map'; +import 'core-js/es/set'; import simulateDrag from './simulate_drag'; import simulateInput from './simulate_input'; diff --git a/app/assets/javascripts/u2f/error.js b/app/assets/javascripts/u2f/error.js index 1a98564ff55..ca0fc0700ad 100644 --- a/app/assets/javascripts/u2f/error.js +++ b/app/assets/javascripts/u2f/error.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export default class U2FError { constructor(errorCode, u2fFlowType) { this.errorCode = errorCode; @@ -8,15 +10,17 @@ export default class U2FError { message() { if (this.errorCode === window.u2f.ErrorCodes.BAD_REQUEST && this.httpsDisabled) { - return 'U2F only works with HTTPS-enabled websites. Contact your administrator for more details.'; + return __( + 'U2F only works with HTTPS-enabled websites. Contact your administrator for more details.', + ); } else if (this.errorCode === window.u2f.ErrorCodes.DEVICE_INELIGIBLE) { if (this.u2fFlowType === 'authenticate') { - return 'This device has not been registered with us.'; + return __('This device has not been registered with us.'); } if (this.u2fFlowType === 'register') { - return 'This device has already been registered with us.'; + return __('This device has already been registered with us.'); } } - return 'There was a problem communicating with your device.'; + return __('There was a problem communicating with your device.'); } } diff --git a/app/assets/javascripts/usage_ping_consent.js b/app/assets/javascripts/usage_ping_consent.js index d3d745a3c11..1e7a5fb19c2 100644 --- a/app/assets/javascripts/usage_ping_consent.js +++ b/app/assets/javascripts/usage_ping_consent.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; import Flash, { hideFlash } from './flash'; import { parseBoolean } from './lib/utils/common_utils'; +import { __ } from './locale'; export default () => { $('body').on('click', '.js-usage-consent-action', e => { @@ -25,7 +26,7 @@ export default () => { }) .catch(() => { hideConsentMessage(); - Flash('Something went wrong. Try again later.'); + Flash(__('Something went wrong. Try again later.')); }); }); }; diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 4017630d6ef..7e6f02b10af 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -5,7 +5,7 @@ import $ from 'jquery'; import _ from 'underscore'; import axios from './lib/utils/axios_utils'; -import { __ } from './locale'; +import { s__, __, sprintf } from './locale'; import ModalStore from './boards/stores/modal_store'; // TODO: remove eventHub hack after code splitting refactor @@ -93,23 +93,22 @@ function UsersSelect(currentUser, els, options = {}) { } // Save current selected user to the DOM - const input = document.createElement('input'); - input.type = 'hidden'; - input.name = $dropdown.data('fieldName'); - - const currentUserInfo = $dropdown.data('currentUserInfo'); - - if (currentUserInfo) { - input.value = currentUserInfo.id; - input.dataset.meta = _.escape(currentUserInfo.name); - } else if (_this.currentUser) { - input.value = _this.currentUser.id; - } + const currentUserInfo = $dropdown.data('currentUserInfo') || {}; + const currentUser = _this.currentUser || {}; + const fieldName = $dropdown.data('fieldName'); + const userName = currentUserInfo.name; + const userId = currentUserInfo.id || currentUser.id; + + const inputHtmlString = _.template(` + <input type="hidden" name="<%- fieldName %>" + data-meta="<%- userName %>" + value="<%- userId %>" /> + `)({ fieldName, userName, userId }); if ($selectbox) { - $dropdown.parent().before(input); + $dropdown.parent().before(inputHtmlString); } else { - $dropdown.after(input); + $dropdown.after(inputHtmlString); } }; @@ -158,14 +157,20 @@ function UsersSelect(currentUser, els, options = {}) { .get(0); if (selectedUsers.length === 0) { - return 'Unassigned'; + return s__('UsersSelect|Unassigned'); } else if (selectedUsers.length === 1) { return firstUser.name; } else if (isSelected) { const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); - return `${selectedUser.name} + ${otherSelected.length} more`; + return sprintf(s__('UsersSelect|%{name} + %{length} more'), { + name: selectedUser.name, + length: otherSelected.length, + }); } else { - return `${firstUser.name} + ${selectedUsers.length - 1} more`; + return sprintf(s__('UsersSelect|%{name} + %{length} more'), { + name: firstUser.name, + length: selectedUsers.length - 1, + }); } }; @@ -219,11 +224,11 @@ function UsersSelect(currentUser, els, options = {}) { tooltipTitle = _.escape(user.name); } else { user = { - name: 'Unassigned', + name: s__('UsersSelect|Unassigned'), username: '', avatar: '', }; - tooltipTitle = __('Assignee'); + tooltipTitle = s__('UsersSelect|Assignee'); } $value.html(assigneeTemplate(user)); $collapsedSidebar.attr('title', tooltipTitle).tooltip('_fixTitle'); @@ -234,7 +239,11 @@ function UsersSelect(currentUser, els, options = {}) { '<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>', ); assigneeTemplate = _.template( - '<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>', + `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> + ${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), { + openingTag: '<a href="#" class="js-assign-yourself">', + closingTag: '</a>', + })}</span> <% } %>`, ); return $dropdown.glDropdown({ showMenuAbove: showMenuAbove, @@ -303,7 +312,7 @@ function UsersSelect(currentUser, els, options = {}) { showDivider += 1; users.unshift({ beforeDivider: true, - name: 'Unassigned', + name: s__('UsersSelect|Unassigned'), id: 0, }); } @@ -311,7 +320,7 @@ function UsersSelect(currentUser, els, options = {}) { showDivider += 1; name = showAnyUser; if (name === true) { - name = 'Any User'; + name = s__('UsersSelect|Any User'); } anyUser = { beforeDivider: true, @@ -597,7 +606,7 @@ function UsersSelect(currentUser, els, options = {}) { showEmailUser = $(select).data('emailUser'); firstUser = $(select).data('firstUser'); return $(select).select2({ - placeholder: 'Search for a user', + placeholder: __('Search for a user'), multiple: $(select).hasClass('multiselect'), minimumInputLength: 0, query: function(query) { @@ -622,7 +631,7 @@ function UsersSelect(currentUser, els, options = {}) { } if (showNullUser) { nullUser = { - name: 'Unassigned', + name: s__('UsersSelect|Unassigned'), id: 0, }; data.results.unshift(nullUser); @@ -630,7 +639,7 @@ function UsersSelect(currentUser, els, options = {}) { if (showAnyUser) { name = showAnyUser; if (name === true) { - name = 'Any User'; + name = s__('UsersSelect|Any User'); } anyUser = { name: name, @@ -646,7 +655,7 @@ function UsersSelect(currentUser, els, options = {}) { ) { var trimmed = query.term.trim(); emailUser = { - name: 'Invite "' + trimmed + '" by email', + name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }), username: trimmed, id: trimmed, invite: true, @@ -689,7 +698,7 @@ UsersSelect.prototype.initSelection = function(element, callback) { id = $(element).val(); if (id === '0') { nullUser = { - name: 'Unassigned', + name: s__('UsersSelect|Unassigned'), }; return callback(nullUser); } else if (id !== '') { diff --git a/app/assets/javascripts/validators/input_validator.js b/app/assets/javascripts/validators/input_validator.js new file mode 100644 index 00000000000..f37373977b8 --- /dev/null +++ b/app/assets/javascripts/validators/input_validator.js @@ -0,0 +1,34 @@ +const invalidInputClass = 'gl-field-error-outline'; + +export default class InputValidator { + constructor() { + this.inputDomElement = {}; + this.inputErrorMessage = {}; + this.errorMessage = null; + this.invalidInput = null; + } + + setValidationStateAndMessage() { + this.setValidationMessage(); + + const isInvalidInput = !this.inputDomElement.checkValidity(); + this.inputDomElement.classList.toggle(invalidInputClass, isInvalidInput); + this.inputErrorMessage.classList.toggle('hide', !isInvalidInput); + } + + setValidationMessage() { + if (this.invalidInput) { + this.inputDomElement.setCustomValidity(this.errorMessage); + this.inputErrorMessage.innerHTML = this.errorMessage; + } else { + this.resetValidationMessage(); + } + } + + resetValidationMessage() { + if (this.inputDomElement.validationMessage === this.errorMessage) { + this.inputDomElement.setCustomValidity(''); + this.inputErrorMessage.innerHTML = this.inputDomElement.title; + } + } +} diff --git a/app/assets/javascripts/visual_review_toolbar/index.js b/app/assets/javascripts/visual_review_toolbar/index.js new file mode 100644 index 00000000000..91d0382feac --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/index.js @@ -0,0 +1,2 @@ +import './styles/toolbar.css'; +import 'vendor/visual_review_toolbar'; diff --git a/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css b/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css new file mode 100644 index 00000000000..342b3599a44 --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css @@ -0,0 +1,149 @@ +/* + As a standalone script, the toolbar has its own css + */ + +#gitlab-collapse > * { + pointer-events: none; +} + +#gitlab-form-wrapper { + display: flex; + flex-direction: column; + width: 100% +} + +#gitlab-review-container { + max-width: 22rem; + max-height: 22rem; + overflow: scroll; + position: fixed; + bottom: 1rem; + right: 1rem; + display: flex; + flex-direction: row-reverse; + padding: 1rem; + background-color: #fff; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, + 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; + font-size: .8rem; + font-weight: 400; + color: #2e2e2e; +} + +.gitlab-open-wrapper { + max-width: 22rem; + max-height: 22rem; +} + +.gitlab-closed-wrapper { + max-width: 3.4rem; + max-height: 3.4rem; +} + +.gitlab-button { + cursor: pointer; + transition: background-color 100ms linear, border-color 100ms linear, color 100ms linear, box-shadow 100ms linear; +} + +.gitlab-button-secondary { + background: none #fff; + margin: 0 .5rem; + border: 1px solid #e3e3e3; +} + +.gitlab-button-secondary:hover { + background-color: #f0f0f0; + border-color: #e3e3e3; + color: #2e2e2e; +} + +.gitlab-button-secondary:active { + color: #2e2e2e; + background-color: #e1e1e1; + border-color: #dadada; +} + +.gitlab-button-success:hover { + color: #fff; + background-color: #137e3f; + border-color: #127339; +} + +.gitlab-button-success:active { + background-color: #168f48; + border-color: #12753a; + color: #fff; +} + +.gitlab-button-success { + background-color: #1aaa55; + border: 1px solid #168f48; + color: #fff; +} + +.gitlab-button-wide { + width: 100%; +} + +.gitlab-button-wrapper { + margin-top: 1rem; + display: flex; + align-items: baseline; + justify-content: flex-end; +} + +.gitlab-collapse { + width: 2.4rem; + height: 2.2rem; + margin-left: 1rem; + padding: .5rem; +} + +.gitlab-collapse-closed { + align-self: center; +} + +.gitlab-checkbox-label { + padding: 0 .2rem; +} + +.gitlab-checkbox-wrapper { + display: flex; + align-items: baseline; +} + +.gitlab-label { + font-weight: 600; + display: inline-block; + width: 100%; +} + +.gitlab-link { + color: #1b69b6; + text-decoration: none; + background-color: transparent; + background-image: none; +} + +.gitlab-message { + padding: .25rem 0; + margin: 0; + line-height: 1.2rem; +} + +.gitlab-metadata-note { + font-size: .7rem; + line-height: 1rem; + color: #666; + margin-bottom: 0; +} + +.gitlab-input { + width: 100%; + border: 1px solid #dfdfdf; + border-radius: 4px; + padding: .1rem .2rem; + min-height: 2rem; + max-width: 17rem; +} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue index da0a9483f8e..abe5bdd2901 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -23,6 +23,8 @@ export default { TooltipOnTruncate, FilteredSearchDropdown, ReviewAppLink, + VisualReviewAppLink: () => + import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'), }, directives: { GlTooltip: GlTooltipDirective, @@ -37,6 +39,20 @@ export default { type: Boolean, required: true, }, + showVisualReviewApp: { + type: Boolean, + required: false, + default: false, + }, + visualReviewAppMeta: { + type: Object, + required: false, + default: () => ({ + sourceProjectId: '', + mergeRequestId: '', + appUrl: '', + }), + }, }, deployedTextMap: { running: __('Deploying to'), @@ -61,16 +77,16 @@ export default { return this.deployment.external_url; }, hasExternalUrls() { - return !!(this.deployment.external_url && this.deployment.external_url_formatted); + return Boolean(this.deployment.external_url && this.deployment.external_url_formatted); }, hasDeploymentTime() { - return !!(this.deployment.deployed_at && this.deployment.deployed_at_formatted); + return Boolean(this.deployment.deployed_at && this.deployment.deployed_at_formatted); }, hasDeploymentMeta() { - return !!(this.deployment.url && this.deployment.name); + return Boolean(this.deployment.url && this.deployment.name); }, hasMetrics() { - return !!this.deployment.metrics_url; + return Boolean(this.deployment.metrics_url); }, deployedText() { return this.$options.deployedTextMap[this.deployment.status]; @@ -168,6 +184,11 @@ export default { :link="deploymentExternalUrl" :css-class="`deploy-link js-deploy-url inline ${slotProps.className}`" /> + <visual-review-app-link + v-if="showVisualReviewApp" + :link="deploymentExternalUrl" + :app-metadata="visualReviewAppMeta" + /> </template> <template slot="result" slot-scope="slotProps"> @@ -187,11 +208,17 @@ export default { </a> </template> </filtered-search-dropdown> - <review-app-link - v-else - :link="deploymentExternalUrl" - css-class="js-deploy-url js-deploy-url-feature-flag deploy-link btn btn-default btn-sm inlin" - /> + <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" + /> + <visual-review-app-link + v-if="showVisualReviewApp" + :link="deploymentExternalUrl" + :app-metadata="visualReviewAppMeta" + /> + </template> </template> <span v-if="deployment.stop_url" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue new file mode 100644 index 00000000000..19a222462b3 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue @@ -0,0 +1,46 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { WARNING, DANGER, WARNING_MESSAGE_CLASS, DANGER_MESSAGE_CLASS } from '../constants'; + +export default { + name: 'MrWidgetAlertMessage', + components: { + GlLink, + Icon, + }, + props: { + type: { + type: String, + required: false, + default: DANGER, + validator: value => [WARNING, DANGER].includes(value), + }, + helpPath: { + type: String, + required: false, + default: undefined, + }, + }, + computed: { + messageClass() { + if (this.type === WARNING) { + return WARNING_MESSAGE_CLASS; + } else if (this.type === DANGER) { + return DANGER_MESSAGE_CLASS; + } + + return ''; + }, + }, +}; +</script> + +<template> + <div class="m-3 ml-7" :class="messageClass"> + <slot></slot> + <gl-link v-if="helpPath" :href="helpPath" target="_blank"> + <icon :size="16" name="question-o" class="align-middle" /> + </gl-link> + </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 3b9fc2661ef..361441640e1 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 @@ -109,33 +109,35 @@ export default { ></div> </div> - <div v-if="mr.isOpen" class="branch-actions d-flex"> - <a - v-if="!mr.sourceBranchRemoved" - v-tooltip - :href="webIdePath" - :title="ideButtonTitle" - :class="{ disabled: !mr.canPushToSourceBranch }" - class="btn btn-default js-web-ide d-none d-md-inline-block append-right-8" - data-placement="bottom" - tabindex="0" - role="button" - > - {{ s__('mrWidget|Open in Web IDE') }} - </a> - <button - :disabled="mr.sourceBranchRemoved" - data-target="#modal_merge_info" - data-toggle="modal" - class="btn btn-default js-check-out-branch append-right-default" - type="button" - > - {{ s__('mrWidget|Check out branch') }} - </button> + <div class="branch-actions d-flex"> + <template v-if="mr.isOpen"> + <a + v-if="!mr.sourceBranchRemoved" + v-tooltip + :href="webIdePath" + :title="ideButtonTitle" + :class="{ disabled: !mr.canPushToSourceBranch }" + class="btn btn-default js-web-ide d-none d-md-inline-block append-right-8" + data-placement="bottom" + tabindex="0" + role="button" + > + {{ s__('mrWidget|Open in Web IDE') }} + </a> + <button + :disabled="mr.sourceBranchRemoved" + data-target="#modal_merge_info" + data-toggle="modal" + class="btn btn-default js-check-out-branch append-right-default" + type="button" + > + {{ s__('mrWidget|Check out branch') }} + </button> + </template> <span class="dropdown"> <button type="button" - class="btn dropdown-toggle" + class="btn dropdown-toggle qa-dropdown-toggle" data-toggle="dropdown" aria-label="Download as" aria-haspopup="true" @@ -145,12 +147,20 @@ export default { </button> <ul class="dropdown-menu dropdown-menu-right"> <li> - <a :href="mr.emailPatchesPath" class="js-download-email-patches" download> + <a + :href="mr.emailPatchesPath" + class="js-download-email-patches qa-download-email-patches" + download + > {{ s__('mrWidget|Email patches') }} </a> </li> <li> - <a :href="mr.plainDiffPath" class="js-download-plain-diff" download> + <a + :href="mr.plainDiffPath" + class="js-download-plain-diff qa-download-plain-diff" + download + > {{ s__('mrWidget|Plain diff') }} </a> </li> 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 f11cf21b0ca..c377c16fb13 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -1,10 +1,13 @@ <script> /* eslint-disable vue/require-default-prop */ +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import { sprintf, __ } from '~/locale'; import PipelineStage from '~/pipelines/components/stage.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import Icon from '~/vue_shared/components/icon.vue'; +import PipelineLink from '~/vue_shared/components/ci_pipeline_link.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline'; export default { name: 'MRWidgetPipeline', @@ -13,7 +16,15 @@ export default { CiIcon, Icon, TooltipOnTruncate, + GlLink, + PipelineLink, + LinkedPipelinesMiniList: () => + import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [mrWidgetPipelineMixin], props: { pipeline: { type: Object, @@ -74,16 +85,21 @@ export default { false, ); }, + isTriggeredByMergeRequest() { + return Boolean(this.pipeline.merge_request); + }, + isMergeRequestPipeline() { + return Boolean(this.pipeline.flags && this.pipeline.flags.merge_request_pipeline); + }, }, }; </script> <template> - <div v-if="hasPipeline || hasCIError" class="ci-widget media"> - <template v-if="hasCIError"> + <div class="ci-widget media js-ci-widget"> + <template v-if="!hasPipeline || hasCIError"> <div - class="add-border ci-status-icon ci-status-icon-failed ci-error - js-ci-error append-right-default" + class="add-border ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-default" > <icon :size="32" name="status_failed_borderless" /> </div> @@ -96,24 +112,61 @@ export default { <div class="ci-widget-container d-flex"> <div class="ci-widget-content"> <div class="media-body"> - <div class="font-weight-bold"> - Pipeline - <a :href="pipeline.path" class="pipeline-id font-weight-normal pipeline-number" - >#{{ pipeline.id }}</a - > - + <div class="font-weight-bold js-pipeline-info-container"> + {{ s__('Pipeline|Pipeline') }} + <pipeline-link + :href="pipeline.path" + :pipeline-id="pipeline.id" + :pipeline-iid="pipeline.iid" + class="pipeline-id pipeline-iid font-weight-normal" + /> {{ pipeline.details.status.label }} - <template v-if="hasCommitInfo"> - for - <a + {{ s__('Pipeline|for') }} + <gl-link :href="pipeline.commit.commit_path" class="commit-sha js-commit-link font-weight-normal" + >{{ pipeline.commit.short_id }}</gl-link > - {{ pipeline.commit.short_id }}</a - > - on + {{ s__('Pipeline|on') }} + <template v-if="isTriggeredByMergeRequest"> + <gl-link + v-gl-tooltip + :href="pipeline.merge_request.path" + :title="pipeline.merge_request.title" + class="font-weight-normal" + >!{{ pipeline.merge_request.iid }}</gl-link + > + {{ s__('Pipeline|with') }} + <tooltip-on-truncate + :title="pipeline.merge_request.source_branch" + truncate-target="child" + class="label-branch label-truncate" + > + <gl-link + :href="pipeline.merge_request.source_branch_path" + class="font-weight-normal" + >{{ pipeline.merge_request.source_branch }}</gl-link + > + </tooltip-on-truncate> + + <template v-if="isMergeRequestPipeline"> + {{ s__('Pipeline|into') }} + <tooltip-on-truncate + :title="pipeline.merge_request.target_branch" + truncate-target="child" + class="label-branch label-truncate" + > + <gl-link + :href="pipeline.merge_request.target_branch_path" + class="font-weight-normal" + >{{ pipeline.merge_request.target_branch }}</gl-link + > + </tooltip-on-truncate> + </template> + </template> <tooltip-on-truncate + v-else :title="sourceBranch" truncate-target="child" class="label-branch label-truncate" @@ -121,20 +174,29 @@ export default { /> </template> </div> - <div v-if="pipeline.coverage" class="coverage">Coverage {{ pipeline.coverage }}%</div> + <div v-if="pipeline.coverage" class="coverage"> + {{ s__('Pipeline|Coverage') }} {{ pipeline.coverage }}% + </div> </div> </div> <div> <span class="mr-widget-pipeline-graph"> - <span v-if="hasStages" class="stage-cell"> - <div - v-for="(stage, i) in pipeline.details.stages" - :key="i" - class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages" - > - <pipeline-stage :stage="stage" /> - </div> + <span class="stage-cell"> + <linked-pipelines-mini-list v-if="triggeredBy.length" :triggered-by="triggeredBy" /> + <template v-if="hasStages"> + <div + v-for="(stage, i) in pipeline.details.stages" + :key="i" + :class="{ + 'has-downstream': hasDownstream(i), + }" + class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages" + > + <pipeline-stage :stage="stage" /> + </div> + </template> </span> + <linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" /> </span> </div> </div> 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 5f5fe67b3c1..03a15ba81ed 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -30,9 +30,6 @@ export default { }, }, computed: { - pipeline() { - return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline; - }, branch() { return this.isPostMerge ? this.mr.targetBranch : this.mr.sourceBranch; }, @@ -48,6 +45,19 @@ export default { hasDeploymentMetrics() { return this.isPostMerge; }, + visualReviewAppMeta() { + return { + appUrl: this.mr.appUrl, + mergeRequestId: this.mr.iid, + sourceProjectId: this.mr.sourceProjectId, + }; + }, + pipeline() { + return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline; + }, + showVisualReviewAppLink() { + return Boolean(this.mr.visualReviewFF && this.mr.visualReviewAppAvailable); + }, }, }; </script> @@ -61,14 +71,18 @@ export default { :source-branch-link="branchLink" :troubleshooting-docs-path="mr.troubleshootingDocsPath" /> - <div v-if="deployments.length" slot="footer" class="mr-widget-extension"> - <deployment - v-for="deployment in deployments" - :key="deployment.id" - :class="deploymentClass" - :deployment="deployment" - :show-metrics="hasDeploymentMetrics" - /> - </div> + <template v-slot:footer> + <div v-if="deployments.length" class="mr-widget-extension"> + <deployment + v-for="deployment in deployments" + :key="deployment.id" + :class="deploymentClass" + :deployment="deployment" + :show-metrics="hasDeploymentMetrics" + :show-visual-review-app="true" + :visual-review-app-meta="visualReviewAppMeta" + /> + </div> + </template> </mr-widget-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index 780ced4d382..392eb6fb425 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -33,7 +33,7 @@ export default { </script> <template> <div class="space-children d-flex append-right-10 widget-status-icon"> - <div v-if="isLoading" class="mr-widget-icon"><gl-loading-icon /></div> + <div v-if="isLoading" class="mr-widget-icon"><gl-loading-icon size="md" /></div> <ci-icon v-else :status="statusObj" :size="24" /> 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 de9c122f268..457a71cab95 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 name="external-link" /> + {{ __('View app') }} <icon css-classes="fgray" name="external-link" /> </a> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue index 780ecdcdac4..6aad2a26a53 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue @@ -14,7 +14,7 @@ export default { </script> <template> - <p v-once class="mr-info-list mr-links source-branch-removal-status append-bottom-0"> + <p v-once class="mr-info-list mr-links append-bottom-0"> <span class="status-text" v-html="removesBranchText"> </span> <i v-tooltip :title="tooltipTitle" :aria-label="tooltipTitle" class="fa fa-question-circle"> </i> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue index a38f25cce35..acd8037cfb2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue @@ -30,6 +30,7 @@ export default { :id="inputId" :value="value" class="form-control js-gfm-input append-bottom-default commit-message-edit" + dir="auto" required="required" rows="7" @input="$emit('input', $event.target.value)" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue index b3c1c0e329d..b6722de5277 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue @@ -20,7 +20,6 @@ export default { <div> <gl-dropdown right - no-caret text="Use an existing commit message" variant="link" class="mr-commit-dropdown" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue index 33963d5e1e6..0312b147b62 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue @@ -14,6 +14,10 @@ export default { type: Boolean, required: true, }, + isFastForwardEnabled: { + type: Boolean, + required: true, + }, commitsCount: { type: Number, required: false, @@ -37,16 +41,22 @@ export default { return n__(__('%d commit'), __('%d commits'), this.isSquashEnabled ? 1 : this.commitsCount); }, modifyLinkMessage() { - return this.isSquashEnabled ? __('Modify commit messages') : __('Modify merge commit'); + if (this.isFastForwardEnabled) return __('Modify commit message'); + else if (this.isSquashEnabled) return __('Modify commit messages'); + return __('Modify merge commit'); }, ariaLabel() { return this.expanded ? __('Collapse') : __('Expand'); }, message() { + const message = this.isFastForwardEnabled + ? s__('mrWidgetCommitsAdded|%{commitCount} will be added to %{targetBranch}.') + : s__( + 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}.', + ); + return sprintf( - s__( - 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}.', - ), + message, { commitCount: `<strong class="commits-count-message">${this.commitsCountMessage}</strong>`, mergeCommitCount: `<strong>${s__('mrWidgetCommitsAdded|1 merge commit')}</strong>`, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue index a3a44dd8e99..83e7d6db9fa 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue @@ -35,9 +35,7 @@ export default { <status-icon status="warning" /> <div class="media-body space-children"> <span class="bold"> - <template v-if="mr.mergeError" - >{{ mr.mergeError }}.</template - > + <template v-if="mr.mergeError">{{ mr.mergeError }}</template> {{ s__('mrWidget|This merge request failed to be merged automatically') }} </span> <button diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue index 2a4dff71d9b..11bc8c73ee9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue @@ -80,7 +80,7 @@ export default { <status-icon :show-disabled-button="true" status="warning" /> <div class="media-body space-children"> <span class="bold"> - <span v-if="mr.mergeError" class="has-error-message"> {{ mergeError }}. </span> + <span v-if="mr.mergeError" class="has-error-message"> {{ mergeError }} </span> <span v-else> {{ s__('mrWidget|Merge failed.') }} </span> <span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue index 1b3af2fccf2..88e1ccbaf35 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue @@ -57,7 +57,7 @@ export default { removeSourceBranch() { const options = { sha: this.mr.sha, - merge_when_pipeline_succeeds: true, + auto_merge_strategy: 'merge_when_pipeline_succeeds', should_remove_source_branch: true, }; @@ -85,7 +85,7 @@ export default { <h4 class="d-flex align-items-start"> <span class="append-right-10"> {{ s__('mrWidget|Set by') }} - <mr-widget-author :author="mr.setToMWPSBy" /> + <mr-widget-author :author="mr.setToAutoMergeBy" /> {{ s__('mrWidget|to be merged automatically when the pipeline succeeds') }} </span> <a diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index ce4207864ea..615d59a7b8e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -3,6 +3,7 @@ import successSvg from 'icons/_icon_status_success.svg'; import warningSvg from 'icons/_icon_status_warning.svg'; import simplePoll from '~/lib/utils/simple_poll'; import { __ } from '~/locale'; +import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge'; import MergeRequest from '../../../merge_request'; import Flash from '../../../flash'; import statusIcon from '../mr_widget_status_icon.vue'; @@ -21,6 +22,7 @@ export default { CommitEdit, CommitMessageDropdown, }, + mixins: [readyToMergeMixin], props: { mr: { type: Object, required: true }, service: { type: Object, required: true }, @@ -29,7 +31,7 @@ export default { return { removeSourceBranch: this.mr.shouldRemoveSourceBranch, mergeWhenBuildSucceeds: false, - setToMergeWhenPipelineSucceeds: false, + autoMergeStrategy: undefined, isMakingRequest: false, isMergingImmediately: false, commitMessage: this.mr.commitMessage, @@ -40,7 +42,7 @@ export default { }; }, computed: { - shouldShowMergeWhenPipelineSucceedsText() { + shouldShowAutoMergeText() { return this.mr.isPipelineActive; }, status() { @@ -85,7 +87,7 @@ export default { mergeButtonText() { if (this.isMergingImmediately) { return __('Merge in progress'); - } else if (this.shouldShowMergeWhenPipelineSucceedsText) { + } else if (this.shouldShowAutoMergeText) { return __('Merge when pipeline succeeds'); } @@ -94,15 +96,6 @@ export default { shouldShowMergeOptionsDropdown() { return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds; }, - isMergeButtonDisabled() { - const { commitMessage } = this; - return Boolean( - !commitMessage.length || - !this.shouldShowMergeControls || - this.isMakingRequest || - this.mr.preventMerge, - ); - }, isRemoveSourceBranchButtonDisabled() { return this.isMergeButtonDisabled; }, @@ -111,7 +104,13 @@ export default { return enableSquashBeforeMerge && commitsCount > 1; }, shouldShowMergeControls() { - return this.mr.isMergeAllowed || this.shouldShowMergeWhenPipelineSucceedsText; + return this.mr.isMergeAllowed || this.shouldShowAutoMergeText; + }, + shouldShowSquashEdit() { + return this.squashBeforeMerge && this.shouldShowSquashBeforeMerge; + }, + shouldShowMergeEdit() { + return !this.mr.ffOnlyEnabled; }, }, methods: { @@ -127,12 +126,12 @@ export default { this.isMergingImmediately = true; } - this.setToMergeWhenPipelineSucceeds = mergeWhenBuildSucceeds === true; + this.autoMergeStrategy = mergeWhenBuildSucceeds ? 'merge_when_pipeline_succeeds' : undefined; const options = { sha: this.mr.sha, commit_message: this.commitMessage, - merge_when_pipeline_succeeds: this.setToMergeWhenPipelineSucceeds, + auto_merge_strategy: this.autoMergeStrategy, should_remove_source_branch: this.removeSourceBranch === true, squash: this.squashBeforeMerge, squash_commit_message: this.squashCommitMessage, @@ -159,9 +158,12 @@ export default { }); }, initiateMergePolling() { - simplePoll((continuePolling, stopPolling) => { - this.handleMergePolling(continuePolling, stopPolling); - }); + simplePoll( + (continuePolling, stopPolling) => { + this.handleMergePolling(continuePolling, stopPolling); + }, + { timeout: 0 }, + ); }, handleMergePolling(continuePolling, stopPolling) { this.service @@ -192,6 +194,7 @@ export default { }) .catch(() => { new Flash(__('Something went wrong while merging this merge request. Please try again.')); // eslint-disable-line + stopPolling(); }); }, initiateRemoveSourceBranchPolling() { @@ -321,43 +324,45 @@ export default { <div v-if="mr.ffOnlyEnabled" class="mr-fast-forward-message"> {{ __('Fast-forward merge without a merge commit') }} </div> - <template v-else> - <commits-header - :is-squash-enabled="squashBeforeMerge" - :commits-count="mr.commitsCount" - :target-branch="mr.targetBranch" - > - <ul class="border-top content-list commits-list flex-list"> - <commit-edit - v-if="squashBeforeMerge" + <commits-header + v-if="shouldShowSquashEdit || shouldShowMergeEdit" + :is-squash-enabled="squashBeforeMerge" + :commits-count="mr.commitsCount" + :target-branch="mr.targetBranch" + :is-fast-forward-enabled="mr.ffOnlyEnabled" + :class="{ 'border-bottom': mr.mergeError }" + > + <ul class="border-top content-list commits-list flex-list"> + <commit-edit + v-if="shouldShowSquashEdit" + v-model="squashCommitMessage" + :label="__('Squash commit message')" + input-id="squash-message-edit" + squash + > + <commit-message-dropdown + slot="header" v-model="squashCommitMessage" - :label="__('Squash commit message')" - input-id="squash-message-edit" - squash - > - <commit-message-dropdown - slot="header" - v-model="squashCommitMessage" - :commits="mr.commits" + :commits="mr.commits" + /> + </commit-edit> + <commit-edit + v-if="shouldShowMergeEdit" + v-model="commitMessage" + :label="__('Merge commit message')" + input-id="merge-message-edit" + > + <label slot="checkbox"> + <input + id="include-description" + type="checkbox" + @change="updateMergeCommitMessage($event.target.checked)" /> - </commit-edit> - <commit-edit - v-model="commitMessage" - :label="__('Merge commit message')" - input-id="merge-message-edit" - > - <label slot="checkbox"> - <input - id="include-description" - type="checkbox" - @change="updateMergeCommitMessage($event.target.checked)" - /> - {{ __('Include merge request description') }} - </label> - </commit-edit> - </ul> - </commits-header> - </template> + {{ __('Include merge request description') }} + </label> + </commit-edit> + </ul> + </commits-header> </template> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue index b1f5655a15a..accb9d9fef1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue @@ -29,8 +29,8 @@ export default { </script> <template> - <div class="accept-control inline"> - <label class="merge-param-checkbox"> + <div class="inline"> + <label> <input :checked="value" :disabled="isDisabled" diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js new file mode 100644 index 00000000000..0a29d55fbd6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -0,0 +1,5 @@ +export const WARNING = 'warning'; +export const DANGER = 'danger'; + +export const WARNING_MESSAGE_CLASS = 'warning_message'; +export const DANGER_MESSAGE_CLASS = 'danger_message'; diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js new file mode 100644 index 00000000000..96e8bb45e34 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js @@ -0,0 +1,15 @@ +export default { + computed: { + triggered() { + return []; + }, + triggeredBy() { + return []; + }, + }, + methods: { + hasDownstream() { + return false; + }, + }, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js new file mode 100644 index 00000000000..b2e64506472 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -0,0 +1,13 @@ +export default { + computed: { + isMergeButtonDisabled() { + const { commitMessage } = this; + return Boolean( + !commitMessage.length || + !this.shouldShowMergeControls || + this.isMakingRequest || + this.mr.preventMerge, + ); + }, + }, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 57c4dfbe3b7..d02bb2f341d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -1,6 +1,6 @@ <script> import _ from 'underscore'; -import { __ } from '~/locale'; +import { sprintf, s__, __ } from '~/locale'; import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; @@ -12,6 +12,7 @@ import WidgetMergeHelp from './components/mr_widget_merge_help.vue'; import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue'; import Deployment from './components/deployment.vue'; import WidgetRelatedLinks from './components/mr_widget_related_links.vue'; +import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue'; import MergedState from './components/states/mr_widget_merged.vue'; import ClosedState from './components/states/mr_widget_closed.vue'; import MergingState from './components/states/mr_widget_merging.vue'; @@ -46,6 +47,7 @@ export default { MrWidgetPipelineContainer, Deployment, 'mr-widget-related-links': WidgetRelatedLinks, + MrWidgetAlertMessage, 'mr-widget-merged': MergedState, 'mr-widget-closed': ClosedState, 'mr-widget-merging': MergingState, @@ -95,7 +97,7 @@ export default { return this.mr.hasCI; }, shouldRenderRelatedLinks() { - return !!this.mr.relatedLinks && !this.mr.isNothingToMergeState; + return Boolean(this.mr.relatedLinks) && !this.mr.isNothingToMergeState; }, shouldRenderSourceBranchRemovalStatus() { return ( @@ -110,6 +112,24 @@ export default { shouldRenderMergedPipeline() { return this.mr.state === 'merged' && !_.isEmpty(this.mr.mergePipeline); }, + showMergePipelineForkWarning() { + return Boolean( + this.mr.mergePipelinesEnabled && this.mr.sourceProjectId !== this.mr.targetProjectId, + ); + }, + showTargetBranchAdvancedError() { + return Boolean( + this.mr.isOpen && + this.mr.pipeline && + this.mr.pipeline.target_sha && + this.mr.pipeline.target_sha !== this.mr.targetBranchSha, + ); + }, + mergeError() { + return sprintf(s__('mrWidget|Merge failed: %{mergeError}. Please try again.'), { + mergeError: this.mr.mergeError, + }); + }, }, watch: { state(newVal, oldVal) { @@ -318,17 +338,49 @@ export default { <div class="mr-widget-section"> <component :is="componentName" :mr="mr" :service="service" /> - <section v-if="shouldRenderCollaborationStatus" class="mr-info-list mr-links"> - {{ s__('mrWidget|Allows commits from members who can merge to the target branch') }} - </section> + <div class="mr-widget-info"> + <section v-if="shouldRenderCollaborationStatus" class="mr-info-list mr-links"> + <p> + {{ s__('mrWidget|Allows commits from members who can merge to the target branch') }} + </p> + </section> + + <mr-widget-related-links + v-if="shouldRenderRelatedLinks" + :state="mr.state" + :related-links="mr.relatedLinks" + /> + + <mr-widget-alert-message + v-if="showMergePipelineForkWarning" + type="warning" + :help-path="mr.mergeRequestPipelinesHelpPath" + > + {{ + s__( + 'mrWidget|Fork merge requests do not create merge request pipelines which validate a post merge result', + ) + }} + </mr-widget-alert-message> + + <mr-widget-alert-message + v-if="showTargetBranchAdvancedError" + type="danger" + :help-path="mr.mergeRequestPipelinesHelpPath" + > + {{ + s__( + 'mrWidget|The target branch has advanced, which invalidates the merge request pipeline. Please update the source branch and retry merging', + ) + }} + </mr-widget-alert-message> - <mr-widget-related-links - v-if="shouldRenderRelatedLinks" - :state="mr.state" - :related-links="mr.relatedLinks" - /> + <mr-widget-alert-message v-if="mr.mergeError" type="danger"> + {{ mergeError }} + </mr-widget-alert-message> - <source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" /> + <source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" /> + </div> </div> <div v-if="shouldRenderMergeHelp" class="mr-widget-footer"><mr-widget-merge-help /></div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index 0cc4fd59f5e..3ab229567f6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -23,8 +23,8 @@ export default function deviseState(data) { return stateKey.pipelineBlocked; } else if (this.isSHAMismatch) { return stateKey.shaMismatch; - } else if (this.mergeWhenPipelineSucceeds) { - return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds; + } else if (this.autoMergeEnabled) { + return this.mergeError ? stateKey.autoMergeFailed : stateKey.autoMergeEnabled; } else if (!this.canMerge) { return stateKey.notAllowedToMerge; } else if (this.canBeMerged) { diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 58363f632a9..32badb0fb08 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -28,9 +28,11 @@ export default class MergeRequestStore { this.iid = data.iid; this.title = data.title; this.targetBranch = data.target_branch; + this.targetBranchSha = data.target_branch_sha; this.sourceBranch = data.source_branch; this.sourceBranchProtected = data.source_branch_protected; this.conflictsDocsPath = data.conflicts_docs_path; + this.mergeRequestPipelinesHelpPath = data.merge_request_pipelines_docs_path; this.mergeStatus = data.merge_status; this.commitMessage = data.default_merge_commit_message; this.shortMergeCommitSha = data.short_merge_commit_sha; @@ -59,7 +61,7 @@ export default class MergeRequestStore { this.updatedAt = data.updated_at; this.metrics = MergeRequestStore.buildMetrics(data.metrics); - this.setToMWPSBy = MergeRequestStore.formatUserObject(data.merge_user || {}); + this.setToAutoMergeBy = MergeRequestStore.formatUserObject(data.merge_user || {}); this.mergeUserId = data.merge_user_id; this.currentUserId = gon.current_user_id; this.sourceBranchPath = data.source_branch_path; @@ -68,15 +70,16 @@ export default class MergeRequestStore { this.targetBranchPath = data.target_branch_commits_path; this.targetBranchTreePath = data.target_branch_tree_path; this.conflictResolutionPath = data.conflict_resolution_path; - this.cancelAutoMergePath = data.cancel_merge_when_pipeline_succeeds_path; + this.cancelAutoMergePath = data.cancel_auto_merge_path; this.removeWIPPath = data.remove_wip_path; this.sourceBranchRemoved = !data.source_branch_exists; this.shouldRemoveSourceBranch = data.remove_source_branch || false; this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false; - this.mergeWhenPipelineSucceeds = data.merge_when_pipeline_succeeds || false; + this.autoMergeEnabled = Boolean(data.auto_merge_enabled); + this.autoMergeStrategy = data.auto_merge_strategy; this.mergePath = data.merge_path; this.ffOnlyEnabled = data.ff_only_enabled; - this.shouldBeRebased = !!data.should_be_rebased; + this.shouldBeRebased = Boolean(data.should_be_rebased); this.statusPath = data.status_path; this.emailPatchesPath = data.email_patches_path; this.plainDiffPath = data.plain_diff_path; @@ -89,9 +92,9 @@ export default class MergeRequestStore { this.isOpen = data.state === 'opened'; this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false; this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false; - this.canMerge = !!data.merge_path; + this.canMerge = Boolean(data.merge_path); this.canCreateIssue = currentUser.can_create_issue || false; - this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path; + this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path); this.isSHAMismatch = this.sha !== data.diff_head_sha; this.canBeMerged = data.can_be_merged || false; this.isMergeAllowed = data.mergeable || false; @@ -99,6 +102,9 @@ export default class MergeRequestStore { this.allowCollaboration = data.allow_collaboration; this.targetProjectFullPath = data.target_project_full_path; this.sourceProjectFullPath = data.source_project_full_path; + this.sourceProjectId = data.source_project_id; + this.targetProjectId = data.target_project_id; + this.mergePipelinesEnabled = data.merge_pipelines_enabled; // Cherry-pick and Revert actions related this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false; @@ -112,7 +118,7 @@ export default class MergeRequestStore { this.ciStatus = data.ci_status; this.isPipelineFailed = this.ciStatus === 'failed' || this.ciStatus === 'canceled'; this.isPipelinePassing = - this.ciStatus === 'success' || this.ciStatus === 'success_with_warnings'; + this.ciStatus === 'success' || this.ciStatus === 'success-with-warnings'; this.isPipelineSkipped = this.ciStatus === 'skipped'; this.pipelineDetailedStatus = pipelineStatus; this.isPipelineActive = data.pipeline ? data.pipeline.active : false; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js index e080ce5c229..48bc6a867f4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -13,7 +13,7 @@ const stateToComponentMap = { unresolvedDiscussions: 'mr-widget-unresolved-discussions', pipelineBlocked: 'mr-widget-pipeline-blocked', pipelineFailed: 'mr-widget-pipeline-failed', - mergeWhenPipelineSucceeds: 'mr-widget-merge-when-pipeline-succeeds', + autoMergeEnabled: 'mr-widget-merge-when-pipeline-succeeds', failedToMerge: 'mr-widget-failed-to-merge', autoMergeFailed: 'mr-widget-auto-merge-failed', shaMismatch: 'sha-mismatch', @@ -45,7 +45,7 @@ export const stateKey = { pipelineBlocked: 'pipelineBlocked', shaMismatch: 'shaMismatch', autoMergeFailed: 'autoMergeFailed', - mergeWhenPipelineSucceeds: 'mergeWhenPipelineSucceeds', + autoMergeEnabled: 'autoMergeEnabled', notAllowedToMerge: 'notAllowedToMerge', readyToMerge: 'readyToMerge', rebase: 'rebase', diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index 2f498c4fa2a..25f80219993 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -21,6 +21,8 @@ import Icon from '../../vue_shared/components/icon.vue'; * - Jobs table * - Jobs show view header * - Jobs show view sidebar + * - Linked pipelines + * - Extended MR Popover */ const validSizes = [8, 12, 16, 18, 24, 32, 48, 72]; diff --git a/app/assets/javascripts/vue_shared/components/ci_pipeline_link.vue b/app/assets/javascripts/vue_shared/components/ci_pipeline_link.vue new file mode 100644 index 00000000000..eae4c06467c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/ci_pipeline_link.vue @@ -0,0 +1,32 @@ +<script> +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; + +export default { + components: { + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + href: { + type: String, + required: true, + }, + pipelineId: { + type: Number, + required: true, + }, + pipelineIid: { + type: Number, + required: true, + }, + }, +}; +</script> +<template> + <gl-link v-gl-tooltip :href="href" :title="__('Pipeline ID (IID)')"> + <span class="pipeline-id">#{{ pipelineId }}</span> + <span class="pipeline-iid">(#{{ pipelineIid }})</span> + </gl-link> +</template> diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index 671b4909839..a620f560b52 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 clipbard" + * title="Copy to clipboard" * text="Content to be copied" * css-class="btn-transparent" * /> diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue index ee685a4b8cd..3ba946e6447 100644 --- a/app/assets/javascripts/vue_shared/components/commit.vue +++ b/app/assets/javascripts/vue_shared/components/commit.vue @@ -1,5 +1,6 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; +import _ from 'underscore'; +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import UserAvatarLink from './user_avatar/user_avatar_link.vue'; import Icon from '../../vue_shared/components/icon.vue'; @@ -10,6 +11,7 @@ export default { components: { UserAvatarLink, Icon, + GlLink, }, props: { /** @@ -33,6 +35,27 @@ export default { required: false, default: () => ({}), }, + + /** + * If provided, is used the render the MR IID and link + * in place of the branch name. Must contains the + * following properties: + * - iid (number) + * - path (non-empty string) + * + * May optionally contain the following properties: + * - title (string): used in a tooltip if provided + * + * Any additional properties are ignored. + */ + mergeRequestRef: { + type: Object, + required: false, + default: undefined, + validator: ref => + _.isUndefined(ref) || (_.isFinite(ref.iid) && _.isString(ref.path) && !_.isEmpty(ref.path)), + }, + /** * Used to link to the commit sha. */ @@ -70,7 +93,11 @@ export default { required: false, default: () => ({}), }, - showBranch: { + + /** + * Indicates whether or not to show the branch/MR ref info + */ + showRefInfo: { type: Boolean, required: false, default: true, @@ -78,14 +105,12 @@ export default { }, computed: { /** - * Used to verify if all the properties needed to render the commit - * ref section were provided. - * - * @returns {Boolean} + * Determines if we shoud render the ref info section based */ - hasCommitRef() { - return this.commitRef && this.commitRef.name && this.commitRef.ref_url; + shouldShowRefInfo() { + return this.showRefInfo && (this.commitRef || this.mergeRequestRef); }, + /** * Used to verify if all the properties needed to render the commit * author section were provided. @@ -108,19 +133,36 @@ export default { }; </script> <template> - <div class="branch-commit"> - <template v-if="hasCommitRef && showBranch"> + <div class="branch-commit cgray"> + <template v-if="shouldShowRefInfo"> <div class="icon-container"> - <i v-if="tag" class="fa fa-tag" aria-hidden="true"> </i> <icon v-if="!tag" name="fork" /> + <icon v-if="tag" name="tag" /> + <icon v-else-if="mergeRequestRef" name="git-merge" /> + <icon v-else name="branch" /> </div> - <a v-gl-tooltip :href="commitRef.ref_url" :title="commitRef.name" class="ref-name"> + <gl-link + v-if="mergeRequestRef" + v-gl-tooltip + :href="mergeRequestRef.path" + :title="mergeRequestRef.title" + class="ref-name" + > + {{ mergeRequestRef.iid }} + </gl-link> + <gl-link + v-else + v-gl-tooltip + :href="commitRef.ref_url" + :title="commitRef.name" + class="ref-name" + > {{ commitRef.name }} - </a> + </gl-link> </template> <icon name="commit" class="commit-icon js-commit-icon" /> - <a :href="commitUrl" class="commit-sha"> {{ shortSha }} </a> + <gl-link :href="commitUrl" class="commit-sha mr-0"> {{ shortSha }} </gl-link> <div class="commit-title flex-truncate-parent"> <span v-if="title" class="flex-truncate-child"> @@ -132,7 +174,7 @@ export default { :tooltip-text="author.username" class="avatar-image-container" /> - <a :href="commitUrl" class="commit-row-message"> {{ title }} </a> + <gl-link :href="commitUrl" class="commit-row-message cgray"> {{ title }} </gl-link> </span> <span v-else> Can't find HEAD commit for this branch </span> </div> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue index 4155e1bab9c..1e6f4c376c1 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue @@ -1,5 +1,4 @@ <script> -import { viewerInformationForPath } from './lib/viewer_utils'; import MarkdownViewer from './viewers/markdown_viewer.vue'; import ImageViewer from './viewers/image_viewer.vue'; import DownloadViewer from './viewers/download_viewer.vue'; @@ -24,15 +23,18 @@ export default { required: false, default: '', }, + type: { + type: String, + required: false, + default: '', + }, }, computed: { viewer() { if (!this.path) return null; + if (!this.type) return DownloadViewer; - const previewInfo = viewerInformationForPath(this.path); - if (!previewInfo) return DownloadViewer; - - switch (previewInfo.id) { + switch (this.type) { case 'markdown': return MarkdownViewer; case 'image': diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js index f01a51da0b3..ba63683f5c0 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js +++ b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js @@ -1,10 +1,12 @@ +import { __ } from '~/locale'; + const viewers = { image: { id: 'image', }, markdown: { id: 'markdown', - previewTitle: 'Preview Markdown', + previewTitle: __('Preview Markdown'), }, }; diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue index c9915f7d685..5fdc915fffb 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -78,8 +78,8 @@ export default { </script> <template> - <div ref="markdown-preview" class="md md-previewer"> + <div ref="markdown-preview" class="md-previewer"> <gl-skeleton-loading v-if="isLoading" /> - <div v-else v-html="previewContent"></div> + <div v-else class="md" v-html="previewContent"></div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue index f085ef35ccc..2b5b2269ec8 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue @@ -40,12 +40,15 @@ export default { }, beforeDestroy() { document.body.removeEventListener('mouseup', this.stopDrag); - this.$refs.dragger.removeEventListener('mousedown', this.startDrag); + document.body.removeEventListener('touchend', this.stopDrag); + document.body.removeEventListener('mousemove', this.dragMove); + document.body.removeEventListener('touchmove', this.dragMove); }, methods: { dragMove(e) { if (!this.dragging) return; - const left = e.pageX - this.$refs.dragTrack.getBoundingClientRect().left; + const moveX = e.pageX || e.touches[0].pageX; + const left = moveX - this.$refs.dragTrack.getBoundingClientRect().left; const dragTrackWidth = this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100; @@ -60,11 +63,13 @@ export default { this.dragging = true; document.body.style.userSelect = 'none'; document.body.addEventListener('mousemove', this.dragMove); + document.body.addEventListener('touchmove', this.dragMove); }, stopDrag() { this.dragging = false; document.body.style.userSelect = ''; document.body.removeEventListener('mousemove', this.dragMove); + document.body.removeEventListener('touchmove', this.dragMove); }, prepareOnionSkin() { if (this.onionOldImgInfo && this.onionNewImgInfo) { @@ -82,6 +87,7 @@ export default { this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100; document.body.addEventListener('mouseup', this.stopDrag); + document.body.addEventListener('touchend', this.stopDrag); } }, onionNewImgLoaded(imgInfo) { @@ -102,7 +108,7 @@ export default { :style="{ width: onionMaxPixelWidth, height: onionMaxPixelHeight, - 'user-select': dragging === true ? 'none' : '', + 'user-select': dragging ? 'none' : null, }" class="onion-skin-frame" > @@ -140,7 +146,14 @@ export default { </div> <div class="controls"> <div class="transparent"></div> - <div ref="dragTrack" class="drag-track" @mousedown="startDrag" @mouseup="stopDrag"> + <div + ref="dragTrack" + class="drag-track" + @mousedown="startDrag" + @mouseup="stopDrag" + @touchstart="startDrag" + @touchend="stopDrag" + > <div ref="dragger" :style="{ left: onionDraggerPixelPos }" class="dragger"></div> </div> <div class="opaque"></div> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue index 1c970b72a66..8d77b156aa4 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue @@ -46,6 +46,8 @@ export default { window.removeEventListener('resize', this.resizeThrottled, false); document.body.removeEventListener('mouseup', this.stopDrag); document.body.removeEventListener('mousemove', this.dragMove); + document.body.removeEventListener('touchend', this.stopDrag); + document.body.removeEventListener('touchmove', this.dragMove); }, mounted() { window.addEventListener('resize', this.resize, false); @@ -54,13 +56,13 @@ export default { dragMove(e) { if (!this.dragging) return; - let leftValue = e.pageX - this.$refs.swipeFrame.getBoundingClientRect().left; - const spaceLeft = 20; + const moveX = e.pageX || e.touches[0].pageX; + let leftValue = moveX - this.$refs.swipeFrame.getBoundingClientRect().left; const { clientWidth } = this.$refs.swipeFrame; if (leftValue <= 0) { leftValue = 0; - } else if (leftValue > clientWidth - spaceLeft) { - leftValue = clientWidth - spaceLeft; + } else if (leftValue > clientWidth) { + leftValue = clientWidth; } this.swipeWrapWidth = (leftValue / clientWidth) * 100; @@ -68,16 +70,16 @@ export default { }, startDrag() { this.dragging = true; - document.body.style.userSelect = 'none'; document.body.addEventListener('mousemove', this.dragMove); + document.body.addEventListener('touchmove', this.dragMove); }, stopDrag() { this.dragging = false; - document.body.style.userSelect = ''; document.body.removeEventListener('mousemove', this.dragMove); + document.body.removeEventListener('touchmove', this.dragMove); }, prepareSwipe() { - if (this.swipeOldImgInfo && this.swipeNewImgInfo) { + if (this.swipeOldImgInfo && this.swipeNewImgInfo && this.swipeOldImgInfo.renderedWidth > 0) { // Add 2 for border width this.swipeMaxWidth = Math.max(this.swipeOldImgInfo.renderedWidth, this.swipeNewImgInfo.renderedWidth) + 2; @@ -85,6 +87,7 @@ export default { Math.max(this.swipeOldImgInfo.renderedHeight, this.swipeNewImgInfo.renderedHeight) + 2; document.body.addEventListener('mouseup', this.stopDrag); + document.body.addEventListener('touchend', this.stopDrag); } }, swipeNewImgLoaded(imgInfo) { @@ -97,6 +100,8 @@ export default { }, resize: _.throttle(function throttledResize() { this.swipeBarPos = 0; + this.swipeWrapWidth = 0; + this.prepareSwipe(); }, 400), }, }; @@ -104,7 +109,15 @@ export default { <template> <div class="swipe view"> - <div ref="swipeFrame" class="swipe-frame"> + <div + ref="swipeFrame" + :style="{ + width: swipeMaxPixelWidth, + height: swipeMaxPixelHeight, + 'user-select': dragging ? 'none' : null, + }" + class="swipe-frame" + > <image-viewer key="swipeOldImg" ref="swipeOldImg" @@ -139,6 +152,8 @@ export default { class="swipe-bar" @mousedown="startDrag" @mouseup="stopDrag" + @touchstart="startDrag" + @touchend="stopDrag" > <span class="top-handle"></span> <span class="bottom-handle"></span> </span> diff --git a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue new file mode 100644 index 00000000000..7d49c87271d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue @@ -0,0 +1,89 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import Icon from './icon.vue'; + +export default { + components: { + Icon, + GlButton, + }, + props: { + size: { + type: String, + required: false, + default: '', + }, + primaryButtonClass: { + type: String, + required: false, + default: '', + }, + dropdownClass: { + type: String, + required: false, + default: '', + }, + actions: { + type: Array, + required: true, + }, + defaultAction: { + type: Number, + required: true, + }, + }, + data() { + return { + selectedAction: this.defaultAction, + }; + }, + computed: { + selectedActionTitle() { + return this.actions[this.selectedAction].title; + }, + buttonSizeClass() { + return `btn-${this.size}`; + }, + }, + methods: { + handlePrimaryActionClick() { + this.$emit('onActionClick', this.actions[this.selectedAction]); + }, + handleActionClick(selectedAction) { + this.selectedAction = selectedAction; + this.$emit('onActionSelect', selectedAction); + }, + }, +}; +</script> + +<template> + <div class="btn-group droplab-dropdown comment-type-dropdown"> + <gl-button :class="primaryButtonClass" :size="size" @click.prevent="handlePrimaryActionClick"> + {{ selectedActionTitle }} + </gl-button> + <button + :class="buttonSizeClass" + type="button" + class="btn dropdown-toggle pl-2 pr-2" + data-display="static" + data-toggle="dropdown" + > + <icon name="arrow-down" aria-label="toggle dropdown" /> + </button> + <ul :class="dropdownClass" class="dropdown-menu dropdown-open-top"> + <template v-for="(action, index) in actions"> + <li :key="index" :class="{ 'droplab-item-selected': selectedAction === index }"> + <gl-button class="btn-transparent" @click.prevent="handleActionClick(index)"> + <i aria-hidden="true" class="fa fa-check icon"> </i> + <div class="description"> + <strong>{{ action.title }}</strong> + <p>{{ action.description }}</p> + </div> + </gl-button> + </li> + <li v-if="index === 0" :key="`${index}-separator`" class="divider droplab-item-ignore"></li> + </template> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/empty_component.js b/app/assets/javascripts/vue_shared/components/empty_component.js new file mode 100644 index 00000000000..e4402020096 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/empty_component.js @@ -0,0 +1,3 @@ +export default { + render: () => null, +}; diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 0cbcdbf2eb4..1bfa91500cb 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -39,7 +39,7 @@ export default { }, data() { return { - mouseOver: false, + dropdownOpen: false, }; }, computed: { @@ -123,8 +123,8 @@ export default { return this.$router.currentRoute.path === `/project${this.file.url}`; }, - toggleHover(over) { - this.mouseOver = over; + toggleDropdown(val) { + this.dropdownOpen = val; }, }, }; @@ -140,8 +140,7 @@ export default { class="file-row" role="button" @click="clickFile" - @mouseover="toggleHover(true)" - @mouseout="toggleHover(false)" + @mouseleave="toggleDropdown(false)" > <div class="file-row-name-container"> <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated"> @@ -160,7 +159,8 @@ export default { :is="extraComponent" v-if="extraComponent && !(hideExtraOnTree && file.type === 'tree')" :file="file" - :mouse-over="mouseOver" + :dropdown-open="dropdownOpen" + @toggle="toggleDropdown($event)" /> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 3f45dc7853b..0bac63b1062 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -37,6 +37,16 @@ export default { type: Number, required: true, }, + itemIid: { + type: Number, + required: false, + default: null, + }, + itemIdTooltip: { + type: String, + required: false, + default: '', + }, time: { type: String, required: true, @@ -85,7 +95,12 @@ export default { <section class="header-main-content"> <ci-icon-badge :status="status" /> - <strong> {{ itemName }} #{{ itemId }} </strong> + <strong v-gl-tooltip :title="itemIdTooltip"> + {{ itemName }} #{{ itemId }} + <template v-if="itemIid" + >(#{{ itemIid }})</template + > + </strong> <template v-if="shouldRenderTriggeredLabel"> triggered @@ -96,9 +111,8 @@ export default { <timeago-tooltip :time="time" /> - by - <template v-if="user"> + by <gl-link v-gl-tooltip :href="user.path" diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue index 7e79e63aa1e..715cf97f0ac 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue @@ -62,6 +62,15 @@ export default { assigneeName: assignee.name, }); }, + // This method is for backward compat + // since Graph query would return camelCase + // props while Rails would return snake_case + webUrl(assignee) { + return assignee.web_url || assignee.webUrl; + }, + avatarUrl(assignee) { + return assignee.avatar_url || assignee.avatarUrl; + }, }, }; </script> @@ -70,9 +79,9 @@ export default { <user-avatar-link v-for="assignee in assigneesToShow" :key="assignee.id" - :link-href="assignee.web_url" + :link-href="webUrl(assignee)" :img-alt="avatarUrlTitle(assignee)" - :img-src="assignee.avatar_url" + :img-src="avatarUrl(assignee)" :img-size="24" class="js-no-trigger" tooltip-placement="bottom" diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue index d5d967e25bf..9b2ee5062b1 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue @@ -17,15 +17,17 @@ export default { required: true, }, }, - data() { - return { - milestoneDue: this.milestone.due_date ? parsePikadayDate(this.milestone.due_date) : null, - milestoneStart: this.milestone.start_date - ? parsePikadayDate(this.milestone.start_date) - : null, - }; - }, computed: { + milestoneDue() { + const dueDate = this.milestone.due_date || this.milestone.dueDate; + + return dueDate ? parsePikadayDate(dueDate) : null; + }, + milestoneStart() { + const startDate = this.milestone.start_date || this.milestone.startDate; + + return startDate ? parsePikadayDate(startDate) : null; + }, isMilestoneStarted() { if (!this.milestoneStart) { return false; @@ -72,7 +74,7 @@ export default { <template> <div ref="milestoneDetails" class="issue-milestone-details"> <icon :size="16" class="inline icon" name="clock" /> - <span class="milestone-title">{{ milestone.title }}</span> + <span class="milestone-title d-inline-block">{{ milestone.title }}</span> <gl-tooltip :target="() => $refs.milestoneDetails" placement="bottom" class="js-item-milestone"> <span class="bold">{{ __('Milestone') }}</span> <br /> <span>{{ milestone.title }}</span> <br /> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue index e92babc499b..e438ff16a41 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue @@ -1,9 +1,17 @@ <script> +import { GlLink } from '@gitlab/ui'; +import _ from 'underscore'; +import { sprintf } from '~/locale'; import icon from '../../../vue_shared/components/icon.vue'; +function buildDocsLinkStart(path) { + return `<a href="${_.escape(path)}" target="_blank" rel="noopener noreferrer">`; +} + export default { components: { icon, + GlLink, }, props: { isLocked: { @@ -16,6 +24,16 @@ export default { default: false, required: false, }, + lockedIssueDocsPath: { + type: String, + required: false, + default: '', + }, + confidentialIssueDocsPath: { + type: String, + required: false, + default: '', + }, }, computed: { warningIcon() { @@ -27,6 +45,17 @@ export default { isLockedAndConfidential() { return this.isConfidential && this.isLocked; }, + confidentialAndLockedDiscussionText() { + return sprintf( + 'This issue is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.', + { + confidentialLinkStart: buildDocsLinkStart(this.confidentialIssueDocsPath), + lockedLinkStart: buildDocsLinkStart(this.lockedIssueDocsPath), + linkEnd: '</a>', + }, + false, + ); + }, }, }; </script> @@ -35,20 +64,26 @@ export default { <icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" /> <span v-if="isLockedAndConfidential"> - {{ __('This issue is confidential and locked.') }} + <span v-html="confidentialAndLockedDiscussionText"></span> {{ - __(`People without permission will never -get a notification and won't be able to comment.`) + __(`People without permission will never get a notification and won't be able to comment.`) }} </span> <span v-else-if="isConfidential"> {{ __('This is a confidential issue.') }} - {{ __('Your comment will not be visible to the public.') }} + {{ __('People without permission will never get a notification.') }} + <gl-link :href="confidentialIssueDocsPath" target="_blank"> + {{ __('Learn more') }} + </gl-link> </span> <span v-else-if="isLocked"> - {{ __('This issue is locked.') }} {{ __('Only project members can comment.') }} + {{ __('This issue is locked.') }} + {{ __('Only project members can comment.') }} + <gl-link :href="lockedIssueDocsPath" target="_blank"> + {{ __('Learn more') }} + </gl-link> </span> </div> </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 new file mode 100644 index 00000000000..05ad7710a62 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue @@ -0,0 +1,141 @@ +<script> +import '~/commons/bootstrap'; +import { GlTooltipDirective } from '@gitlab/ui'; +import { sprintf } from '~/locale'; +import IssueMilestone from '../../components/issue/issue_milestone.vue'; +import IssueAssignees from '../../components/issue/issue_assignees.vue'; +import relatedIssuableMixin from '../../mixins/related_issuable_mixin'; +import CiIcon from '../ci_icon.vue'; + +export default { + name: 'IssueItem', + components: { + IssueMilestone, + IssueAssignees, + CiIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [relatedIssuableMixin], + props: { + canReorder: { + type: Boolean, + required: false, + default: false, + }, + greyLinkWhenMerged: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + stateTitle() { + return sprintf( + '<span class="bold">%{state}</span> %{timeInWords}<br/><span class="text-tertiary">%{timestamp}</span>', + { + state: this.stateText, + timeInWords: this.stateTimeInWords, + timestamp: this.stateTimestamp, + }, + ); + }, + issueableLinkClass() { + return this.greyLinkWhenMerged + ? `sortable-link ${this.state === 'merged' ? ' text-secondary' : ''}` + : 'sortable-link'; + }, + }, +}; +</script> + +<template> + <div + :class="{ + 'issuable-info-container': !canReorder, + 'card-body': canReorder, + }" + class="item-body d-flex align-items-center p-2 p-lg-3 p-xl-2 pl-xl-3" + > + <div class="item-contents d-flex align-items-center flex-wrap flex-grow-1 flex-xl-nowrap"> + <div class="item-title d-flex align-items-center mb-1 mb-xl-0"> + <icon + v-if="hasState" + v-tooltip + :css-classes="iconClass" + :name="iconName" + :size="16" + :title="stateTitle" + :aria-label="state" + data-html="true" + /> + <icon + v-if="confidential" + v-gl-tooltip + name="eye-slash" + :size="16" + :title="__('Confidential')" + class="confidential-icon append-right-4 align-self-baseline align-self-md-auto mt-xl-0" + :aria-label="__('Confidential')" + /> + <a :href="computedPath" :class="issueableLinkClass">{{ title }}</a> + </div> + <div class="item-meta d-flex flex-wrap mt-xl-0 justify-content-xl-end flex-xl-nowrap"> + <div + class="d-flex align-items-center item-path-id order-md-0 mt-md-0 mt-1 ml-xl-2 mr-xl-auto" + > + <icon + v-if="hasState" + v-tooltip + :css-classes="iconClass" + :name="iconName" + :size="16" + :title="stateTitle" + :aria-label="state" + data-html="true" + class="d-xl-none" + /> + <span v-tooltip :title="itemPath" class="path-id-text d-inline-block">{{ + itemPath + }}</span> + {{ pathIdSeparator }}{{ itemId }} + </div> + <div + class="item-meta-child d-flex align-items-center order-0 flex-wrap mr-md-1 ml-md-auto ml-xl-2 flex-xl-nowrap" + > + <span v-if="hasPipeline" class="mr-ci-status pr-2"> + <a :href="pipelineStatus.details_path"> + <ci-icon v-gl-tooltip :status="pipelineStatus" :title="pipelineStatusTooltip" /> + </a> + </span> + <issue-milestone + v-if="hasMilestone" + :milestone="milestone" + class="d-flex align-items-center item-milestone" + /> + <slot name="dueDate"></slot> + <slot name="weight"></slot> + </div> + <issue-assignees + v-if="assignees.length" + :assignees="assignees" + class="item-assignees d-inline-flex align-items-center align-self-end ml-auto ml-md-0 mb-md-0 order-2 flex-xl-grow-0 mt-xl-0 mr-xl-1" + /> + </div> + </div> + <button + v-if="canRemove" + ref="removeButton" + v-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" + title="Remove" + aria-label="Remove" + @click="onRemoveRequest" + > + <icon :size="16" class="btn-item-remove-icon" name="close" /> + </button> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js new file mode 100644 index 00000000000..d1aba99ac22 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js @@ -0,0 +1,20 @@ +/* eslint-disable import/prefer-default-export */ + +function trimFirstCharOfLineContent(text) { + if (!text) { + return text; + } + + return text.replace(/^( |\+|-)/, ''); +} + +function cleanSuggestionLine(line = {}) { + return { + ...line, + text: trimFirstCharOfLineContent(line.text), + }; +} + +export function selectDiffLines(lines) { + return lines.filter(line => line.type !== 'match').map(line => cleanSuggestionLine(line)); +} diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue index 7a53d053eec..216f6c62e69 100644 --- a/app/assets/javascripts/vue_shared/components/loading_button.vue +++ b/app/assets/javascripts/vue_shared/components/loading_button.vue @@ -53,7 +53,7 @@ export default { <template> <button :class="containerClass" :disabled="loading || disabled" type="button" @click="onClick"> - <transition name="fade"> + <transition name="fade-in"> <gl-loading-icon v-if="loading" :inline="true" @@ -63,7 +63,7 @@ export default { class="js-loading-button-icon" /> </transition> - <transition name="fade"> + <transition name="fade-in"> <slot> <span v-if="label" class="js-loading-button-label"> {{ label }} </span> </slot> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 3f607aa2a0a..0f3b3568414 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -76,6 +76,7 @@ export default { hasSuggestion: false, markdownPreviewLoading: false, previewMarkdown: false, + suggestions: this.note.suggestions || [], }; }, computed: { @@ -109,9 +110,6 @@ export default { } return lineNumber; }, - suggestions() { - return this.note.suggestions || []; - }, lineType() { return this.line ? this.line.type : ''; }, @@ -175,6 +173,7 @@ export default { this.referencedCommands = data.references.commands; this.referencedUsers = data.references.users; this.hasSuggestion = data.references.suggestions && data.references.suggestions.length; + this.suggestions = data.references.suggestions; } this.$nextTick() @@ -189,7 +188,7 @@ export default { <div ref="gl-form" :class="{ 'prepend-top-default append-bottom-default': addSpacingClasses }" - class="md-area js-vue-markdown-field" + class="js-vue-markdown-field md-area position-relative" > <markdown-header :preview-markdown="previewMarkdown" @@ -215,7 +214,7 @@ export default { <div v-show="previewMarkdown" ref="markdown-preview" - class="md-preview js-vue-md-preview md md-preview-holder" + class="js-vue-md-preview md-preview-holder" > <suggestions v-if="hasSuggestion" @@ -233,7 +232,7 @@ export default { <div v-show="previewMarkdown" ref="markdown-preview" - class="md-preview js-vue-md-preview md md-preview-holder" + class="js-vue-md-preview md md-preview-holder" v-html="markdownPreview" ></div> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index dbfa32cd0ce..a5a5b2ef415 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -38,7 +38,7 @@ export default { ].join('\n'); }, mdSuggestion() { - return ['```suggestion', `{text}`, '```'].join('\n'); + return ['```suggestion:-0+0', `{text}`, '```'].join('\n'); }, }, mounted() { @@ -79,7 +79,7 @@ export default { <ul class="nav-links clearfix"> <li :class="{ active: !previewMarkdown }" class="md-header-tab"> <button class="js-write-link" tabindex="-1" type="button" @click="writeMarkdownTab($event)"> - Write + {{ __('Write') }} </button> </li> <li :class="{ active: previewMarkdown }" class="md-header-tab"> @@ -89,36 +89,41 @@ export default { type="button" @click="previewMarkdownTab($event)" > - Preview + {{ __('Preview') }} </button> </li> <li :class="{ active: !previewMarkdown }" class="md-header-toolbar"> - <toolbar-button tag="**" button-title="Add bold text" icon="bold" /> - <toolbar-button tag="*" button-title="Add italic text" icon="italic" /> - <toolbar-button :prepend="true" tag="> " button-title="Insert a quote" icon="quote" /> - <toolbar-button tag="`" tag-block="```" button-title="Insert code" icon="code" /> + <toolbar-button tag="**" :button-title="__('Add bold text')" icon="bold" /> + <toolbar-button tag="*" :button-title="__('Add italic text')" icon="italic" /> + <toolbar-button + :prepend="true" + tag="> " + :button-title="__('Insert a quote')" + icon="quote" + /> + <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" /> <toolbar-button tag="[{text}](url)" tag-select="url" - button-title="Add a link" + :button-title="__('Add a link')" icon="link" /> <toolbar-button :prepend="true" tag="* " - button-title="Add a bullet list" + :button-title="__('Add a bullet list')" icon="list-bulleted" /> <toolbar-button :prepend="true" tag="1. " - button-title="Add a numbered list" + :button-title="__('Add a numbered list')" icon="list-numbered" /> <toolbar-button :prepend="true" tag="* [ ] " - button-title="Add a task list" + :button-title="__('Add a task list')" icon="task-done" /> <toolbar-button @@ -139,11 +144,11 @@ export default { /> <button v-gl-tooltip - aria-label="Go full screen" + :aria-label="__('Go full screen')" class="toolbar-btn toolbar-fullscreen-btn js-zen-enter" data-container="body" tabindex="-1" - title="Go full screen" + :title="__('Go full screen')" type="button" > <icon name="screen-full" /> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue index a351ca62c94..2eb4ec12a4a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -1,24 +1,14 @@ <script> import SuggestionDiffHeader from './suggestion_diff_header.vue'; +import SuggestionDiffRow from './suggestion_diff_row.vue'; +import { selectDiffLines } from '../lib/utils/diff_utils'; export default { components: { SuggestionDiffHeader, + SuggestionDiffRow, }, props: { - newLines: { - type: Array, - required: true, - }, - fromContent: { - type: String, - required: false, - default: '', - }, - fromLine: { - type: Number, - required: true, - }, suggestion: { type: Object, required: true, @@ -33,6 +23,11 @@ export default { required: true, }, }, + computed: { + lines() { + return selectDiffLines(this.suggestion.diff_lines); + }, + }, methods: { applySuggestion(callback) { this.$emit('apply', { suggestionId: this.suggestion.id, callback }); @@ -52,22 +47,11 @@ export default { /> <table class="mb-3 md-suggestion-diff js-syntax-highlight code"> <tbody> - <!-- Old Line --> - <tr class="line_holder old"> - <td class="diff-line-num old_line qa-old-diff-line-number old">{{ fromLine }}</td> - <td class="diff-line-num new_line old"></td> - <td class="line_content old"> - <span>{{ fromContent }}</span> - </td> - </tr> - <!-- New Line(s) --> - <tr v-for="(line, key) of newLines" :key="key" class="line_holder new"> - <td class="diff-line-num old_line new"></td> - <td class="diff-line-num new_line qa-new-diff-line-number new">{{ line.lineNumber }}</td> - <td class="line_content new"> - <span>{{ line.content }}</span> - </td> - </tr> + <suggestion-diff-row + v-for="(line, index) of lines" + :key="`${index}-${line.text}`" + :line="line" + /> </tbody> </table> </div> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index c5a2aa1f2af..32783b85df4 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -1,8 +1,10 @@ <script> import Icon from '~/vue_shared/components/icon.vue'; +import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; export default { - components: { Icon }, + components: { Icon, GlButton, GlLoadingIcon }, + directives: { 'gl-tooltip': GlTooltipDirective }, props: { canApply: { type: Boolean, @@ -21,7 +23,6 @@ export default { }, data() { return { - isAppliedSuccessfully: false, isApplying: false, }; }, @@ -47,14 +48,19 @@ export default { </a> </div> <span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span> - <button - v-if="canApply" - type="button" - class="btn qa-apply-btn" + <div v-if="isApplying" class="d-flex align-items-center text-secondary"> + <gl-loading-icon class="d-flex-center mr-2" /> + <span>{{ __('Applying suggestion') }}</span> + </div> + <gl-button + v-else-if="canApply" + v-gl-tooltip.viewport="__('This also resolves the discussion')" + class="btn-inverted qa-apply-btn" :disabled="isApplying" + variant="success" @click="applySuggestion" > {{ __('Apply suggestion') }} - </button> + </gl-button> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue new file mode 100644 index 00000000000..c09bdfec250 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue @@ -0,0 +1,32 @@ +<script> +export default { + name: 'SuggestionDiffRow', + props: { + line: { + type: Object, + required: true, + }, + }, + computed: { + lineType() { + return this.line.type; + }, + }, +}; +</script> + +<template> + <tr class="line_holder" :class="lineType"> + <td class="diff-line-num old_line border-top-0 border-bottom-0" :class="lineType"> + {{ line.old_line }} + </td> + <td class="diff-line-num new_line border-top-0 border-bottom-0" :class="lineType"> + {{ line.new_line }} + </td> + <td class="line_content" :class="lineType"> + <span v-if="line.text">{{ line.text }}</span> + <!-- TODO: replace this hack with zero-width whitespace when we have rich_text from BE --> + <span v-else>​</span> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index dcda701f049..8d3705e1e4a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -6,16 +6,6 @@ import Flash from '~/flash'; export default { components: { SuggestionDiff }, props: { - fromLine: { - type: Number, - required: false, - default: 0, - }, - fromContent: { - type: String, - required: false, - default: '', - }, lineType: { type: String, required: false, @@ -71,41 +61,19 @@ export default { suggestionElements.forEach((suggestionEl, i) => { const suggestionParentEl = suggestionEl.parentElement; - const newLines = this.extractNewLines(suggestionParentEl); - const diffComponent = this.generateDiff(newLines, i); + const diffComponent = this.generateDiff(i); diffComponent.$mount(suggestionParentEl); }); this.isRendered = true; }, - extractNewLines(suggestionEl) { - // extracts the suggested lines from the markdown - // calculates a line number for each line - - const newLines = suggestionEl.querySelectorAll('.line'); - const fromLine = this.suggestions.length ? this.suggestions[0].from_line : this.fromLine; - const lines = []; - - newLines.forEach((line, i) => { - const content = `${line.innerText}\n`; - const lineNumber = fromLine + i; - lines.push({ content, lineNumber }); - }); - - return lines; - }, - generateDiff(newLines, suggestionIndex) { - // generates the diff <suggestion-diff /> component - // all `suggestion` markdown will be swapped out by this component - + generateDiff(suggestionIndex) { const { suggestions, disabled, helpPagePath } = this; const suggestion = suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {}; - const fromContent = suggestion.from_content || this.fromContent; - const fromLine = suggestion.from_line || this.fromLine; const SuggestionDiffComponent = Vue.extend(SuggestionDiff); const suggestionDiff = new SuggestionDiffComponent({ - propsData: { newLines, fromLine, fromContent, disabled, suggestion, helpPagePath }, + propsData: { disabled, suggestion, helpPagePath }, }); suggestionDiff.$on('apply', ({ suggestionId, callback }) => { @@ -130,6 +98,6 @@ export default { <template> <div> <div class="flash-container js-suggestions-flash"></div> - <div v-show="isRendered" ref="container" v-html="noteHtml"></div> + <div v-show="isRendered" ref="container" class="md" v-html="noteHtml"></div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 3b57b5e8da4..d6c398c8946 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -33,37 +33,36 @@ export default { <div class="comment-toolbar clearfix"> <div class="toolbar-text"> <template v-if="!hasQuickActionsDocsPath && markdownDocsPath"> - <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1"> - Markdown is supported - </gl-link> + <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1" + >Markdown is supported</gl-link + > </template> <template v-if="hasQuickActionsDocsPath && markdownDocsPath"> - <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1"> Markdown </gl-link> - and - <gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1"> - quick actions - </gl-link> + <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">Markdown</gl-link> and + <gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1">quick actions</gl-link> are supported </template> </div> <span v-if="canAttachFile" class="uploading-container"> <span class="uploading-progress-container hide"> - <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"> </i> - <span class="attaching-file-message"></span> <span class="uploading-progress">0%</span> + <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i> + <span class="attaching-file-message"></span> + <span class="uploading-progress">0%</span> <span class="uploading-spinner"> - <i class="fa fa-spinner fa-spin toolbar-button-icon" aria-hidden="true"> </i> + <i class="fa fa-spinner fa-spin toolbar-button-icon" aria-hidden="true"></i> </span> </span> <span class="uploading-error-container hide"> <span class="uploading-error-icon"> - <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"> </i> + <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i> </span> <span class="uploading-error-message"></span> <button class="retry-uploading-link" type="button">Try again</button> or <button class="attach-new-file markdown-selector" type="button">attach a new file</button> </span> - <button class="markdown-selector button-attach-file" tabindex="-1" type="button"> - <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"> </i> Attach a file + <button class="markdown-selector button-attach-file btn-link" tabindex="-1" type="button"> + <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i + ><span class="text-attach-file">Attach a file</span> </button> <button class="btn btn-default btn-sm hide button-cancel-uploading-files" type="button"> Cancel diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue new file mode 100644 index 00000000000..bf59a6abf3f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue @@ -0,0 +1,121 @@ +<script> +import $ from 'jquery'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import Clipboard from 'clipboard'; + +export default { + components: { + GlButton, + Icon, + }, + + directives: { + GlTooltip: GlTooltipDirective, + }, + + props: { + text: { + type: String, + required: false, + default: '', + }, + container: { + type: String, + required: false, + default: '', + }, + modalId: { + type: String, + required: false, + default: '', + }, + target: { + type: String, + required: false, + default: '', + }, + title: { + type: String, + required: true, + }, + tooltipPlacement: { + type: String, + required: false, + default: 'top', + }, + tooltipContainer: { + type: String, + required: false, + default: null, + }, + }, + + copySuccessText: __('Copied'), + + computed: { + modalDomId() { + return this.modalId ? `#${this.modalId}` : ''; + }, + }, + + mounted() { + this.$nextTick(() => { + this.clipboard = new Clipboard(this.$el, { + container: + document.querySelector(`${this.modalDomId} div.modal-content`) || + document.getElementById(this.container) || + document.body, + }); + this.clipboard + .on('success', e => { + this.updateTooltip(e.trigger); + this.$emit('success', e); + // Clear the selection and blur the trigger so it loses its border + e.clearSelection(); + $(e.trigger).blur(); + }) + .on('error', e => this.$emit('error', e)); + }); + }, + + destroyed() { + if (this.clipboard) { + this.clipboard.destroy(); + } + }, + + methods: { + updateTooltip(target) { + const $target = $(target); + const originalTitle = $target.data('originalTitle'); + + if ($target.tooltip) { + /** + * The original tooltip will continue staying there unless we remove it by hand. + * $target.tooltip('hide') isn't working. + */ + $('.tooltip').remove(); + $target.attr('title', this.$options.copySuccessText); + $target.tooltip('_fixTitle'); + $target.tooltip('show'); + $target.attr('title', originalTitle); + $target.tooltip('_fixTitle'); + } + }, + }, +}; +</script> +<template> + <gl-button + v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }" + :data-clipboard-target="target" + :data-clipboard-text="text" + :title="title" + > + <slot> + <icon name="duplicate" /> + </slot> + </gl-button> +</template> diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index 8d3a3009c55..baed26a157c 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -51,13 +51,13 @@ export default { <div class="note-header"> <div class="note-header-info"> <a :href="getUserData.path"> - <span class="d-none d-sm-inline-block">{{ getUserData.name }}</span> + <span class="d-none d-sm-inline-block bold">{{ getUserData.name }}</span> <span class="note-headline-light">@{{ getUserData.username }}</span> </a> </div> </div> <div class="note-body"> - <div class="note-text"> + <div class="note-text md"> <p>{{ note.body }}</p> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index b0af8399955..3c86b7e4c61 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -22,6 +22,7 @@ import noteHeader from '~/notes/components/note_header.vue'; import Icon from '~/vue_shared/components/icon.vue'; import TimelineEntryItem from './timeline_entry_item.vue'; import { spriteIcon } from '../../../lib/utils/common_utils'; +import initMRPopovers from '~/mr_popover/'; const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; @@ -71,6 +72,9 @@ export default { ); }, }, + mounted() { + initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request')); + }, }; </script> @@ -93,7 +97,7 @@ export default { 'system-note-commit-list': hasMoreCommits, 'hide-shade': expanded, }" - class="note-text" + class="note-text md" v-html="note.note_html" ></div> <div v-if="hasMoreCommits" class="flex-list"> diff --git a/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue b/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue index 06974a12aed..f316c4fe112 100644 --- a/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue +++ b/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue @@ -1,9 +1,3 @@ -<script> -export default { - name: 'TimelineEntryItem', -}; -</script> - <template> <li class="timeline-entry"> <div class="timeline-entry-inner"><slot></slot></div> diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue index fa502b9beb9..8104d919bf6 100644 --- a/app/assets/javascripts/vue_shared/components/pikaday.vue +++ b/app/assets/javascripts/vue_shared/components/pikaday.vue @@ -34,7 +34,7 @@ export default { format: 'yyyy-mm-dd', container: this.$el, defaultDate: this.selectedDate, - setDefaultDate: !!this.selectedDate, + setDefaultDate: Boolean(this.selectedDate), minDate: this.minDate, maxDate: this.maxDate, parse: dateString => parsePikadayDate(dateString), diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue new file mode 100644 index 00000000000..071bae7f665 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue @@ -0,0 +1,74 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue'; +import highlight from '~/lib/utils/highlight'; +import { truncateNamespace } from '~/lib/utils/text_utility'; +import _ from 'underscore'; + +export default { + name: 'ProjectListItem', + components: { + Icon, + ProjectAvatar, + GlButton, + }, + props: { + project: { + type: Object, + required: true, + validator: p => _.isFinite(p.id) && _.isString(p.name) && _.isString(p.name_with_namespace), + }, + selected: { + type: Boolean, + required: true, + }, + matcher: { + type: String, + required: false, + default: '', + }, + }, + computed: { + truncatedNamespace() { + return truncateNamespace(this.project.name_with_namespace); + }, + highlightedProjectName() { + return highlight(this.project.name, this.matcher); + }, + }, + methods: { + onClick() { + this.$emit('click'); + }, + }, +}; +</script> +<template> + <gl-button + class="d-flex align-items-center btn pt-1 pb-1 border-0 project-list-item" + @click="onClick" + > + <icon + class="prepend-left-10 append-right-10 flex-shrink-0 position-top-0 js-selected-icon" + :class="{ 'js-selected visible': selected, 'js-unselected invisible': !selected }" + name="mobile-issue-close" + /> + <project-avatar class="flex-shrink-0 js-project-avatar" :project="project" :size="32" /> + <div class="d-flex flex-wrap project-namespace-name-container"> + <div + v-if="truncatedNamespace" + :title="project.name_with_namespace" + class="text-secondary text-truncate js-project-namespace" + > + {{ truncatedNamespace }} + <span v-if="truncatedNamespace" class="text-secondary">/ </span> + </div> + <div + :title="project.name" + class="js-project-name text-truncate" + v-html="highlightedProjectName" + ></div> + </div> + </gl-button> +</template> 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 new file mode 100644 index 00000000000..596fd48f96a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue @@ -0,0 +1,103 @@ +<script> +import _ from 'underscore'; +import { GlLoadingIcon } from '@gitlab/ui'; +import ProjectListItem from './project_list_item.vue'; + +const SEARCH_INPUT_TIMEOUT_MS = 500; + +export default { + name: 'ProjectSelector', + components: { + GlLoadingIcon, + ProjectListItem, + }, + props: { + projectSearchResults: { + type: Array, + required: true, + }, + selectedProjects: { + type: Array, + required: true, + }, + showNoResultsMessage: { + type: Boolean, + required: false, + default: false, + }, + showMinimumSearchQueryMessage: { + type: Boolean, + required: false, + default: false, + }, + showLoadingIndicator: { + type: Boolean, + required: false, + default: false, + }, + showSearchErrorMessage: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + searchQuery: '', + }; + }, + methods: { + projectClicked(project) { + this.$emit('projectClicked', project); + }, + isSelected(project) { + return Boolean(_.findWhere(this.selectedProjects, { id: project.id })); + }, + focusSearchInput() { + this.$refs.searchInput.focus(); + }, + onInput: _.debounce(function debouncedOnInput() { + this.$emit('searched', this.searchQuery); + }, SEARCH_INPUT_TIMEOUT_MS), + }, +}; +</script> +<template> + <div> + <input + ref="searchInput" + v-model="searchQuery" + :placeholder="__('Search your projects')" + type="search" + class="form-control mb-3 js-project-selector-input" + autofocus + @input="onInput" + /> + <div class="d-flex flex-column"> + <gl-loading-icon v-if="showLoadingIndicator" :size="2" class="py-2 px-4" /> + <div v-if="!showLoadingIndicator" class="d-flex flex-column"> + <project-list-item + v-for="project in projectSearchResults" + :key="project.id" + :selected="isSelected(project)" + :project="project" + :matcher="searchQuery" + class="js-project-list-item" + @click="projectClicked(project)" + /> + </div> + <div v-if="showNoResultsMessage" class="text-muted ml-2 js-no-results-message"> + {{ __('Sorry, no projects matched your search') }} + </div> + <div + v-if="showMinimumSearchQueryMessage" + class="text-muted ml-2 js-minimum-search-query-message" + > + {{ __('Enter at least three characters to search') }} + </div> + <div v-if="showSearchErrorMessage" class="text-danger ml-2 js-search-error-message"> + {{ __('Something went wrong, unable to search projects') }} + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue new file mode 100644 index 00000000000..1f3d248e991 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue @@ -0,0 +1,40 @@ +<script> +import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; +import $ from 'jquery'; + +export default { + data() { + return { + width: 0, + height: 0, + }; + }, + beforeDestroy() { + this.contentResizeHandler.off('content.resize', this.debouncedResize); + window.removeEventListener('resize', this.debouncedResize); + }, + created() { + this.debouncedResize = debounceByAnimationFrame(this.onResize); + + // Handle when we explicictly trigger a custom resize event + this.contentResizeHandler = $(document).on('content.resize', this.debouncedResize); + + // Handle window resize + window.addEventListener('resize', this.debouncedResize); + }, + methods: { + onResize() { + // Slot dimensions + const { clientWidth, clientHeight } = this.$refs.chartWrapper; + this.width = clientWidth; + this.height = clientHeight; + }, + }, +}; +</script> + +<template> + <div ref="chartWrapper"> + <slot :width="width" :height="height"> </slot> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue index 3074ea859cc..6d2612556ff 100644 --- a/app/assets/javascripts/vue_shared/components/select2_select.vue +++ b/app/assets/javascripts/vue_shared/components/select2_select.vue @@ -1,6 +1,6 @@ <script> import $ from 'jquery'; -import 'select2/select2'; +import 'select2'; export default { name: 'Select2Select', 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 f66e81b1e08..9c258c4651f 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 @@ -75,6 +75,16 @@ export default { required: false, default: false, }, + enableScopedLabels: { + type: Boolean, + require: false, + default: false, + }, + scopedLabelsDocumentationLink: { + type: String, + require: false, + default: '#', + }, }, computed: { hiddenInputName() { @@ -123,7 +133,12 @@ export default { @onValueClick="handleCollapsedValueClick" /> <dropdown-title :can-edit="canEdit" /> - <dropdown-value :labels="context.labels" :label-filter-base-path="labelFilterBasePath"> + <dropdown-value + :labels="context.labels" + :label-filter-base-path="labelFilterBasePath" + :scoped-labels-documentation-link="scopedLabelsDocumentationLink" + :enable-scoped-labels="enableScopedLabels" + > <slot></slot> </dropdown-value> <div v-if="canEdit" class="selectbox js-selectbox" style="display: none;"> @@ -142,6 +157,8 @@ export default { :namespace="namespace" :labels="context.labels" :show-extra-options="!showCreate" + :scoped-labels-documentation-link="scopedLabelsDocumentationLink" + :enable-scoped-labels="enableScopedLabels" /> <div class="dropdown-menu dropdown-select dropdown-menu-paging diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue index 498b507d11d..1eed8907bb7 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue @@ -31,6 +31,16 @@ export default { type: Boolean, required: true, }, + enableScopedLabels: { + type: Boolean, + require: false, + default: false, + }, + scopedLabelsDocumentationLink: { + type: String, + require: false, + default: '#', + }, }, computed: { dropdownToggleText() { @@ -61,6 +71,8 @@ export default { :data-labels="labelsPath" :data-namespace-path="namespace" :data-show-any="showExtraOptions" + :data-scoped-labels="enableScopedLabels" + :data-scoped-labels-documentation-link="scopedLabelsDocumentationLink" type="button" class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal" data-toggle="dropdown" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue index 6faf3fafad1..4abf7c478ee 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue @@ -1,9 +1,12 @@ <script> -import tooltip from '~/vue_shared/directives/tooltip'; +import DropdownValueScopedLabel from './dropdown_value_scoped_label.vue'; +import DropdownValueRegularLabel from './dropdown_value_regular_label.vue'; +import { isScopedLabel } from '~/lib/utils/common_utils'; export default { - directives: { - tooltip, + components: { + DropdownValueScopedLabel, + DropdownValueRegularLabel, }, props: { labels: { @@ -14,6 +17,16 @@ export default { type: String, required: true, }, + enableScopedLabels: { + type: Boolean, + required: false, + default: false, + }, + scopedLabelsDocumentationLink: { + type: String, + required: false, + default: '#', + }, }, computed: { isEmpty() { @@ -30,6 +43,12 @@ export default { backgroundColor: label.color, }; }, + scopedLabelsDescription({ description = '' }) { + return `<span class="font-weight-bold scoped-label-tooltip-title">Scoped label</span><br />${description}`; + }, + showScopedLabels(label) { + return this.enableScopedLabels && isScopedLabel(label); + }, }, }; </script> @@ -44,17 +63,24 @@ export default { <span v-if="isEmpty" class="text-secondary"> <slot>{{ __('None') }}</slot> </span> - <a v-for="label in labels" v-else :key="label.id" :href="labelFilterUrl(label)"> - <span - v-tooltip - :style="labelStyle(label)" - :title="label.description" - class="badge color-label" - data-placement="bottom" - data-container="body" - > - {{ label.title }} - </span> - </a> + + <template v-for="label in labels" v-else> + <dropdown-value-scoped-label + v-if="showScopedLabels(label)" + :key="label.id" + :label="label" + :label-filter-url="labelFilterUrl(label)" + :label-style="labelStyle(label)" + :scoped-labels-documentation-link="scopedLabelsDocumentationLink" + /> + + <dropdown-value-regular-label + v-else + :key="label.id" + :label="label" + :label-filter-url="labelFilterUrl(label)" + :label-style="labelStyle(label)" + /> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue index 373794fb1f2..05446903286 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue @@ -14,10 +14,12 @@ export default { }, computed: { labelsList() { - const labelsString = this.labels - .slice(0, 5) - .map(label => label.title) - .join(', '); + const labelsString = this.labels.length + ? this.labels + .slice(0, 5) + .map(label => label.title) + .join(', ') + : s__('LabelSelect|Labels'); if (this.labels.length > 5) { return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue new file mode 100644 index 00000000000..282b181f11e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue @@ -0,0 +1,35 @@ +<script> +import { GlLink, GlTooltip } from '@gitlab/ui'; + +export default { + components: { + GlTooltip, + GlLink, + }, + props: { + label: { + type: Object, + required: true, + }, + labelStyle: { + type: Object, + required: true, + }, + labelFilterUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <a ref="regularLabelRef" :href="labelFilterUrl"> + <span :style="labelStyle" class="badge color-label"> + {{ label.title }} + </span> + <gl-tooltip :target="() => $refs.regularLabelRef" placement="top" boundary="viewport"> + {{ label.description }} + </gl-tooltip> + </a> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue new file mode 100644 index 00000000000..ad5a86de166 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue @@ -0,0 +1,47 @@ +<script> +import { GlLink, GlTooltip } from '@gitlab/ui'; + +export default { + components: { + GlTooltip, + GlLink, + }, + props: { + label: { + type: Object, + required: true, + }, + labelStyle: { + type: Object, + required: true, + }, + scopedLabelsDocumentationLink: { + type: String, + required: true, + }, + labelFilterUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <span class="d-inline-block position-relative scoped-label-wrapper"> + <a :href="labelFilterUrl"> + <span :ref="`labelTitleRef`" :style="labelStyle" class="badge color-label label"> + {{ label.title }} + </span> + <gl-tooltip :target="() => $refs.labelTitleRef" placement="top" boundary="viewport"> + <span class="font-weight-bold scoped-label-tooltip-title">{{ __('Scoped label') }}</span + ><br /> + {{ label.description }} + </gl-tooltip> + </a> + + <gl-link :href="scopedLabelsDocumentationLink" target="_blank" class="label scoped-label" + ><i class="fa fa-question-circle" :style="labelStyle"></i + ></gl-link> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/components/svg_gradient.vue b/app/assets/javascripts/vue_shared/components/svg_gradient.vue index cca90af275e..5ce45d492f9 100644 --- a/app/assets/javascripts/vue_shared/components/svg_gradient.vue +++ b/app/assets/javascripts/vue_shared/components/svg_gradient.vue @@ -4,10 +4,16 @@ export default { colors: { type: Array, required: true, + validator(value) { + return value.length === 2; + }, }, opacity: { type: Array, required: true, + validator(value) { + return value.length === 2; + }, }, identifierName: { type: String, diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.vue b/app/assets/javascripts/vue_shared/components/table_pagination.vue index 2a34b4630f2..9cce9a4e542 100644 --- a/app/assets/javascripts/vue_shared/components/table_pagination.vue +++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue @@ -54,15 +54,14 @@ export default { return this.pageInfo.nextPage; }, getItems() { - const total = this.pageInfo.totalPages; - const { page } = this.pageInfo; + const { totalPages, nextPage, previousPage, page } = this.pageInfo; const items = []; if (page > 1) { items.push({ title: FIRST, first: true }); } - if (page > 1) { + if (previousPage) { items.push({ title: PREV, prev: true }); } else { items.push({ title: PREV, disabled: true, prev: true }); @@ -70,32 +69,34 @@ export default { if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true }); - const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1); - const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, total); + if (totalPages) { + const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1); + const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, totalPages); - for (let i = start; i <= end; i += 1) { - const isActive = i === page; - items.push({ title: i, active: isActive, page: true }); - } + for (let i = start; i <= end; i += 1) { + const isActive = i === page; + items.push({ title: i, active: isActive, page: true }); + } - if (total - page > PAGINATION_UI_BUTTON_LIMIT) { - items.push({ title: SPREAD, separator: true, page: true }); + if (totalPages - page > PAGINATION_UI_BUTTON_LIMIT) { + items.push({ title: SPREAD, separator: true, page: true }); + } } - if (page === total) { - items.push({ title: NEXT, disabled: true, next: true }); - } else if (total - page >= 1) { + if (nextPage) { items.push({ title: NEXT, next: true }); + } else { + items.push({ title: NEXT, disabled: true, next: true }); } - if (total - page >= 1) { + if (totalPages && totalPages - page >= 1) { items.push({ title: LAST, last: true }); } return items; }, showPagination() { - return this.pageInfo.totalPages > 1; + return this.pageInfo.nextPage || this.pageInfo.previousPage; }, }, methods: { @@ -120,7 +121,7 @@ export default { this.change(1); break; default: - this.change(+text); + this.change(Number(text)); break; } }, @@ -149,9 +150,9 @@ export default { }" class="page-item" > - <a class="page-link" @click.prevent="changePage(item.title, item.disabled)"> + <button type="button" class="page-link" @click="changePage(item.title, item.disabled)"> {{ item.title }} - </a> + </button> </li> </ul> </div> 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 f9773622001..a60d5eb491e 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -1,11 +1,13 @@ <script> import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; import { glEmojiTag } from '../../../emoji'; export default { name: 'UserPopover', components: { + Icon, GlPopover, GlSkeletonLoading, UserAvatarImage, @@ -68,16 +70,31 @@ export default { <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" /> </div> <div class="text-secondary"> - <div v-if="user.bio" class="js-bio">{{ user.bio }}</div> - <div v-if="user.organization" class="js-organization">{{ user.organization }}</div> + <div v-if="user.bio" class="js-bio d-flex mb-1"> + <icon name="profile" css-classes="category-icon flex-shrink-0" /> + <span class="ml-1">{{ user.bio }}</span> + </div> + <div v-if="user.organization" class="js-organization d-flex mb-1"> + <icon + v-show="!jobInfoIsLoading" + name="work" + css-classes="category-icon flex-shrink-0" + /> + <span class="ml-1">{{ user.organization }}</span> + </div> <gl-skeleton-loading v-if="jobInfoIsLoading" :lines="1" class="animation-container-small mb-1" /> </div> - <div class="text-secondary"> - {{ user.location }} + <div class="js-location text-secondary d-flex"> + <icon + v-show="!locationIsLoading && user.location" + name="location" + css-classes="category-icon flex-shrink-0" + /> + <span class="ml-1">{{ user.location }}</span> <gl-skeleton-loading v-if="locationIsLoading" :lines="1" diff --git a/app/assets/javascripts/vue_shared/directives/tooltip.js b/app/assets/javascripts/vue_shared/directives/tooltip.js index 549d27e96d9..2d1f7a1cfd0 100644 --- a/app/assets/javascripts/vue_shared/directives/tooltip.js +++ b/app/assets/javascripts/vue_shared/directives/tooltip.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import '~/commons/bootstrap'; export default { bind(el) { diff --git a/app/assets/javascripts/vue_shared/mixins/is_ee.js b/app/assets/javascripts/vue_shared/mixins/is_ee.js new file mode 100644 index 00000000000..8e00d93ef18 --- /dev/null +++ b/app/assets/javascripts/vue_shared/mixins/is_ee.js @@ -0,0 +1,10 @@ +import Vue from 'vue'; +import { isEE } from '~/lib/utils/common_utils'; + +Vue.mixin({ + computed: { + isEE() { + return isEE(); + }, + }, +}); diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js new file mode 100644 index 00000000000..8e0e4baa75a --- /dev/null +++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js @@ -0,0 +1,217 @@ +import _ from 'underscore'; +import { sprintf, __ } from '~/locale'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import tooltip from '~/vue_shared/directives/tooltip'; +import icon from '~/vue_shared/components/icon.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +const mixins = { + data() { + return { + removeDisabled: false, + }; + }, + props: { + idKey: { + type: Number, + required: true, + }, + displayReference: { + type: String, + required: true, + }, + pathIdSeparator: { + type: String, + required: true, + }, + eventNamespace: { + type: String, + required: false, + default: '', + }, + confidential: { + type: Boolean, + required: false, + default: false, + }, + title: { + type: String, + required: false, + default: '', + }, + path: { + type: String, + required: false, + default: '', + }, + state: { + type: String, + required: false, + default: '', + }, + createdAt: { + type: String, + required: false, + default: '', + }, + closedAt: { + type: String, + required: false, + default: '', + }, + mergedAt: { + type: String, + required: false, + default: '', + }, + milestone: { + type: Object, + required: false, + default: () => ({}), + }, + dueDate: { + type: String, + required: false, + default: '', + }, + assignees: { + type: Array, + required: false, + default: () => [], + }, + weight: { + type: Number, + required: false, + default: 0, + }, + canRemove: { + type: Boolean, + required: false, + default: false, + }, + isMergeRequest: { + type: Boolean, + required: false, + default: false, + }, + pipelineStatus: { + type: Object, + required: false, + default: () => ({}), + }, + }, + components: { + icon, + }, + directives: { + tooltip, + }, + mixins: [timeagoMixin], + computed: { + hasState() { + return this.state && this.state.length > 0; + }, + hasPipeline() { + return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length; + }, + isOpen() { + return this.state === 'opened'; + }, + isClosed() { + return this.state === 'closed'; + }, + isMerged() { + return this.state === 'merged'; + }, + hasTitle() { + return this.title.length > 0; + }, + hasMilestone() { + return !_.isEmpty(this.milestone); + }, + iconName() { + if (this.isMergeRequest && this.isMerged) { + return 'merge'; + } + + return this.isOpen ? 'issue-open-m' : 'issue-close'; + }, + iconClass() { + if (this.isMergeRequest && this.isClosed) { + return 'merge-request-status closed issue-token-state-icon-closed'; + } + + return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed'; + }, + computedLinkElementType() { + return this.path.length > 0 ? 'a' : 'span'; + }, + computedPath() { + return this.path.length ? this.path : null; + }, + itemPath() { + return this.displayReference.split(this.pathIdSeparator)[0]; + }, + itemId() { + return this.displayReference.split(this.pathIdSeparator).pop(); + }, + createdAtInWords() { + return this.createdAt ? this.timeFormated(this.createdAt) : ''; + }, + createdAtTimestamp() { + return this.createdAt ? formatDate(new Date(this.createdAt)) : ''; + }, + mergedAtTimestamp() { + return this.mergedAt ? formatDate(new Date(this.mergedAt)) : ''; + }, + mergedAtInWords() { + return this.mergedAt ? this.timeFormated(this.mergedAt) : ''; + }, + closedAtInWords() { + return this.closedAt ? this.timeFormated(this.closedAt) : ''; + }, + closedAtTimestamp() { + return this.closedAt ? formatDate(new Date(this.closedAt)) : ''; + }, + stateText() { + if (this.isMerged) { + return __('Merged'); + } + + return this.isOpen ? __('Opened') : __('Closed'); + }, + stateTimeInWords() { + if (this.isMerged) { + return this.mergedAtInWords; + } + + return this.isOpen ? this.createdAtInWords : this.closedAtInWords; + }, + stateTimestamp() { + if (this.isMerged) { + return this.mergedAtTimestamp; + } + + return this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp; + }, + pipelineStatusTooltip() { + return this.hasPipeline + ? sprintf(__('Pipeline: %{status}'), { status: this.pipelineStatus.label }) + : ''; + }, + }, + methods: { + onRemoveRequest() { + let namespacePrefix = ''; + if (this.eventNamespace && this.eventNamespace.length > 0) { + namespacePrefix = `${this.eventNamespace}`; + } + + this.$emit(`${namespacePrefix}RemoveRequest`, this.idKey); + + this.removeDisabled = true; + }, + }, +}; + +export default mixins; diff --git a/app/assets/javascripts/vue_shared/models/label.js b/app/assets/javascripts/vue_shared/models/label.js deleted file mode 100644 index 2d2732d0661..00000000000 --- a/app/assets/javascripts/vue_shared/models/label.js +++ /dev/null @@ -1,13 +0,0 @@ -export default class ListLabel { - constructor(obj) { - this.id = obj.id; - this.title = obj.title; - this.type = obj.type; - this.color = obj.color; - this.textColor = obj.text_color; - this.description = obj.description; - this.priority = obj.priority !== null ? obj.priority : Infinity; - } -} - -window.ListLabel = ListLabel; diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 83ad8766cb5..a2f518cd24e 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -2,44 +2,36 @@ * This is a manifest file that'll automatically include all the stylesheets available in this directory * and any sub-directories. You're free to add application-wide styles to this file and they'll appear at * the top of the compiled file, but it's generally better to create a new file per style scope. - *= require jquery.atwho - *= require select2 *= require_self *= require cropper.css */ -/* - * Welcome to GitLab css! - * If you need to add or modify UI component that is common for many pages - * like a table or typography then make changes in the framework/ directory. - * If you need to add unique style that should affect only one page - use pages/ - * directory. - */ - +// Welcome to GitLab css! +// If you need to add or modify UI component that is common for many pages +// like a table or typography then make changes in the framework/ directory. +// If you need to add unique style that should affect only one page - use pages/ +// directory. +@import "../../../node_modules/at.js/dist/css/jquery.atwho"; @import "../../../node_modules/pikaday/scss/pikaday"; @import "../../../node_modules/dropzone/dist/basic"; +@import "../../../node_modules/select2/select2"; -/* - * GitLab UI framework - */ +// GitLab UI framework @import "framework"; -/* - * Font icons - */ +// Font icons @import "font-awesome"; -/* - * Page specific styles (issues, projects etc): - */ +// Page specific styles (issues, projects etc): @import "pages/**/*"; -/* - * Component specific styles, will be moved to gitlab-ui - */ +// Component specific styles, will be moved to gitlab-ui @import "components/**/*"; -/* - * Styles for JS behaviors. - */ +// Vendors specific styles +@import "vendors/**/*"; + +// Styles for JS behaviors. @import "behaviors"; + +@import "utilities"; diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index c8357f7751c..7f6384f4eea 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -22,7 +22,9 @@ body, .form-control, .search form { // Override default font size used in non-csslab UI - font-size: 14px; + // Use rem to keep default font-size at 14px on body so 1rem still + // fits 8px grid, but also allow users to change browser font size + font-size: .875rem; } legend { @@ -343,16 +345,6 @@ input[type=color].form-control { } } -// Bootstrap 3 compatibility because bootstrap_form Gem is not updated yet -.input-group-btn:first-child { - @extend .input-group-prepend; -} - -// Bootstrap 3 compatibility because bootstrap_form Gem is not updated yet -.input-group-btn:last-child { - @extend .input-group-append; -} - /* Bootstrap 4.1.2 introduced a new default vertical alignment which breaks our icons, so we need to reset the vertical alignment to the default value. See: diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss new file mode 100644 index 00000000000..1afa5ed90f4 --- /dev/null +++ b/app/assets/stylesheets/components/avatar.scss @@ -0,0 +1,202 @@ +$avatar-sizes: ( + 16: ( + font-size: 10px, + line-height: 16px, + border-radius: $border-radius-small + ), + 18: ( + border-radius: $border-radius-small + ), + 20: ( + border-radius: $border-radius-small + ), + 24: ( + font-size: 12px, + line-height: 24px, + border-radius: $border-radius-default + ), + 26: ( + font-size: 20px, + line-height: 1.33, + border-radius: $border-radius-default + ), + 32: ( + font-size: 14px, + line-height: 32px, + border-radius: $border-radius-default + ), + 40: ( + font-size: 16px, + line-height: 38px, + border-radius: $border-radius-default + ), + 48: ( + font-size: 20px, + line-height: 48px, + border-radius: $border-radius-large + ), + 60: ( + font-size: 32px, + line-height: 58px, + border-radius: $border-radius-large + ), + 64: ( + font-size: 28px, + line-height: 64px, + border-radius: $border-radius-large + ), + 90: ( + font-size: 36px, + line-height: 88px, + border-radius: $border-radius-large + ), + 100: ( + font-size: 36px, + line-height: 98px, + border-radius: $border-radius-large + ), + 160: ( + font-size: 96px, + line-height: 158px, + border-radius: $border-radius-large + ) +); + +$identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $identicon-blue, $identicon-teal, + $identicon-orange, $gray-darker; + +.avatar-circle { + float: left; + margin-right: $gl-padding; + border-radius: $avatar-radius; + border: 1px solid $gray-normal; + + @each $size, $size-config in $avatar-sizes { + &.s#{$size} { + @include avatar-size(#{$size}px, if($size < 48, 8px, 16px)); + } + } +} + +.avatar { + @extend .avatar-circle; + transition-property: none; + + width: 40px; + height: 40px; + padding: 0; + background: $gray-lightest; + overflow: hidden; + border-color: rgba($black, $gl-avatar-border-opacity); + + &.avatar-inline { + float: none; + display: inline-block; + margin-left: 2px; + flex-shrink: 0; + + &.s16 { + margin-right: 4px; + } + + &.s24 { + margin-right: 4px; + } + } + + &.center { + font-size: 14px; + line-height: 1.8em; + text-align: center; + } + + &.avatar-tile { + border-radius: 0; + border: 0; + } + + &.avatar-placeholder { + border: 0; + } +} + +.identicon { + text-align: center; + vertical-align: top; + color: $gray-800; + background-color: $gray-darker; + + // Sizes + @each $size, $size-config in $avatar-sizes { + $keys: map-keys($size-config); + + &.s#{$size} { + @each $key in $keys { + // We don't want `border-radius` to be included here. + @if ($key != 'border-radius') { + #{$key}: map-get($size-config, #{$key}); + } + } + } + } + + // Background colors + @for $i from 1 through length($identicon-backgrounds) { + &.bg#{$i} { + background-color: nth($identicon-backgrounds, $i); + } + } +} + +.avatar-container { + @extend .avatar-circle; + overflow: hidden; + display: flex; + + a { + width: 100%; + height: 100%; + display: flex; + text-decoration: none; + } + + .avatar { + border-radius: 0; + border: 0; + height: auto; + width: 100%; + margin: 0; + align-self: center; + } + + &.s40 { + min-width: 40px; + min-height: 40px; + } + + &.s64 { + min-width: 64px; + min-height: 64px; + } +} + +.rect-avatar { + border-radius: $border-radius-small; + + @each $size, $size-config in $avatar-sizes { + &.s#{$size} { + border-radius: map-get($size-config, 'border-radius'); + } + } +} + +.avatar-counter { + background-color: $gray-darkest; + color: $white-light; + border: 1px solid $gray-normal; + border-radius: 1em; + font-family: $regular-font; + font-size: 9px; + line-height: 16px; + text-align: center; +} diff --git a/app/assets/stylesheets/components/dashboard_skeleton.scss b/app/assets/stylesheets/components/dashboard_skeleton.scss new file mode 100644 index 00000000000..a104d035a9a --- /dev/null +++ b/app/assets/stylesheets/components/dashboard_skeleton.scss @@ -0,0 +1,77 @@ +.dashboard-cards { + margin-right: -$gl-padding-8; + margin-left: -$gl-padding-8; +} + +.dashboard-card { + &-header { + &-warning { + background-color: $orange-100; + } + } + + &-body { + min-height: 120px; + + &-warning { + background-color: $orange-50; + } + + &-failed { + background-color: $red-50; + } + } + + &-icon { + color: $gray-500; + } + + &-footer { + border-radius: $gl-padding; + height: $gl-padding-32; + + &-arrow { + color: $gray-300; + } + + &-downstream { + margin-right: -$gl-padding-8; + } + + &-extra { + background-color: $gray-400; + font-size: 10px; + line-height: $gl-line-height; + width: $gl-padding; + } + } + + &-header, + &-footer { + &-failed { + background-color: $red-100; + } + } + + &-skeleton-info { + border-radius: $gl-padding; + height: $gl-padding; + overflow: hidden; + + &::after { + content: ' '; + display: block; + animation: blockTextShine 1s linear infinite forwards; + background-repeat: no-repeat; + background-size: cover; + background-image: linear-gradient(to right, + $gray-100 0%, + $gray-50 20%, + $gray-100 40%, + $gray-100 100%); + border-radius: $gl-padding; + height: $gl-padding; + margin-top: -$gl-padding-8; + } + } +} diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss index 2f4d30fe923..774be9ef588 100644 --- a/app/assets/stylesheets/components/popover.scss +++ b/app/assets/stylesheets/components/popover.scss @@ -5,5 +5,68 @@ padding: $gl-padding-8; font-size: $gl-font-size-small; line-height: $gl-line-height; + + .category-icon { + color: $gray-600; + } + } + + &.blue { + background-color: $blue-600; + + .popover-body { + color: $white-light; + } + + &.bs-popover-bottom { + .arrow::after { + border-bottom-color: $blue-600; + } + } + + &.bs-popover-top { + .arrow::after { + border-top-color: $blue-600; + } + } + } +} + +.mr-popover { + .text-secondary { + font-size: 12px; + line-height: 1.33; + } +} + +.onboarding-popover { + box-shadow: 0 2px 4px $dropdown-shadow-color; + + .popover-body { + font-size: $gl-font-size; + line-height: $gl-line-height; + padding: $gl-padding; + } + + .popover-header { + display: none; + } + + .accept-mr-label { + background-color: $accepting-mr-label-color; + color: $white-light; + } +} + +.onboarding-welcome-page { + .popover { + min-width: auto; + max-width: 40%; + + .popover-body { + padding-top: $gl-padding; + padding-bottom: $gl-padding; + font-size: $gl-font-size-small; + } } } diff --git a/app/assets/stylesheets/components/project_list_item.scss b/app/assets/stylesheets/components/project_list_item.scss new file mode 100644 index 00000000000..8e7c2c4398c --- /dev/null +++ b/app/assets/stylesheets/components/project_list_item.scss @@ -0,0 +1,24 @@ +.project-list-item { + &:not(:disabled):not(.disabled) { + &:focus, + &:active, + &:focus:active { + outline: none; + box-shadow: none; + } + } +} + +// When housed inside a modal, the edge of each item +// should extend to the edge of the modal. +.modal-body { + .project-list-item { + border-radius: 0; + margin-left: -$gl-padding; + margin-right: -$gl-padding; + + .project-namespace-name-container { + overflow: hidden; + } + } +} diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss index 048a5c0300c..7f9cf1266b1 100644 --- a/app/assets/stylesheets/components/related_items_list.scss +++ b/app/assets/stylesheets/components/related_items_list.scss @@ -11,18 +11,24 @@ $item-weight-max-width: 48px; } } +.sortable-link { + max-width: 85%; +} + .item-body { - display: flex; position: relative; - align-items: center; - padding: $gl-padding-8; line-height: $gl-line-height; - .item-contents { - display: flex; - align-items: center; - flex-wrap: wrap; - flex-grow: 1; + .issue-token-state-icon-open { + color: $green-500; + } + + .issue-token-state-icon-closed { + color: $blue-500; + } + + .merge-request-status.closed { + color: $red-500; } .issue-token-state-icon-open, @@ -40,235 +46,185 @@ $item-weight-max-width: 48px; } .confidential-icon { - align-self: baseline; color: $orange-600; - margin-right: $gl-padding-4; } .item-title { flex-basis: 100%; - margin-bottom: $gl-padding-8; font-size: $gl-font-size-small; &.mr-title { font-weight: $gl-font-weight-bold; } - .sortable-link { - max-width: 85%; - } - .issue-token-state-icon-open, .issue-token-state-icon-closed { display: none; } } - .item-meta { - display: flex; - flex-wrap: wrap; - flex-basis: 100%; - font-size: $gl-font-size-small; + .item-path-id .path-id-text, + .item-milestone .milestone-title, + .item-due-date, + .item-weight .board-card-info-text { color: $gl-text-color-secondary; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } +} - .item-meta-child { - order: 0; - display: flex; - flex-wrap: wrap; - flex-basis: 100%; - - .item-due-date, - .item-weight { - margin-left: $gl-padding-8; - } +.item-meta { + flex-basis: 100%; + font-size: $gl-font-size-small; + color: $gl-text-color-secondary; - .item-milestone, - .item-weight { - cursor: help; - } + .item-meta-child { + flex-basis: 100%; + } - .item-milestone { - text-decoration: none; - max-width: $item-milestone-max-width; - } + .item-milestone, + .item-weight { + cursor: help; + } - .item-due-date { - margin-right: 0; - } + .item-milestone { + text-decoration: none; + max-width: $item-milestone-max-width; - .item-weight { - margin-right: 0; - max-width: $item-weight-max-width; - } + .ic-clock { + color: $gl-text-color-tertiary; + margin-right: $gl-padding-4; } + } - .item-path-id .path-id-text, - .item-milestone .milestone-title, - .item-due-date, - .item-weight .board-card-info-text { - color: $gl-text-color-secondary; - display: inline-block; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } + .item-weight { + max-width: $item-weight-max-width; + } - .item-path-id { - margin-top: $gl-padding-4; - font-size: $gl-font-size-xs; - white-space: nowrap; + .item-assignees { + .user-avatar-link { + margin-right: -$gl-padding-4; - .path-id-text { - font-weight: $gl-font-weight-bold; - max-width: $item-path-max-width; + &:nth-of-type(1) { + z-index: 2; } - .issue-token-state-icon-open, - .issue-token-state-icon-closed { - display: block; + &:nth-of-type(2) { + z-index: 1; } - &:not(.mr-item-path) { - order: 1; + &:last-child { + margin-right: 0; } } - .item-milestone .ic-clock { - color: $gl-text-color-tertiary; - margin-right: $gl-padding-4; + .avatar { + height: $gl-padding; + width: $gl-padding; + margin-right: 0; + vertical-align: bottom; } - .item-assignees { - order: 2; - align-self: flex-end; - align-items: center; - margin-left: auto; - - .user-avatar-link { - margin-right: -$gl-padding-4; - - &:nth-of-type(1) { - z-index: 2; - } + .avatar-counter { + height: $gl-padding; + border: 1px solid transparent; + background-color: $gl-text-color-tertiary; + font-weight: $gl-font-weight-bold; + padding: 0 $gl-padding-4; + line-height: $gl-padding; + } + } +} - &:nth-of-type(2) { - z-index: 1; - } +.item-path-id { + font-size: $gl-font-size-xs; + white-space: nowrap; - &:last-child { - margin-right: 0; - } - } + .path-id-text { + font-weight: $gl-font-weight-bold; + max-width: $item-path-max-width; + } - .avatar { - height: $gl-padding; - width: $gl-padding; - margin-right: 0; - vertical-align: bottom; - } + .issue-token-state-icon-open, + .issue-token-state-icon-closed { + display: block; + } - .avatar-counter { - height: $gl-padding; - border: 1px solid transparent; - background-color: $gl-text-color-tertiary; - font-weight: $gl-font-weight-bold; - padding: 0 $gl-padding-4; - line-height: $gl-padding; - } + @include media-breakpoint-down(sm) { + &:not(.mr-item-path) { + order: 1; } } +} - .btn-item-remove { - position: absolute; - right: 0; - top: $gl-padding-4 / 2; - padding: $gl-padding-4; - margin-right: $gl-padding-4 / 2; - line-height: 0; - border-color: transparent; - color: $gl-text-color-secondary; +.btn-item-remove { + position: absolute; + right: 0; + top: $gl-padding-4 / 2; + padding: $gl-padding-4; + margin-right: $gl-padding-4 / 2; + line-height: 0; + border-color: transparent; + color: $gl-text-color-secondary; - &:hover { - color: $gl-text-color; - } + &:hover { + color: $gl-text-color; } } .mr-status-wrapper, -.mr-ci-status - { +.mr-ci-status { line-height: 0; } @include media-breakpoint-up(sm) { - .item-body { - .item-contents .item-title { - .mr-title-link, - .sortable-link { - max-width: 90%; - } - } + .sortable-link { + max-width: 90%; } } /* Small devices (landscape phones, 768px and up) */ @include media-breakpoint-up(md) { + .sortable-link { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + max-width: 100%; + } + .item-body { .item-contents { min-width: 0; + } - .item-title { - flex-basis: unset; - // 95% because we compensate - // for remove button which is - // positioned absolutely - width: 95%; - margin-bottom: $gl-padding-4; - - .mr-title-link, - .sortable-link { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - max-width: 100%; - } - } - - .item-meta { - .item-path-id { - order: 0; - margin-top: 0; - } - - .item-meta-child { - flex-basis: unset; - margin-left: auto; - margin-right: $gl-padding-4; - - ~ .item-assignees { - margin-left: $gl-padding-4; - } - } - - .item-assignees { - margin-bottom: 0; - margin-left: 0; - order: 2; - } - } + .item-title { + flex-basis: unset; + // 95% because we compensate + // for remove button which is + // positioned absolutely + width: 95%; } .btn-item-remove { order: 1; } } + + .item-meta { + .item-meta-child { + flex-basis: unset; + + ~ .item-assignees { + margin-left: $gl-padding-4; + } + } + } } /* Medium devices (desktops, 992px and up) */ @include media-breakpoint-up(lg) { .item-body { - padding: $gl-padding; - .item-title { font-size: $gl-font-size; } @@ -276,106 +232,60 @@ $item-weight-max-width: 48px; .item-meta .item-path-id { font-size: inherit; // Base size given to `item-meta` is `$gl-font-size-small` } - - .issue-token-state-icon-open, - .issue-token-state-icon-closed { - margin-right: $gl-padding-4; - } } } /* Large devices (large desktops, 1200px and up) */ @include media-breakpoint-up(xl) { .item-body { - padding: $gl-padding-8; - padding-left: $gl-padding; + .item-title { + min-width: 0; + width: auto; + flex-basis: unset; + font-weight: $gl-font-weight-normal; - .item-contents { - flex-wrap: nowrap; - overflow: hidden; - - .item-title { - display: flex; - margin-bottom: 0; - min-width: 0; - width: auto; - flex-basis: unset; - font-weight: $gl-font-weight-normal; - - .mr-title-link, - .sortable-link { - display: block; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - } - - .issue-token-state-icon-open, - .issue-token-state-icon-closed { - display: block; - margin-right: $gl-padding-8; - } - - .confidential-icon { - align-self: auto; - margin-top: 0; - } + .issue-token-state-icon-open, + .issue-token-state-icon-closed { + display: block; + margin-right: $gl-padding-8; } + } + } - .item-meta { - margin-top: 0; - justify-content: flex-end; - flex: 1; - flex-wrap: nowrap; - - .item-path-id { - order: 0; - margin-top: 0; - margin-left: $gl-padding-8; - margin-right: auto; - - .issue-token-state-icon-open, - .issue-token-state-icon-closed { - display: none; - } - } - - .item-meta-child { - margin-left: $gl-padding-8; - flex-wrap: nowrap; - } - - .item-assignees { - flex-grow: 0; - margin-top: 0; - margin-right: $gl-padding-4; - - .avatar { - height: $gl-padding-24; - width: $gl-padding-24; - } - - .avatar-counter { - height: $gl-padding-24; - min-width: $gl-padding-24; - line-height: $gl-padding-24; - border-radius: $gl-padding-24; - } - } - } + .item-contents { + overflow: hidden; + } + + .item-meta { + flex: 1; + } + + .item-assignees { + .avatar { + height: $gl-padding-24; + width: $gl-padding-24; } - .btn-item-remove { - position: relative; - align-self: center; - top: initial; - right: 0; - margin-right: 0; - padding: $btn-sm-side-margin; + .avatar-counter { + height: $gl-padding-24; + min-width: $gl-padding-24; + line-height: $gl-padding-24; + border-radius: $gl-padding-24; + } + } - &:hover { - border-color: $border-color; - } + .btn-item-remove { + position: relative; + top: initial; + right: 0; + padding: $btn-sm-side-margin; + + &:hover { + border-color: $border-color; } } + + .sortable-link { + line-height: 1.3; + } } diff --git a/app/assets/stylesheets/components/toast.scss b/app/assets/stylesheets/components/toast.scss new file mode 100644 index 00000000000..33e1c4e5349 --- /dev/null +++ b/app/assets/stylesheets/components/toast.scss @@ -0,0 +1,53 @@ +/* +* These styles are specific to the gl-toast component. +* Documentation: https://design.gitlab.com/components/toasts +* Note: Styles below are nested in order to override some of vue-toasted's default styling +*/ +.toasted-container { + + max-width: $toast-max-width; + + @include media-breakpoint-down(xs) { + width: 100%; + padding-right: $toast-padding-right; + } + + .toasted.gl-toast { + border-radius: $border-radius-default; + font-size: $gl-font-size; + padding: $gl-padding-8 $gl-padding-24; + margin-top: $toast-default-margin; + line-height: $gl-line-height; + background-color: rgba($gray-900, $toast-background-opacity); + + @include media-breakpoint-down(xs) { + .action:first-child { + // Ensures actions buttons are right aligned on mobile + margin-left: auto; + } + } + + .action { + color: $blue-300; + margin: 0 0 0 $toast-action-margin-left; + text-transform: none; + font-size: $gl-font-size; + + &:first-child { + padding-right: 0; + } + } + + .toast-close { + font-size: $default-icon-size; + margin-left: $toast-default-margin; + padding-left: $gl-padding; + } + } +} + +// Overrides the default positioning of toasts +body .toasted-container.bottom-left { + bottom: $toast-offset; + left: $toast-offset; +} diff --git a/app/assets/stylesheets/errors.scss b/app/assets/stylesheets/errors.scss index 658e0ff638e..8c32b6c8985 100644 --- a/app/assets/stylesheets/errors.scss +++ b/app/assets/stylesheets/errors.scss @@ -17,7 +17,7 @@ body { text-align: center; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; margin: auto; - font-size: 14px; + font-size: .875rem; } h1 { diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 555ea276c6c..9b0d19b0ef0 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -8,7 +8,6 @@ @import 'framework/animations'; @import 'framework/vue_transitions'; -@import 'framework/avatar'; @import 'framework/asciidoctor'; @import 'framework/banner'; @import 'framework/blocks'; @@ -60,9 +59,11 @@ @import 'framework/memory_graph'; @import 'framework/responsive_tables'; @import 'framework/stacked_progress_bar'; +@import 'framework/sortable'; @import 'framework/ci_variable_list'; @import 'framework/feature_highlight'; @import 'framework/terms'; @import 'framework/read_more'; @import 'framework/flex_grid'; @import 'framework/system_messages'; +@import "framework/spinner"; diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 70d50c74ca9..6f5a2e561af 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -27,7 +27,7 @@ &.flipOutY, &.bounceIn, &.bounceOut { - @include webkit-prefix(animation-duration, .75s); + @include webkit-prefix(animation-duration, 0.75s); } &.short { @@ -73,22 +73,10 @@ @mixin disable-all-animation { /*CSS transitions*/ - -o-transition-property: none !important; - -moz-transition-property: none !important; - -ms-transition-property: none !important; - -webkit-transition-property: none !important; transition-property: none !important; /*CSS transforms*/ - -o-transform: none !important; - -moz-transform: none !important; - -ms-transform: none !important; - -webkit-transform: none !important; transform: none !important; /*CSS animations*/ - -webkit-animation: none !important; - -moz-animation: none !important; - -o-animation: none !important; - -ms-animation: none !important; animation: none !important; } @@ -202,7 +190,7 @@ a { } } - [class^="skeleton-line-"] { + [class^='skeleton-line-'] { position: relative; background-color: $gray-100; height: 10px; @@ -218,13 +206,11 @@ a { animation: blockTextShine 1s linear infinite forwards; background-repeat: no-repeat; background-size: cover; - background-image: linear-gradient( - to right, - $gray-100 0%, - $gray-50 20%, - $gray-100 40%, - $gray-100 100% - ); + background-image: linear-gradient(to right, + $gray-100 0%, + $gray-50 20%, + $gray-100 40%, + $gray-100 100%); height: 10px; } } @@ -282,3 +268,27 @@ $skeleton-line-widths: ( @include webkit-prefix(animation-duration, 1s); transform-origin: 50% 50%; } + +/* ---------------------------------------------- + * Generated by Animista on 2019-4-26 17:40:41 + * w: http://animista.net, t: @cssanimista + * ---------------------------------------------- */ +@keyframes slide-in-fwd-bottom { + 0% { + transform: translateZ(-1400px) translateY(800px); + opacity: 0; + } + + 100% { + transform: translateZ(0) translateY(0); + opacity: 1; + } +} + +.slide-in-fwd-bottom-enter-active { + animation: slide-in-fwd-bottom 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; +} + +.slide-in-fwd-bottom-leave-active { + animation: slide-in-fwd-bottom 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) both reverse; +} diff --git a/app/assets/stylesheets/framework/asciidoctor.scss b/app/assets/stylesheets/framework/asciidoctor.scss index 62493c32833..1586265d40e 100644 --- a/app/assets/stylesheets/framework/asciidoctor.scss +++ b/app/assets/stylesheets/framework/asciidoctor.scss @@ -1,7 +1,7 @@ .admonitionblock td.icon { width: 1%; - [class^="fa icon-"] { + [class^='fa icon-'] { @extend .fa-2x; } diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss deleted file mode 100644 index bfd3d776bd4..00000000000 --- a/app/assets/stylesheets/framework/avatar.scss +++ /dev/null @@ -1,160 +0,0 @@ -@mixin avatar-size($size, $margin-right) { - width: $size; - height: $size; - margin-right: $margin-right; -} - -.avatar-circle { - float: left; - margin-right: 15px; - border-radius: $avatar-radius; - border: 1px solid $gray-normal; - &.s16 { @include avatar-size(16px, 6px); } - &.s18 { @include avatar-size(18px, 6px); } - &.s19 { @include avatar-size(19px, 6px); } - &.s20 { @include avatar-size(20px, 7px); } - &.s24 { @include avatar-size(24px, 8px); } - &.s26 { @include avatar-size(26px, 8px); } - &.s32 { @include avatar-size(32px, 10px); } - &.s36 { @include avatar-size(36px, 10px); } - &.s40 { @include avatar-size(40px, 10px); } - &.s46 { @include avatar-size(46px, 15px); } - &.s48 { @include avatar-size(48px, 10px); } - &.s60 { @include avatar-size(60px, 12px); } - &.s64 { @include avatar-size(64px, 14px); } - &.s70 { @include avatar-size(70px, 14px); } - &.s90 { @include avatar-size(90px, 15px); } - &.s100 { @include avatar-size(100px, 15px); } - &.s110 { @include avatar-size(110px, 15px); } - &.s140 { @include avatar-size(140px, 15px); } - &.s160 { @include avatar-size(160px, 20px); } -} - -.avatar { - @extend .avatar-circle; - transition-property: none; - - width: 40px; - height: 40px; - padding: 0; - background: $gray-lightest; - overflow: hidden; - - &.avatar-inline { - float: none; - display: inline-block; - margin-left: 2px; - flex-shrink: 0; - -webkit-flex-shrink: 0; - - &.s16 { margin-right: 4px; } - &.s24 { margin-right: 4px; } - } - - &.center { - font-size: 14px; - line-height: 1.8em; - text-align: center; - } - - &.avatar-tile { - border-radius: 0; - border: 0; - } - - &:not([href]):hover { - border-color: darken($gray-normal, 10%); - } -} - -.identicon { - text-align: center; - vertical-align: top; - color: $gl-gray-700; - background-color: $gray-darker; - - // Sizes - &.s16 { font-size: 12px; line-height: 1.33; } - &.s24 { font-size: 13px; line-height: 1.8; } - &.s26 { font-size: 20px; line-height: 1.33; } - &.s32 { font-size: 20px; line-height: 30px; } - &.s40 { font-size: 16px; line-height: 38px; } - &.s48 { font-size: 20px; line-height: 46px; } - &.s60 { font-size: 32px; line-height: 58px; } - &.s64 { font-size: 32px; line-height: 64px; } - &.s70 { font-size: 34px; line-height: 70px; } - &.s90 { font-size: 36px; line-height: 88px; } - &.s100 { font-size: 36px; line-height: 98px; } - &.s110 { font-size: 40px; line-height: 108px; font-weight: $gl-font-weight-normal; } - &.s140 { font-size: 72px; line-height: 138px; } - &.s160 { font-size: 96px; line-height: 158px; } - - // Background colors - &.bg1 { background-color: $identicon-red; } - &.bg2 { background-color: $identicon-purple; } - &.bg3 { background-color: $identicon-indigo; } - &.bg4 { background-color: $identicon-blue; } - &.bg5 { background-color: $identicon-teal; } - &.bg6 { background-color: $identicon-orange; } - &.bg7 { background-color: $gray-darker; } -} - -.avatar-container { - @extend .avatar-circle; - overflow: hidden; - display: flex; - - a { - width: 100%; - height: 100%; - display: flex; - text-decoration: none; - } - - .avatar { - border-radius: 0; - border: 0; - height: auto; - width: 100%; - margin: 0; - align-self: center; - } - - &.s40 { min-width: 40px; min-height: 40px; } - &.s64 { min-width: 64px; min-height: 64px; } -} - -.rect-avatar { - border-radius: $border-radius-small; - &.s16 { border-radius: $border-radius-small; } - &.s18 { border-radius: $border-radius-small; } - &.s19 { border-radius: $border-radius-small; } - &.s20 { border-radius: $border-radius-small; } - &.s24 { border-radius: $border-radius-default; } - &.s26 { border-radius: $border-radius-default; } - &.s32 { border-radius: $border-radius-default; } - &.s36 { border-radius: $border-radius-default; } - &.s40 { border-radius: $border-radius-default; } - &.s46 { border-radius: $border-radius-default; } - &.s48 { border-radius: $border-radius-large; } - &.s60 { border-radius: $border-radius-large; } - &.s64 { border-radius: $border-radius-large; } - &.s70 { border-radius: $border-radius-large; } - &.s90 { border-radius: $border-radius-large; } - &.s96 { border-radius: $border-radius-large; } - &.s100 { border-radius: $border-radius-large; } - &.s110 { border-radius: $border-radius-large; } - &.s140 { border-radius: $border-radius-large; } - &.s160 { border-radius: $border-radius-large; } -} - -.avatar-counter { - background-color: $gray-darkest; - color: $white-light; - border: 1px solid $gray-normal; - border-radius: 1em; - font-family: $regular-font; - font-size: 9px; - line-height: 16px; - text-align: center; -} diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index 5cfd5bbd4f5..7760c48cb92 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -23,9 +23,9 @@ box-shadow: 0 6px 12px $award-emoji-menu-shadow; pointer-events: none; opacity: 0; - transform: scale(.2); + transform: scale(0.2); transform-origin: 0 -45px; - transition: .3s cubic-bezier(.67, .06, .19, 1.44); + transition: 0.3s cubic-bezier(0.67, 0.06, 0.19, 1.44); transition-property: transform, opacity; &.is-rendered { @@ -62,7 +62,7 @@ } .emoji-search { - background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAFu0lEQVRIia1WTahkVxH+quqce7vf6zdvJpHoIlkYJ2SiJiIokmQjgoGgIAaEIYuYXWICgojiwkmC4taFwhjcyIDusogEIwwiSSCKPwsdwzAg0SjJ9Izzk5n3+nXfe8+pqizOvd395scfsJqi6dPnnDr11Vc/NJ1OwUTosqJLCmYCHCAC2mSHs+ojZv6AO46Y+20AhIneJsafhPhXVZSXDk7qi+aOLhtQNuBmQtcarAKjTXpn2+l3u2yPunvZSABRucjcAV/eMZuM48/Go/g1d19kc4wq+e8MZjWkbI/P5t2P3RFFbv7SQdyBlBUx8N8OTuqjMcof+N94yMPrY2DMm/ytnb32J0QrY+6AqsHM4Q64O9SKDmerKDD3Oy/tNL9vk342CC8RuU6n0ymCMHb22scu7zQngtASOjUHE1BX4UUAv4b7Ow6qiXCXuz/UdvogAAweDY943/b4cAz0ZlYHXeMsnT07RVb7wMUr8ykI4H5HVkMd5Rcb4/jNURVOL5qErAaAUUdCCIJ5kx5q2nw8m39ImEAAsjpE6PStB0YfMcd1wqqG3Xn7A3PfZyyKnNjaqD4fmE/fCNKshirIyY1xvI+Av6g5QIAIIWX7cJPssboSiBBEeKmsZne0Sb8kzAUWNYyq8NvbDo0fZ6beqxuLmqOOMr/lwOh+YXpXtbjERGja9JyZ9+HxpXKb9Gj5oywRESbj+Cj1ENG1QViTGBl1FbC1We1tbVRfHWIoQkhqH9xbpE92XUbb6VJZ1R4crjRz1JWcDMJvLdoMcyAEhjuwHo8Bfndg3mbszhOY+adVlMtD3po51OwzIQiEaams7oeJhxRw1FFOVpFRRUYIhMBAFRnjOsC8IFHHUA4TQQhgAqpAiIFfGbxkIqj54ayGbL7UoOqHCniAEKHLNr26l+D9wQJzeUwMAnfHvEnLECzZRwRV++d60ptjW9VLZeolEJG6GwCCE0CFVNB+Ay0NEqoQYG4YYFu7B8IEVRt3uRzy/osIoLV9QZimWXGHUMFdmI6M64DUF2Je88R9VZqCSP+QlcF5k+4tCzSsXaqjINuK6UyE0+s/mk6/qFq8oAIL9pqMLhkGsNrOyoOIlszust3aJv0U9+kFdwjTGwWl1YdF+KWlQSZ0Se/psj8yGVdg5tJyfH96EBWmLtoEMwMzMFt031NzGWLLzKhC+KV7H5ZeeaMOPxemma2x68puc0LN3+/u6LJiePS6MKHvn4wu6cPzJj0hsioeMfDrEvjv5r6W9gBvjKJujuKzQ0URIZj75NylvT+mbHfXQa4rwAMaVRTMm/SFyzvNy0yF6+4AM+1ubcSnqkAIUjQKl1RKSbE5jt+vovx1MBqF0WW7/d1Z80ab9BtmuJ3Xk5cJKds9TZt/uLPXvtiTrQ+dIwqfAejUvM1os6FNikXKUHfQ+ekUsXT5u85enJ0CaBSkkGEo1syUQ+DfMdE/4GA1uzupf9zdbzhOmLsF4efHVXjaHHAzmDtGdQRd/Nc5wAEJjNki3XfhyvwVNz80xANrht3LsENY9cBBdN1L9GUyyvFRFZ42t75sBvCQRykbRlU4tT2pPxoCvzx09d4GmPs200M6wKdWSDGK8mppYSWdhAlt0qeaLv+IadXU9/Evq4FAZ8ej+LmtcTxaRX4NWI0Uag5Vg1p5MYg8BnlhXIdPHDow+vTWZvVMVttXDLqkTzZdPj6Qii6cP1cSvIdl3iQkNYyi9HH0I22y+93tY3DcQkTZgQtM+POoCr8x97eylkmtrgKuztrvXJ21x/aNKuqIkZ/fntRfCdcTfhUTAIhRzoDojJD0aSNLLwMzmpT7+JaLtyf1MwDo6qz9djFaUq3t9MlFmy/c1OCSceY9fMsVaL9mvH9ocXdkdWxv1scAePG0THAhMOaLdOw/Gvxfxb1w4eCapyIENUcV5M3/u8FitAxZ25P6GAHT3UX39Srw+QOb1ZffA98Dl2Wy1BYkAAAAAElFTkSuQmCC"); + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAFu0lEQVRIia1WTahkVxH+quqce7vf6zdvJpHoIlkYJ2SiJiIokmQjgoGgIAaEIYuYXWICgojiwkmC4taFwhjcyIDusogEIwwiSSCKPwsdwzAg0SjJ9Izzk5n3+nXfe8+pqizOvd395scfsJqi6dPnnDr11Vc/NJ1OwUTosqJLCmYCHCAC2mSHs+ojZv6AO46Y+20AhIneJsafhPhXVZSXDk7qi+aOLhtQNuBmQtcarAKjTXpn2+l3u2yPunvZSABRucjcAV/eMZuM48/Go/g1d19kc4wq+e8MZjWkbI/P5t2P3RFFbv7SQdyBlBUx8N8OTuqjMcof+N94yMPrY2DMm/ytnb32J0QrY+6AqsHM4Q64O9SKDmerKDD3Oy/tNL9vk342CC8RuU6n0ymCMHb22scu7zQngtASOjUHE1BX4UUAv4b7Ow6qiXCXuz/UdvogAAweDY943/b4cAz0ZlYHXeMsnT07RVb7wMUr8ykI4H5HVkMd5Rcb4/jNURVOL5qErAaAUUdCCIJ5kx5q2nw8m39ImEAAsjpE6PStB0YfMcd1wqqG3Xn7A3PfZyyKnNjaqD4fmE/fCNKshirIyY1xvI+Av6g5QIAIIWX7cJPssboSiBBEeKmsZne0Sb8kzAUWNYyq8NvbDo0fZ6beqxuLmqOOMr/lwOh+YXpXtbjERGja9JyZ9+HxpXKb9Gj5oywRESbj+Cj1ENG1QViTGBl1FbC1We1tbVRfHWIoQkhqH9xbpE92XUbb6VJZ1R4crjRz1JWcDMJvLdoMcyAEhjuwHo8Bfndg3mbszhOY+adVlMtD3po51OwzIQiEaams7oeJhxRw1FFOVpFRRUYIhMBAFRnjOsC8IFHHUA4TQQhgAqpAiIFfGbxkIqj54ayGbL7UoOqHCniAEKHLNr26l+D9wQJzeUwMAnfHvEnLECzZRwRV++d60ptjW9VLZeolEJG6GwCCE0CFVNB+Ay0NEqoQYG4YYFu7B8IEVRt3uRzy/osIoLV9QZimWXGHUMFdmI6M64DUF2Je88R9VZqCSP+QlcF5k+4tCzSsXaqjINuK6UyE0+s/mk6/qFq8oAIL9pqMLhkGsNrOyoOIlszust3aJv0U9+kFdwjTGwWl1YdF+KWlQSZ0Se/psj8yGVdg5tJyfH96EBWmLtoEMwMzMFt031NzGWLLzKhC+KV7H5ZeeaMOPxemma2x68puc0LN3+/u6LJiePS6MKHvn4wu6cPzJj0hsioeMfDrEvjv5r6W9gBvjKJujuKzQ0URIZj75NylvT+mbHfXQa4rwAMaVRTMm/SFyzvNy0yF6+4AM+1ubcSnqkAIUjQKl1RKSbE5jt+vovx1MBqF0WW7/d1Z80ab9BtmuJ3Xk5cJKds9TZt/uLPXvtiTrQ+dIwqfAejUvM1os6FNikXKUHfQ+ekUsXT5u85enJ0CaBSkkGEo1syUQ+DfMdE/4GA1uzupf9zdbzhOmLsF4efHVXjaHHAzmDtGdQRd/Nc5wAEJjNki3XfhyvwVNz80xANrht3LsENY9cBBdN1L9GUyyvFRFZ42t75sBvCQRykbRlU4tT2pPxoCvzx09d4GmPs200M6wKdWSDGK8mppYSWdhAlt0qeaLv+IadXU9/Evq4FAZ8ej+LmtcTxaRX4NWI0Uag5Vg1p5MYg8BnlhXIdPHDow+vTWZvVMVttXDLqkTzZdPj6Qii6cP1cSvIdl3iQkNYyi9HH0I22y+93tY3DcQkTZgQtM+POoCr8x97eylkmtrgKuztrvXJ21x/aNKuqIkZ/fntRfCdcTfhUTAIhRzoDojJD0aSNLLwMzmpT7+JaLtyf1MwDo6qz9djFaUq3t9MlFmy/c1OCSceY9fMsVaL9mvH9ocXdkdWxv1scAePG0THAhMOaLdOw/Gvxfxb1w4eCapyIENUcV5M3/u8FitAxZ25P6GAHT3UX39Srw+QOb1ZffA98Dl2Wy1BYkAAAAAElFTkSuQmCC'); background-repeat: no-repeat; background-position: right 5px center; background-size: 16px; @@ -90,7 +90,7 @@ background: none; border: 0; border-radius: $border-radius-base; - transition: transform .15s cubic-bezier(.3, 0, .2, 2); + transition: transform 0.15s cubic-bezier(0.3, 0, 0.2, 2); &:hover { background-color: transparent; @@ -151,8 +151,7 @@ outline: 0; .award-control-icon svg { - background: $award-emoji-positive-add-bg; - fill: $award-emoji-positive-add-lines; + fill: $blue-500; } .award-control-icon-neutral { @@ -233,10 +232,7 @@ height: $default-icon-size; width: $default-icon-size; border-radius: 50%; - } - - path { - fill: $border-gray-normal; + fill: $gray-700; } } diff --git a/app/assets/stylesheets/framework/blank.scss b/app/assets/stylesheets/framework/blank.scss index 91dbb2a6365..cbd390e7145 100644 --- a/app/assets/stylesheets/framework/blank.scss +++ b/app/assets/stylesheets/framework/blank.scss @@ -69,6 +69,7 @@ @include media-breakpoint-up(sm) { display: flex; + height: 100%; align-items: center; padding: 50px 30px; } @@ -99,3 +100,30 @@ } } } + +@include media-breakpoint-up(lg) { + .column-large { + flex: 2; + } + + .column-small { + flex: 1; + margin-bottom: 15px; + + .blank-state { + max-width: 400px; + flex-wrap: wrap; + margin-left: 15px; + } + + .blank-state-icon { + margin-bottom: 30px; + } + } +} + +@include media-breakpoint-down(xs) { + .blank-state-icon svg { + width: 315px; + } +} diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 43b7c26b272..65c0ee74c60 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -22,6 +22,10 @@ } } +.oneline { + line-height: 35px; +} + .row-content-block { margin-top: 0; background-color: $gray-light; @@ -77,20 +81,13 @@ color: $gl-text-color; } - .oneline { - line-height: 35px; - } - > p:last-child { margin-bottom: 0; } .block-controls { - display: -webkit-flex; display: flex; - -webkit-justify-content: flex-end; justify-content: flex-end; - -webkit-flex: 1; flex: 1; .control { @@ -111,10 +108,6 @@ padding: 11px 0; margin-bottom: 11px; - .oneline { - line-height: 35px; - } - &.no-bottom-space { border-bottom: 0; margin-bottom: 0; @@ -153,7 +146,7 @@ display: inline-block; margin-left: 5px; font-size: 18px; - color: color("gray"); + color: color('gray'); } p { @@ -163,8 +156,6 @@ } .cover-desc { - color: $gl-text-color; - &.username:last-child { padding-bottom: $gl-padding; } @@ -208,6 +199,7 @@ &.user-cover-block { padding: 24px 0 0; + border-bottom: 1px solid $border-color; .nav-links { width: 100%; @@ -228,7 +220,6 @@ } .group-info { - h1 { display: inline; font-weight: $gl-font-weight-normal; @@ -242,14 +233,6 @@ margin-top: -1px; } -.nav-block { - .controls { - float: right; - margin-top: 8px; - padding-bottom: 8px; - } -} - .content-block { padding: $gl-padding 0; border-bottom: 1px solid $white-dark; diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index cb2c8879c5f..767832e242c 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -1,12 +1,12 @@ @mixin btn-comment-icon { border-radius: 50%; background: $white-light; - padding: 1px 5px; + padding: 1px; font-size: 12px; color: $blue-500; + border: 1px solid $blue-500; width: 24px; height: 24px; - border: 1px solid $blue-500; &:hover, &.inverted { @@ -21,7 +21,7 @@ } @mixin btn-default { - border-radius: 3px; + border-radius: $border-radius-default; font-size: $gl-font-size; font-weight: $gl-font-weight-normal; padding: $gl-vert-padding $gl-btn-padding; @@ -37,7 +37,7 @@ @include btn-default; } -@mixin btn-outline($background, $text, $border, $hover-background, $hover-text, $hover-border, $active-background, $active-border) { +@mixin btn-outline($background, $text, $border, $hover-background, $hover-text, $hover-border, $active-background, $active-border, $active-text) { background-color: $background; color: $text; border-color: $border; @@ -61,13 +61,22 @@ } } + &:focus { + box-shadow: 0 0 4px 1px $blue-300; + } + &:active { background-color: $active-background; border-color: $active-border; - color: $hover-text; + box-shadow: inset 0 2px 4px 0 rgba($black, 0.2); + color: $active-text; > .icon { - color: $hover-text; + color: $active-text; + } + + &:focus { + box-shadow: inset 0 2px 4px 0 rgba($black, 0.2); } } } @@ -139,6 +148,7 @@ @include btn-white; color: $gl-text-color; + white-space: nowrap; &:focus:active { outline: 0; @@ -163,21 +173,21 @@ &.btn-inverted { &.btn-success { - @include btn-outline($white-light, $green-600, $green-500, $green-500, $white-light, $green-600, $green-600, $green-700); + @include btn-outline($white-light, $green-600, $green-500, $green-100, $green-700, $green-500, $green-200, $green-600, $green-800); } &.btn-remove, &.btn-danger { - @include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700); + @include btn-outline($white-light, $red-500, $red-500, $red-100, $red-700, $red-500, $red-200, $red-600, $red-800); } &.btn-warning { - @include btn-outline($white-light, $orange-500, $orange-500, $orange-500, $white-light, $orange-600, $orange-600, $orange-700); + @include btn-outline($white-light, $orange-500, $orange-500, $orange-100, $orange-700, $orange-500, $orange-200, $orange-600, $orange-800); } &.btn-primary, &.btn-info { - @include btn-outline($white-light, $blue-500, $blue-500, $blue-500, $white-light, $blue-600, $blue-600, $blue-700); + @include btn-outline($white-light, $blue-500, $blue-500, $blue-100, $blue-700, $blue-500, $blue-200, $blue-600, $blue-800); } } @@ -192,11 +202,11 @@ &.btn-close, &.btn-close-color { - @include btn-outline($white-light, $orange-600, $orange-500, $orange-500, $white-light, $orange-600, $orange-600, $orange-700); + @include btn-outline($white-light, $orange-600, $orange-500, $orange-100, $orange-700, $orange-500, $orange-200, $orange-600, $orange-800); } &.btn-spam { - @include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700); + @include btn-outline($white-light, $red-500, $red-500, $red-100, $red-700, $red-500, $red-200, $red-600, $red-800); } &.btn-danger, @@ -239,7 +249,7 @@ padding: 6px 16px; border-color: $border-color; color: $gray-darkest; - background-color: $gray-light; + background-color: $white-light; &:hover, &:active, @@ -248,7 +258,6 @@ box-shadow: none; border-color: lighten($blue-300, 20%); color: $gray-darkest; - background-color: $gray-light; } } @@ -329,6 +338,8 @@ svg { top: auto; + width: 16px; + height: 16px; } } @@ -395,15 +406,13 @@ cursor: default; &:active { - -moz-box-shadow: inset 0 0 0 $white-light; - -webkit-box-shadow: inset 0 0 0 $white-light; box-shadow: inset 0 0 0 $white-light; } } .btn-inverted { &-secondary { - @include btn-outline($white-light, $blue-500, $blue-500, $blue-500, $white-light, $blue-600, $blue-600, $blue-700); + @include btn-outline($white-light, $blue-500, $blue-500, $blue-100, $blue-700, $blue-500, $blue-200, $blue-600, $blue-800); } } @@ -445,7 +454,8 @@ border-color: transparent; } - &.btn-secondary-hover-link { + &.btn-secondary-hover-link, + &.btn-default-hover-link { color: $gl-text-color-secondary; &:hover, diff --git a/app/assets/stylesheets/framework/callout.scss b/app/assets/stylesheets/framework/callout.scss index 0d8e4afa76f..643b20c56bc 100644 --- a/app/assets/stylesheets/framework/callout.scss +++ b/app/assets/stylesheets/framework/callout.scss @@ -28,6 +28,10 @@ background-color: $red-100; border-color: $red-200; color: $red-700; + + a { + color: $red-700; + } } .bs-callout-warning { diff --git a/app/assets/stylesheets/framework/ci_variable_list.scss b/app/assets/stylesheets/framework/ci_variable_list.scss index 7207e5119ce..28d7492b99c 100644 --- a/app/assets/stylesheets/framework/ci_variable_list.scss +++ b/app/assets/stylesheets/framework/ci_variable_list.scss @@ -47,6 +47,7 @@ display: flex; align-items: flex-start; width: 100%; + padding-bottom: $gl-padding; @include media-breakpoint-down(xs) { display: block; @@ -66,6 +67,7 @@ } } +.ci-variable-masked-item, .ci-variable-protected-item { flex: 0 1 auto; display: flex; diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index fa424532879..db09118ba15 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -5,6 +5,9 @@ .cgreen { color: $green-600; } .cdark { color: $common-gray-dark; } +.fwhite { fill: $white-light; } +.fgray { fill: $gray-700; } + .text-plain, .text-plain:hover { color: $gl-text-color; @@ -48,6 +51,10 @@ color: $brand-info; } +.bg-gray-light { + background-color: $gray-light; +} + .text-break-word { word-break: break-all; } @@ -57,7 +64,11 @@ text-decoration: underline; } -.hint { font-style: italic; color: $gl-gray-400; } +.hint { + font-style: italic; + color: $gl-gray-400; +} + .light { color: $gl-text-color; } .slead { @@ -116,7 +127,7 @@ hr { text-overflow: ellipsis; white-space: nowrap; - > div, + > div:not(.block), .str-truncated { display: inline; } @@ -158,13 +169,14 @@ p.time { text-shadow: none; } -.thin_area { +.thin-area { height: 150px; } // Fix issue with notes & lists creating a bunch of bottom borders. li.note { img { max-width: 100%; } + .note-title { li { border-bottom: 0 !important; @@ -183,11 +195,6 @@ li.note { background-color: inherit; } -.show-suppressed-diff, -.show-all-commits { - cursor: pointer; -} - .error-message { padding: 10px; background: $red-400; @@ -200,12 +207,12 @@ li.note { } } -.warning_message { - border-left: 4px solid $orange-200; - color: $orange-700; +@mixin message($background-color, $border-color, $text-color) { + border-left: 4px solid $border-color; + color: $text-color; padding: 10px; margin-bottom: 10px; - background: $orange-100; + background: $background-color; padding-left: 20px; &.centered { @@ -213,6 +220,14 @@ li.note { } } +.warning_message { + @include message($orange-100, $orange-200, $orange-700); +} + +.danger_message { + @include message($red-100, $red-200, $red-900); +} + .gitlab-promo { a { color: $gl-gray-350; @@ -335,7 +350,7 @@ img.emoji { .disabled-content { pointer-events: none; - opacity: .5; + opacity: 0.5; } .break-word { @@ -371,18 +386,23 @@ img.emoji { .prepend-top-default { margin-top: $gl-padding !important; } .prepend-top-16 { margin-top: 16px; } .prepend-top-20 { margin-top: 20px; } +.prepend-top-32 { margin-top: 32px; } .prepend-left-4 { margin-left: 4px; } .prepend-left-5 { margin-left: 5px; } .prepend-left-8 { margin-left: 8px; } .prepend-left-10 { margin-left: 10px; } +.prepend-left-15 { margin-left: 15px; } .prepend-left-default { margin-left: $gl-padding; } .prepend-left-20 { margin-left: 20px; } +.prepend-left-32 { margin-left: 32px; } .append-right-4 { margin-right: 4px; } .append-right-5 { margin-right: 5px; } .append-right-8 { margin-right: 8px; } .append-right-10 { margin-right: 10px; } +.append-right-15 { margin-right: 15px; } .append-right-default { margin-right: $gl-padding; } .append-right-20 { margin-right: 20px; } +.prepend-right-32 { margin-right: 32px; } .append-bottom-0 { margin-bottom: 0; } .append-bottom-4 { margin-bottom: $gl-padding-4; } .append-bottom-5 { margin-bottom: 5px; } @@ -391,15 +411,20 @@ img.emoji { .append-bottom-15 { margin-bottom: 15px; } .append-bottom-20 { margin-bottom: 20px; } .append-bottom-default { margin-bottom: $gl-padding; } +.prepend-bottom-32 { margin-bottom: 32px; } .inline { display: inline-block; } .center { text-align: center; } +.block { display: block; } +.flex { display: flex; } .vertical-align-middle { vertical-align: middle; } .vertical-align-sub { vertical-align: sub; } .flex-align-self-center { align-self: center; } .flex-grow { flex-grow: 1; } .flex-no-shrink { flex-shrink: 0; } .ws-initial { white-space: initial; } +.ws-normal { white-space: normal; } .overflow-auto { overflow: auto; } + .d-flex-center { display: flex; align-items: center; @@ -413,27 +438,24 @@ img.emoji { .mw-460 { max-width: 460px; } .mw-6em { max-width: 6em; } +.mw-70p { max-width: 70%; } .min-height-0 { min-height: 0; } -.w-3 { width: #{3 * $grid-size}; } - -.h-3 { width: #{3 * $grid-size}; } +.svg-w-100 { + svg { + width: 100%; + } +} /** COMMON SPACING CLASSES **/ -.gl-pl-0 { padding-left: 0; } -.gl-pl-1 { padding-left: #{0.5 * $grid-size}; } -.gl-pl-2 { padding-left: $grid-size; } -.gl-pl-3 { padding-left: #{2 * $grid-size}; } -.gl-pl-4 { padding-left: #{3 * $grid-size}; } -.gl-pl-5 { padding-left: #{4 * $grid-size}; } - -.gl-pr-0 { padding-right: 0; } -.gl-pr-1 { padding-right: #{0.5 * $grid-size}; } -.gl-pr-2 { padding-right: $grid-size; } -.gl-pr-3 { padding-right: #{2 * $grid-size}; } -.gl-pr-4 { padding-right: #{3 * $grid-size}; } -.gl-pr-5 { padding-right: #{4 * $grid-size}; } +@each $index, $padding in $spacing-scale { + #{'.gl-p-#{$index}'} { padding: $padding; } + #{'.gl-pl-#{$index}'} { padding-left: $padding; } + #{'.gl-pr-#{$index}'} { padding-right: $padding; } + #{'.gl-pt-#{$index}'} { padding-top: $padding; } + #{'.gl-pb-#{$index}'} { padding-bottom: $padding; } +} /** * Removes browser specific clear icon from input fields in @@ -447,10 +469,10 @@ img.emoji { } /** COMMON POSITIONING CLASSES */ -.position-bottom-0 { bottom: 0; } -.position-left-0 { left: 0; } -.position-right-0 { right: 0; } -.position-top-0 { top: 0; } +.position-bottom-0 { bottom: 0 !important; } +.position-left-0 { left: 0 !important; } +.position-right-0 { right: 0 !important; } +.position-top-0 { top: 0 !important; } .drag-handle { width: 4px; @@ -463,3 +485,54 @@ img.emoji { background-color: $gray-600; } } + +.cursor-pointer { + cursor: pointer; +} + +// Make buttons/dropdowns full-width on mobile +.full-width-mobile { + @include media-breakpoint-down(xs) { + width: 100%; + + > .dropdown-menu, + > .btn { + width: 100%; + } + } +} + +.onboarding-helper-container { + bottom: 40px; + right: 40px; + font-size: $gl-font-size-small; + background: $gray-100; + width: 200px; + border-radius: 24px; + box-shadow: 0 2px 4px $issue-boards-card-shadow; + z-index: 10000; + + .collapsible { + max-height: 0; + transition: max-height 0.5s cubic-bezier(0, 1, 0, 1); + } + + &.expanded { + border-bottom-right-radius: $border-radius-default; + border-bottom-left-radius: $border-radius-default; + + .collapsible { + max-height: 1000px; + transition: max-height 1s ease-in-out; + } + } + + .avatar { + border-color: darken($gray-normal, 10%); + + img { + width: 32px; + height: 32px; + } + } +} diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 8b6a7017c47..3238b01c6c0 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -5,7 +5,7 @@ padding-left: $contextual-sidebar-collapsed-width; } - @include media-breakpoint-up(lg) { + @include media-breakpoint-up(xl) { padding-left: $contextual-sidebar-width; } @@ -15,7 +15,7 @@ } .page-with-icon-sidebar { - @include media-breakpoint-up(sm) { + @include media-breakpoint-up(md) { padding-left: $contextual-sidebar-collapsed-width; } } @@ -71,6 +71,44 @@ } } +@mixin collapse-contextual-sidebar-content { + .context-header { + height: 60px; + width: $contextual-sidebar-collapsed-width; + + a { + padding: 10px 4px; + } + } + + .sidebar-top-level-items > li { + .sidebar-sub-level-items { + &:not(.flyout-list) { + display: none; + } + } + } + + .nav-icon-container { + margin-right: 0; + } + + .toggle-sidebar-button { + padding: 16px; + width: $contextual-sidebar-collapsed-width - 1px; + + .collapse-text, + .icon-angle-double-left { + display: none; + } + + .icon-angle-double-right { + display: block; + margin: 0; + } + } +} + .nav-sidebar { transition: width $sidebar-transition-duration, left $sidebar-transition-duration; position: fixed; @@ -89,7 +127,7 @@ } } - &.sidebar-collapsed-desktop { + @mixin collapse-contextual-sidebar { width: $contextual-sidebar-collapsed-width; .nav-sidebar-inner-scroll { @@ -115,6 +153,10 @@ } } + &.sidebar-collapsed-desktop { + @include collapse-contextual-sidebar; + } + &.sidebar-expanded-mobile { left: 0; } @@ -150,7 +192,7 @@ } } - @include media-breakpoint-down(xs) { + @include media-breakpoint-down(sm) { left: (-$contextual-sidebar-width); } @@ -167,16 +209,19 @@ height: 16px; width: 16px; } + + @media (min-width: map-get($grid-breakpoints, md)) and (max-width: map-get($grid-breakpoints, xl) - 1px) { + &:not(.sidebar-expanded-mobile) { + @include collapse-contextual-sidebar; + @include collapse-contextual-sidebar-content; + } + } } .nav-sidebar-inner-scroll { height: 100%; width: 100%; overflow: auto; - - @include media-breakpoint-up(sm) { - overflow: hidden; - } } .with-performance-bar .nav-sidebar { @@ -346,53 +391,13 @@ } } -.toggle-sidebar-button { - @include media-breakpoint-down(xs) { - display: none; - } -} - .collapse-text { white-space: nowrap; overflow: hidden; } .sidebar-collapsed-desktop { - .context-header { - height: 60px; - width: $contextual-sidebar-collapsed-width; - - a { - padding: 10px 4px; - } - } - - .sidebar-top-level-items > li { - .sidebar-sub-level-items { - &:not(.flyout-list) { - display: none; - } - } - } - - .nav-icon-container { - margin-right: 0; - } - - .toggle-sidebar-button { - padding: 16px; - width: $contextual-sidebar-collapsed-width - 1px; - - .collapse-text, - .icon-angle-double-left { - display: none; - } - - .icon-angle-double-right { - display: block; - margin: 0; - } - } + @include collapse-contextual-sidebar-content; } .fly-out-top-item { @@ -428,16 +433,14 @@ color: $gl-text-color-secondary; } - @include media-breakpoint-down(xs) { + @include media-breakpoint-down(sm) { display: flex; align-items: center; i { font-size: 18px; } - } - @include media-breakpoint-down(xs) { + .breadcrumbs-links { padding-left: $gl-padding; border-left: 1px solid $gl-text-color-quaternary; @@ -445,21 +448,25 @@ } } -@include media-breakpoint-down(xs) { +@include media-breakpoint-down(sm) { .close-nav-button { display: flex; } -} -.mobile-overlay { - display: none; + .toggle-sidebar-button { + display: none; + } - &.mobile-nav-open { - display: block; - position: fixed; - background-color: $black-transparent; - height: 100%; - width: 100%; - z-index: 300; + .mobile-overlay { + display: none; + + &.mobile-nav-open { + display: block; + position: fixed; + background-color: $black-transparent; + height: 100%; + width: 100%; + z-index: 300; + } } } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index b90db135b4a..cd951f67293 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -351,6 +351,10 @@ // Expects up to 3 digits on the badge margin-right: 40px; } + + .dropdown-menu-content { + padding: $dropdown-item-padding-y $dropdown-item-padding-x; + } } .droplab-dropdown { @@ -566,10 +570,10 @@ } .dropdown-menu-close { - right: 5px; + top: $gl-padding-4; + right: $gl-padding-8; width: 20px; height: 20px; - top: -1px; } .dropdown-menu-close-icon { diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss index be85e03430e..13c5541da92 100644 --- a/app/assets/stylesheets/framework/emojis.scss +++ b/app/assets/stylesheets/framework/emojis.scss @@ -2,7 +2,7 @@ gl-emoji { font-style: normal; display: inline-flex; vertical-align: middle; - font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; font-size: 1.4em; line-height: 1em; } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 6108eaa1ad0..536a26a6ffe 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -25,10 +25,6 @@ } } - table { - @extend .table; - } - .file-title { position: relative; background-color: $gray-light; @@ -123,7 +119,7 @@ } } - &.wiki { + &.md { padding: $gl-padding; @include media-breakpoint-up(md) { @@ -245,6 +241,7 @@ */ &.code { padding: 0; + border-radius: 0 0 $border-radius-default $border-radius-default; } .list-inline.previews { @@ -332,9 +329,13 @@ span.idiff { background-color: $gray-light; border-bottom: 1px solid $border-color; border-top: 1px solid $border-color; - padding: 5px $gl-padding; + padding: $gl-padding-8 $gl-padding; margin: 0; border-radius: $border-radius-default $border-radius-default 0 0; + + &.is-stuck { + border-radius: 0; + } } .file-header-content { @@ -365,10 +366,6 @@ span.idiff { color: $gl-text-color; } - small { - margin: 0 10px 0 0; - } - .file-actions .btn { padding: 0 10px; font-size: 13px; @@ -456,6 +453,28 @@ span.idiff { } } + .note-container { + .user-avatar-link.new-comment { + position: absolute; + margin: 40px $gl-padding 0 116px; + + ~ .note-edit-form form.edit-note { + @include media-breakpoint-up(sm) { + margin-left: $note-icon-gutter-width; + } + } + } + } + + .diff-discussions:not(:last-child) .discussion .discussion-body { + padding-bottom: $gl-padding; + + .discussion-reply-holder { + border-bottom: 1px solid $gray-100; + border-radius: 0; + } + } + .md-previewer { padding: $gl-padding; } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index f48b3ddc912..26cbb7f5c13 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -50,19 +50,15 @@ } .filtered-search-wrapper { - display: -webkit-flex; display: flex; @include media-breakpoint-down(xs) { - -webkit-flex-direction: column; flex-direction: column; } .tokens-container { - display: -webkit-flex; display: flex; flex: 1; - -webkit-flex: 1; padding-left: 12px; position: relative; margin-bottom: 0; @@ -82,21 +78,18 @@ .input-token:only-child, .input-token:last-child { flex: 1; - -webkit-flex: 1; max-width: inherit; } } .filtered-search-token, .filtered-search-term { - display: -webkit-flex; display: flex; flex-shrink: 0; margin-top: 4px; margin-bottom: 4px; .selectable { - display: -webkit-flex; display: flex; } @@ -115,6 +108,8 @@ } .value-container { + display: flex; + align-items: center; background-color: $white-normal; color: $filter-value-text-color; border-radius: 0 2px 2px 0; @@ -128,7 +123,7 @@ .remove-token { display: inline-block; - padding-left: 4px; + padding-left: 8px; padding-right: 0; .fa-close { @@ -176,7 +171,6 @@ } .scroll-container { - display: -webkit-flex; display: flex; overflow-x: auto; white-space: nowrap; @@ -186,7 +180,6 @@ .filtered-search-box { position: relative; flex: 1; - display: -webkit-flex; display: flex; width: 100%; min-width: 0; @@ -194,7 +187,6 @@ background-color: $white-light; @include media-breakpoint-down(xs) { - -webkit-flex: 1 1 auto; flex: 1 1 auto; margin-bottom: 10px; } @@ -226,7 +218,7 @@ min-width: 200px; padding-right: 25px; padding-left: 0; - height: $input-height; + height: $input-height - 2; line-height: inherit; border-color: transparent; @@ -349,7 +341,6 @@ } .filter-dropdown-container { - display: -webkit-flex; display: flex; .dropdown-toggle { @@ -423,3 +414,10 @@ padding: 8px 16px; text-align: center; } + +.search-token-target-branch { + .value { + font-family: $monospace-font; + font-size: 13px; + } +} diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index afa85f0e4ae..e3dd127366d 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -6,6 +6,19 @@ position: relative; z-index: 1; + .flash-notice, + .flash-alert, + .flash-success, + .flash-warning { + border-radius: $border-radius-default; + color: $white-light; + + .container-fluid, + .container-fluid.container-limited { + background: transparent; + } + } + .flash-notice { @extend .alert; background-color: $blue-500; @@ -28,7 +41,8 @@ .flash-warning { @extend .alert; - background-color: $orange-500; + background-color: $orange-100; + color: $orange-900; margin: 0; } @@ -60,19 +74,6 @@ margin: 0; } - .flash-notice, - .flash-alert, - .flash-success, - .flash-warning { - border-radius: $border-radius-default; - color: $white-light; - - .container-fluid, - .container-fluid.container-limited { - background: transparent; - } - } - &.flash-container-page { margin-bottom: 0; diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index cbf9ee24ec5..2a601afff53 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -27,10 +27,16 @@ input[type='text'].danger { } label { + font-weight: $gl-font-weight-bold; + &.inline-label { margin: 0; } + &.form-check-label { + font-weight: $gl-font-weight-normal; + } + &.label-bold { font-weight: $gl-font-weight-bold; } @@ -41,14 +47,6 @@ label { margin: 0; } -.form-label { - @extend label; -} - -.form-control-label { - @extend .col-md-2; -} - .inline-input-group { width: 250px; } @@ -81,44 +79,14 @@ label { margin-left: 0; margin-right: 0; - .form-control-label { - font-weight: $gl-font-weight-bold; - padding-top: 4px; - } - .form-control { height: 29px; background: $white-light; font-family: $monospace-font; } - .input-group-prepend .btn, - .input-group-append .btn { - padding: 3px $gl-btn-padding; - background-color: $gray-light; - border: 1px solid $border-color; - } - - .text-block { - line-height: 0.8; - padding-top: 9px; - - code { - line-height: 1.8; - } - - img { - margin-right: $gl-padding; - } - } - @include media-breakpoint-down(xs) { padding: 0 $gl-padding; - - .form-control-label, - .text-block { - padding-left: 0; - } } } @@ -128,7 +96,7 @@ label { .form-control { @include box-shadow(none); - border-radius: 2px; + border-radius: $border-radius-default; padding: $gl-vert-padding $gl-input-padding; &.input-short { @@ -140,25 +108,14 @@ label { } } -.select-wrapper { - position: relative; - - .fa-chevron-down { - position: absolute; - font-size: 10px; - right: 10px; - top: 12px; - color: $gray-darkest; - pointer-events: none; - } -} - .select-control { padding-left: 10px; padding-right: 10px; + appearance: none; + /* stylelint-disable property-no-vendor-prefix */ -webkit-appearance: none; -moz-appearance: none; - appearance: none; + /* stylelint-enable property-no-vendor-prefix */ &::-ms-expand { display: none; @@ -173,12 +130,7 @@ label { margin-top: 35px; } -.form-group .form-control-label, -.form-group .form-control-label-full-width { - font-weight: $gl-font-weight-normal; -} - -.form-control::-webkit-input-placeholder { +.form-control::placeholder { color: $gl-text-color-tertiary; } @@ -203,10 +155,13 @@ label { .form-text.text-muted { margin-bottom: 0; margin-top: #{$grid-size / 2}; + font-size: $gl-font-size; } -.gl-field-error { +.gl-field-error, +.invalid-feedback { color: $red-500; + font-size: $gl-font-size; } .gl-show-field-errors { @@ -218,7 +173,8 @@ label { border: 1px solid $green-600; &:focus { - box-shadow: 0 0 0 1px $green-600 inset, 0 1px 1px $gl-field-focus-shadow inset, 0 0 4px 0 $green-600; + box-shadow: 0 0 0 1px $green-600 inset, 0 1px 1px $gl-field-focus-shadow inset, + 0 0 4px 0 $green-600; border: 0 none; } } @@ -227,7 +183,8 @@ label { border: 1px solid $red-500; &:focus { - box-shadow: 0 0 0 1px $red-500 inset, 0 1px 1px $gl-field-focus-shadow inset, 0 0 4px 0 $gl-field-focus-shadow-error; + box-shadow: 0 0 0 1px $red-500 inset, 0 1px 1px $gl-field-focus-shadow inset, + 0 0 4px 0 $gl-field-focus-shadow-error; border: 0 none; } } @@ -253,16 +210,26 @@ label { } } -.input-icon-wrapper { +.input-icon-wrapper, +.select-wrapper { position: relative; +} - .input-icon-right { - position: absolute; - right: 0.8em; - top: 50%; - transform: translateY(-50%); - color: $gray-600; - } +.select-wrapper > .fa-chevron-down { + position: absolute; + font-size: 10px; + right: 10px; + top: 12px; + color: $gray-darkest; + pointer-events: none; +} + +.input-icon-wrapper > .input-icon-right { + position: absolute; + right: 0.8em; + top: 50%; + transform: translateY(-50%); + color: $gray-600; } .input-md { @@ -274,3 +241,21 @@ label { max-width: $input-lg-width; width: 100%; } + +.input-group-text { + max-height: $input-height; +} + +.gl-form-checkbox { + align-items: baseline; + + &.form-check-inline .form-check-input { + align-self: flex-start; + margin-right: $gl-padding-8; + height: 1.5 * $gl-font-size; + } + + .help-text { + margin-bottom: 0; + } +} diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss index 50d4298d418..6943bfbc3d0 100644 --- a/app/assets/stylesheets/framework/gfm.scss +++ b/app/assets/stylesheets/framework/gfm.scss @@ -32,7 +32,7 @@ height: $chip-size; background: $white-light; background-image: linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%), - linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%); + linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%); background-size: $bg-size $bg-size; background-position: 0 0, $bg-pos $bg-pos; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 23dcc1817b1..1bc597bd4ae 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -39,7 +39,6 @@ .header-content { width: 100%; - display: -webkit-flex; display: flex; justify-content: space-between; position: relative; @@ -47,11 +46,8 @@ padding-left: 0; .title-container { - display: -webkit-flex; display: flex; - -webkit-align-items: stretch; align-items: stretch; - -webkit-flex: 1 1 auto; flex: 1 1 auto; padding-top: 0; overflow: visible; @@ -60,7 +56,6 @@ .title { padding-right: 0; color: currentColor; - display: -webkit-flex; display: flex; position: relative; margin: 0; @@ -85,7 +80,6 @@ } a { - display: -webkit-flex; display: flex; align-items: center; padding: 2px 8px; @@ -173,7 +167,6 @@ .navbar-nav { @include media-breakpoint-down(xs) { - display: -webkit-flex; display: flex; padding-right: 10px; flex-direction: row; @@ -258,7 +251,6 @@ > li { > a, > button { - display: -webkit-flex; display: flex; align-items: center; justify-content: center; @@ -294,7 +286,6 @@ } .navbar-sub-nav { - display: -webkit-flex; display: flex; margin: 0 0 0 6px; @@ -313,7 +304,9 @@ } } -.caret-down { +.caret-down, +.btn .caret-down { + top: 0; height: 11px; width: 11px; margin-left: 4px; @@ -326,14 +319,12 @@ } .breadcrumbs { - display: -webkit-flex; display: flex; min-height: $breadcrumb-min-height; color: $gl-text-color; } .breadcrumbs-container { - display: -webkit-flex; display: flex; width: 100%; position: relative; @@ -344,7 +335,6 @@ } .breadcrumbs-links { - -webkit-flex: 1; flex: 1; min-width: 0; align-self: center; @@ -379,7 +369,6 @@ } .breadcrumbs-list { - display: -webkit-flex; display: flex; margin-bottom: 0; line-height: 16px; @@ -430,7 +419,6 @@ } .breadcrumbs-extra { - display: -webkit-flex; display: flex; flex: 0 0 auto; margin-left: auto; @@ -459,29 +447,44 @@ } } +.title-container, .navbar-nav { - li { - .badge.badge-pill { - position: inherit; - font-weight: $gl-font-weight-normal; - margin-left: -6px; - font-size: 11px; - color: $white-light; - padding: 0 5px; - line-height: 12px; - border-radius: 7px; - box-shadow: 0 1px 0 rgba($gl-header-color, 0.2); - - &.issues-count { - background-color: $green-500; - } + .badge.badge-pill { + position: inherit; + font-weight: $gl-font-weight-normal; + margin-left: -6px; + font-size: 11px; + color: $white-light; + padding: 0 5px; + line-height: 12px; + border-radius: 7px; + box-shadow: 0 1px 0 rgba($gl-header-color, 0.2); + + &.green-badge { + background-color: $green-500; + } - &.merge-requests-count { - background-color: $orange-600; - } + &.merge-requests-count { + background-color: $orange-600; + } + + &.todos-count { + background-color: $blue-500; + } + } + + .canary-badge { + .badge { + font-size: $gl-font-size-small; + line-height: $gl-line-height; + padding: 0 $grid-size; + } - &.todos-count { - background-color: $blue-500; + &:hover { + text-decoration: none; + + .badge { + text-decoration: none; } } } @@ -594,10 +597,15 @@ .emoji-menu-toggle-button { @include emoji-menu-toggle-button; + padding: $gl-vert-padding $gl-btn-padding; } .input-group { - height: 34px; + &, + .input-group-prepend, + .input-group-append { + height: $input-height; + } } } diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 946f575ac13..741f92110c3 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -8,7 +8,7 @@ pre { padding: 10px 0; border: 0; - border-radius: 0; + border-radius: 0 0 $border-radius-default $border-radius-default; font-family: $monospace-font; font-size: $code-font-size; line-height: 19px; @@ -42,6 +42,7 @@ padding: 10px; text-align: right; float: left; + border-bottom-left-radius: $border-radius-default; a { font-family: $monospace-font; diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 49b9b7014ae..1be5ef276fd 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -20,8 +20,8 @@ } .ci-status-icon-pending, -.ci-status-icon-failed_with_warnings, -.ci-status-icon-success_with_warnings { +.ci-status-icon-failed-with-warnings, +.ci-status-icon-success-with-warnings { svg { fill: $orange-500; } @@ -31,6 +31,7 @@ } } +.ci-status-icon-preparing, .ci-status-icon-running { svg { fill: $blue-400; diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index d9d4a210f5f..555a3fe0dc7 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -15,7 +15,7 @@ word-wrap: break-word; &::after { - content: " "; + content: ' '; display: table; clear: both; } @@ -156,6 +156,12 @@ ul.content-list { margin-top: 3px; margin-bottom: 4px; + &.btn-ldap-override { + @include media-breakpoint-up(sm) { + margin-bottom: 0; + } + } + &.has-tooltip, &:last-child { margin-right: 0; @@ -167,15 +173,7 @@ ul.content-list { } .no-comments { - opacity: .5; - } - } - - .member-controls { - float: none; - - @include media-breakpoint-up(sm) { - float: right; + opacity: 0.5; } } @@ -196,8 +194,6 @@ ul.content-list { // Content list using flexbox .flex-list { .flex-row { - display: -webkit-flex; - display: -ms-flexbox; display: flex; align-items: center; white-space: nowrap; diff --git a/app/assets/stylesheets/framework/logo.scss b/app/assets/stylesheets/framework/logo.scss index 429cfbe7235..c5feefb8c54 100644 --- a/app/assets/stylesheets/framework/logo.scss +++ b/app/assets/stylesheets/framework/logo.scss @@ -9,7 +9,6 @@ } .tanuki-logo { - .tanuki-left-ear, .tanuki-right-ear, .tanuki-nose { @@ -34,7 +33,9 @@ .tanuki-left-cheek { @include include-keyframes(animate-tanuki-left-cheek) { - 0%, 10%, 100% { + 0%, + 10%, + 100% { fill: lighten($tanuki-yellow, 25%); } @@ -46,11 +47,13 @@ .tanuki-left-eye { @include include-keyframes(animate-tanuki-left-eye) { - 10%, 80% { + 10%, + 80% { fill: $tanuki-orange; } - 20%, 90% { + 20%, + 90% { fill: lighten($tanuki-orange, 25%); } } @@ -58,11 +61,13 @@ .tanuki-left-ear { @include include-keyframes(animate-tanuki-left-ear) { - 10%, 80% { + 10%, + 80% { fill: $tanuki-red; } - 20%, 90% { + 20%, + 90% { fill: lighten($tanuki-red, 25%); } } @@ -70,11 +75,13 @@ .tanuki-nose { @include include-keyframes(animate-tanuki-nose) { - 20%, 70% { + 20%, + 70% { fill: $tanuki-red; } - 30%, 80% { + 30%, + 80% { fill: lighten($tanuki-red, 25%); } } @@ -82,11 +89,13 @@ .tanuki-right-eye { @include include-keyframes(animate-tanuki-right-eye) { - 30%, 60% { + 30%, + 60% { fill: $tanuki-orange; } - 40%, 70% { + 40%, + 70% { fill: lighten($tanuki-orange, 25%); } } @@ -94,11 +103,13 @@ .tanuki-right-ear { @include include-keyframes(animate-tanuki-right-ear) { - 30%, 60% { + 30%, + 60% { fill: $tanuki-red; } - 40%, 70% { + 40%, + 70% { fill: lighten($tanuki-red, 25%); } } diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index d6c4e68f68f..bfd96a4bc05 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -50,10 +50,6 @@ transition: opacity 200ms ease-in-out; } -.md-area { - position: relative; -} - .md-header { .nav-links { a { @@ -61,6 +57,10 @@ padding-top: 0; line-height: 19px; + &.btn.btn-sm { + padding: 2px 5px; + } + &:focus { margin-top: -10px; padding-top: 10px; @@ -131,30 +131,6 @@ width: 100%; } -.md:not(.use-csslab) { - &.md-preview-holder { - // Reset ul style types since we're nested inside a ul already - @include bulleted-list; - } - - // On diffs code should wrap nicely and not overflow - code { - white-space: pre-wrap; - word-break: keep-all; - } - - hr { - // Darken 'whitesmoke' a bit to make it more visible in note bodies - border-color: darken($gray-normal, 8%); - margin: 10px 0; - } - - - table:not(.js-syntax-highlight) { - @include markdown-table; - } -} - .toolbar-btn { float: left; padding: 0 7px; @@ -187,88 +163,6 @@ } } -.atwho-view { - overflow-y: auto; - overflow-x: hidden; - - .name, - small.aliases, - small.params { - float: left; - } - - small.aliases, - small.params { - padding: 2px 5px; - } - - small.description { - float: right; - padding: 3px 5px; - } - - .avatar-inline { - margin-bottom: 0; - } - - .has-warning { - .name, - .description { - color: $orange-700; - } - } - - .cur { - .avatar { - @include disable-all-animation; - border: 1px solid $white-light; - } - } - - ul > li { - @include clearfix; - white-space: nowrap; - } - - // TODO: fallback to global style - .atwho-view-ul { - padding: 8px 1px; - - li { - padding: 8px 16px; - border: 0; - - &.cur { - background-color: $gray-darker; - color: $gl-text-color; - - small { - color: inherit; - } - - &.has-warning { - color: $orange-700; - background-color: $orange-100; - } - } - - div.avatar { - display: inline-flex; - justify-content: center; - align-items: center; - - .center { - line-height: 14px; - } - } - - strong { - color: $gl-text-color; - } - } - } -} - .md-suggestion-diff { display: table !important; border: 1px solid $border-color !important; @@ -293,15 +187,6 @@ } @include media-breakpoint-down(xs) { - .atwho-view-ul { - width: 350px; - } - - .atwho-view ul li { - overflow: hidden; - text-overflow: ellipsis; - } - .referenced-users { margin-right: 0; } diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 3b0869e31a9..e7278554e6e 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -22,32 +22,6 @@ } /* - * Mixin for markdown tables - */ -@mixin markdown-table { - width: auto; - display: inline-block; - overflow-x: auto; - border: 0; - border-color: $gl-gray-100; - - @supports (width: fit-content) { - display: block; - width: fit-content; - } - - tr { - th { - border-bottom: solid 2px $gl-gray-100; - } - - td { - border-color: $gl-gray-100; - } - } -} - -/* * Base mixin for lists in GitLab */ @mixin basic-list { @@ -99,20 +73,6 @@ } } -@mixin bulleted-list { - > ul { - list-style-type: disc; - - ul { - list-style-type: circle; - - ul { - list-style-type: square; - } - } - } -} - @mixin webkit-prefix($property, $value) { #{'-webkit-' + $property}: $value; #{$property}: $value; @@ -120,16 +80,13 @@ /* http://phrappe.com/css/conditional-css-for-webkit-based-browsers/ */ @mixin on-webkit-only { + /* stylelint-disable-next-line media-feature-name-no-vendor-prefix */ @media screen and (-webkit-min-device-pixel-ratio: 0) { @content; } } @mixin keyframes($animation-name) { - @-webkit-keyframes #{$animation-name} { - @content; - } - @keyframes #{$animation-name} { @content; } @@ -169,12 +126,10 @@ width: 43px; height: 30px; transition-duration: 0.3s; - -webkit-transform: translateZ(0); - background: linear-gradient( - to $gradient-direction, - $gradient-color 45%, - rgba($gradient-color, 0.4) - ); + transform: translateZ(0); + background: linear-gradient(to $gradient-direction, + $gradient-color 45%, + rgba($gradient-color, 0.4)); &.scrolling { visibility: visible; @@ -263,16 +218,22 @@ } } -@mixin build-trace-top-bar($height) { +// Used in EE for Web Terminal +@mixin build-trace-bar($height) { height: $height; min-height: $height; background: $gray-light; border: 1px solid $border-color; color: $gl-text-color; - position: sticky; + padding: $grid-size; +} + +@mixin build-trace-top-bar($height) { + @include build-trace-bar($height); + position: -webkit-sticky; + position: sticky; top: $header-height; - padding: $grid-size; .with-performance-bar & { top: $header-height + $performance-bar-height; @@ -370,8 +331,8 @@ line-height: 1; padding: 0; min-width: 16px; - color: $gray-darkest; - fill: $gray-darkest; + color: $gray-600; + fill: $gray-600; .fa { position: relative; @@ -421,3 +382,17 @@ } } } + +/* +* Mixin that handles the size and right margin of avatars. +*/ +@mixin avatar-size($size, $margin-right) { + width: $size; + height: $size; + margin-right: $margin-right; +} + +@mixin code-icon-size() { + width: $gl-font-size * $code-line-height * 0.9; + height: $gl-font-size * $code-line-height * 0.9; +} diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index 3703b7568c8..f75e5b55506 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -34,10 +34,10 @@ .modal-body { background-color: $modal-body-bg; line-height: $line-height-base; - min-height: $modal-body-height; position: relative; padding: #{3 * $grid-size} #{2 * $grid-size}; text-align: left; + white-space: normal; .form-actions { margin: #{2 * $grid-size} #{-2 * $grid-size} #{-2 * $grid-size}; @@ -52,24 +52,22 @@ display: flex; flex-direction: row; - .btn + .btn { + .btn + .btn:not(.dropdown-toggle-split), + .btn + .btn-group, + .btn-group + .btn { margin-left: $grid-size; } @include media-breakpoint-down(xs) { flex-direction: column; - .btn + .btn { + .btn + .btn:not(.dropdown-toggle-split), + .btn + .btn-group, + .btn-group + .btn { margin-left: 0; margin-top: $grid-size; } } - - @include media-breakpoint-up(sm) { - .btn:nth-child(1) { - margin-left: auto; - } - } } body.modal-open { diff --git a/app/assets/stylesheets/framework/notes.scss b/app/assets/stylesheets/framework/notes.scss index d349e3fad9c..85ddf11d6fe 100644 --- a/app/assets/stylesheets/framework/notes.scss +++ b/app/assets/stylesheets/framework/notes.scss @@ -4,7 +4,7 @@ } // Diff is side by side - .notes_content.parallel & { + .notes-content.parallel & { // We hide at double what we normally hide at because // there are two columns of notes @media (#{$condition}-width: (2 * $breakpoint-width)) { diff --git a/app/assets/stylesheets/framework/page_title.scss b/app/assets/stylesheets/framework/page_title.scss index e8302953a63..c77e2be8e5a 100644 --- a/app/assets/stylesheets/framework/page_title.scss +++ b/app/assets/stylesheets/framework/page_title.scss @@ -1,8 +1,4 @@ .page-title-holder { - @extend .d-flex; - @extend .align-items-center; - - padding-top: $gl-padding-top; border-bottom: 1px solid $border-color; .page-title { diff --git a/app/assets/stylesheets/framework/panels.scss b/app/assets/stylesheets/framework/panels.scss index 3a117106cff..cd3d6f8297e 100644 --- a/app/assets/stylesheets/framework/panels.scss +++ b/app/assets/stylesheets/framework/panels.scss @@ -7,7 +7,6 @@ margin-bottom: $gl-vert-padding; } - .card-header { padding: $gl-vert-padding $gl-padding; line-height: 36px; diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index 19640ab5986..ada8f2fe1a6 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -13,8 +13,8 @@ a, button { - padding: $gl-btn-padding; - padding-bottom: 11px; + padding: $gl-padding-8; + padding-bottom: $gl-padding-8 + 1; font-size: 14px; line-height: 28px; color: $gl-text-color-secondary; @@ -58,8 +58,12 @@ } .top-area { - @include clearfix; border-bottom: 1px solid $border-color; + display: flex; + + @include media-breakpoint-down(md) { + flex-flow: column-reverse wrap; + } .nav-text { padding-top: 16px; @@ -75,9 +79,8 @@ } .nav-links { - margin-bottom: 0; border-bottom: 0; - float: left; + flex: 1; &.wide { width: 100%; @@ -98,16 +101,23 @@ &.mobile-separator { border-bottom: 1px solid $border-color; + margin-bottom: $gl-padding-8; } } } .nav-controls { display: inline-block; - float: right; text-align: right; - padding: $gl-padding-8 0; - margin-bottom: 0; + + @include media-breakpoint-down(sm) { + margin-top: $gl-padding-8; + } + + @include media-breakpoint-up(md) { + display: flex; + align-items: center; + } > .btn, > .btn-container, @@ -115,8 +125,6 @@ > input, > form { margin-right: $gl-padding-top; - display: inline-block; - vertical-align: top; &:last-child { margin-right: 0; @@ -143,7 +151,7 @@ @include media-breakpoint-up(lg) { width: 250px; } } - @include media-breakpoint-down(xs) { + @include media-breakpoint-down(sm) { padding-bottom: 0; width: 100%; @@ -153,7 +161,7 @@ .dropdown-toggle, .dropdown-menu-toggle, .form-control { - margin: 0 0 10px; + margin: 0 0 $gl-padding-8; display: block; width: 100%; } @@ -165,7 +173,7 @@ form { display: block; height: auto; - margin-bottom: 14px; + margin-bottom: $gl-padding-8; input { width: 100%; @@ -181,6 +189,33 @@ margin: 0; width: 100%; } + + &.inline { + display: flex; + flex-flow: row wrap; + justify-content: space-between; + + > .btn, + > .btn-container, + > .dropdown, + > input, + > form { + flex: 1 1 auto; + margin: 0 0 10px; + margin-left: $gl-padding-top; + width: auto; + + &:first-child { + margin-left: 0; + float: none; + } + } + + .btn-full { + flex: 1 1 100%; + margin-left: 0; + } + } } } @@ -209,20 +244,11 @@ width: 100%; } - @include media-breakpoint-down(xs) { - flex-flow: row wrap; - + @include media-breakpoint-down(md) { .nav-controls { $controls-margin: $btn-margin-5 - 2px; flex: 0 0 100%; - - &.controls-flex { - display: flex; - flex-flow: row wrap; - align-items: center; - justify-content: center; - padding: 0 0 $gl-padding-top; - } + margin-top: $gl-padding-8; .controls-item, .controls-item-full, @@ -299,8 +325,8 @@ .fade-right, .fade-left { - top: 16px; - bottom: auto; + bottom: $gl-padding; + top: auto; } &.is-smaller { @@ -340,6 +366,7 @@ display: flex; border-bottom: 1px solid $border-color; overflow: hidden; + align-items: center; .nav-links { border-bottom: 0; diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index bcd601e198a..81ccea1e01f 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -32,7 +32,7 @@ } &::after { - content: "\f078"; + content: '\f078'; position: absolute; z-index: 1; text-align: center; @@ -264,6 +264,16 @@ } } +.project-result { + .project-name { + font-weight: $gl-font-weight-bold; + } + + .project-path { + color: $gl-gray-400; + } +} + .user-result { min-height: 24px; display: flex; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index c4dbcf2ddc9..43d0e51e4c9 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -157,3 +157,55 @@ .sidebar-collapsed-icon .sidebar-collapsed-value { font-size: 12px; } + +.ancestor-tree { + .vertical-timeline { + position: relative; + list-style: none; + margin: 0; + padding: 0; + + &::before { + content: ''; + border-left: 1px solid $gray-500; + position: absolute; + top: $gl-padding; + bottom: $gl-padding; + left: map-get($spacers, 2) - 1px; + } + + &-row { + margin-top: map-get($spacers, 3); + + &:nth-child(1) { + margin-top: 0; + } + } + + &-icon { + /** + * 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; + z-index: 1; + position: relative; + top: -3px; + padding: $gl-padding-4 0; + background-color: $gray-light; + + &.opened { + color: $green-500; + } + + &.closed { + color: $blue-500; + } + } + + &-content { + line-height: initial; + margin-left: $gl-padding-8; + } + } +} diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss index 36ab38f1c9d..3ab83f4c8e6 100644 --- a/app/assets/stylesheets/framework/snippets.scss +++ b/app/assets/stylesheets/framework/snippets.scss @@ -22,6 +22,10 @@ .snippet-file-content { border-radius: 3px; + + .file-title-flex-parent .btn-clipboard { + line-height: 28px; + } } .snippet-header { diff --git a/app/assets/stylesheets/framework/sortable.scss b/app/assets/stylesheets/framework/sortable.scss new file mode 100644 index 00000000000..8c070200135 --- /dev/null +++ b/app/assets/stylesheets/framework/sortable.scss @@ -0,0 +1,92 @@ +.sortable-container { + background-color: $gray-light; + + .flex-list { + padding: 5px; + margin-bottom: 0; + } +} + +.sortable-row { + .flex-row { + display: flex; + + &.issuable-info-container { + padding-right: 0; + } + } + + .sortable-link { + color: $black; + } +} + +.gl-sortable { + .header { + user-select: none; + + &:hover { + cursor: pointer; + background-color: $gray-100; + } + + &:focus { + outline: 1px solid $blue-300; + } + } +} + +.related-issues-list-item { + .card-body, + .issuable-info-container { + padding: $gl-padding-4 $gl-padding-4 $gl-padding-4 $gl-padding; + + .block-truncated { + padding: $gl-padding-8 0; + line-height: $gl-btn-line-height; + } + + @include media-breakpoint-down(md) { + padding-left: $gl-padding; + + .block-truncated { + flex-direction: column-reverse; + padding: $gl-padding-4 0; + + .text-secondary { + margin-top: $gl-padding-4; + } + + .issue-token-title-text { + display: block; + } + } + + .issue-item-remove-button { + align-self: baseline; + } + } + + @include media-breakpoint-only(md) { + .block-truncated .issue-token-title-text { + white-space: nowrap; + } + + .issue-item-remove-button { + align-self: center; + } + } + + @include media-breakpoint-down(sm) { + padding-left: $gl-padding-8; + + .block-truncated .issue-token-title-text { + white-space: normal; + } + } + } + + &.is-dragging { + padding: 0; + } +} diff --git a/app/assets/stylesheets/framework/spinner.scss b/app/assets/stylesheets/framework/spinner.scss new file mode 100644 index 00000000000..91fe75075dc --- /dev/null +++ b/app/assets/stylesheets/framework/spinner.scss @@ -0,0 +1,51 @@ +@mixin spinner-color($color) { + border-color: rgba($color, 0.25); + border-top-color: $color; +} + +@mixin spinner-size($size, $border-width) { + width: $size; + height: $size; + border-width: $border-width; + @include webkit-prefix(transform-origin, 50% 50% calc((#{$size} / 2) + #{$border-width})); +} + +@keyframes spinner-rotate { + 0% { + transform: rotate(0); + } + + 100% { + transform: rotate(360deg); + } +} + +.spinner { + border-radius: 50%; + position: relative; + margin: 0 auto; + animation-name: spinner-rotate; + animation-duration: 0.6s; + animation-timing-function: linear; + animation-iteration-count: infinite; + border-style: solid; + display: inline-flex; + @include spinner-size(16px, 2px); + @include spinner-color($orange-600); + + &.spinner-md { + @include spinner-size(32px, 3px); + } + + &.spinner-lg { + @include spinner-size(64px, 4px); + } + + &.spinner-dark { + @include spinner-color($gray-700); + } + + &.spinner-light { + @include spinner-color($white); + } +} diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss index 3d66136938f..6205ccaa52f 100644 --- a/app/assets/stylesheets/framework/system_messages.scss +++ b/app/assets/stylesheets/framework/system_messages.scss @@ -12,8 +12,9 @@ p { @include str-truncated(100%); - margin-top: 0; + margin-top: -1px; margin-bottom: 0; + font-size: $gl-font-size-small; } } diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss index 295a5b5ee7a..ba406bac50b 100644 --- a/app/assets/stylesheets/framework/tables.scss +++ b/app/assets/stylesheets/framework/tables.scss @@ -161,4 +161,3 @@ table { border-top: 0; } } - diff --git a/app/assets/stylesheets/framework/terms.scss b/app/assets/stylesheets/framework/terms.scss index 3f4be8829d7..b07d6023127 100644 --- a/app/assets/stylesheets/framework/terms.scss +++ b/app/assets/stylesheets/framework/terms.scss @@ -13,7 +13,6 @@ .card { .card-header { - display: -webkit-flex; display: flex; align-items: center; justify-content: space-between; diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index 3d5208c3db5..42a739e88f7 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -42,8 +42,8 @@ } } - .avatar { - margin-right: 15px; + img.avatar { + margin-right: $gl-padding; } .controls { @@ -55,4 +55,5 @@ .discussion .timeline-entry { margin: 0; border-right: 0; + border-radius: $border-radius-default $border-radius-default 0 0; } diff --git a/app/assets/stylesheets/framework/toggle.scss b/app/assets/stylesheets/framework/toggle.scss index 8258da07e4d..5f8ac3b7e37 100644 --- a/app/assets/stylesheets/framework/toggle.scss +++ b/app/assets/stylesheets/framework/toggle.scss @@ -34,7 +34,7 @@ background: $gl-gray-400; border-radius: 12px; padding: 3px; - transition: all .4s ease; + transition: all 0.4s ease; &::selection, &::before::selection, @@ -52,7 +52,7 @@ left: 0; border-radius: 9px; background: $feature-toggle-color; - transition: all .2s ease; + transition: all 0.2s ease; &, .toggle-icon-svg { @@ -135,12 +135,18 @@ } @keyframes animate-enabled { - 0%, 35% { opacity: 0; } + 0%, + + 35% { opacity: 0; } + 100% { opacity: 1; } } @keyframes animate-disabled { - 0%, 35% { opacity: 0; } + 0%, + + 35% { opacity: 0; } + 100% { opacity: 1; } } } diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 1b36c1f4862..7c152efd9c7 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -1,13 +1,44 @@ -@mixin md-typography { +/** + * Apply Markdown typography + * + */ +.md:not(.use-csslab) { color: $gl-text-color; word-wrap: break-word; - [dir="auto"] { + [dir='auto'] { text-align: initial; } + *:first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + + p { + color: $gl-text-color; + margin: 0 0 16px; + + > code { + font-weight: inherit; + } + + a:not(.no-attachment-icon) img { + // Remove bottom padding because + // <p> already has $gl-padding bottom + margin-bottom: 0; + } + } + a { color: $blue-600; + + > code { + color: $blue-600; + } } img:not(.emoji) { @@ -28,18 +59,12 @@ max-width: 100%; } - p a:not(.no-attachment-icon) img { - // Remove bottom padding because - // <p> already has $gl-padding bottom - margin-bottom: 0; - } - - *:first-child { - margin-top: 0; - } - - > :last-child { - margin-bottom: 0; + &:not(.md-file) img:not(.emoji) { + border: 1px solid $white-normal; + padding: 5px; + margin: 5px 0; + // Ensure that image does not exceed viewport + max-height: calc(100vh - 100px); } // Single code lines should wrap @@ -47,6 +72,7 @@ font-family: $monospace-font; white-space: pre-wrap; word-wrap: normal; + word-break: keep-all; } kbd { @@ -131,20 +157,34 @@ } } - p { - color: $gl-text-color; - margin: 0 0 16px; + hr { + // Darken 'whitesmoke' a bit to make it more visible in note bodies + border-color: darken($gray-normal, 8%); + margin: 10px 0; } - table:not(.js-syntax-highlight) { + table:not(.code) { @extend .table; @extend .table-bordered; margin: 16px 0; color: $gl-text-color; border: 0; + width: auto; + display: block; + overflow-x: auto; - th { - background: $label-gray-bg; + tbody { + background-color: $white-light; + } + + tr { + th { + border-bottom: solid 2px $gl-gray-200; + } + + td { + border-color: $gl-gray-200; + } } } @@ -173,14 +213,6 @@ } } - p > code { - font-weight: inherit; - } - - a > code { - color: $blue-600; - } - dd { margin-left: $gl-padding; } @@ -196,6 +228,18 @@ margin: 3px 28px 3px 0 !important; } + > ul { + list-style-type: disc; + + ul { + list-style-type: circle; + + ul { + list-style-type: square; + } + } + } + li { line-height: 1.6em; margin-left: 25px; @@ -224,8 +268,8 @@ } } - a[href*="/uploads/"], - a[href*="storage.googleapis.com/google-code-attachments/"] { + a[href*='/uploads/'], + a[href*='storage.googleapis.com/google-code-attachments/'] { &::before { margin-right: 4px; @@ -233,17 +277,17 @@ font-size: inherit; text-rendering: auto; -webkit-font-smoothing: antialiased; - content: "\f0c6"; + content: '\f0c6'; } &:hover::before { text-decoration: none; } - } - a.no-attachment-icon { - &::before { - display: none; + &.no-attachment-icon { + &::before { + display: none; + } } } @@ -362,28 +406,6 @@ code { } /** - * Apply Markdown typography - * - */ -.wiki:not(.use-csslab) { - @include md-typography; -} - -.md:not(.use-csslab) { - @include md-typography; - - &:not(.wiki) { - img:not(.emoji) { - border: 1px solid $white-normal; - padding: 5px; - margin: 5px 0; - // Ensure that image does not exceed viewport - max-height: calc(100vh - 100px); - } - } -} - -/** * Textareas intended for GFM * */ @@ -423,6 +445,7 @@ h4 { /** * form text input i.e. search bar, comments, forms, etc. */ +/* stylelint-disable selector-no-vendor-prefix */ input, textarea { &::-webkit-input-placeholder { @@ -447,5 +470,10 @@ textarea { color: $gl-text-color-tertiary; } } +/* stylelint-enable */ .lh-100 { line-height: 1; } + +wbr { + display: inline-block; +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 25b272ab3a9..dc451a97e17 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -11,6 +11,14 @@ $default-transition-duration: 0.15s; $contextual-sidebar-width: 220px; $contextual-sidebar-collapsed-width: 50px; $toggle-sidebar-height: 48px; +$spacing-scale: ( + 0: 0, + 1: #{0.5 * $grid-size}, + 2: $grid-size, + 3: #{2 * $grid-size}, + 4: #{3 * $grid-size}, + 5: #{4 * $grid-size} +); /* * Color schema @@ -23,6 +31,7 @@ $darken-border-dashed-factor: 25%; $white-light: #fff; $white-normal: #f0f0f0; $white-dark: #eaeaea; +$white-transparent: rgba(255, 255, 255, 0.8); $gray-lightest: #fdfdfd; $gray-light: #fafafa; @@ -41,13 +50,13 @@ $t-gray-a-04: rgba($black, 0.04); $t-gray-a-06: rgba($black, 0.06); $t-gray-a-08: rgba($black, 0.08); -$gl-gray-100: #dddddd; -$gl-gray-200: #cccccc; -$gl-gray-350: #aaaaaa; -$gl-gray-400: #999999; -$gl-gray-500: #777777; -$gl-gray-600: #666666; -$gl-gray-700: #555555; +$gl-gray-100: #ddd; +$gl-gray-200: #ccc; +$gl-gray-350: #aaa; +$gl-gray-400: #999; +$gl-gray-500: #777; +$gl-gray-600: #666; +$gl-gray-700: #555; $green-50: #f1fdf6; $green-100: #dcf5e7; @@ -100,7 +109,7 @@ $red-950: #4b140b; $gray-50: #fafafa; $gray-100: #f2f2f2; $gray-200: #dfdfdf; -$gray-300: #cccccc; +$gray-300: #ccc; $gray-400: #bababa; $gray-500: #a7a7a7; $gray-600: #919191; @@ -109,6 +118,84 @@ $gray-800: #4f4f4f; $gray-900: #2e2e2e; $gray-950: #1f1f1f; +$greens: ( + '50': $green-50, + '100': $green-100, + '200': $green-200, + '300': $green-300, + '400': $green-400, + '500': $green-500, + '600': $green-600, + '700': $green-700, + '800': $green-800, + '900': $green-900, + '950': $green-950 +); + +$blues: ( + '50': $blue-50, + '100': $blue-100, + '200': $blue-200, + '300': $blue-300, + '400': $blue-400, + '500': $blue-500, + '600': $blue-600, + '700': $blue-700, + '800': $blue-800, + '900': $blue-900, + '950': $blue-950 +); + +$oranges: ( + '50': $orange-50, + '100': $orange-100, + '200': $orange-200, + '300': $orange-300, + '400': $orange-400, + '500': $orange-500, + '600': $orange-600, + '700': $orange-700, + '800': $orange-800, + '900': $orange-900, + '950': $orange-950 +); + +$reds: ( + '50': $red-50, + '100': $red-100, + '200': $red-200, + '300': $red-300, + '400': $red-400, + '500': $red-500, + '600': $red-600, + '700': $red-700, + '800': $red-800, + '900': $red-900, + '950': $red-950 +); + +$grays: ( + '50': $gray-50, + '100': $gray-100, + '200': $gray-200, + '300': $gray-300, + '400': $gray-400, + '500': $gray-500, + '600': $gray-600, + '700': $gray-700, + '800': $gray-800, + '900': $gray-900, + '950': $gray-950 +); + +$color-ranges: ( + 'primary': $blues, + 'secondary': $grays, + 'success': $greens, + 'warning': $oranges, + 'danger': $reds +); + // GitLab themes $indigo-50: #f7f7ff; @@ -218,6 +305,15 @@ $gl-gray-dark: #313236; $gl-gray-light: #5c5c5c; $gl-header-color: #4c4e54; +$type-scale: ( + 1: 12px, + 2: 14px, + 3: 16px, + 4: 20px, + 5: 28px, + 6: 42px +); + /* * Lists */ @@ -277,7 +373,7 @@ $general-hover-transition-duration: 100ms; $general-hover-transition-curve: linear; $highlight-changes-color: rgb(235, 255, 232); $performance-bar-height: 35px; -$system-header-height: 35px; +$system-header-height: 16px; $system-footer-height: $system-header-height; $flash-height: 52px; $context-header-height: 60px; @@ -285,9 +381,13 @@ $breadcrumb-min-height: 48px; $home-panel-title-row-height: 64px; $home-panel-avatar-mobile-size: 24px; $gl-line-height: 16px; +$gl-line-height-20: 20px; $gl-line-height-24: 24px; $gl-line-height-14: 14px; +$issue-box-upcoming-bg: #8f8f8f; +$pages-group-name-color: #4c4e54; + /* * Common component specific colors */ @@ -323,8 +423,8 @@ $line-select-yellow: #fcf8e7; $line-select-yellow-dark: #f0e2bd; $dark-diff-match-bg: rgba(255, 255, 255, 0.3); $dark-diff-match-color: rgba(255, 255, 255, 0.1); -$diff-image-info-color: gray; -$diff-view-modes-color: gray; +$diff-image-info-color: #808080; +$diff-view-modes-color: #808080; $diff-view-modes-border: #c1c1c1; $diff-jagged-border-gradient-color: darken($white-normal, 8%); @@ -333,7 +433,7 @@ $diff-jagged-border-gradient-color: darken($white-normal, 8%); */ $monospace-font: 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace; -$regular-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, +$regular-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans', Ubuntu, Cantarell, 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; @@ -343,6 +443,7 @@ $regular-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-San $dropdown-width: 300px; $dropdown-min-height: 40px; $dropdown-max-height: 312px; +$dropdown-max-height-lg: 445px; $dropdown-vertical-offset: 4px; $dropdown-empty-row-bg: rgba(#000, 0.04); $dropdown-shadow-color: rgba(#000, 0.1); @@ -398,6 +499,17 @@ $pagination-line-height: 20px; $pagination-disabled-color: #cdcdcd; /* +* Toasts +*/ +$toast-offset: 24px; +$toast-height: 48px; +$toast-max-width: 586px; +$toast-padding-right: 42px; +$toast-default-margin: 8px; +$toast-action-margin-left: 16px; +$toast-background-opacity: 0.95; + +/* * Status icons */ $status-icon-size: 22px; @@ -409,7 +521,7 @@ $award-emoji-menu-shadow: rgba(0, 0, 0, 0.175); $award-emoji-positive-add-bg: #fed159; $award-emoji-positive-add-lines: #bb9c13; $award-emoji-width: 376px; -$award-emoji-width-xs: 300px; +$award-emoji-width-xs: 90%; /* * Search Box @@ -478,6 +590,7 @@ $issue-board-list-difference-md: $issue-board-list-difference-sm + $issue-boards */ $avatar-radius: 50%; $gl-avatar-size: 40px; +$gl-avatar-border-opacity: 0.1; /* * Blame @@ -528,6 +641,7 @@ $input-lg-width: 320px; */ $document-index-color: #888; $help-shortcut-header-color: #333; +$accepting-mr-label-color: #69d100; /* * Issues @@ -570,6 +684,11 @@ $feature-toggle-text-color: #fff; $feature-toggle-color-enabled: #4a8bee; /* + * Monitor Charts + */ +$chart-tooltip-max-width: 512px; + +/* Stat Graph */ $stat-graph-common-bg: #f3f3f3; @@ -625,6 +744,18 @@ Animation Functions $dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1); /* +GitLab Plans +*/ +$gl-gold-plan: #d4af37; +$gl-silver-plan: #91a1ab; +$gl-bronze-plan: #cd7f32; + +/* +Cross-project Pipelines + */ +$linked-project-column-margin: 60px; + +/* Performance Bar */ $perf-bar-production: #222; @@ -648,6 +779,17 @@ $image-comment-cursor-left-offset: 12; $image-comment-cursor-top-offset: 12; /* +Add GitLab Slack Application +*/ +$add-to-slack-popup-max-width: 400px; +$add-to-slack-gif-max-width: 850px; +$add-to-slack-well-max-width: 750px; +$add-to-slack-logo-size: 100px; +$double-headed-arrow-width: 100px; +$double-headed-arrow-height: 25px; +$right-arrow-size: 16px; + +/* Popup */ $popup-triangle-size: 15px; @@ -682,3 +824,16 @@ $mr-version-controls-height: 56px; Compare Branches */ $compare-branches-sticky-header-height: 68px; + +/** + Bootstrap 4.2.0 introduced new icons for validating forms. + Our design system does not use those, so we are disabling them for now: + - Docs: https://getbootstrap.com/docs/4.3/components/forms/#server-side + - Issue: https://gitlab.com/gitlab-org/design.gitlab.com/issues/242 + */ +$enable-validation-icons: false; + +/* +Licenses +*/ +$license-header-cell-width: 150px; diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss index 1dfe2a69a2f..ea96381a098 100644 --- a/app/assets/stylesheets/framework/variables_overrides.scss +++ b/app/assets/stylesheets/framework/variables_overrides.scss @@ -7,9 +7,9 @@ $secondary: $gray-light; $input-disabled-bg: $gray-light; $input-border-color: $gray-200; $input-color: $gl-text-color; +$input-font-size: $gl-font-size; $font-family-sans-serif: $regular-font; $font-family-monospace: $monospace-font; -$input-line-height: 20px; $btn-line-height: 20px; $table-accent-bg: $gray-light; $card-border-color: $border-color; @@ -37,9 +37,14 @@ $h6-font-size: 14px; $spacer: $grid-size; $spacers: ( 0: 0, - 1: ($spacer * .5), + 1: ($spacer * 0.5), 2: ($spacer), 3: ($spacer * 2), 4: ($spacer * 3), - 5: ($spacer * 4) + 5: ($spacer * 4), + 6: ($spacer * 5), + 7: ($spacer * 6), + 8: ($spacer * 7), + 9: ($spacer * 8) ); +$pagination-color: $gl-text-color; diff --git a/app/assets/stylesheets/framework/vue_transitions.scss b/app/assets/stylesheets/framework/vue_transitions.scss index e07a177e153..e3bdc0b0199 100644 --- a/app/assets/stylesheets/framework/vue_transitions.scss +++ b/app/assets/stylesheets/framework/vue_transitions.scss @@ -1,9 +1,13 @@ .fade-enter-active, -.fade-leave-active { +.fade-leave-active, +.fade-in-enter-active, +.fade-out-leave-active { transition: opacity $sidebar-transition-duration $general-hover-transition-curve; } .fade-enter, +.fade-in-enter, +.fade-out-leave-to, .fade-leave-to { opacity: 0; } diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss index 161943766d4..434cbd6d21c 100644 --- a/app/assets/stylesheets/framework/wells.scss +++ b/app/assets/stylesheets/framework/wells.scss @@ -12,6 +12,10 @@ border-bottom: 1px solid $well-inner-border; } + &.borderless { + border-bottom: 0; + } + &.branch-info { .commit-sha, .commit-info { diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss index 2b0794759d5..ac3214a07d9 100644 --- a/app/assets/stylesheets/highlight/common.scss +++ b/app/assets/stylesheets/highlight/common.scss @@ -1,4 +1,4 @@ -@import "../framework/variables"; +@import '../framework/variables'; @mixin diff-background($background, $idiff, $border) { background: $background; diff --git a/app/assets/stylesheets/highlight/embedded.scss b/app/assets/stylesheets/highlight/embedded.scss index 44c8a1d39ec..74364ee4ddb 100644 --- a/app/assets/stylesheets/highlight/embedded.scss +++ b/app/assets/stylesheets/highlight/embedded.scss @@ -1,3 +1,3 @@ .code { - @import "white_base"; + @import 'white_base'; } diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index 23ec3380ce9..ee0ec94c636 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -1,6 +1,6 @@ /* https://github.com/aahan/pygments-github-style */ -@import "./common"; +@import './common'; /* * White Syntax Colors @@ -37,16 +37,16 @@ $white-kt: #458; $white-m: #099; $white-s: #d14; $white-n: #333; -$white-na: teal; +$white-na: #008080; $white-nb: #0086b3; $white-nc: #458; -$white-no: teal; -$white-ni: purple; +$white-no: #008080; +$white-ni: #800080; $white-ne: #900; $white-nf: #900; $white-nn: #555; -$white-nt: navy; -$white-nv: teal; +$white-nt: #000080; +$white-nv: #008080; $white-w: #bbb; $white-mf: #099; $white-mh: #099; @@ -64,9 +64,9 @@ $white-sr: #009926; $white-s1: #d14; $white-ss: #990073; $white-bp: #999; -$white-vc: teal; -$white-vg: teal; -$white-vi: teal; +$white-vc: #008080; +$white-vg: #008080; +$white-vi: #008080; $white-il: #099; $white-gc-color: #999; $white-gc-bg: #eaf2f5; @@ -77,7 +77,7 @@ $white-gc-bg: #eaf2f5; background-color: $gray-light; } - // Line numbers +// Line numbers .line-numbers, .diff-line-num { background-color: $gray-light; @@ -103,7 +103,6 @@ pre.code, // Diff line .line_holder { - &.match .line_content, .new-nonewline.line_content, .old-nonewline.line_content { @@ -201,25 +200,38 @@ pre .hll { background-color: $white-pre-hll-bg !important; } - // Search result highlight +// Search result highlight span.highlight_word { background-color: $white-highlight !important; } - // Links to URLs, emails, or dependencies +// Links to URLs, emails, or dependencies .line a { color: $white-nb; } .hll { background-color: $white-hll-bg; } -.c { color: $white-c; font-style: italic; } -.err { color: $white-err; background-color: $white-err-bg; } + +.c { color: $white-c; + font-style: italic; } + +.err { color: $white-err; + background-color: $white-err-bg; } .k { font-weight: $gl-font-weight-bold; } .o { font-weight: $gl-font-weight-bold; } -.cm { color: $white-cm; font-style: italic; } -.cp { color: $white-cp; font-weight: $gl-font-weight-bold; } -.c1 { color: $white-c1; font-style: italic; } -.cs { color: $white-cs; font-weight: $gl-font-weight-bold; font-style: italic; } + +.cm { color: $white-cm; + font-style: italic; } + +.cp { color: $white-cp; + font-weight: $gl-font-weight-bold; } + +.c1 { color: $white-c1; + font-style: italic; } + +.cs { color: $white-cs; + font-weight: $gl-font-weight-bold; + font-style: italic; } .gd { color: $white-gd; @@ -248,24 +260,34 @@ span.highlight_word { .go { color: $white-go; } .gp { color: $white-gp; } .gs { font-weight: $gl-font-weight-bold; } -.gu { color: $white-gu; font-weight: $gl-font-weight-bold; } + +.gu { color: $white-gu; + font-weight: $gl-font-weight-bold; } .gt { color: $white-gt; } .kc { font-weight: $gl-font-weight-bold; } .kd { font-weight: $gl-font-weight-bold; } .kn { font-weight: $gl-font-weight-bold; } .kp { font-weight: $gl-font-weight-bold; } .kr { font-weight: $gl-font-weight-bold; } -.kt { color: $white-kt; font-weight: $gl-font-weight-bold; } + +.kt { color: $white-kt; + font-weight: $gl-font-weight-bold; } .m { color: $white-m; } .s { color: $white-s; } .n { color: $white-n; } .na { color: $white-na; } .nb { color: $white-nb; } -.nc { color: $white-nc; font-weight: $gl-font-weight-bold; } + +.nc { color: $white-nc; + font-weight: $gl-font-weight-bold; } .no { color: $white-no; } .ni { color: $white-ni; } -.ne { color: $white-ne; font-weight: $gl-font-weight-bold; } -.nf { color: $white-nf; font-weight: $gl-font-weight-bold; } + +.ne { color: $white-ne; + font-weight: $gl-font-weight-bold; } + +.nf { color: $white-nf; + font-weight: $gl-font-weight-bold; } .nn { color: $white-nn; } .nt { color: $white-nt; } .nv { color: $white-nv; } @@ -291,4 +313,6 @@ span.highlight_word { .vg { color: $white-vg; } .vi { color: $white-vi; } .il { color: $white-il; } -.gc { color: $white-gc-color; background-color: $white-gc-bg; } + +.gc { color: $white-gc-color; + background-color: $white-gc-bg; } diff --git a/app/assets/stylesheets/mailers/highlighted_diff_email.scss b/app/assets/stylesheets/mailers/highlighted_diff_email.scss index 8b234a5a656..33c114838c2 100644 --- a/app/assets/stylesheets/mailers/highlighted_diff_email.scss +++ b/app/assets/stylesheets/mailers/highlighted_diff_email.scss @@ -1,4 +1,4 @@ -@import "framework/variables"; +@import 'framework/variables'; // This file is largely copied from `highlight/white.scss`, but modified to // avoid all descendant selectors (`table td`). This is because the CSS inlining @@ -40,16 +40,16 @@ $highlighted-kt: #458; $highlighted-m: #099; $highlighted-s: #d14; $highlighted-n: #333; -$highlighted-na: teal; +$highlighted-na: #008080; $highlighted-nb: #0086b3; $highlighted-nc: #458; -$highlighted-no: teal; -$highlighted-ni: purple; +$highlighted-no: #008080; +$highlighted-ni: #800080; $highlighted-ne: #900; $highlighted-nf: #900; $highlighted-nn: #555; -$highlighted-nt: navy; -$highlighted-nv: teal; +$highlighted-nt: #000080; +$highlighted-nv: #008080; $highlighted-w: #bbb; $highlighted-mf: #099; $highlighted-mh: #099; @@ -67,9 +67,9 @@ $highlighted-sr: #009926; $highlighted-s1: #d14; $highlighted-ss: #990073; $highlighted-bp: #999; -$highlighted-vc: teal; -$highlighted-vg: teal; -$highlighted-vi: teal; +$highlighted-vc: #008080; +$highlighted-vg: #008080; +$highlighted-vi: #008080; $highlighted-il: #099; $highlighted-gc: #999; $highlighted-gc-bg: #eaf2f5; @@ -151,14 +151,27 @@ span.highlight_word { } .hll { background-color: $highlighted-hll-bg; } -.c { color: $highlighted-c; font-style: italic; } -.err { color: $highlighted-err; background-color: $highlighted-err-bg; } + +.c { color: $highlighted-c; + font-style: italic; } + +.err { color: $highlighted-err; + background-color: $highlighted-err-bg; } .k { font-weight: $gl-font-weight-bold; } .o { font-weight: $gl-font-weight-bold; } -.cm { color: $highlighted-cm; font-style: italic; } -.cp { color: $highlighted-cp; font-weight: $gl-font-weight-bold; } -.c1 { color: $highlighted-c1; font-style: italic; } -.cs { color: $highlighted-cs; font-weight: $gl-font-weight-bold; font-style: italic; } + +.cm { color: $highlighted-cm; + font-style: italic; } + +.cp { color: $highlighted-cp; + font-weight: $gl-font-weight-bold; } + +.c1 { color: $highlighted-c1; + font-style: italic; } + +.cs { color: $highlighted-cs; + font-weight: $gl-font-weight-bold; + font-style: italic; } .gd { color: $highlighted-gd; @@ -187,24 +200,34 @@ span.highlight_word { .go { color: $highlighted-go; } .gp { color: $highlighted-gp; } .gs { font-weight: $gl-font-weight-bold; } -.gu { color: $highlighted-gu; font-weight: $gl-font-weight-bold; } + +.gu { color: $highlighted-gu; + font-weight: $gl-font-weight-bold; } .gt { color: $highlighted-gt; } .kc { font-weight: $gl-font-weight-bold; } .kd { font-weight: $gl-font-weight-bold; } .kn { font-weight: $gl-font-weight-bold; } .kp { font-weight: $gl-font-weight-bold; } .kr { font-weight: $gl-font-weight-bold; } -.kt { color: $highlighted-kt; font-weight: $gl-font-weight-bold; } + +.kt { color: $highlighted-kt; + font-weight: $gl-font-weight-bold; } .m { color: $highlighted-m; } .s { color: $highlighted-s; } .n { color: $highlighted-n; } .na { color: $highlighted-na; } .nb { color: $highlighted-nb; } -.nc { color: $highlighted-nc; font-weight: $gl-font-weight-bold; } + +.nc { color: $highlighted-nc; + font-weight: $gl-font-weight-bold; } .no { color: $highlighted-no; } .ni { color: $highlighted-ni; } -.ne { color: $highlighted-ne; font-weight: $gl-font-weight-bold; } -.nf { color: $highlighted-nf; font-weight: $gl-font-weight-bold; } + +.ne { color: $highlighted-ne; + font-weight: $gl-font-weight-bold; } + +.nf { color: $highlighted-nf; + font-weight: $gl-font-weight-bold; } .nn { color: $highlighted-nn; } .nt { color: $highlighted-nt; } .nv { color: $highlighted-nv; } @@ -230,4 +253,6 @@ span.highlight_word { .vg { color: $highlighted-vg; } .vi { color: $highlighted-vi; } .il { color: $highlighted-il; } -.gc { color: $highlighted-gc; background-color: $highlighted-gc-bg; } + +.gc { color: $highlighted-gc; + background-color: $highlighted-gc-bg; } diff --git a/app/assets/stylesheets/notify.scss b/app/assets/stylesheets/notify.scss index f24c80bd81c..d77b7dfad68 100644 --- a/app/assets/stylesheets/notify.scss +++ b/app/assets/stylesheets/notify.scss @@ -1,4 +1,4 @@ -@import "framework/variables"; +@import 'framework/variables'; img { max-width: 100%; diff --git a/app/assets/stylesheets/page_bundles/_ide_mixins.scss b/app/assets/stylesheets/page_bundles/_ide_mixins.scss index 896a3466cb4..9465dd5bed6 100644 --- a/app/assets/stylesheets/page_bundles/_ide_mixins.scss +++ b/app/assets/stylesheets/page_bundles/_ide_mixins.scss @@ -2,17 +2,17 @@ display: flex; flex-direction: column; height: 100%; - margin-top: -$grid-size; - margin-bottom: -$grid-size; - &.build-page .top-bar { + .top-bar { + @include build-trace-bar(35px); + top: 0; - height: auto; font-size: 12px; border-top-right-radius: $border-radius-default; - } - - .top-bar { margin-left: -$gl-padding; + + .controllers { + @include build-controllers(15px, center, false, 0, inline, 0); + } } } diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index a80158943c6..f08fa80495d 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -179,6 +179,14 @@ $ide-commit-header-height: 48px; display: none; } + .monaco-editor .selected-text { + z-index: 1; + } + + .monaco-editor .view-lines { + z-index: 2; + } + .is-readonly, .editor.original { .view-lines { @@ -711,7 +719,7 @@ $ide-commit-header-height: 48px; border: 1px solid $white-dark; } -.ide-commit-radios { +.ide-commit-options { label { font-weight: normal; diff --git a/app/assets/stylesheets/page_bundles/xterm.scss b/app/assets/stylesheets/page_bundles/xterm.scss index 7f040ac9b96..de3f2a1177d 100644 --- a/app/assets/stylesheets/page_bundles/xterm.scss +++ b/app/assets/stylesheets/page_bundles/xterm.scss @@ -6,11 +6,11 @@ $black: #000; $red: #ea1010; - $green: #009900; - $yellow: #999900; + $green: #090; + $yellow: #990; $blue: #0073e6; $magenta: #d411d4; - $cyan: #009999; + $cyan: #099; $white: #ccc; $l-black: #373b41; $l-red: #ff6161; diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index a9324ba2ed0..5e3652db48f 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -1,9 +1,4 @@ -[v-cloak] { - display: none; -} - .user-can-drag { - cursor: -webkit-grab; cursor: grab; } @@ -17,13 +12,13 @@ -ms-user-select: none; user-select: none; // !important to make sure no style can override this when dragging - cursor: -webkit-grabbing !important; cursor: grabbing !important; } } .is-ghost { opacity: 0.3; + pointer-events: none; } .dropdown-projects { @@ -36,19 +31,15 @@ width: 320px; .dropdown-content { - max-height: 162px; + max-height: 140px; } } .issue-board-dropdown-content { - margin: 0 8px 10px; - padding-bottom: 10px; - border-bottom: 1px solid $dropdown-divider-bg; - - > p { - margin: 0; - font-size: 14px; - } + margin: 0; + padding: $gl-padding-4 $gl-padding $gl-padding; + border-bottom: 0; + color: $gl-text-color-secondary; } .issue-boards-page { @@ -58,8 +49,6 @@ } .boards-app { - position: relative; - @include media-breakpoint-up(sm) { transition: width $sidebar-transition-duration; width: 100%; @@ -70,17 +59,9 @@ } } -.boards-app-loading { - width: 100%; - font-size: 34px; -} - .boards-list { height: calc(100vh - #{$issue-board-list-difference-xs}); - width: 100%; - padding: $gl-padding ($gl-padding / 2); overflow-x: scroll; - white-space: nowrap; min-height: 200px; @include media-breakpoint-only(sm) { @@ -105,13 +86,7 @@ } .board { - display: inline-block; width: calc(85vw - 15px); - height: 100%; - padding-right: ($gl-padding / 2); - padding-left: ($gl-padding / 2); - white-space: normal; - vertical-align: top; @include media-breakpoint-up(sm) { width: 400px; @@ -126,23 +101,7 @@ &.is-collapsed { width: 50px; - .board-header { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - - button { - display: none; - } - } - .board-title { - padding: 0; - border-bottom: 0; - justify-content: center; - > span { width: 100%; margin-top: -12px; @@ -158,34 +117,16 @@ left: 50%; margin-left: -10px; } - - .board-list-component, - .issue-count-badge { - display: none; - } - } - - &:not(.is-collapsed) { - .board-list-component { - display: flex; - flex-direction: column; - } } } .board-inner { - position: relative; - height: 100%; font-size: $issue-boards-font-size; background: $gray-light; border: 1px solid $border-color; - border-radius: $border-radius-default; - flex: 1; } .board-header { - position: relative; - &.has-border::before { border-top: 3px solid; border-color: inherit; @@ -210,30 +151,19 @@ } } -.board-inner-container { - border-bottom: 1px solid $border-color; - padding: $gl-padding; -} - .board-title { - margin: 0; - padding: $gl-padding-8 $gl-padding; font-size: 1em; border-bottom: 1px solid $border-color; - display: flex; - align-items: center; } .board-title-text { - margin-right: auto; + margin: $gl-vert-padding auto $gl-vert-padding 0; } .board-delete { margin-right: 10px; - padding: 0; color: $gray-darkest; background-color: transparent; - border: 0; outline: 0; &:hover { @@ -241,8 +171,8 @@ } } -.board-blank-state { - padding: $gl-padding; +.board-blank-state, +.board-promotion-state { background-color: $white-light; flex: 1; overflow-y: auto; @@ -250,35 +180,23 @@ } .board-blank-state-list { - list-style: none; - > li:not(:last-child) { margin-bottom: 8px; } .label-color { - position: relative; top: 2px; - display: inline-block; width: 16px; height: 16px; margin-right: 3px; - border-radius: $border-radius-default; } } .board-list-component { - position: relative; - flex: 1; min-height: 0; // firefox fix } .board-list { - height: 100%; - width: 100%; - margin-bottom: 0; - padding: $gl-padding-4; - list-style: none; overflow-y: auto; overflow-x: hidden; } @@ -289,14 +207,11 @@ } .board-card { - position: relative; - padding: $gl-padding; background: $white-light; - border-radius: $border-radius-default; border: 1px solid $gray-200; box-shadow: 0 1px 2px $issue-boards-card-shadow; - list-style: none; line-height: $gl-padding; + list-style: none; &:not(:last-child) { margin-bottom: $gl-padding-8; @@ -323,10 +238,6 @@ } } - svg { - vertical-align: top; - } - .confidential-icon { color: $orange-600; cursor: help; @@ -351,11 +262,10 @@ } .board-card-header { - display: flex; + text-align: initial; } .board-card-assignee { - display: flex; margin-top: -$gl-padding-4; margin-bottom: -$gl-padding-4; @@ -415,34 +325,16 @@ .board-card-number { font-size: $gl-font-size-xs; color: $gl-text-color-secondary; - overflow: hidden; @include media-breakpoint-up(md) { font-size: $label-font-size; } } -.board-card-number-container { - overflow: hidden; -} - -.issue-boards-search { - width: 395px; - - .form-control { - display: inline-block; - width: 210px; - } -} - .board-list-count { padding: 10px 0; color: $gl-text-color-secondary; font-size: 13px; - - > .fa { - margin-right: 5px; - } } .board-new-issue-form { @@ -450,16 +342,9 @@ margin: 5px; } -.page-with-contextual-sidebar.layout-page .issue-boards-sidebar { - .issuable-sidebar-header { - position: relative; - } - +.issue-boards-sidebar { .gutter-toggle { - position: absolute; - top: 0; bottom: 15px; - right: 0; width: 22px; color: $gray-darkest; @@ -479,10 +364,6 @@ .issuable-header-text { @include overflow-break-word(); padding-right: 35px; - - > strong { - font-weight: $gl-font-weight-bold; - } } } @@ -501,51 +382,25 @@ } .add-issues-modal { - display: -webkit-flex; - display: flex; - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; background-color: rgba($black, 0.3); z-index: 9999; } .add-issues-container { - display: -webkit-flex; - display: flex; - -webkit-flex-direction: column; - flex-direction: column; width: 90vw; height: 85vh; max-width: 1100px; min-height: 500px; - margin: auto; padding: 25px 15px 0; background-color: $white-light; - border-radius: $border-radius-default; box-shadow: 0 2px 12px rgba($black, 0.5); .empty-state { - display: -webkit-flex; - display: flex; - -webkit-flex: 1; - flex: 1; - margin-top: 0; - &.add-issues-empty-state-filter { - -webkit-flex-direction: column; flex-direction: column; - -webkit-justify-content: center; justify-content: center; } - > .row { - width: 100%; - margin: auto 0; - } - .svg-content { margin-top: -40px; } @@ -554,27 +409,15 @@ .add-issues-header { margin: -25px -15px -5px; - border-top: 0; border-bottom: 1px solid $border-color; border-top-right-radius: $border-radius-default; border-top-left-radius: $border-radius-default; > h2 { - margin: 0; font-size: 18px; } } -.add-issues-search { - display: -webkit-flex; - display: flex; - - .issues-filters { - -webkit-flex: 1; - flex: 1; - } -} - .add-issues-list-column { width: 100%; @@ -588,10 +431,6 @@ } .add-issues-list { - display: -webkit-flex; - display: flex; - -webkit-flex: 1; - flex: 1; padding-top: 3px; margin-left: -$gl-vert-padding; margin-right: -$gl-vert-padding; @@ -608,15 +447,6 @@ } } -.add-issues-list-loading { - -webkit-align-self: center; - align-self: center; - width: 100%; - padding-left: $gl-vert-padding; - padding-right: $gl-vert-padding; - font-size: 35px; -} - .add-issues-footer { margin: auto -15px 0; padding-left: 15px; @@ -644,27 +474,6 @@ border-radius: 50%; } -.modal-filters { - display: flex; - - > .dropdown { - display: none; - margin-right: 10px; - - @include media-breakpoint-up(sm) { - display: block; - } - } - - .dropdown-menu-toggle { - width: 100px; - - @include media-breakpoint-up(md) { - width: 140px; - } - } -} - .board-card-info { color: $gl-text-color-secondary; white-space: nowrap; diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index fa5a182243c..6fc742871e7 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -46,11 +46,6 @@ } .build-page { - .build-trace-container { - position: relative; - } - - .build-trace { @include build-trace(); } @@ -105,18 +100,6 @@ top: 0; } - .truncated-info { - .truncated-info-size { - margin: 0 5px; - } - - .raw-link { - color: $gl-text-color; - margin-left: 5px; - text-decoration: underline; - } - } - .controllers { @include build-controllers(15px, center, false, 0, inline, 0); } @@ -143,12 +126,6 @@ } } -.with-performance-bar .build-page { - .top-bar.affix { - top: $header-height + $performance-bar-height; - } -} - .build-header { .ci-header-container, .header-action-buttons { @@ -234,7 +211,6 @@ } .trigger-variables-btn-container { - @extend .d-flex; justify-content: space-between; align-items: center; @@ -278,12 +254,6 @@ .retry-link { display: block; - .btn { - i { - margin-left: 5px; - } - } - .btn-inverted-secondary { color: $blue-500; @@ -330,16 +300,12 @@ } } - .build-job { - position: relative; - - .icon-arrow-right { - position: absolute; - left: 15px; - top: 20px; - display: block; - } + .icon-arrow-right { + left: 15px; + top: 20px; + } + .build-job { &.active { font-weight: $gl-font-weight-bold; } @@ -351,10 +317,6 @@ &:hover { background-color: $gray-darker; } - - .icon-retry { - margin-left: 3px; - } } } @@ -392,3 +354,14 @@ right: 0; margin-top: -17px; } + +@include media-breakpoint-down(sm) { + .top-bar { + .truncated-info { + white-space: nowrap; + overflow: hidden; + max-width: 220px; + text-overflow: ellipsis; + } + } +} diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index 809ba6d4953..255383d89c8 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -69,6 +69,8 @@ align-self: flex-start; font-weight: 500; font-size: 20px; + color: $orange-900; + opacity: 1; margin: $gl-padding-8 14px 0 0; } diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 11966931a6c..77a36e59b03 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -58,14 +58,6 @@ display: inline-block; vertical-align: middle; - .stage-cell .stage-container { - margin: 0 3px 3px 0; - } - - .stage-container:last-child { - margin-right: 0; - } - .dropdown-menu { margin-top: 11px; } @@ -128,18 +120,9 @@ } .commit-row-title { - .notes_count { - float: right; - margin-right: 10px; - } - .str-truncated { max-width: 70%; } - - .commit-row-message { - color: $gl-text-color; - } } .text-expander { @@ -171,8 +154,6 @@ } .avatar-cell { - width: 46px; - img { margin-right: 0; } @@ -185,7 +166,7 @@ flex-grow: 1; min-width: 0; - .project_namespace { + .project-namespace { color: $gl-text-color-secondary; } } @@ -208,10 +189,6 @@ } } - .ci-status-link { - display: inline-flex; - } - .ci-status-icon svg { vertical-align: text-bottom; } @@ -239,7 +216,6 @@ } .label-monospace { - @extend .monospace; user-select: text; color: $gl-text-color; background-color: $gray-light; @@ -266,7 +242,7 @@ } .commit, -.generic_commit_status { +.generic-commit-status { a, button { vertical-align: baseline; @@ -278,37 +254,22 @@ &.autodevops-badge { color: $white-light; } - - &.autodevops-link { - color: $blue-600; - } } .commit-row-description { @extend %commit-description-base; display: none; flex: 1; - - a { - color: $gl-text-color; - } } &.inline-commit { .commit-row-title { font-size: 13px; } - - .committed_ago { - @extend .cgray; - float: right; - } } } .branch-commit { - color: $gl-text-color; - .commit-icon { text-align: center; display: inline-block; @@ -320,14 +281,15 @@ fill: $gl-text-color-secondary; } } +} +.commit, +.generic-commit-status, +.branch-commit { + .autodevops-link, .commit-sha { color: $blue-600; } - - .commit-row-message { - color: $gl-text-color; - } } .gpg-status-box { @@ -342,11 +304,11 @@ } &.invalid { - @include status-color($gray-dark, color("gray"), $gray-darkest); + @include status-color($gray-dark, color('gray'), $gray-darkest); border-color: $gray-darkest; &:not(span):hover { - color: color("gray"); + color: color('gray'); } } } diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index ec2108b15be..2b932d164a5 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -330,7 +330,6 @@ // Custom Styles for stage items .item-build-component { - .item-title { .icon-build-status { float: left; diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss index cb5f1a84005..c386493231c 100644 --- a/app/assets/stylesheets/pages/detail_page.scss +++ b/app/assets/stylesheets/pages/detail_page.scss @@ -21,7 +21,6 @@ .detail-page-header-body { position: relative; - line-height: 35px; display: flex; flex: 1 1; min-width: 0; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index d001dff7986..3b0d740def3 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -7,7 +7,9 @@ cursor: pointer; @media (min-width: map-get($grid-breakpoints, md)) { - $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height; + // The `-1` below is to prevent two borders from clashing up against eachother - + // the bottom of the compare-versions header and the top of the file header + $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height - 1; position: -webkit-sticky; position: sticky; @@ -54,6 +56,11 @@ background-color: $gray-normal; } + a, + button { + color: $gray-700; + } + svg { vertical-align: middle; top: -1px; @@ -85,138 +92,6 @@ } } - .note-text { - table { - font-family: $font-family-sans-serif; - } - } - - table { - width: 100%; - font-family: $monospace-font; - border: 0; - border-collapse: separate; - margin: 0; - padding: 0; - table-layout: fixed; - border-radius: 0 0 $border-radius-default $border-radius-default; - - .diff-line-num { - width: 50px; - position: relative; - - a { - transition: none; - } - } - - .line_holder td { - line-height: $code-line-height; - font-size: $code-font-size; - vertical-align: top; - - &.noteable_line { - position: relative; - } - - span { - white-space: pre-wrap; - - &.context-cell { - display: inline-block; - width: 100%; - height: 100%; - } - } - - .line { - word-wrap: break-word; - } - } - - &.left-side-selected { - td.line_content.parallel.right-side { - user-select: none; - } - } - - &.right-side-selected { - td.line_content.parallel.left-side { - user-select: none; - } - } - } - - tr.line_holder.parallel { - td.line_content.parallel { - width: 46%; - } - - .add-diff-note { - margin-left: -55px; - } - } - - .old_line, - .new_line { - user-select: none; - margin: 0; - border: 0; - padding: 0 5px; - border-right: 1px solid; - text-align: right; - min-width: 35px; - max-width: 50px; - width: 35px; - - a { - float: left; - width: 35px; - font-weight: $gl-font-weight-normal; - - &[disabled] { - cursor: default; - - &:hover, - &:active { - text-decoration: none; - } - } - } - } - - .line_content { - display: block; - margin: 0; - padding: 0 1.5em; - border: 0; - position: relative; - - &.parallel { - display: table-cell; - - span { - word-break: break-all; - } - } - - &.old { - &::before { - content: '-'; - position: absolute; - left: 0.5em; - } - } - - &.new { - &::before { - content: '+'; - position: absolute; - left: 0.5em; - } - } - } - .diff-loading-error-block { padding: $gl-padding * 2 $gl-padding; text-align: center; @@ -239,22 +114,18 @@ img { border: 1px solid $white-light; - background-image: linear-gradient( - 45deg, - $border-color 25%, - transparent 25%, - transparent 75%, - $border-color 75%, - $border-color 100% - ), - linear-gradient( - 45deg, - $border-color 25%, - transparent 25%, - transparent 75%, - $border-color 75%, - $border-color 100% - ); + background-image: linear-gradient(45deg, + $border-color 25%, + transparent 25%, + transparent 75%, + $border-color 75%, + $border-color 100%), + linear-gradient(45deg, + $border-color 25%, + transparent 25%, + transparent 75%, + $border-color 75%, + $border-color 100%); background-size: 10px 10px; background-position: 0 0, 5px 5px; max-width: 100%; @@ -286,11 +157,34 @@ .swipe-wrap { overflow: hidden; - border-left: 1px solid $gl-gray-400; + border-right: 1px solid $gl-gray-400; position: absolute; display: block; top: 13px; right: 7px; + + &.left-oriented { + /* only for commit view (different swipe viewer) */ + border-right: 0; + border-left: 1px solid $gl-gray-400; + } + } + + .frame { + top: 0; + right: 0; + + &.old-diff { + /* only for commit / compare view */ + position: absolute; + } + + &.deleted { + margin: 0; + display: block; + top: 13px; + right: 7px; + } } .swipe-bar { @@ -443,10 +337,6 @@ } } - .line_content { - white-space: pre-wrap; - } - .diff-file-container { .frame.deleted { border: 1px solid $deleted; @@ -508,12 +398,126 @@ } } +table.code { + width: 100%; + font-family: $monospace-font; + border: 0; + border-collapse: separate; + margin: 0; + padding: 0; + table-layout: fixed; + border-radius: 0 0 $border-radius-default $border-radius-default; + + tr.line_holder td { + line-height: $code-line-height; + font-size: $code-font-size; + vertical-align: top; + + span { + white-space: pre-wrap; + + &.context-cell { + display: inline-block; + width: 100%; + height: 100%; + } + + &.line { + word-wrap: break-word; + } + } + + &.diff-line-num { + user-select: none; + margin: 0; + padding: 0 10px 0 5px; + border-right-width: 1px; + border-right-style: solid; + text-align: right; + width: 50px; + position: relative; + + a { + transition: none; + float: left; + width: 100%; + font-weight: $gl-font-weight-normal; + + &[disabled] { + cursor: default; + + &:hover, + &:active { + text-decoration: none; + } + } + } + + &:not(.js-unfold-bottom) a::before { + content: attr(data-linenumber); + } + } + + &.line_content { + display: block; + margin: 0; + padding: 0 1.5em; + border: 0; + position: relative; + white-space: pre-wrap; + + &.parallel { + display: table-cell; + width: 46%; + + span { + word-break: break-all; + } + } + + &.old { + &::before { + content: '-'; + position: absolute; + left: 0.5em; + } + } + + &.new { + &::before { + content: '+'; + position: absolute; + left: 0.5em; + } + } + } + } + + .line_holder:last-of-type { + td:first-child { + border-bottom-left-radius: $border-radius-default; + } + } + + &.left-side-selected { + td.line_content.parallel.right-side { + user-select: none; + } + } + + &.right-side-selected { + td.line_content.parallel.left-side { + user-select: none; + } + } +} + .diff-stats { align-items: center; - padding: 0 .25rem; + padding: 0 0.25rem; .diff-stats-group { - padding: 0 .25rem; + padding: 0 0.25rem; } svg.diff-stats-icon { @@ -522,7 +526,7 @@ &.is-compare-versions-header { .diff-stats-group { - padding: 0 .5rem; + padding: 0 0.5rem; } } } @@ -608,22 +612,11 @@ } } -.file-holder { - .diff-line-num:not(.js-unfold-bottom) { - a { - &::before { - content: attr(data-linenumber); - } - } - } -} - .diff-comment-avatar-holders { position: absolute; - height: 19px; - width: 19px; - margin-left: -15px; + margin-left: -$gl-padding; z-index: 100; + @include code-icon-size(); &:hover { .diff-comment-avatar, @@ -657,26 +650,28 @@ .diff-comments-more-count { position: absolute; left: 0; - width: 19px; - height: 19px; margin-right: 0; border-color: $white-light; cursor: pointer; transition: all 0.1s ease-out; + @include code-icon-size(); @for $i from 1 through 4 { &:nth-child(#{$i}) { z-index: (4 - $i); } } + + .avatar { + @include code-icon-size(); + } } .diff-comments-more-count { - width: 19px; - min-width: 19px; padding-left: 0; padding-right: 0; overflow: hidden; + @include code-icon-size(); } .diff-comments-more-count, @@ -685,12 +680,15 @@ } .diff-notes-collapse { - width: 24px; - height: 24px; + border: 0; border-radius: 50%; padding: 0; transition: transform 0.1s ease-out; z-index: 100; + display: flex; + justify-content: center; + align-items: center; + @include code-icon-size(); .collapse-icon { height: 50%; @@ -834,34 +832,26 @@ width: 100%; height: 10px; background-color: $white-light; - background-image: linear-gradient( - 45deg, - transparent, - transparent 73%, - $diff-jagged-border-gradient-color 75%, - $white-light 80% - ), - linear-gradient( - 225deg, - transparent, - transparent 73%, - $diff-jagged-border-gradient-color 75%, - $white-light 80% - ), - linear-gradient( - 135deg, - transparent, - transparent 73%, - $diff-jagged-border-gradient-color 75%, - $white-light 80% - ), - linear-gradient( - -45deg, - transparent, - transparent 73%, - $diff-jagged-border-gradient-color 75%, - $white-light 80% - ); + background-image: linear-gradient(45deg, + transparent, + transparent 73%, + $diff-jagged-border-gradient-color 75%, + $white-light 80%), + linear-gradient(225deg, + transparent, + transparent 73%, + $diff-jagged-border-gradient-color 75%, + $white-light 80%), + linear-gradient(135deg, + transparent, + transparent 73%, + $diff-jagged-border-gradient-color 75%, + $white-light 80%), + linear-gradient(-45deg, + transparent, + transparent 73%, + $diff-jagged-border-gradient-color 75%, + $white-light 80%); background-position: 5px 5px, 0 5px, 0 5px, 5px 5px; background-size: 10px 10px; background-repeat: repeat; @@ -904,7 +894,7 @@ } } -.files:not([data-can-create-note="true"]) .frame { +.files:not([data-can-create-note='true']) .frame { cursor: auto; } @@ -913,15 +903,14 @@ .btn-transparent.image-diff-overlay-add-comment { position: relative; cursor: image-url('illustrations/image_comment_light_cursor.svg') - $image-comment-cursor-left-offset $image-comment-cursor-top-offset, + $image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; // Retina cursor - cursor: -webkit-image-set( - image-url('illustrations/image_comment_light_cursor.svg') 1x, - image-url('illustrations/image_comment_light_cursor@2x.svg') 2x - ) - $image-comment-cursor-left-offset $image-comment-cursor-top-offset, + // scss-lint:disable DuplicateProperty + cursor: image-set(image-url('illustrations/image_comment_light_cursor.svg') 1x, + image-url('illustrations/image_comment_light_cursor@2x.svg') 2x) + $image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; .comment-indicator { @@ -1019,6 +1008,10 @@ display: block; } } + + .note-edit-form { + margin-left: $note-icon-gutter-width; + } } .discussion-body .image .frame { @@ -1106,7 +1099,10 @@ flex-direction: column; .diff-tree-list { - width: 100%; + position: relative; + top: 0; + // !important is required to override inline styles of resizable sidebar + width: 100% !important; } .tree-list-holder { diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 61ecf133b02..93dffb5ff09 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -12,34 +12,6 @@ .environments-container { .ci-table { - .deployment-column { - > span { - word-break: break-all; - } - - .avatar { - float: none; - } - } - - .btn-group { - > .btn:not(.btn-danger) { - color: $gl-text-color-secondary; - } - - svg path { - fill: $gl-text-color-secondary; - } - - .dropdown { - outline: none; - } - } - - .btn .text-center { - display: inline; - } - .commit-title { margin: 0; } @@ -49,47 +21,16 @@ color: $gl-text-color-secondary; } - .dropdown-menu { - .fa { - margin-right: 6px; - color: $gl-text-color-secondary; - } - } - .build-link, .ref-name { color: $gl-text-color; } - .stop-env-link, - .external-url { - color: $gl-text-color-secondary; - - .stop-env-icon { - font-size: 14px; - } - } - - .deployment .build-column { - .build-link { - color: $gl-text-color; - } - - .avatar { - float: none; - margin-right: 0; - } - } - .folder-icon { margin-right: 3px; color: $gl-text-color-secondary; display: inline-block; vertical-align: text-top; - - .fa:nth-child(1) { - margin-right: 3px; - } } .folder-name { @@ -103,12 +44,6 @@ text-align: center; } - .branch-commit { - .commit-sha { - margin-right: 0; - } - } - .no-btn { border: 0; background: none; @@ -168,11 +103,6 @@ opacity: 0.25; } -.prometheus-graph-overlay { - fill: none; - opacity: 0; - pointer-events: all; -} .rect-text-metric { fill: $white-light; @@ -203,274 +133,10 @@ stroke: $gray-darkest; } -.prometheus-graphs { - .environments { - .dropdown-menu-toggle { - svg { - position: absolute; - right: 5%; - top: 25%; - } - } - - .dropdown-menu-toggle, - .dropdown-menu { - width: 240px; - } - } -} - .environments-actions { .external-url, .monitoring-url, - .terminal-button, - .stop-env-link { + .terminal-button { width: 38px; } } - -.prometheus-panel { - margin-top: 20px; -} - -.prometheus-graph-group { - display: flex; - flex-wrap: wrap; - padding: $gl-padding / 2; -} - -.prometheus-graph { - padding: $gl-padding / 2; -} - -.prometheus-graph-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: $gl-padding-8; - - h5 { - font-size: $gl-font-size-large; - margin: 0; - } -} - -.prometheus-graph-cursor { - position: absolute; - background: $gray-600; - width: 1px; -} - -.prometheus-graph-flag { - display: block; - min-width: 160px; - border: 0; - box-shadow: 0 1px 4px 0 $black-transparent; - - h5 { - padding: 0; - margin: 0; - font-size: 14px; - line-height: 1.2; - } - - .deploy-meta-content { - border-bottom: 1px solid $white-dark; - - svg { - height: 15px; - vertical-align: bottom; - } - } - - &.popover { - padding: 0; - - &.left { - left: auto; - right: 0; - margin-right: 10px; - - > .arrow { - right: -14px; - border-left-color: $border-color; - } - - > .arrow::after { - border-top: 6px solid transparent; - border-bottom: 6px solid transparent; - border-left: 4px solid $gray-50; - } - - .arrow-shadow { - right: -3px; - box-shadow: 1px 0 9px 0 $black-transparent; - } - } - - &.right { - left: 0; - right: auto; - margin-left: 10px; - - > .arrow { - left: -7px; - border-right-color: $border-color; - } - - > .arrow::after { - border-top: 6px solid transparent; - border-bottom: 6px solid transparent; - border-right: 4px solid $gray-50; - } - - .arrow-shadow { - left: -3px; - box-shadow: 1px 0 8px 0 $black-transparent; - } - } - - > .arrow { - top: 10px; - margin: 0; - } - - .arrow-shadow { - content: ''; - position: absolute; - width: 7px; - height: 7px; - background-color: transparent; - transform: rotate(45deg); - top: 13px; - } - - > .popover-title, - > .popover-content { - padding: 8px; - font-size: 12px; - white-space: nowrap; - position: relative; - } - - > .popover-title { - background-color: $gray-50; - border-radius: $border-radius-default $border-radius-default 0 0; - } - } - - strong { - font-weight: 600; - } -} - -.prometheus-table { - border-collapse: collapse; - padding: 0; - margin: 0; - - td { - vertical-align: middle; - - + td { - padding-left: 8px; - vertical-align: top; - } - } - - .legend-metric-title { - font-size: 12px; - vertical-align: middle; - } -} - -.prometheus-svg-container { - position: relative; - height: 0; - width: 100%; - padding: 0; - padding-bottom: 100%; - - .text-metric-usage { - fill: $black; - font-weight: $gl-font-weight-normal; - font-size: 12px; - } - - > svg { - position: absolute; - height: 100%; - width: 100%; - left: 0; - top: 0; - - text { - fill: $gl-text-color; - stroke-width: 0; - } - - .text-metric-bold { - font-weight: $gl-font-weight-bold; - } - - .label-axis-text { - fill: $black; - font-weight: $gl-font-weight-normal; - font-size: 10px; - } - - .legend-axis-text { - fill: $black; - } - - .tick { - > line { - stroke: $gray-darker; - } - - > text { - fill: $gray-600; - font-size: 10px; - } - } - - .y-label-text, - .x-label-text { - fill: $gray-darkest; - } - - .axis-tick { - stroke: $gray-darker; - } - - .deploy-info-text { - dominant-baseline: text-before-edge; - font-size: 12px; - } - - .deploy-info-text-link { - font-family: $monospace-font; - fill: $blue-600; - - &:hover { - fill: $blue-800; - } - } - - @include media-breakpoint-down(sm) { - .label-axis-text, - .text-metric-usage, - .legend-axis-text { - font-size: 8px; - } - - .tick > text { - font-size: 8px; - } - } - } -} - -.prometheus-table-row-highlight { - background-color: $gray-100; -} diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index 618f23d81b1..500f5816d38 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -8,7 +8,7 @@ border-bottom: 1px solid $white-normal; color: $gl-text-color-secondary; position: relative; - line-height: $gl-line-height; + line-height: $gl-line-height-20; .system-note-image { position: absolute; @@ -48,7 +48,7 @@ } .event-user-info { - margin-bottom: $gl-padding-8; + margin-bottom: $gl-padding-4; .author_name { a { @@ -67,7 +67,7 @@ } .event-body { - margin-top: $gl-padding-8; + margin-top: $gl-padding-4; margin-right: 174px; color: $gl-text-color; @@ -156,6 +156,10 @@ &:hover { background: none; } + + a { + color: $blue-600; + } } } } diff --git a/app/assets/stylesheets/pages/graph.scss b/app/assets/stylesheets/pages/graph.scss index 83b1680512d..3febf4cf826 100644 --- a/app/assets/stylesheets/pages/graph.scss +++ b/app/assets/stylesheets/pages/graph.scss @@ -71,12 +71,10 @@ .svg-graph-container-with-grab { cursor: grab; - cursor: -webkit-grab; } .svg-graph-container-grabbed { cursor: grabbing; - cursor: -webkit-grabbing; } @keyframes flickerAnimation { diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 8ade995525a..656202f4e58 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -15,6 +15,11 @@ word-wrap: nowrap; } +.content-list .group-name { + font-weight: $gl-font-weight-bold; + color: $pages-group-name-color; +} + .group-row { @include basic-list-stats; @@ -30,9 +35,6 @@ } .group-nav-container .nav-controls { - align-items: flex-start; - padding: $gl-padding-top 0 0; - .group-filter-form { flex: 1 1 auto; margin-right: $gl-padding-8; @@ -172,6 +174,50 @@ } } +.card { + .shared_runners_limit_under_quota { + color: $green-500; + } + + .shared_runners_limit_over_quota { + color: $red-500; + } +} + +.pipeline-quota { + border-top: 1px solid $table-border-color; + border-bottom: 1px solid $table-border-color; + margin: 0 0 $gl-padding; + + .row { + padding-top: 10px; + padding-bottom: 10px; + } + + .right { + text-align: right; + } + + .progress { + height: 6px; + width: 100%; + margin-bottom: 0; + margin-top: 4px; + } +} + +.user-settings-pipeline-quota { + margin-top: $gl-padding; + + .pipeline-quota { + border-top: 0; + } +} + +table.pipeline-project-metrics tr td { + padding: $gl-padding; +} + .mattermost-icon svg { width: 16px; height: 16px; diff --git a/app/assets/stylesheets/pages/help.scss b/app/assets/stylesheets/pages/help.scss index 2c23f31c240..7610c5cf6f3 100644 --- a/app/assets/stylesheets/pages/help.scss +++ b/app/assets/stylesheets/pages/help.scss @@ -30,19 +30,11 @@ .key { @extend .badge.badge-pill; background-color: $label-inverse-bg; - font: 11px Consolas, "Liberation Mono", Menlo, Courier, monospace; + font: 11px Consolas, 'Liberation Mono', Menlo, Courier, monospace; padding: 3px 5px; } } .documentation { padding: 7px; - - // Border around images in the help pages. - img:not(.emoji) { - border: 1px solid $white-normal; - padding: 5px; - margin: 5px; - max-height: calc(100vh - 100px); - } } diff --git a/app/assets/stylesheets/pages/import.scss b/app/assets/stylesheets/pages/import.scss index 7f800367cad..74f80a11471 100644 --- a/app/assets/stylesheets/pages/import.scss +++ b/app/assets/stylesheets/pages/import.scss @@ -18,8 +18,6 @@ } .import-namespace-select { - width: auto !important; - > .select2-choice { border-radius: $border-radius-default 0 0 $border-radius-default; position: relative; @@ -49,3 +47,15 @@ .import-projects-loading-icon { margin-top: $gl-padding-32; } + +.btn-import { + .loading-icon { + display: none; + } + + &.is-loading { + .loading-icon { + display: inline-block; + } + } +} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index e0bdc1341b1..79282f9043c 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -1,8 +1,3 @@ -// Limit MR description for side-by-side diff view -.fixed-width-container { - @include fixed-width-container; -} - .issuable-warning-icon { background-color: $orange-100; border-radius: $border-radius-default; @@ -22,11 +17,12 @@ .detail-page-header, .page-content-header, .commit-box, + .info-well, .commit-ci-menu, .files-changed-inner, .limited-header-width, .limited-width-notes { - @extend .fixed-width-container; + @include fixed-width-container; } .issuable-details { @@ -34,13 +30,13 @@ .mr-source-target, .mr-state-widget, .merge-manually { - @extend .fixed-width-container; + @include fixed-width-container; } } .merge-request-details { .emoji-list-container { - @extend .fixed-width-container; + @include fixed-width-container; } } } @@ -64,6 +60,7 @@ overflow-wrap: break-word; min-width: 0; width: 100%; + text-align: initial; } .btn-edit { @@ -71,16 +68,12 @@ height: $gl-padding * 2; } - // Border around images in issue and MR descriptions. - .description img:not(.emoji) { - border: 1px solid $white-normal; - padding: 5px; - max-height: calc(100vh - 100px); - max-width: 100%; - } - .emoji-block { - padding: 10px 0; + padding: $gl-padding-4 0; + + @include media-breakpoint-down(md) { + padding: $gl-padding-8 0; + } } } @@ -117,6 +110,20 @@ font-size: 0; margin-bottom: -5px; } + + .scoped-label-wrapper { + > a { + max-width: 100%; + } + + .color-label { + padding-right: $gl-padding-24; + } + + .scoped-label { + right: 12px; + } + } } .right-sidebar { @@ -129,6 +136,10 @@ z-index: 200; overflow: hidden; + @include media-breakpoint-down(sm) { + z-index: 251; + } + a:not(.btn) { color: inherit; @@ -136,7 +147,7 @@ color: $blue-800; .avatar { - border-color: rgba($gray-normal, .2); + border-color: rgba($gray-normal, 0.2); } } @@ -215,7 +226,7 @@ .title { color: $gl-text-color; - margin-bottom: 10px; + margin-bottom: $gl-padding-8; line-height: 1; .avatar { @@ -223,7 +234,7 @@ } a.edit-link:not([href]):hover { - color: rgba($gray-normal, .2); + color: rgba($gray-normal, 0.2); } .lock-edit, // uses same style, different js behaviour @@ -263,6 +274,10 @@ .selectbox { display: none; + + &.show { + display: block; + } } .btn-clipboard:hover { @@ -316,6 +331,7 @@ } .no-value, + .btn-default-hover-link, .btn-secondary-hover-link { color: $gl-text-color-secondary; } @@ -596,10 +612,8 @@ .participants-list { display: flex; flex-wrap: wrap; - margin: -7px; } - .user-list { display: flex; flex-wrap: wrap; @@ -607,7 +621,7 @@ .participants-author { display: inline-block; - padding: 7px; + padding: 0 $gl-padding-8 $gl-padding-8 0; &:nth-of-type(7n) { padding-right: 0; @@ -634,7 +648,6 @@ .participants-more, .user-list-more { - margin-top: 5px; margin-left: 5px; a, @@ -711,14 +724,11 @@ .issuable-list { li { - .issue-box { - display: -webkit-flex; display: flex; } .issuable-info-container { - -webkit-flex: 1; flex: 1; display: flex; padding-right: $gl-padding; @@ -726,6 +736,7 @@ .issuable-main-info { flex: 1 auto; margin-right: 10px; + min-width: 0; .issue-weight-icon { vertical-align: sub; @@ -787,6 +798,7 @@ @media(max-width: map-get($grid-breakpoints, lg)-1) { .task-status, .issuable-due-date, + .issuable-weight, .project-ref-path { display: none; } @@ -813,7 +825,6 @@ .sidebar-collapsed-icon { - > .stopwatch-svg { display: inline-block; } @@ -871,11 +882,11 @@ } .help-state-toggle-enter-active { - transition: all .8s ease; + transition: all 0.8s ease; } .help-state-toggle-leave-active { - transition: all .5s ease; + transition: all 0.5s ease; } .help-state-toggle-enter, diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 0037364978c..48289c8f381 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -58,8 +58,6 @@ form.edit-issue { } ul.related-merge-requests > li { - display: -ms-flexbox; - display: -webkit-flex; display: flex; align-items: center; @@ -147,6 +145,11 @@ ul.related-merge-requests > li { } } +.issues-footer { + padding-top: $gl-padding; + padding-bottom: 37px; +} + .issues-nav-controls { font-size: 0; @@ -255,8 +258,15 @@ ul.related-merge-requests > li { } } -.discussion-reply-holder .note-edit-form { - display: block; +.discussion-reply-holder { + .avatar-note-form-holder .note-edit-form { + display: block; + margin-left: $note-icon-gutter-width; + + @include media-breakpoint-down(xs) { + margin-left: 0; + } + } } .issue-sort-dropdown { diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 2372640277e..11e8a32389f 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -34,7 +34,7 @@ .dropdown-new-label { .dropdown-content { - max-height: 136px; + max-height: initial; } } @@ -75,7 +75,7 @@ padding: 0; margin-bottom: 0; - > li:not(.empty-message):not(.is-not-draggable) { + > li:not(.empty-message):not(.no-border) { background-color: $white-light; margin-bottom: 5px; display: flex; @@ -92,16 +92,14 @@ opacity: 0.3; } - .prioritized-labels & { + .prioritized-labels:not(.is-not-draggable) & { box-shadow: 0 1px 2px $issue-boards-card-shadow; cursor: move; - cursor: -webkit-grab; - cursor: -moz-grab; + cursor: grab; border: 0; &:active { - cursor: -webkit-grabbing; - cursor: -moz-grabbing; + cursor: grabbing; } } } @@ -355,7 +353,7 @@ @media (max-width: map-get($grid-breakpoints, md)-1) { .manage-labels-list { - > li:not(.empty-message):not(.is-not-draggable) { + > li:not(.empty-message):not(.no-border) { flex-wrap: wrap; } @@ -404,3 +402,67 @@ .priority-labels-empty-state .svg-content img { max-width: $priority-label-empty-state-width; } + +.scoped-label-tooltip-title { + color: $indigo-300; +} + +.scoped-label-wrapper { + max-width: 100%; + vertical-align: top; + + .badge { + text-overflow: ellipsis; + overflow-x: hidden; + } + + &.label-link .color-label a { + color: inherit; + } + + .color-label { + padding-right: $gl-padding-24; + max-width: 100%; + } + + .scoped-label { + position: absolute; + top: 4px; + right: 8px; + padding: 0; + margin: 0; + line-height: $gl-line-height; + } + + &.board-label { + .scoped-label { + top: 1px; + } + } +} + +// Label inside title of Delete Label Modal +.modal-header .page-title { + .scoped-label-wrapper { + .scoped-label { + line-height: 20px; + } + + span.color-label { + padding-right: $gl-padding-24; + } + } +} + +// Don't hide the overflow in system messages +.system-note-message, +.issuable-details, +.md-preview-holder, +.referenced-commands, +.note-body { + .scoped-label-wrapper { + .badge { + overflow: initial; + } + } +} diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index 67d7a8175ac..d8aabecc036 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -21,13 +21,6 @@ color: $login-brand-holder-color; } - h1:first-child { - font-weight: $gl-font-weight-normal; - margin-bottom: 0.68em; - margin-top: 0; - font-size: 34px; - } - h3 { font-size: 22px; } @@ -49,8 +42,8 @@ .login-box, .omniauth-container { box-shadow: 0 0 0 1px $border-color; - border-bottom-right-radius: $border-radius-small; - border-bottom-left-radius: $border-radius-small; + border-bottom-right-radius: $border-radius; + border-bottom-left-radius: $border-radius; padding: 15px; .login-heading h3 { @@ -80,7 +73,8 @@ .login-body { font-size: 13px; - input + p { + input + p, + input ~ p.field-validation { margin-top: 5px; } @@ -95,7 +89,7 @@ } .omniauth-container { - border-radius: $border-radius-small; + border-radius: $border-radius; font-size: 13px; p { @@ -120,7 +114,6 @@ } .new-session-tabs { - display: -webkit-flex; display: flex; box-shadow: 0 0 0 1px $border-color; border-top-right-radius: $border-radius-default; @@ -190,7 +183,7 @@ margin-top: 16px; } - input[type="submit"] { + input[type='submit'] { @extend .btn-block; margin-bottom: 0; } diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index 99609a96976..68af01f9ccc 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -14,20 +14,14 @@ } .member { - .list-item-name { - @include media-breakpoint-up(sm) { - float: left; - width: 50%; - } - - strong { - font-weight: $gl-font-weight-bold; + &.is-overridden { + .btn-ldap-override { + display: none !important; } } .controls { @include media-breakpoint-up(sm) { - display: -webkit-flex; display: flex; } @@ -38,10 +32,11 @@ .form-group { margin-bottom: 0; + } - @include media-breakpoint-down(sm) { - display: block; - margin-left: 5px; + .member-controls { + .fa { + line-height: inherit; } } @@ -61,23 +56,12 @@ } .member-form-control { - @include media-breakpoint-down(sm) { - width: $dropdown-member-form-control-width; - margin-left: 0; - padding-bottom: 5px; - } - @include media-breakpoint-down(xs) { margin-right: 0; width: auto; } } -.member-access-text { - margin-left: auto; - line-height: 43px; -} - .member-search-form { position: relative; @@ -123,6 +107,46 @@ outline: 0; } +.members-ldap { + align-self: center; +} + +.alert-member-ldap { + background-color: $orange-50; + + @include media-breakpoint-up(sm) { + line-height: 40px; + } + + > p { + float: left; + margin-bottom: 10px; + color: $orange-600; + + @include media-breakpoint-up(sm) { + padding-left: 55px; + margin-bottom: 0; + } + } + + .controls { + width: 100%; + + @include media-breakpoint-up(sm) { + width: auto; + } + } +} + +.btn-ldap-override { + width: 100%; + + @include media-breakpoint-up(sm) { + margin-left: 10px; + width: auto; + } +} + .flex-project-members-panel { display: flex; flex-direction: row; @@ -176,9 +200,6 @@ } .content-list.members-list li { - display: flex; - justify-content: space-between; - .list-item-name { float: none; display: flex; @@ -207,33 +228,24 @@ align-self: flex-start; } + @include media-breakpoint-down(sm) { + .member-access-text { + margin: 0 0 $gl-padding-4 ($grid-size * 6); + } + } + @include media-breakpoint-down(xs) { display: block; - .controls > .btn { - margin-left: 0; - margin-right: 0; + .controls > .btn, + .controls .member-form-control { + margin: 0 0 $gl-padding-8; display: block; } - .controls > .btn:last-child { - margin-left: 5px; - margin-right: 5px; - width: auto; - } - .form-control { width: 100%; } - - .member-access-text { - line-height: 0; - margin-left: 50px; - } - - .member-controls { - margin-top: 5px; - } } } diff --git a/app/assets/stylesheets/pages/merge_conflicts.scss b/app/assets/stylesheets/pages/merge_conflicts.scss index e0f7d075fc7..278a9014458 100644 --- a/app/assets/stylesheets/pages/merge_conflicts.scss +++ b/app/assets/stylesheets/pages/merge_conflicts.scss @@ -20,81 +20,81 @@ $colors: ( white-header-not-chosen : #f0f0f0, white-line-not-chosen : $gray-light, - dark-header-head-neutral : rgba(#3f3, .2), - dark-line-head-neutral : rgba(#3f3, .1), + dark-header-head-neutral : rgba(#3f3, 0.2), + dark-line-head-neutral : rgba(#3f3, 0.1), dark-button-head-neutral : #40874f, - dark-header-head-chosen : rgba(#3f3, .33), - dark-line-head-chosen : rgba(#3f3, .2), + dark-header-head-chosen : rgba(#3f3, 0.33), + dark-line-head-chosen : rgba(#3f3, 0.2), dark-button-head-chosen : #258537, - dark-header-origin-neutral : rgba(#2878c9, .4), - dark-line-origin-neutral : rgba(#2878c9, .3), + dark-header-origin-neutral : rgba(#2878c9, 0.4), + dark-line-origin-neutral : rgba(#2878c9, 0.3), dark-button-origin-neutral : #2a5c8c, - dark-header-origin-chosen : rgba(#2878c9, .6), - dark-line-origin-chosen : rgba(#2878c9, .4), + dark-header-origin-chosen : rgba(#2878c9, 0.6), + dark-line-origin-chosen : rgba(#2878c9, 0.4), dark-button-origin-chosen : #1d6cbf, - dark-header-not-chosen : rgba(#fff, .25), - dark-line-not-chosen : rgba(#fff, .1), + dark-header-not-chosen : rgba(#fff, 0.25), + dark-line-not-chosen : rgba(#fff, 0.1), - monokai-header-head-neutral : rgba(#a6e22e, .25), - monokai-line-head-neutral : rgba(#a6e22e, .1), + monokai-header-head-neutral : rgba(#a6e22e, 0.25), + monokai-line-head-neutral : rgba(#a6e22e, 0.1), monokai-button-head-neutral : #376b20, - monokai-header-head-chosen : rgba(#a6e22e, .4), - monokai-line-head-chosen : rgba(#a6e22e, .25), + monokai-header-head-chosen : rgba(#a6e22e, 0.4), + monokai-line-head-chosen : rgba(#a6e22e, 0.25), monokai-button-head-chosen : #39800d, - monokai-header-origin-neutral : rgba(#60d9f1, .35), - monokai-line-origin-neutral : rgba(#60d9f1, .15), + monokai-header-origin-neutral : rgba(#60d9f1, 0.35), + monokai-line-origin-neutral : rgba(#60d9f1, 0.15), monokai-button-origin-neutral : #38848c, - monokai-header-origin-chosen : rgba(#60d9f1, .5), - monokai-line-origin-chosen : rgba(#60d9f1, .35), + monokai-header-origin-chosen : rgba(#60d9f1, 0.5), + monokai-line-origin-chosen : rgba(#60d9f1, 0.35), monokai-button-origin-chosen : #3ea4b2, - monokai-header-not-chosen : rgba(#76715d, .24), - monokai-line-not-chosen : rgba(#76715d, .1), + monokai-header-not-chosen : rgba(#76715d, 0.24), + monokai-line-not-chosen : rgba(#76715d, 0.1), - solarized-light-header-head-neutral : rgba(#859900, .37), - solarized-light-line-head-neutral : rgba(#859900, .2), + solarized-light-header-head-neutral : rgba(#859900, 0.37), + solarized-light-line-head-neutral : rgba(#859900, 0.2), solarized-light-button-head-neutral : #afb262, - solarized-light-header-head-chosen : rgba(#859900, .5), - solarized-light-line-head-chosen : rgba(#859900, .37), + solarized-light-header-head-chosen : rgba(#859900, 0.5), + solarized-light-line-head-chosen : rgba(#859900, 0.37), solarized-light-button-head-chosen : #94993d, - solarized-light-header-origin-neutral : rgba(#2878c9, .37), - solarized-light-line-origin-neutral : rgba(#2878c9, .15), + solarized-light-header-origin-neutral : rgba(#2878c9, 0.37), + solarized-light-line-origin-neutral : rgba(#2878c9, 0.15), solarized-light-button-origin-neutral : #60a1bf, - solarized-light-header-origin-chosen : rgba(#2878c9, .6), - solarized-light-line-origin-chosen : rgba(#2878c9, .37), + solarized-light-header-origin-chosen : rgba(#2878c9, 0.6), + solarized-light-line-origin-chosen : rgba(#2878c9, 0.37), solarized-light-button-origin-chosen : #2482b2, - solarized-light-header-not-chosen : rgba(#839496, .37), - solarized-light-line-not-chosen : rgba(#839496, .2), + solarized-light-header-not-chosen : rgba(#839496, 0.37), + solarized-light-line-not-chosen : rgba(#839496, 0.2), - solarized-dark-header-head-neutral : rgba(#859900, .35), - solarized-dark-line-head-neutral : rgba(#859900, .15), + solarized-dark-header-head-neutral : rgba(#859900, 0.35), + solarized-dark-line-head-neutral : rgba(#859900, 0.15), solarized-dark-button-head-neutral : #376b20, - solarized-dark-header-head-chosen : rgba(#859900, .5), - solarized-dark-line-head-chosen : rgba(#859900, .35), + solarized-dark-header-head-chosen : rgba(#859900, 0.5), + solarized-dark-line-head-chosen : rgba(#859900, 0.35), solarized-dark-button-head-chosen : #39800d, - solarized-dark-header-origin-neutral : rgba(#2878c9, .35), - solarized-dark-line-origin-neutral : rgba(#2878c9, .15), + solarized-dark-header-origin-neutral : rgba(#2878c9, 0.35), + solarized-dark-line-origin-neutral : rgba(#2878c9, 0.15), solarized-dark-button-origin-neutral : #086799, - solarized-dark-header-origin-chosen : rgba(#2878c9, .6), - solarized-dark-line-origin-chosen : rgba(#2878c9, .35), + solarized-dark-header-origin-chosen : rgba(#2878c9, 0.6), + solarized-dark-line-origin-chosen : rgba(#2878c9, 0.35), solarized-dark-button-origin-chosen : #0082cc, - solarized_dark_header_not_chosen : rgba(#839496, .25), - solarized_dark_line_not_chosen : rgba(#839496, .15), + solarized_dark_header_not_chosen : rgba(#839496, 0.25), + solarized_dark_line_not_chosen : rgba(#839496, 0.15), none_header_head_neutral : $gray-normal, none_line_head_neutral : $gray-normal, @@ -210,26 +210,20 @@ $colors: ( } #conflicts { - .white { - @include color-scheme('white') - } + @include color-scheme('white'); } .dark { - @include color-scheme('dark') - } + @include color-scheme('dark'); } .monokai { - @include color-scheme('monokai') - } + @include color-scheme('monokai'); } .solarized-light { - @include color-scheme('solarized-light') - } + @include color-scheme('solarized-light'); } .solarized-dark { - @include color-scheme('solarized-dark') - } + @include color-scheme('solarized-dark'); } .diff-wrap-lines .line_content { white-space: normal; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index cfd3faab122..8cb3fab74e0 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -87,6 +87,11 @@ padding: $gl-padding; } +.mr-widget-info { + padding-left: $gl-padding-50 - $gl-padding-32; + padding-right: $gl-padding; +} + .mr-state-widget { color: $gl-text-color; @@ -166,6 +171,7 @@ float: left; .accept-merge-request { + &.ci-preparing, &.ci-pending, &.ci-running { @include btn-blue; @@ -179,46 +185,6 @@ } } } - - .accept-control { - display: inline-block; - float: left; - margin: 0; - margin-left: 20px; - padding: 5px; - padding-top: 8px; - line-height: 20px; - - &.right { - float: right; - padding-right: 0; - } - - .modify-merge-commit-link { - padding: 0; - background-color: transparent; - border: 0; - color: $gl-text-color; - - &:hover, - &:focus { - text-decoration: underline; - } - } - - .merge-param-checkbox { - margin: 0; - } - - a .fa-question-circle { - color: $gl-text-color-secondary; - - &:hover, - &:focus { - color: $link-hover-color; - } - } - } } .ci-widget { @@ -401,12 +367,6 @@ width: 100%; text-align: center; } - - .accept-control { - width: 100%; - text-align: center; - margin: 0; - } } .commit-message-editor { @@ -491,14 +451,22 @@ .merge-request { padding: 10px 0 10px 15px; position: relative; - display: -webkit-flex; display: flex; .issuable-info-container { - -webkit-flex: 1; flex: 1; } + .issuable-meta { + .author-link { + display: inline-block; + } + + .issuable-comments { + height: 18px; + } + } + .merge-request-title { margin-bottom: 2px; @@ -551,6 +519,10 @@ .mr-links { padding-left: $status-icon-size + $gl-btn-padding; + + &:last-child { + padding-bottom: $gl-padding; + } } .mr-info-list { @@ -596,7 +568,6 @@ color: $gl-text-color; } - .git-merge-container { justify-content: space-between; flex: 1; @@ -738,7 +709,6 @@ background: $gray-light; color: $gl-text-color; margin-top: -1px; - border-top: 1px solid $border-color; .mr-version-menus-container { display: flex; @@ -761,6 +731,7 @@ .content-block { padding: $gl-padding-top $gl-padding; + border-bottom: 0; } .comments-disabled-notif { @@ -785,16 +756,18 @@ padding-right: 5px; } + // Shortening button height by 1px to make compare-versions + // header 56px and fit into our 8px design grid + button { + height: 34px; + } + @include media-breakpoint-up(md) { position: -webkit-sticky; position: sticky; top: $header-height + $mr-tabs-height; - width: 100%; - - &.is-fileTreeOpen { - margin-left: -16px; - width: calc(100% + 32px); - } + margin-left: -16px; + width: calc(100% + 32px); .mr-version-menus-container { flex-wrap: nowrap; @@ -806,15 +779,16 @@ } } -.merge-request-tabs-holder { +.merge-request-tabs-holder, +.epic-tabs-holder { top: $header-height; - z-index: 300; + z-index: 250; background-color: $white-light; border-bottom: 1px solid $border-color; @include media-breakpoint-up(sm) { - position: sticky; position: -webkit-sticky; + position: sticky; } &.affix { @@ -824,11 +798,6 @@ @include media-breakpoint-down(xs) { right: 0; } - - .merge-request-tabs-container { - padding-left: $gl-padding; - padding-right: $gl-padding; - } } .nav-links { @@ -836,11 +805,21 @@ } } -.with-performance-bar .merge-request-tabs-holder { - top: $header-height + $performance-bar-height; +.merge-request-tabs-holder.affix .merge-request-tabs-container, +.epic-tabs-holder.affix .epic-tabs-container { + padding-left: $gl-padding; + padding-right: $gl-padding; +} + +.with-performance-bar { + .merge-request-tabs-holder, + .epic-tabs-holder { + top: $header-height + $performance-bar-height; + } } -.merge-request-tabs { +.merge-request-tabs, +.epic-tabs { display: flex; flex-wrap: nowrap; margin-bottom: 0; @@ -848,7 +827,8 @@ } .limit-container-width { - .merge-request-tabs-container { + .merge-request-tabs-container, + .epic-tabs-container { max-width: $limited-layout-width; margin-left: auto; margin-right: auto; @@ -861,31 +841,56 @@ } } -.merge-request-tabs-container { +.merge-request-tabs-container, +.epic-tabs-container { display: flex; justify-content: space-between; - @include media-breakpoint-down(sm) { - flex-direction: column-reverse; + @include media-breakpoint-down(xs) { + .discussion-filter-container, + .line-resolve-all-container { + margin-bottom: $gl-padding-4; + } } .discussion-filter-container { - margin-top: $gl-padding-8; - &:not(:only-child) { - padding-right: $gl-padding-8; + margin: $gl-padding-4; } } + + .merge-request-tabs { + height: $grid-size * 6; + } } -.limit-container-width:not(.container-limited) { - .merge-request-tabs-holder:not(.affix) { - .merge-request-tabs-container { - max-width: $limited-layout-width - ($gl-padding * 2); +// Wrap MR tabs/buttons so you don't have to scroll on desktop +@include media-breakpoint-down(md) { + .merge-request-tabs-container, + .epic-tabs-container { + flex-direction: column-reverse; + padding-top: $gl-padding-8; + } +} + +@include media-breakpoint-down(lg) { + .right-sidebar-expanded { + .merge-request-tabs-container, + .epic-tabs-container { + flex-direction: column-reverse; + align-items: flex-start; + padding-top: $gl-padding-8; } } } +.limit-container-width:not(.container-limited) { + .merge-request-tabs-holder:not(.affix) .merge-request-tabs-container, + .epic-tabs-holder:not(.affix) .epic-tabs-container { + max-width: $limited-layout-width - ($gl-padding * 2); + } +} + .mr-memory-usage { width: 100%; @@ -955,10 +960,6 @@ } } - .btn svg { - fill: $gray-700; - } - .dropdown-menu { width: 400px; } @@ -1017,7 +1018,12 @@ background: $black-transparent; } -.source-branch-removal-status { - padding-left: 50px; - padding-bottom: $gl-padding; +.mr-compare { + .diff-file .file-title-flex-parent { + top: $header-height + 51px; + + .with-performance-bar & { + top: $performance-bar-height + $header-height + 51px; + } + } } diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss index 15f3a2ef4a8..49608a3964f 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/pages/milestone.scss @@ -67,18 +67,14 @@ $status-box-line-height: 26px; .card-header { line-height: $line-height-base; padding: 14px 16px; - display: -webkit-flex; display: flex; .title { - -webkit-flex: 1; - -webkit-flex-grow: 1; flex: 1; flex-grow: 2; } .counter { - -webkit-flex: 1; flex: 0; padding-left: 16px; } @@ -239,6 +235,7 @@ $status-box-line-height: 26px; padding: 0; } + .popover-body, .popover-content { padding: 0; } diff --git a/app/assets/stylesheets/pages/monitor.scss b/app/assets/stylesheets/pages/monitor.scss new file mode 100644 index 00000000000..25ff5abd774 --- /dev/null +++ b/app/assets/stylesheets/pages/monitor.scss @@ -0,0 +1,5 @@ +.chart-tooltip > .popover { + min-width: 0; + width: max-content; + max-width: $chart-tooltip-max-width; +} diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 51f755c67af..c6bac33e888 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -58,7 +58,8 @@ border: 1px solid $border-color; border-radius: $border-radius-base; transition: border-color ease-in-out 0.15s, - box-shadow ease-in-out 0.15s; + box-shadow ease-in-out 0.15s; + background-color: $white-light; &.is-focused { @extend .form-control:focus; @@ -72,7 +73,7 @@ &.is-dropzone-hover { border-color: $green-500; box-shadow: 0 0 2px $black-transparent, - 0 0 4px $green-500-focus; + 0 0 4px $green-500-focus; .comment-toolbar, .nav-links { @@ -84,9 +85,7 @@ .md-header .nav-links { display: flex; - display: -webkit-flex; flex-flow: row wrap; - -webkit-flex-flow: row wrap; width: 100%; .float-right { @@ -105,6 +104,11 @@ margin: auto; align-items: center; + a { + color: $orange-600; + text-decoration: underline; + } + .icon { margin-right: $issuable-warning-icon-margin; vertical-align: text-bottom; @@ -170,6 +174,16 @@ .discussion-form { background-color: $white-light; + + @include media-breakpoint-down(xs) { + .user-avatar-link { + display: none; + } + + .note-edit-form { + margin-left: 0; + } + } } table { @@ -236,13 +250,25 @@ table { .diff-file, .commit-diff { .discussion-reply-holder { - background-color: $white-light; + background-color: $gray-light; border-radius: 0 0 3px 3px; padding: $gl-padding; + border-top: 1px solid $gray-100; + + + .new-note { + background-color: $gray-light; + border-top: 1px solid $gray-100; + } &.is-replying { padding-bottom: $gl-padding; } + + .user-avatar-link { + img { + margin-top: -3px; + } + } } } @@ -336,7 +362,7 @@ table { .toolbar-button-icon { position: relative; top: 1px; - margin-right: 3px; + margin-right: $gl-padding-4; color: inherit; font-size: 16px; } @@ -444,7 +470,7 @@ table { .uploading-error-message { @include media-breakpoint-down(xs) { &::after { - content: "\a"; + content: '\a'; white-space: pre; } } @@ -463,6 +489,15 @@ table { border: 0; font-size: 14px; line-height: 16px; + + &:hover, + &:focus { + text-decoration: none; + + .text-attach-file { + text-decoration: underline; + } + } } .markdown-selector { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 7e7eff1346a..69dd583bc5b 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -68,7 +68,7 @@ $note-form-margin-left: 72px; } } - .notes_content { + .notes-content { border: 0; border-top: 1px solid $border-color; } @@ -80,21 +80,17 @@ $note-form-margin-left: 72px; } } - li.note { - border-bottom: 1px solid $border-color; - } - .replies-toggle { background-color: $gray-light; padding: $gl-padding-8 $gl-padding; + border-top: 1px solid $gray-100; + border-bottom: 1px solid $gray-100; .collapse-replies-btn:hover { color: $blue-600; } &.expanded { - border-bottom: 1px solid $border-color; - span { cursor: pointer; } @@ -107,6 +103,7 @@ $note-form-margin-left: 72px; &.collapsed { color: $gl-text-color-secondary; + border-radius: 0 0 $border-radius-default $border-radius-default; svg { float: left; @@ -210,8 +207,13 @@ $note-form-margin-left: 72px; display: none; } + .user-avatar-link img { + margin-top: $gl-padding-8; + } + .note-edit-form { display: block; + margin-left: 0; &.current-note-edit-form + .note-awards { display: none; @@ -224,14 +226,7 @@ $note-form-margin-left: 72px; overflow-y: hidden; .note-text { - @include md-typography; - // Reset ul style types since we're nested inside a ul already - @include bulleted-list; word-wrap: break-word; - - table { - @include markdown-table; - } } } @@ -270,8 +265,8 @@ $note-form-margin-left: 72px; } .system-note { - padding: 6px 21px; - margin: $gl-padding-24 0; + padding: $gl-padding-4 20px; + margin: $gl-padding 0; background-color: transparent; .note-header-info { @@ -283,8 +278,6 @@ $note-form-margin-left: 72px; } .system-note-message { - display: inline; - &::first-letter { text-transform: lowercase; } @@ -303,26 +296,6 @@ $note-form-margin-left: 72px; } } - .timeline-icon { - float: left; - display: flex; - align-items: center; - background-color: $white-light; - width: $system-note-icon-size; - height: $system-note-icon-size; - border: 1px solid $border-color; - border-radius: $system-note-icon-size; - margin: -6px $gl-padding 0 0; - - svg { - width: $system-note-svg-size; - height: $system-note-svg-size; - fill: $gray-darkest; - display: block; - margin: 0 auto; - } - } - .timeline-content { @include notes-media('min', map-get($grid-breakpoints, sm)) { margin-left: 30px; @@ -380,6 +353,37 @@ $note-form-margin-left: 72px; } } } + + .system-note, + .discussion-filter-note { + .timeline-icon { + float: left; + display: flex; + align-items: center; + background-color: $white-light; + width: $system-note-icon-size; + height: $system-note-icon-size; + border: 1px solid $border-color; + border-radius: $system-note-icon-size; + margin: -6px 20px 0 0; + + svg { + width: $system-note-svg-size; + height: $system-note-svg-size; + fill: $gray-darkest; + display: block; + margin: 0 auto; + } + } + } + + .discussion-filter-note { + .timeline-icon { + width: $system-note-icon-size + 6; + height: $system-note-icon-size + 6; + margin-top: -8px; + } + } } // Diff code in discussion view @@ -434,7 +438,9 @@ $note-form-margin-left: 72px; .diff-file { .is-over { .add-diff-note { - display: inline-block; + display: inline-flex; + justify-content: center; + align-items: center; } } @@ -451,7 +457,7 @@ $note-form-margin-left: 72px; // Merge request notes in diffs // Diff is inline - .notes_content .note-header .note-headline-light { + .notes-content .note-header .note-headline-light { display: inline-block; position: relative; } @@ -463,12 +469,17 @@ $note-form-margin-left: 72px; border: 1px solid $border-color; border-left: 0; - &.notes_content { + &.notes-content { border-width: 1px 0; padding: 0; vertical-align: top; white-space: normal; + // Fixes subpixel rounding issue https://gitlab.com/gitlab-org/gitlab-ce/issues/53973 + // background-color is needed for dark code preference + padding-bottom: 1px; + background-color: $white-light; + &.parallel { border-width: 1px; @@ -509,12 +520,30 @@ $note-form-margin-left: 72px; } } -.commit-diff { - .notes_content { - background-color: $white-light; +.code-commit .notes-content, +.diff-viewer > .image ~ .note-container { + background-color: $white-light; + + .avatar-note-form-holder { + .user-avatar-link img { + margin: 13px $gl-padding $gl-padding; + } + + form, + ~ .discussion-form-container { + padding: $gl-padding; + + @include media-breakpoint-up(sm) { + margin-left: $note-icon-gutter-width; + } + } } } +.diff-viewer > .image ~ .note-container form.new-note { + margin-left: 0; +} + .discussion-header, .note-header-info { a { @@ -540,7 +569,7 @@ $note-form-margin-left: 72px; } .discussion-header { - min-height: 72px; + min-height: 74px; .note-header-info { padding-bottom: 0; @@ -553,8 +582,10 @@ $note-form-margin-left: 72px; } .unresolved { - .note-header-info { - margin-top: $gl-padding-8; + .discussion-header { + .note-header-info { + margin-top: $gl-padding-8; + } } } @@ -596,12 +627,6 @@ $note-form-margin-left: 72px; } .note-headline-meta { - display: inline-block; - - .system-note-message { - white-space: normal; - } - .system-note-separator { color: $gl-text-color-disabled; } @@ -643,7 +668,7 @@ $note-form-margin-left: 72px; display: inline-flex; align-items: center; margin-left: 10px; - color: $gray-darkest; + color: $gray-600; @include notes-media('max', map-get($grid-breakpoints, sm) - 1) { float: none; @@ -739,7 +764,7 @@ $note-form-margin-left: 72px; .add-diff-note { @include btn-comment-icon; opacity: 0; - margin-left: -55px; + margin-left: -52px; position: absolute; top: 50%; transform: translateY(-50%); @@ -758,15 +783,13 @@ $note-form-margin-left: 72px; background-color: $white-light; } - a { + a:not(.learn-more) { color: $blue-600; } } .line-resolve-all-container { - @include notes-media('min', map-get($grid-breakpoints, sm)) { - margin-right: 0; - } + margin: $gl-padding-4; > div { white-space: nowrap; @@ -782,6 +805,8 @@ $note-form-margin-left: 72px; } .btn { + line-height: $gl-line-height; + svg { fill: $gray-darkest; } @@ -807,10 +832,11 @@ $note-form-margin-left: 72px; .line-resolve-all { vertical-align: middle; display: inline-block; - padding: 6px 10px; + padding: $gl-padding-4 10px; background-color: $gray-light; border: 1px solid $border-color; border-radius: $border-radius-default; + font-size: $gl-btn-small-font-size; &.has-next-btn { border-top-right-radius: 0; @@ -820,11 +846,16 @@ $note-form-margin-left: 72px; .line-resolve-btn { margin-right: 5px; + color: $gray-darkest; svg { vertical-align: middle; } } + + @include media-breakpoint-down(xs) { + flex: 1; + } } .line-resolve-btn { @@ -834,7 +865,6 @@ $note-form-margin-left: 72px; background-color: transparent; border: 0; outline: 0; - color: $gray-darkest; transition: color $general-hover-transition-duration $general-hover-transition-curve; &.is-disabled { @@ -898,14 +928,9 @@ $note-form-margin-left: 72px; .diff-comment-form { display: block; } - - .add-diff-note svg { - margin-top: 4px; - } } .discussion-filter-container { - .btn > svg { width: $gl-col-padding; height: $gl-col-padding; @@ -927,7 +952,6 @@ $note-form-margin-left: 72px; //This needs to be deleted when Snippet/Commit comments are convered to Vue // See https://gitlab.com/gitlab-org/gitlab-ce/issues/53918#note_117038785 .unstyled-comments { - .discussion-header { padding: $gl-padding; border-bottom: 1px solid $border-color; diff --git a/app/assets/stylesheets/pages/notifications.scss b/app/assets/stylesheets/pages/notifications.scss index e98cb444f0a..e1cbf0e6654 100644 --- a/app/assets/stylesheets/pages/notifications.scss +++ b/app/assets/stylesheets/pages/notifications.scss @@ -4,6 +4,34 @@ .dropdown-menu { @extend .dropdown-menu-right; } + + @include media-breakpoint-down(sm) { + .notification-dropdown { + width: 100%; + } + + .notification-form { + display: block; + } + + .notifications-btn, + .btn-group { + width: 100%; + } + + .table-section { + border-top: 0; + min-height: unset; + + &:not(:first-child) { + padding-top: 0; + } + } + + .update-notifications { + width: 100%; + } + } } .notification { diff --git a/app/assets/stylesheets/pages/pipeline_schedules.scss b/app/assets/stylesheets/pages/pipeline_schedules.scss index 617b3db2fae..85c4902eee2 100644 --- a/app/assets/stylesheets/pages/pipeline_schedules.scss +++ b/app/assets/stylesheets/pages/pipeline_schedules.scss @@ -1,9 +1,4 @@ -.js-pipeline-schedule-form { - .dropdown-select, - .dropdown-menu-toggle { - width: 100% !important; - } - +.pipeline-schedule-form { .gl-field-error { margin: 10px 0 0; } diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index e676d48c1f4..aa6bbc8e473 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -341,13 +341,15 @@ &.builds .ci-table tr { height: 71px; } -} -.build-failures { - th { - border-top: 0; + .ci-table { + thead th { + border-top: 0; + } } +} +.build-failures { .build-state { padding: 20px 2px; @@ -496,7 +498,8 @@ list-style: none; } - &:last-child { + // when downstream pipelines are present, the last stage isn't the last column + &:last-child:not(.has-downstream) { .build { // Remove right connecting horizontal line from first build in last stage &:first-child::after { @@ -513,7 +516,8 @@ } } - &:first-child { + // when upstream pipelines are present, the first stage isn't the first column + &:first-child:not(.has-upstream) { .build { // Remove left curved connectors from all builds in first stage &:not(:first-child)::before { @@ -561,6 +565,7 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + line-height: 2.2em; } .build { @@ -697,6 +702,11 @@ } } } + + .stage-action svg { + left: 1px; + top: -2px; + } } // Triggers the dropdown in the big pipeline graph @@ -708,15 +718,9 @@ top: 8px; } +.ci-build-text, .ci-status-text { - max-width: 110px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - vertical-align: bottom; - display: inline-block; - position: relative; - font-weight: $gl-font-weight-normal; + font-weight: 200; } @mixin mini-pipeline-graph-color( @@ -787,10 +791,11 @@ } &.ci-status-icon-pending, - &.ci-status-icon-success_with_warnings { + &.ci-status-icon-success-with-warnings { @include mini-pipeline-graph-color($white, $orange-100, $orange-200, $orange-500, $orange-600, $orange-700); } + &.ci-status-icon-preparing, &.ci-status-icon-running { @include mini-pipeline-graph-color($white, $blue-100, $blue-200, $blue-500, $blue-600, $blue-700); } @@ -898,7 +903,7 @@ button.mini-pipeline-graph-dropdown-toggle { // Match dropdown.scss for all `a` tags &.non-details-job-component { - padding: 8px 16px; + padding: $gl-padding-8 $gl-btn-horz-padding; } .ci-job-name-component { @@ -907,26 +912,6 @@ button.mini-pipeline-graph-dropdown-toggle { flex: 1; } - // build name - .ci-build-text, - .ci-status-text { - font-weight: 200; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - max-width: 70%; - margin-left: 2px; - display: inline-block; - - &::after { - content: ''; - display: block; - } - - @include media-breakpoint-down(xs) { - max-width: 60%; - } - } .ci-status-icon { @extend .append-right-8; @@ -994,7 +979,6 @@ button.mini-pipeline-graph-dropdown-toggle { * Top arrow in the dropdown in the mini pipeline graph */ .mini-pipeline-graph-dropdown-menu { - &::before, &::after { content: ''; diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index a1e847009fc..87cef43b923 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -266,23 +266,6 @@ padding-top: 20px; } - .cover-controls { - position: static; - padding: 0 16px; - margin-bottom: 20px; - display: -webkit-flex; - display: flex; - - .btn { - -webkit-flex-grow: 1; - flex-grow: 1; - - &:first-child { - margin-left: 0; - } - } - } - .user-profile-nav { a { margin-right: 0; @@ -322,6 +305,7 @@ table.u2f-registrations { margin: 20px -5px 0; .bordered-box { + padding: 32px; border: 1px solid $blue-300; border-radius: $border-radius-default; background-color: $blue-50; @@ -455,8 +439,17 @@ table.u2f-registrations { } } + .form-group > label { + font-weight: $gl-font-weight-bold; + } + + .form-group > .form-text { + font-size: $gl-font-size; + } + .emoji-menu-toggle-button { @include emoji-menu-toggle-button; + padding: 6px 10px; .no-emoji-placeholder { position: relative; @@ -478,3 +471,41 @@ table.u2f-registrations { .help-block { color: $gl-text-color-secondary; } + +.gitlab-slack-gif { + width: 100%; + max-width: $add-to-slack-gif-max-width; +} + +.gitlab-slack-well { + background-color: $white-light; + box-shadow: none; + max-width: $add-to-slack-well-max-width; +} + +.gitlab-slack-logo { + width: $add-to-slack-logo-size; + height: $add-to-slack-logo-size; +} + +.gitlab-slack-popup { + width: 100%; + max-width: $add-to-slack-popup-max-width; +} + +.gitlab-slack-right-arrow svg { + fill: $white-dark; + width: $right-arrow-size; + height: $right-arrow-size; + vertical-align: text-bottom; +} + +.gitlab-slack-double-headed-arrow { + vertical-align: text-top; + + svg { + fill: $gray-darker; + width: $double-headed-arrow-width; + height: $double-headed-arrow-height; + } +} diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 277030ad3af..151af843c95 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -18,12 +18,9 @@ } .input-group { - display: flex; - .select2-container { display: unset; max-width: unset; - width: unset !important; flex-grow: 1; } @@ -70,6 +67,10 @@ } } +.classification-label { + background-color: $red-500; +} + .toggle-wrapper { margin-top: 5px; } @@ -212,8 +213,7 @@ } } - .access-request-link, - .home-panel-topic-list { + .access-request-link { padding-left: $gl-padding-8; border-left: 1px solid $gl-text-color-secondary; } @@ -571,9 +571,7 @@ .import-buttons { padding-left: 0; - display: -webkit-flex; display: flex; - -webkit-flex-wrap: wrap; flex-wrap: wrap; .btn { @@ -695,10 +693,6 @@ } } -.project-empty-note-panel { - border-bottom: 1px solid $border-color; -} - .project-stats, .project-buttons { .scrolling-tabs-container { @@ -1168,6 +1162,8 @@ pre.light-well { .cannot-be-merged:hover { color: $red-500; margin-top: 2px; + position: relative; + z-index: 2; } .private-forks-notice .private-fork-icon { @@ -1450,3 +1446,86 @@ pre.light-well { } } } + +.project-filters { + .btn svg { + color: $gl-gray-700; + } + + .button-filter-group { + .btn { + width: 96px; + } + + a { + color: $black; + } + + .active { + background: $btn-active-gray; + } + } + + .filtered-search-dropdown-label { + min-width: 68px; + + @include media-breakpoint-down(xs) { + min-width: 60px; + } + } + + .filtered-search { + min-width: 30%; + flex-basis: 0; + + .project-filter-form .project-filter-form-field { + padding-right: $gl-padding-8; + } + + .filtered-search, + .filtered-search-nav, + .filtered-search-dropdown { + flex-basis: 0; + } + + @include media-breakpoint-down(lg) { + min-width: 15%; + + .project-filter-form-field { + min-width: 150px; + } + } + + @include media-breakpoint-down(md) { + min-width: 30%; + } + } + + .filtered-search-box { + border-radius: 3px 0 0 3px; + } + + .dropdown-menu-toggle { + margin-left: $gl-padding-8; + } + + @include media-breakpoint-down(md) { + .extended-filtered-search-box { + min-width: 55%; + } + + .filtered-search-dropdown { + width: 50%; + + .dropdown-menu-toggle { + width: 100%; + } + } + } + + @include media-breakpoint-down(xs) { + .filtered-search-dropdown { + width: 100%; + } + } +} diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss new file mode 100644 index 00000000000..c03554b287f --- /dev/null +++ b/app/assets/stylesheets/pages/prometheus.scss @@ -0,0 +1,270 @@ +.prometheus-graphs { + .dropdowns { + .dropdown-menu-toggle { + svg { + position: absolute; + right: 5%; + top: 25%; + } + } + + .dropdown-menu-toggle, + .dropdown-menu { + width: 240px; + } + } +} + +.prometheus-panel { + margin-top: 20px; +} + +.prometheus-graph-group { + display: flex; + flex-wrap: wrap; + padding: $gl-padding / 2; +} + +.prometheus-graph { + padding: $gl-padding / 2; +} + +.prometheus-graph-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: $gl-padding-8; + + h5 { + font-size: $gl-font-size-large; + margin: 0; + } +} + +.prometheus-graph-cursor { + position: absolute; + background: $gray-600; + width: 1px; +} + +.prometheus-graph-flag { + display: block; + min-width: 160px; + border: 0; + box-shadow: 0 1px 4px 0 $black-transparent; + + h5 { + padding: 0; + margin: 0; + font-size: 14px; + line-height: 1.2; + } + + .deploy-meta-content { + border-bottom: 1px solid $white-dark; + + svg { + height: 15px; + vertical-align: bottom; + } + } + + &.popover { + padding: 0; + + &.left { + left: auto; + right: 0; + margin-right: 10px; + + > .arrow { + right: -14px; + border-left-color: $border-color; + } + + > .arrow::after { + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-left: 4px solid $gray-50; + } + + .arrow-shadow { + right: -3px; + box-shadow: 1px 0 9px 0 $black-transparent; + } + } + + &.right { + left: 0; + right: auto; + margin-left: 10px; + + > .arrow { + left: -7px; + border-right-color: $border-color; + } + + > .arrow::after { + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-right: 4px solid $gray-50; + } + + .arrow-shadow { + left: -3px; + box-shadow: 1px 0 8px 0 $black-transparent; + } + } + + > .arrow { + top: 10px; + margin: 0; + } + + .arrow-shadow { + content: ''; + position: absolute; + width: 7px; + height: 7px; + background-color: transparent; + transform: rotate(45deg); + top: 13px; + } + + > .popover-title, + > .popover-content, + > .popover-header, + > .popover-body { + padding: 8px; + font-size: 12px; + white-space: nowrap; + position: relative; + } + + > .popover-title { + background-color: $gray-50; + border-radius: $border-radius-default $border-radius-default 0 0; + } + } + + strong { + font-weight: 600; + } +} + +.prometheus-table { + border-collapse: collapse; + padding: 0; + margin: 0; + + td { + vertical-align: middle; + + + td { + padding-left: 8px; + vertical-align: top; + } + } + + .legend-metric-title { + font-size: 12px; + vertical-align: middle; + } +} + +.prometheus-svg-container { + position: relative; + height: 0; + width: 100%; + padding: 0; + padding-bottom: 100%; + + .text-metric-usage { + fill: $black; + font-weight: $gl-font-weight-normal; + font-size: 12px; + } + + > svg { + position: absolute; + height: 100%; + width: 100%; + left: 0; + top: 0; + + text { + fill: $gl-text-color; + stroke-width: 0; + } + + .text-metric-bold { + font-weight: $gl-font-weight-bold; + } + + .label-axis-text { + fill: $black; + font-weight: $gl-font-weight-normal; + font-size: 10px; + } + + .legend-axis-text { + fill: $black; + } + + .tick { + > line { + stroke: $gray-darker; + } + + > text { + fill: $gray-600; + font-size: 10px; + } + } + + .y-label-text, + .x-label-text { + fill: $gray-darkest; + } + + .axis-tick { + stroke: $gray-darker; + } + + .deploy-info-text { + dominant-baseline: text-before-edge; + font-size: 12px; + } + + .deploy-info-text-link { + font-family: $monospace-font; + fill: $blue-600; + + &:hover { + fill: $blue-800; + } + } + + @include media-breakpoint-down(sm) { + .label-axis-text, + .text-metric-usage, + .legend-axis-text { + font-size: 8px; + } + + .tick > text { + font-size: 8px; + } + } + } +} + +.prometheus-table-row-highlight { + background-color: $gray-100; +} + +.prometheus-graph-overlay { + fill: none; + opacity: 0; + pointer-events: all; +} diff --git a/app/assets/stylesheets/pages/reports.scss b/app/assets/stylesheets/pages/reports.scss index f7619ccbd20..94da72622af 100644 --- a/app/assets/stylesheets/pages/reports.scss +++ b/app/assets/stylesheets/pages/reports.scss @@ -52,11 +52,6 @@ .report-block-list-icon .loading-container { position: relative; left: -2px; - // needed to make the next element align with the - // elements below that have a svg with 16px width - .fa-spinner { - width: 16px; - } } } diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 149c3254d84..dbf600df9d6 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -75,6 +75,8 @@ input[type='checkbox']:hover { } .search-input-wrap { + width: 100%; + .search-icon, .clear-icon { position: absolute; @@ -87,6 +89,7 @@ input[type='checkbox']:hover { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; + user-select: none; } .clear-icon { @@ -185,13 +188,11 @@ input[type='checkbox']:hover { .search-holder { @include media-breakpoint-up(sm) { - display: -webkit-flex; display: flex; } .search-field-holder, .project-filter-form { - -webkit-flex: 1 0 auto; flex: 1 0 auto; position: relative; margin-right: 0; @@ -260,3 +261,13 @@ input[type='checkbox']:hover { color: $blue-600; } } + +// Disable webkit input icons, link to solution: https://stackoverflow.com/questions/9421551/how-do-i-remove-all-default-webkit-search-field-styling +/* stylelint-disable property-no-vendor-prefix */ +input[type='search']::-webkit-search-decoration, +input[type='search']::-webkit-search-cancel-button, +input[type='search']::-webkit-search-results-button, +input[type='search']::-webkit-search-results-decoration { + -webkit-appearance: none; +} +/* stylelint-enable */ diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 811cc310a8f..0a9c56f5625 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -23,7 +23,10 @@ } .settings { - border-bottom: 1px solid $gray-darker; + // border-top for each item except the top one + + .settings { + border-top: 1px solid $border-color; + } &:first-of-type { margin-top: 10px; @@ -36,7 +39,7 @@ .settings-header { position: relative; - padding: 20px 110px 10px 0; + padding: 20px 110px 0 0; h4 { margin-top: 0; @@ -213,6 +216,31 @@ } } +.nested-settings { + padding-left: 20px; +} + +.input-btn-group { + display: flex; + + .input-large { + flex: 1; + } + + .btn { + margin-left: 10px; + } +} + +.content-list > .settings-flex-row { + display: flex; + align-items: center; + + .float-right { + margin-left: auto; + } +} + .prometheus-metrics-monitoring { .card { .card-toggle { @@ -243,6 +271,27 @@ } } + .custom-monitored-metrics { + .card-title { + display: flex; + align-items: center; + + > .btn-success { + margin-left: auto; + } + } + + .custom-metric { + display: flex; + align-items: center; + } + + .custom-metric-link-bold { + font-weight: $gl-font-weight-bold; + text-decoration: none; + } + } + .loading-metrics, .empty-metrics { padding: 30px 10px; @@ -277,6 +326,12 @@ } } +.saml-settings.info-well { + .form-control[readonly] { + background: $white-light; + } +} + .modal-doorkeepr-auth { .modal-body { padding: $gl-padding; @@ -316,8 +371,4 @@ .push-pull-table { margin-top: 1em; - - .mirror-action-buttons { - padding-right: 0; - } } diff --git a/app/assets/stylesheets/pages/stat_graph.scss b/app/assets/stylesheets/pages/stat_graph.scss index d331edaa302..31ccdacbc02 100644 --- a/app/assets/stylesheets/pages/stat_graph.scss +++ b/app/assets/stylesheets/pages/stat_graph.scss @@ -18,21 +18,15 @@ @include make-col-ready(); @include make-col(12); } - - svg { - width: 100%; - } } #contributors { + flex: 1; + .contributors-list { margin: 0 0 10px; list-style: none; padding: 0; - - svg { - width: 100%; - } } .person { diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss index 7d59dd3b5d1..613f643af3a 100644 --- a/app/assets/stylesheets/pages/status.scss +++ b/app/assets/stylesheets/pages/status.scss @@ -33,17 +33,18 @@ border-color: $gl-text-color; &:not(span):hover { - background-color: rgba($gl-text-color, .07); + background-color: rgba($gl-text-color, 0.07); } } &.ci-pending, - &.ci-failed_with_warnings, - &.ci-success_with_warnings { + &.ci-failed-with-warnings, + &.ci-success-with-warnings { @include status-color($orange-100, $orange-500, $orange-700); } &.ci-info, + &.ci-preparing, &.ci-running { @include status-color($blue-100, $blue-500, $blue-600); } @@ -54,7 +55,7 @@ border-color: $gl-text-color-secondary; &:not(span):hover { - background-color: rgba($gl-text-color-secondary, .07); + background-color: rgba($gl-text-color-secondary, 0.07); } } } diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index 3fc37e20c36..586365eb1ce 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -6,9 +6,7 @@ .todos-list > .todo { // workaround because we cannot use border-colapse border-top: 1px solid transparent; - display: -webkit-flex; display: flex; - -webkit-flex-direction: row; flex-direction: row; &:hover { @@ -29,23 +27,18 @@ .todo-avatar, .todo-actions { @include transition(opacity); - -webkit-flex: 0 0 auto; flex: 0 0 auto; } .todo-actions { - display: -webkit-flex; display: flex; - -webkit-justify-content: center; justify-content: center; - -webkit-flex-direction: column; flex-direction: column; margin-left: 10px; min-width: 55px; } .todo-item { - -webkit-flex: 0 1 100%; flex: 0 1 100%; min-width: 0; } @@ -60,13 +53,13 @@ .todo-avatar, .todo-item { - opacity: .6; + opacity: 0.6; } } .todo-avatar, .todo-item { - opacity: .2; + opacity: 0.2; } .btn { @@ -82,7 +75,6 @@ display: flex; > .title-item { - -webkit-flex: 0 0 auto; flex: 0 0 auto; margin: 0 2px; @@ -96,7 +88,6 @@ } .todo-label { - -webkit-flex: 0 1 auto; flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; @@ -119,45 +110,38 @@ } .todo-body { - .todo-note { - word-wrap: break-word; - - .md { - color: $gl-grayish-blue; - font-size: $gl-font-size; - - .badge.badge-pill { - color: $gl-text-color; - } + .badge.badge-pill, + p { + color: $gl-text-color; + } - p { - color: $gl-text-color; - } - } + .md { + color: $gl-grayish-blue; + font-size: $gl-font-size; + } - code { - white-space: pre-wrap; - } + code { + white-space: pre-wrap; + } - pre { - border: 0; - background: $gray-light; - border-radius: 0; - color: $gl-gray-500; - margin: 0 20px; - overflow: hidden; - } + pre { + border: 0; + background: $gray-light; + border-radius: 0; + color: $gl-gray-500; + margin: 0 20px; + overflow: hidden; + } - .note-image-attach { - margin-top: 4px; - margin-left: 0; - max-width: 200px; - float: none; - } + .note-image-attach { + margin-top: 4px; + margin-left: 0; + max-width: 200px; + float: none; + } - p:last-child { - margin-bottom: 0; - } + p:last-child { + margin-bottom: 0; } } } @@ -222,23 +206,19 @@ } .todos-empty { - display: -webkit-flex; display: flex; - -webkit-flex-direction: column; flex-direction: column; max-width: 900px; margin-left: auto; margin-right: auto; @include media-breakpoint-up(sm) { - -webkit-flex-direction: row; flex-direction: row; padding-top: 80px; } } .todos-empty-content { - -webkit-align-self: center; align-self: center; max-width: 480px; margin-right: 20px; @@ -252,7 +232,6 @@ @include media-breakpoint-up(sm) { width: 300px; margin-right: 0; - -webkit-order: 2; order: 2; } } diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index a46b8679a42..5664f46484e 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -172,26 +172,6 @@ text-decoration: inherit; } } - - .tree_commit { - max-width: 320px; - - .str-truncated { - max-width: 100%; - } - } - - .tree_time_ago { - min-width: 135px; - } - } - - .tree_author { - padding-right: 8px; - - .commit-author-name { - color: $gl-text-color; - } } .tree-truncated-warning { diff --git a/app/assets/stylesheets/pages/ui_dev_kit.scss b/app/assets/stylesheets/pages/ui_dev_kit.scss index 84c617c7ec0..7744fd814d0 100644 --- a/app/assets/stylesheets/pages/ui_dev_kit.scss +++ b/app/assets/stylesheets/pages/ui_dev_kit.scss @@ -10,7 +10,7 @@ margin-bottom: 15px; &::before { - content: "Example"; + content: 'Example'; color: $ui-dev-kit-example-color; } } diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss index 82e887aa62a..3260aed143e 100644 --- a/app/assets/stylesheets/pages/wiki.scss +++ b/app/assets/stylesheets/pages/wiki.scss @@ -179,9 +179,3 @@ ul.wiki-pages-list.content-list { } } } - -.wiki:not(.use-csslab) { - table { - @include markdown-table; - } -} diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss index 9c01a2f8bda..5a8940ffd6d 100644 --- a/app/assets/stylesheets/performance_bar.scss +++ b/app/assets/stylesheets/performance_bar.scss @@ -79,8 +79,12 @@ table { color: $black; - strong { - color: $black; + td { + vertical-align: top; + } + + .backtrace-row { + display: none; } } diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index bb10928a037..9ed1600419d 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -1,21 +1,21 @@ -.wiki h1, -.wiki h2, -.wiki h3, -.wiki h4, -.wiki h5, -.wiki h6 { +.md h1, +.md h2, +.md h3, +.md h4, +.md h5, +.md h6 { margin-top: 17px; } -.wiki h1 { +.md h1 { font-size: 30px; } -.wiki h2 { +.md h2 { font-size: 22px; } -.wiki h3 { +.md h3 { font-size: 18px; font-weight: 600; } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss new file mode 100644 index 00000000000..3648ec5e239 --- /dev/null +++ b/app/assets/stylesheets/utilities.scss @@ -0,0 +1,17 @@ +@each $variant, $range in $color-ranges { + @each $suffix, $color in $range { + #{'.bg-#{$variant}-#{$suffix}'} { + background-color: $color; + } + + #{'.text-#{$variant}-#{$suffix}'} { + color: $color; + } + } +} + +@each $index, $size in $type-scale { + #{'.text-#{$index}'} { + font-size: $size; + } +} diff --git a/app/assets/stylesheets/vendors/atwho.scss b/app/assets/stylesheets/vendors/atwho.scss new file mode 100644 index 00000000000..ccf3824ea56 --- /dev/null +++ b/app/assets/stylesheets/vendors/atwho.scss @@ -0,0 +1,92 @@ +.atwho-view { + overflow-y: auto; + overflow-x: hidden; + + .name, + small.aliases, + small.params { + float: left; + } + + small.aliases, + small.params { + padding: 2px 5px; + } + + small.description { + float: right; + padding: 3px 5px; + } + + .avatar-inline { + margin-bottom: 0; + } + + .has-warning { + .name, + .description { + color: $orange-700; + } + } + + .cur { + .avatar { + @include disable-all-animation; + border: 1px solid $white-light; + } + } + + ul > li { + @include clearfix; + white-space: nowrap; + } + + // TODO: fallback to global style + .atwho-view-ul { + padding: 8px 1px; + + li { + padding: 8px 16px; + border: 0; + + &.cur { + background-color: $gray-darker; + color: $gl-text-color; + + small { + color: inherit; + } + + &.has-warning { + color: $orange-700; + background-color: $orange-100; + } + } + + div.avatar { + display: inline-flex; + justify-content: center; + align-items: center; + + .center { + line-height: 14px; + } + } + + strong { + color: $gl-text-color; + } + } + } +} + +@include media-breakpoint-down(xs) { + .atwho-view-ul { + width: 350px; + } + + .atwho-view ul li { + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb index 68e14f0c2e5..7d8016f763d 100644 --- a/app/controllers/abuse_reports_controller.rb +++ b/app/controllers/abuse_reports_controller.rb @@ -16,7 +16,7 @@ class AbuseReportsController < ApplicationController if @abuse_report.save @abuse_report.notify - message = "Thank you for your report. A GitLab administrator will look into it shortly." + message = _("Thank you for your report. A GitLab administrator will look into it shortly.") redirect_to @abuse_report.user, notice: message else render :new @@ -37,9 +37,9 @@ class AbuseReportsController < ApplicationController @user = User.find_by(id: params[:user_id]) if @user.nil? - redirect_to root_path, alert: "Cannot create the abuse report. The user has been deleted." + redirect_to root_path, alert: _("Cannot create the abuse report. The user has been deleted.") elsif @user.blocked? - redirect_to @user, alert: "Cannot create the abuse report. This user has been blocked." + redirect_to @user, alert: _("Cannot create the abuse report. This user has been blocked.") end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/acme_challenges_controller.rb b/app/controllers/acme_challenges_controller.rb new file mode 100644 index 00000000000..67a39d8870b --- /dev/null +++ b/app/controllers/acme_challenges_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AcmeChallengesController < ActionController::Base + def show + if acme_order + render plain: acme_order.challenge_file_content, content_type: 'text/plain' + else + head :not_found + end + end + + private + + def acme_order + @acme_order ||= PagesDomainAcmeOrder.find_by_domain_and_token(params[:domain], params[:token]) + end +end diff --git a/app/controllers/admin/appearances_controller.rb b/app/controllers/admin/appearances_controller.rb index 2b9cae21da2..383ec2a7d16 100644 --- a/app/controllers/admin/appearances_controller.rb +++ b/app/controllers/admin/appearances_controller.rb @@ -14,7 +14,7 @@ class Admin::AppearancesController < Admin::ApplicationController @appearance = Appearance.new(appearance_params) if @appearance.save - redirect_to admin_appearances_path, notice: 'Appearance was successfully created.' + redirect_to admin_appearances_path, notice: _('Appearance was successfully created.') else render action: 'show' end @@ -22,7 +22,7 @@ class Admin::AppearancesController < Admin::ApplicationController def update if @appearance.update(appearance_params) - redirect_to admin_appearances_path, notice: 'Appearance was successfully updated.' + redirect_to admin_appearances_path, notice: _('Appearance was successfully updated.') else render action: 'show' end @@ -33,21 +33,21 @@ class Admin::AppearancesController < Admin::ApplicationController @appearance.save - redirect_to admin_appearances_path, notice: 'Logo was successfully removed.' + redirect_to admin_appearances_path, notice: _('Logo was successfully removed.') end def header_logos @appearance.remove_header_logo! @appearance.save - redirect_to admin_appearances_path, notice: 'Header logo was successfully removed.' + redirect_to admin_appearances_path, notice: _('Header logo was successfully removed.') end def favicon @appearance.remove_favicon! @appearance.save - redirect_to admin_appearances_path, notice: 'Favicon was successfully removed.' + redirect_to admin_appearances_path, notice: _('Favicon was successfully removed.') end private @@ -78,6 +78,7 @@ class Admin::AppearancesController < Admin::ApplicationController footer_message message_background_color message_font_color + email_header_and_footer_enabled ] end end diff --git a/app/controllers/admin/application_controller.rb b/app/controllers/admin/application_controller.rb index ef182b981f1..b742b7e19cf 100644 --- a/app/controllers/admin/application_controller.rb +++ b/app/controllers/admin/application_controller.rb @@ -4,10 +4,7 @@ # # Automatically sets the layout and ensures an administrator is logged in class Admin::ApplicationController < ApplicationController - before_action :authenticate_admin! - layout 'admin' + include EnforcesAdminAuthentication - def authenticate_admin! - render_404 unless current_user.admin? - end + layout 'admin' end diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 8f267eccc8a..d5bc723aa8c 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -48,7 +48,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController respond_to do |format| if successful format.json { head :ok } - format.html { redirect_to redirect_path, notice: 'Application settings saved successfully' } + format.html { redirect_to redirect_path, notice: _('Application settings saved successfully') } else format.json { head :bad_request } format.html { render :show } @@ -70,13 +70,13 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController def reset_registration_token @application_setting.reset_runners_registration_token! - flash[:notice] = 'New runners registration token has been generated!' + flash[:notice] = _('New runners registration token has been generated!') redirect_to admin_runners_path end def reset_health_check_token @application_setting.reset_health_check_access_token! - flash[:notice] = 'New health check access token has been generated!' + flash[:notice] = _('New health check access token has been generated!') redirect_back_or_default end @@ -85,10 +85,17 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController redirect_to( admin_application_settings_path, - notice: 'Started asynchronous removal of all repository check states.' + notice: _('Started asynchronous removal of all repository check states.') ) end + # Getting ToS url requires `directory` api call to Let's Encrypt + # which could result in 500 error/slow rendering on settings page + # Because of that we use separate controller action + def lets_encrypt_terms_of_service + redirect_to ::Gitlab::LetsEncrypt.terms_of_service_url + end + private def set_application_setting @@ -124,7 +131,10 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController end def visible_application_setting_attributes - ApplicationSettingsHelper.visible_attributes + [ + [ + *::ApplicationSettingsHelper.visible_attributes, + *::ApplicationSettingsHelper.external_authorization_service_attributes, + *lets_encrypt_visible_attributes, :domain_blacklist_file, disabled_oauth_sign_in_sources: [], import_sources: [], @@ -132,4 +142,13 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController restricted_visibility_levels: [] ] end + + def lets_encrypt_visible_attributes + return [] unless Feature.enabled?(:pages_auto_ssl) + + [ + :lets_encrypt_notification_email, + :lets_encrypt_terms_of_service_accepted + ] + end end diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index 6fc336714b6..3648c8be426 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -34,7 +34,7 @@ class Admin::ApplicationsController < Admin::ApplicationController def update if @application.update(application_params) - redirect_to admin_application_path(@application), notice: 'Application was successfully updated.' + redirect_to admin_application_path(@application), notice: _('Application was successfully updated.') else render :edit end @@ -42,7 +42,7 @@ class Admin::ApplicationsController < Admin::ApplicationController def destroy @application.destroy - redirect_to admin_applications_url, status: 302, notice: 'Application was successfully destroyed.' + redirect_to admin_applications_url, status: 302, notice: _('Application was successfully destroyed.') end private diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb index a91d9a534cd..6e5dd1a1f55 100644 --- a/app/controllers/admin/broadcast_messages_controller.rb +++ b/app/controllers/admin/broadcast_messages_controller.rb @@ -19,7 +19,7 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController @broadcast_message = BroadcastMessage.new(broadcast_message_params) if @broadcast_message.save - redirect_to admin_broadcast_messages_path, notice: 'Broadcast Message was successfully created.' + redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully created.') else render :index end @@ -27,7 +27,7 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController def update if @broadcast_message.update(broadcast_message_params) - redirect_to admin_broadcast_messages_path, notice: 'Broadcast Message was successfully updated.' + redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully updated.') else render :edit end diff --git a/app/controllers/admin/clusters/applications_controller.rb b/app/controllers/admin/clusters/applications_controller.rb new file mode 100644 index 00000000000..7400cc16175 --- /dev/null +++ b/app/controllers/admin/clusters/applications_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Admin::Clusters::ApplicationsController < Clusters::ApplicationsController + include EnforcesAdminAuthentication + + private + + def clusterable + @clusterable ||= InstanceClusterablePresenter.fabricate(Clusters::Instance.new, current_user: current_user) + end +end diff --git a/app/controllers/admin/clusters_controller.rb b/app/controllers/admin/clusters_controller.rb new file mode 100644 index 00000000000..f54933de10f --- /dev/null +++ b/app/controllers/admin/clusters_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Admin::ClustersController < Clusters::ClustersController + include EnforcesAdminAuthentication + + layout 'admin' + + private + + def clusterable + @clusterable ||= InstanceClusterablePresenter.fabricate(Clusters::Instance.new, current_user: current_user) + end +end diff --git a/app/controllers/admin/deploy_keys_controller.rb b/app/controllers/admin/deploy_keys_controller.rb index 49ce275ad14..180f7d4c803 100644 --- a/app/controllers/admin/deploy_keys_controller.rb +++ b/app/controllers/admin/deploy_keys_controller.rb @@ -25,7 +25,7 @@ class Admin::DeployKeysController < Admin::ApplicationController def update if deploy_key.update(update_params) - flash[:notice] = 'Deploy key was successfully updated.' + flash[:notice] = _('Deploy key was successfully updated.') redirect_to admin_deploy_keys_path else render 'edit' diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 46e85e1424f..15f7ef881c8 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -36,7 +36,7 @@ class Admin::GroupsController < Admin::ApplicationController if @group.save @group.add_owner(current_user) - redirect_to [:admin, @group], notice: "Group '#{@group.name}' was successfully created." + redirect_to [:admin, @group], notice: _('Group %{group_name} was successfully created.') % { group_name: @group.name } else render "new" end @@ -44,7 +44,7 @@ class Admin::GroupsController < Admin::ApplicationController def update if @group.update(group_params) - redirect_to [:admin, @group], notice: 'Group was successfully updated.' + redirect_to [:admin, @group], notice: _('Group was successfully updated.') else render "edit" end @@ -55,7 +55,7 @@ class Admin::GroupsController < Admin::ApplicationController result = Members::CreateService.new(current_user, member_params.merge(limit: -1)).execute(@group) if result[:status] == :success - redirect_to [:admin, @group], notice: 'Users were successfully added.' + redirect_to [:admin, @group], notice: _('Users were successfully added.') else redirect_to [:admin, @group], alert: result[:message] end @@ -66,7 +66,7 @@ class Admin::GroupsController < Admin::ApplicationController redirect_to admin_groups_path, status: 302, - alert: "Group '#{@group.name}' was scheduled for deletion." + alert: _('Group %{group_name} was scheduled for deletion.') % { group_name: @group.name } end private @@ -89,7 +89,8 @@ class Admin::GroupsController < Admin::ApplicationController :request_access_enabled, :visibility_level, :require_two_factor_authentication, - :two_factor_grace_period + :two_factor_grace_period, + :project_creation_level ] end end diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb index d0abdec50ae..51b0f45c5be 100644 --- a/app/controllers/admin/hooks_controller.rb +++ b/app/controllers/admin/hooks_controller.rb @@ -14,7 +14,7 @@ class Admin::HooksController < Admin::ApplicationController @hook = SystemHook.new(hook_params.to_h) if @hook.save - redirect_to admin_hooks_path, notice: 'Hook was successfully created.' + redirect_to admin_hooks_path, notice: _('Hook was successfully created.') else @hooks = SystemHook.all render :index @@ -26,7 +26,7 @@ class Admin::HooksController < Admin::ApplicationController def update if hook.update(hook_params) - flash[:notice] = 'System hook was successfully updated.' + flash[:notice] = _('System hook was successfully updated.') redirect_to admin_hooks_path else render 'edit' diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb index b51c2f678ca..f518f7a657f 100644 --- a/app/controllers/admin/identities_controller.rb +++ b/app/controllers/admin/identities_controller.rb @@ -13,7 +13,7 @@ class Admin::IdentitiesController < Admin::ApplicationController @identity.user_id = user.id if @identity.save - redirect_to admin_user_identities_path(@user), notice: 'User identity was successfully created.' + redirect_to admin_user_identities_path(@user), notice: _('User identity was successfully created.') else render :new end @@ -29,7 +29,7 @@ class Admin::IdentitiesController < Admin::ApplicationController def update if @identity.update(identity_params) RepairLdapBlockedUserService.new(@user).execute - redirect_to admin_user_identities_path(@user), notice: 'User identity was successfully updated.' + redirect_to admin_user_identities_path(@user), notice: _('User identity was successfully updated.') else render :edit end @@ -38,9 +38,9 @@ class Admin::IdentitiesController < Admin::ApplicationController def destroy if @identity.destroy RepairLdapBlockedUserService.new(@user).execute - redirect_to admin_user_identities_path(@user), status: 302, notice: 'User identity was successfully removed.' + redirect_to admin_user_identities_path(@user), status: 302, notice: _('User identity was successfully removed.') else - redirect_to admin_user_identities_path(@user), status: 302, alert: 'Failed to remove user identity.' + redirect_to admin_user_identities_path(@user), status: 302, alert: _('Failed to remove user identity.') end end diff --git a/app/controllers/admin/impersonation_tokens_controller.rb b/app/controllers/admin/impersonation_tokens_controller.rb index 706bcc1e549..c35619a944e 100644 --- a/app/controllers/admin/impersonation_tokens_controller.rb +++ b/app/controllers/admin/impersonation_tokens_controller.rb @@ -12,7 +12,7 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController if @impersonation_token.save PersonalAccessToken.redis_store!(current_user.id, @impersonation_token.token) - redirect_to admin_user_impersonation_tokens_path, notice: "A new impersonation token has been created." + redirect_to admin_user_impersonation_tokens_path, notice: _("A new impersonation token has been created.") else set_index_vars render :index @@ -23,9 +23,9 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController @impersonation_token = finder.find(params[:id]) if @impersonation_token.revoke! - flash[:notice] = "Revoked impersonation token #{@impersonation_token.name}!" + flash[:notice] = _("Revoked impersonation token %{token_name}!") % { token_name: @impersonation_token.name } else - flash[:alert] = "Could not revoke impersonation token #{@impersonation_token.name}." + flash[:alert] = _("Could not revoke impersonation token %{token_name}.") % { token_name: @impersonation_token.name } end redirect_to admin_user_impersonation_tokens_path @@ -49,7 +49,7 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def set_index_vars - @scopes = Gitlab::Auth.available_scopes(current_user) + @scopes = Gitlab::Auth.available_scopes_for(current_user) @impersonation_token ||= finder.build @inactive_impersonation_tokens = finder(state: 'inactive').execute diff --git a/app/controllers/admin/keys_controller.rb b/app/controllers/admin/keys_controller.rb index 4e9262ccc96..340eecd7632 100644 --- a/app/controllers/admin/keys_controller.rb +++ b/app/controllers/admin/keys_controller.rb @@ -17,9 +17,9 @@ class Admin::KeysController < Admin::ApplicationController respond_to do |format| if key.destroy - format.html { redirect_to keys_admin_user_path(user), status: 302, notice: 'User key was successfully removed.' } + format.html { redirect_to keys_admin_user_path(user), status: 302, notice: _('User key was successfully removed.') } else - format.html { redirect_to keys_admin_user_path(user), status: 302, alert: 'Failed to remove user key.' } + format.html { redirect_to keys_admin_user_path(user), status: 302, alert: _('Failed to remove user key.') } end end end diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb index aa5eae7a474..90c1694fd2e 100644 --- a/app/controllers/admin/labels_controller.rb +++ b/app/controllers/admin/labels_controller.rb @@ -21,7 +21,7 @@ class Admin::LabelsController < Admin::ApplicationController @label = Labels::CreateService.new(label_params).execute(template: true) if @label.persisted? - redirect_to admin_labels_url, notice: "Label was created" + redirect_to admin_labels_url, notice: _("Label was created") else render :new end @@ -31,7 +31,7 @@ class Admin::LabelsController < Admin::ApplicationController @label = Labels::UpdateService.new(label_params).execute(@label) if @label.valid? - redirect_to admin_labels_path, notice: 'Label was successfully updated.' + redirect_to admin_labels_path, notice: _('Label was successfully updated.') else render :edit end @@ -43,7 +43,7 @@ class Admin::LabelsController < Admin::ApplicationController respond_to do |format| format.html do - redirect_to admin_labels_path, status: 302, notice: 'Label was removed' + redirect_to admin_labels_path, status: 302, notice: _('Label was removed') end format.js end diff --git a/app/controllers/admin/logs_controller.rb b/app/controllers/admin/logs_controller.rb index 06b0e6a15a3..704e727b1da 100644 --- a/app/controllers/admin/logs_controller.rb +++ b/app/controllers/admin/logs_controller.rb @@ -15,7 +15,8 @@ class Admin::LogsController < Admin::ApplicationController Gitlab::EnvironmentLogger, Gitlab::SidekiqLogger, Gitlab::RepositoryCheckLogger, - Gitlab::ProjectServiceLogger + Gitlab::ProjectServiceLogger, + Gitlab::Kubernetes::Logger ] end end diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb index 550f29a58d2..70db15916b9 100644 --- a/app/controllers/admin/projects_controller.rb +++ b/app/controllers/admin/projects_controller.rb @@ -3,7 +3,7 @@ class Admin::ProjectsController < Admin::ApplicationController include MembersPresentation - before_action :project, only: [:show, :transfer, :repository_check] + before_action :project, only: [:show, :transfer, :repository_check, :destroy] before_action :group, only: [:show, :transfer] def index @@ -15,7 +15,7 @@ class Admin::ProjectsController < Admin::ApplicationController format.html format.json do render json: { - html: view_to_html_string("admin/projects/_projects", locals: { projects: @projects }) + html: view_to_html_string("admin/projects/_projects", projects: @projects) } end end @@ -35,12 +35,21 @@ class Admin::ProjectsController < Admin::ApplicationController end # rubocop: enable CodeReuse/ActiveRecord + def destroy + ::Projects::DestroyService.new(@project, current_user, {}).async_execute + flash[:notice] = _("Project '%{project_name}' is in the process of being deleted.") % { project_name: @project.full_name } + + redirect_to admin_projects_path, status: :found + rescue Projects::DestroyService::DestroyError => ex + redirect_to admin_projects_path, status: 302, alert: ex.message + end + # rubocop: disable CodeReuse/ActiveRecord def transfer namespace = Namespace.find_by(id: params[:new_namespace_id]) ::Projects::TransferService.new(@project, current_user, params.dup).execute(namespace) - @project.reload + @project.reset redirect_to admin_project_path(@project) end # rubocop: enable CodeReuse/ActiveRecord @@ -50,7 +59,7 @@ class Admin::ProjectsController < Admin::ApplicationController redirect_to( admin_project_path(@project), - notice: 'Repository check was triggered.' + notice: _('Repository check was triggered.') ) end diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index 0b6ff491c66..783c59822f1 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Admin::RunnersController < Admin::ApplicationController - before_action :runner, except: :index + before_action :runner, except: [:index, :tag_list] def index finder = Admin::RunnersFinder.new(params: params) @@ -34,20 +34,26 @@ class Admin::RunnersController < Admin::ApplicationController def resume if Ci::UpdateRunnerService.new(@runner).update(active: true) - redirect_to admin_runners_path, notice: 'Runner was successfully updated.' + redirect_to admin_runners_path, notice: _('Runner was successfully updated.') else - redirect_to admin_runners_path, alert: 'Runner was not updated.' + redirect_to admin_runners_path, alert: _('Runner was not updated.') end end def pause if Ci::UpdateRunnerService.new(@runner).update(active: false) - redirect_to admin_runners_path, notice: 'Runner was successfully updated.' + redirect_to admin_runners_path, notice: _('Runner was successfully updated.') else - redirect_to admin_runners_path, alert: 'Runner was not updated.' + redirect_to admin_runners_path, alert: _('Runner was not updated.') end end + def tag_list + tags = Autocomplete::ActsAsTaggableOn::TagsFinder.new(params: params).execute + + render json: ActsAsTaggableOn::TagSerializer.new.represent(tags) + end + private def runner diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index 18d22c95b61..45cf0d3207e 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -14,7 +14,7 @@ class Admin::SpamLogsController < Admin::ApplicationController spam_log.remove_user(deleted_by: current_user) redirect_to admin_spam_logs_path, status: 302, - notice: "User #{spam_log.user.username} was successfully removed." + notice: _('User %{username} was successfully removed.') % { username: spam_log.user.username } else spam_log.destroy head :ok @@ -25,9 +25,9 @@ class Admin::SpamLogsController < Admin::ApplicationController spam_log = SpamLog.find(params[:id]) if HamService.new(spam_log).mark_as_ham! - redirect_to admin_spam_logs_path, notice: 'Spam log successfully submitted as ham.' + redirect_to admin_spam_logs_path, notice: _('Spam log successfully submitted as ham.') else - redirect_to admin_spam_logs_path, alert: 'Error with Akismet. Please check the logs for more info.' + redirect_to admin_spam_logs_path, alert: _('Error with Akismet. Please check the logs for more info.') end end end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index bfa7c7d0109..a02d0843615 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -39,19 +39,19 @@ class Admin::UsersController < Admin::ApplicationController warden.set_user(user, scope: :user) - Gitlab::AppLogger.info("User #{current_user.username} has started impersonating #{user.username}") + Gitlab::AppLogger.info(_("User %{current_user_username} has started impersonating %{username}") % { current_user_username: current_user.username, username: user.username }) - flash[:alert] = "You are now impersonating #{user.username}" + flash[:alert] = _("You are now impersonating %{username}") % { username: user.username } redirect_to root_path else flash[:alert] = if user.blocked? - "You cannot impersonate a blocked user" + _("You cannot impersonate a blocked user") elsif user.internal? - "You cannot impersonate an internal user" + _("You cannot impersonate an internal user") else - "You cannot impersonate a user who cannot log in" + _("You cannot impersonate a user who cannot log in") end redirect_to admin_user_path(user) @@ -60,35 +60,35 @@ class Admin::UsersController < Admin::ApplicationController def block if update_user { |user| user.block } - redirect_back_or_admin_user(notice: "Successfully blocked") + redirect_back_or_admin_user(notice: _("Successfully blocked")) else - redirect_back_or_admin_user(alert: "Error occurred. User was not blocked") + redirect_back_or_admin_user(alert: _("Error occurred. User was not blocked")) end end def unblock if user.ldap_blocked? - redirect_back_or_admin_user(alert: "This user cannot be unlocked manually from GitLab") + redirect_back_or_admin_user(alert: _("This user cannot be unlocked manually from GitLab")) elsif update_user { |user| user.activate } - redirect_back_or_admin_user(notice: "Successfully unblocked") + redirect_back_or_admin_user(notice: _("Successfully unblocked")) else - redirect_back_or_admin_user(alert: "Error occurred. User was not unblocked") + redirect_back_or_admin_user(alert: _("Error occurred. User was not unblocked")) end end def unlock if update_user { |user| user.unlock_access! } - redirect_back_or_admin_user(alert: "Successfully unlocked") + redirect_back_or_admin_user(alert: _("Successfully unlocked")) else - redirect_back_or_admin_user(alert: "Error occurred. User was not unlocked") + redirect_back_or_admin_user(alert: _("Error occurred. User was not unlocked")) end end def confirm if update_user { |user| user.confirm } - redirect_back_or_admin_user(notice: "Successfully confirmed") + redirect_back_or_admin_user(notice: _("Successfully confirmed")) else - redirect_back_or_admin_user(alert: "Error occurred. User was not confirmed") + redirect_back_or_admin_user(alert: _("Error occurred. User was not confirmed")) end end @@ -96,7 +96,7 @@ class Admin::UsersController < Admin::ApplicationController update_user { |user| user.disable_two_factor! } redirect_to admin_user_path(user), - notice: 'Two-factor Authentication has been disabled for this user' + notice: _('Two-factor Authentication has been disabled for this user') end def create @@ -109,7 +109,7 @@ class Admin::UsersController < Admin::ApplicationController respond_to do |format| if @user.persisted? - format.html { redirect_to [:admin, @user], notice: 'User was successfully created.' } + format.html { redirect_to [:admin, @user], notice: _('User was successfully created.') } format.json { render json: @user, status: :created, location: @user } else format.html { render "new" } @@ -138,7 +138,7 @@ class Admin::UsersController < Admin::ApplicationController end if result[:status] == :success - format.html { redirect_to [:admin, user], notice: 'User was successfully updated.' } + format.html { redirect_to [:admin, user], notice: _('User was successfully updated.') } format.json { head :ok } else # restore username to keep form action url. @@ -153,7 +153,7 @@ class Admin::UsersController < Admin::ApplicationController user.delete_async(deleted_by: current_user, params: params.permit(:hard_delete)) respond_to do |format| - format.html { redirect_to admin_users_path, status: 302, notice: "The user is being deleted." } + format.html { redirect_to admin_users_path, status: 302, notice: _("The user is being deleted.") } format.json { head :ok } end end @@ -164,11 +164,11 @@ class Admin::UsersController < Admin::ApplicationController respond_to do |format| if success - format.html { redirect_back_or_admin_user(notice: 'Successfully removed email.') } + format.html { redirect_back_or_admin_user(notice: _('Successfully removed email.')) } format.json { head :ok } else - format.html { redirect_back_or_admin_user(alert: 'There was an error removing the e-mail.') } - format.json { render json: 'There was an error removing the e-mail.', status: :bad_request } + format.html { redirect_back_or_admin_user(alert: _('There was an error removing the e-mail.')) } + format.json { render json: _('There was an error removing the e-mail.'), status: :bad_request } end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index af0b0c64814..7321f719deb 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -27,6 +27,7 @@ class ApplicationController < ActionController::Base before_action :check_impersonation_availability around_action :set_locale + around_action :set_session_storage after_action :set_page_title_header, if: :json_request? after_action :limit_unauthenticated_session_times @@ -41,9 +42,12 @@ class ApplicationController < ActionController::Base :bitbucket_server_import_enabled?, :google_code_import_enabled?, :fogbugz_import_enabled?, :git_import_enabled?, :gitlab_project_import_enabled?, - :manifest_import_enabled? + :manifest_import_enabled?, :phabricator_import_enabled? + # Adds `no-store` to the DEFAULT_CACHE_CONTROL, to prevent security + # concerns due to caching private data. DEFAULT_GITLAB_CACHE_CONTROL = "#{ActionDispatch::Http::Cache::Response::DEFAULT_CACHE_CONTROL}, no-store".freeze + DEFAULT_GITLAB_CONTROL_NO_CACHE = "#{DEFAULT_GITLAB_CACHE_CONTROL}, no-cache".freeze rescue_from Encoding::CompatibilityError do |exception| log_exception(exception) @@ -125,7 +129,7 @@ class ApplicationController < ActionController::Base payload[:ua] = request.env["HTTP_USER_AGENT"] payload[:remote_ip] = request.remote_ip - payload[Gitlab::CorrelationId::LOG_KEY] = Gitlab::CorrelationId.current_id + payload[Labkit::Correlation::CorrelationId::LOG_KEY] = Labkit::Correlation::CorrelationId.current_id logged_user = auth_user @@ -235,9 +239,9 @@ class ApplicationController < ActionController::Base end def no_cache_headers - response.headers["Cache-Control"] = "no-cache, no-store, max-age=0, must-revalidate" - response.headers["Pragma"] = "no-cache" - response.headers["Expires"] = "Fri, 01 Jan 1990 00:00:00 GMT" + headers['Cache-Control'] = DEFAULT_GITLAB_CONTROL_NO_CACHE + headers['Pragma'] = 'no-cache' # HTTP 1.0 compatibility + headers['Expires'] = 'Fri, 01 Jan 1990 00:00:00 GMT' end def default_headers @@ -247,10 +251,16 @@ class ApplicationController < ActionController::Base headers['X-Content-Type-Options'] = 'nosniff' if current_user - # Adds `no-store` to the DEFAULT_CACHE_CONTROL, to prevent security - # concerns due to caching private data. - headers['Cache-Control'] = DEFAULT_GITLAB_CACHE_CONTROL - headers["Pragma"] = "no-cache" # HTTP 1.0 compatibility + headers['Cache-Control'] = default_cache_control + headers['Pragma'] = 'no-cache' # HTTP 1.0 compatibility + end + end + + def default_cache_control + if request.xhr? + ActionDispatch::Http::Cache::Response::DEFAULT_CACHE_CONTROL + else + DEFAULT_GITLAB_CACHE_CONTROL end end @@ -284,7 +294,7 @@ class ApplicationController < ActionController::Base unless Gitlab::Auth::LDAP::Access.allowed?(current_user) sign_out current_user - flash[:alert] = "Access denied for your LDAP account." + flash[:alert] = _("Access denied for your LDAP account.") redirect_to new_user_session_path end end @@ -331,7 +341,7 @@ class ApplicationController < ActionController::Base def require_email if current_user && current_user.temp_oauth_email? && session[:impersonator_id].nil? - return redirect_to profile_path, notice: 'Please complete your profile with email address' + return redirect_to profile_path, notice: _('Please complete your profile with email address') end end @@ -414,6 +424,10 @@ class ApplicationController < ActionController::Base Group.supports_nested_objects? && Gitlab::CurrentSettings.import_sources.include?('manifest') end + def phabricator_import_enabled? + Gitlab::PhabricatorImport.available? + end + # U2F (universal 2nd factor) devices need a unique identifier for the application # to perform authentication. # https://developers.yubico.com/U2F/App_ID.html @@ -425,6 +439,12 @@ class ApplicationController < ActionController::Base Gitlab::I18n.with_user_locale(current_user, &block) end + def set_session_storage(&block) + return yield if sessionless_user? + + Gitlab::Session.with_session(session, &block) + end + def set_page_title_header # Per https://tools.ietf.org/html/rfc5987, headers need to be ISO-8859-1, not UTF-8 response.headers['Page-Title'] = URI.escape(page_title('GitLab')) diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 0d5c8657c9e..091327931c2 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class AutocompleteController < ApplicationController - skip_before_action :authenticate_user!, only: [:users, :award_emojis] + skip_before_action :authenticate_user!, only: [:users, :award_emojis, :merge_request_target_branches] def users project = Autocomplete::ProjectFinder @@ -38,4 +38,11 @@ class AutocompleteController < ApplicationController def award_emojis render json: AwardedEmojiFinder.new(current_user).execute end + + def merge_request_target_branches + merge_requests = MergeRequestsFinder.new(current_user, params).execute + target_branches = merge_requests.recent_target_branches + + render json: target_branches.map { |target_branch| { title: target_branch } } + end end diff --git a/app/controllers/clusters/applications_controller.rb b/app/controllers/clusters/applications_controller.rb index c4e7fc950f9..16c2365f85d 100644 --- a/app/controllers/clusters/applications_controller.rb +++ b/app/controllers/clusters/applications_controller.rb @@ -3,26 +3,54 @@ class Clusters::ApplicationsController < Clusters::BaseController before_action :cluster before_action :authorize_create_cluster!, only: [:create] + before_action :authorize_update_cluster!, only: [:update] + before_action :authorize_admin_cluster!, only: [:destroy] def create - Clusters::Applications::CreateService - .new(@cluster, current_user, create_cluster_application_params) - .execute(request) + request_handler do + Clusters::Applications::CreateService + .new(@cluster, current_user, cluster_application_params) + .execute(request) + end + end + + def update + request_handler do + Clusters::Applications::UpdateService + .new(@cluster, current_user, cluster_application_params) + .execute(request) + end + end + + def destroy + request_handler do + Clusters::Applications::DestroyService + .new(@cluster, current_user, cluster_application_destroy_params) + .execute(request) + end + end + + private + + def request_handler + yield head :no_content - rescue Clusters::Applications::CreateService::InvalidApplicationError + rescue Clusters::Applications::BaseService::InvalidApplicationError render_404 rescue StandardError head :bad_request end - private - def cluster @cluster ||= clusterable.clusters.find(params[:id]) || render_404 end - def create_cluster_application_params + def cluster_application_params params.permit(:application, :hostname, :email) end + + def cluster_application_destroy_params + params.permit(:application) + end end diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index 68a2a83f0de..80ee7c35906 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -123,25 +123,25 @@ class Clusters::ClustersController < Clusters::BaseController private def update_params - if cluster.managed? + if cluster.provided_by_user? params.require(:cluster).permit( :enabled, + :name, :environment_scope, :base_domain, platform_kubernetes_attributes: [ + :api_url, + :token, + :ca_cert, :namespace ] ) else params.require(:cluster).permit( :enabled, - :name, :environment_scope, :base_domain, platform_kubernetes_attributes: [ - :api_url, - :token, - :ca_cert, :namespace ] ) @@ -153,6 +153,7 @@ class Clusters::ClustersController < Clusters::BaseController :enabled, :name, :environment_scope, + :managed, provider_gcp_attributes: [ :gcp_project_id, :zone, @@ -171,6 +172,7 @@ class Clusters::ClustersController < Clusters::BaseController :enabled, :name, :environment_scope, + :managed, platform_kubernetes_attributes: [ :namespace, :api_url, diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index 5507328f8ae..4926062f9ca 100644 --- a/app/controllers/concerns/authenticates_with_two_factor.rb +++ b/app/controllers/concerns/authenticates_with_two_factor.rb @@ -8,13 +8,6 @@ module AuthenticatesWithTwoFactor extend ActiveSupport::Concern - included do - # This action comes from DeviseController, but because we call `sign_in` - # manually, not skipping this action would cause a "You are already signed - # in." error message to be shown upon successful login. - skip_before_action :require_no_authentication, only: [:create], raise: false - end - # Store the user's ID in the session for later retrieval and render the # two factor code prompt # @@ -36,7 +29,7 @@ module AuthenticatesWithTwoFactor end def locked_user_redirect(user) - flash.now[:alert] = 'Invalid Login or password' + flash.now[:alert] = _('Invalid Login or password') render 'devise/sessions/new' end @@ -66,7 +59,7 @@ module AuthenticatesWithTwoFactor else user.increment_failed_attempts! Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=OTP") - flash.now[:alert] = 'Invalid two-factor code.' + flash.now[:alert] = _('Invalid two-factor code.') prompt_for_two_factor(user) end end @@ -83,7 +76,7 @@ module AuthenticatesWithTwoFactor else user.increment_failed_attempts! Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=U2F") - flash.now[:alert] = 'Authentication via U2F device failed.' + flash.now[:alert] = _('Authentication via U2F device failed.') prompt_for_two_factor(user) end end diff --git a/app/controllers/concerns/boards_actions.rb b/app/controllers/concerns/boards_actions.rb new file mode 100644 index 00000000000..ed7ea2f0e04 --- /dev/null +++ b/app/controllers/concerns/boards_actions.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module BoardsActions + include Gitlab::Utils::StrongMemoize + extend ActiveSupport::Concern + + included do + include BoardsResponses + + before_action :boards, only: :index + before_action :board, only: :show + end + + def index + respond_with_boards + end + + def show + # Add / update the board in the recent visits table + Boards::Visits::CreateService.new(parent, current_user).execute(board) if request.format.html? + + respond_with_board + end + + private + + def boards + strong_memoize(:boards) do + Boards::ListService.new(parent, current_user).execute + end + end + + def board + strong_memoize(:board) do + boards.find(params[:id]) + end + end +end diff --git a/app/controllers/concerns/continue_params.rb b/app/controllers/concerns/continue_params.rb index f0e6adf4dec..54c0510497f 100644 --- a/app/controllers/concerns/continue_params.rb +++ b/app/controllers/concerns/continue_params.rb @@ -6,7 +6,7 @@ module ContinueParams def continue_params continue_params = params[:continue] - return nil unless continue_params + return unless continue_params continue_params = continue_params.permit(:to, :notice, :notice_now) continue_params[:to] = safe_redirect_path(continue_params[:to]) diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb index b3777fd2b0f..e8e681ce649 100644 --- a/app/controllers/concerns/creates_commit.rb +++ b/app/controllers/concerns/creates_commit.rb @@ -31,7 +31,7 @@ module CreatesCommit respond_to do |format| format.html { redirect_to success_path } - format.json { render json: { message: "success", filePath: success_path } } + format.json { render json: { message: _("success"), filePath: success_path } } end else flash[:alert] = result[:message] @@ -45,7 +45,7 @@ module CreatesCommit redirect_to failure_path end end - format.json { render json: { message: "failed", filePath: failure_path } } + format.json { render json: { message: _("failed"), filePath: failure_path } } end end end @@ -60,15 +60,22 @@ module CreatesCommit private def update_flash_notice(success_notice) - flash[:notice] = success_notice || "Your changes have been successfully committed." + flash[:notice] = success_notice || _("Your changes have been successfully committed.") if create_merge_request? - if merge_request_exists? - flash[:notice] = nil - else - target = different_project? ? "project" : "branch" - flash[:notice] = flash[:notice] + " You can now submit a merge request to get this change into the original #{target}." - end + flash[:notice] = + if merge_request_exists? + nil + else + mr_message = + if different_project? + _("You can now submit a merge request to get this change into the original project.") + else + _("You can now submit a merge request to get this change into the original branch.") + end + + flash[:notice] += " " + mr_message + end end end diff --git a/app/controllers/concerns/enforces_admin_authentication.rb b/app/controllers/concerns/enforces_admin_authentication.rb new file mode 100644 index 00000000000..3ef92730df6 --- /dev/null +++ b/app/controllers/concerns/enforces_admin_authentication.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# == EnforcesAdminAuthentication +# +# Controller concern to enforce that users are authenticated as admins +# +# Upon inclusion, adds `authenticate_admin!` as a before_action +# +module EnforcesAdminAuthentication + extend ActiveSupport::Concern + + included do + before_action :authenticate_admin! + end + + def authenticate_admin! + render_404 unless current_user.admin? + end +end diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb index 71bdef8ce03..0fddf15d197 100644 --- a/app/controllers/concerns/enforces_two_factor_authentication.rb +++ b/app/controllers/concerns/enforces_two_factor_authentication.rb @@ -16,7 +16,7 @@ module EnforcesTwoFactorAuthentication end def check_two_factor_requirement - if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor? + if two_factor_authentication_required? && current_user && !current_user.temp_oauth_email? && !current_user.two_factor_enabled? && !skip_two_factor? redirect_to profile_two_factor_auth_path end end diff --git a/app/controllers/concerns/import_url_params.rb b/app/controllers/concerns/import_url_params.rb new file mode 100644 index 00000000000..e51e4157f50 --- /dev/null +++ b/app/controllers/concerns/import_url_params.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module ImportUrlParams + def import_url_params + return {} unless params.dig(:project, :import_url).present? + + { import_url: import_params_to_full_url(params[:project]) } + end + + def import_params_to_full_url(params) + Gitlab::UrlSanitizer.new( + params[:import_url], + credentials: { + user: params[:import_url_user], + password: params[:import_url_password] + } + ).full_url + end +end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index cd3fa641e89..065d2d3a4ec 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -8,7 +8,7 @@ module IssuableActions before_action :authorize_destroy_issuable!, only: :destroy before_action :authorize_admin_issuable!, only: :bulk_update before_action only: :show do - push_frontend_feature_flag(:reply_to_individual_notes) + push_frontend_feature_flag(:scoped_labels, default_enabled: true) end end @@ -192,12 +192,7 @@ module IssuableActions def bulk_update_params permitted_keys_array = permitted_keys.dup - - if resource_name == 'issue' - permitted_keys_array << { assignee_ids: [] } - else - permitted_keys_array.unshift(:assignee_id) - end + permitted_keys_array << { assignee_ids: [] } params.require(:update).permit(permitted_keys_array) end diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index c529aabf797..9cf25915e92 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -41,6 +41,7 @@ module IssuableCollections return if pagination_disabled? @issuables = @issuables.page(params[:page]) + @issuables = per_page_for_relative_position if params[:sort] == 'relative_position' @issuable_meta_data = issuable_meta_data(@issuables, collection_type) @total_pages = issuable_page_count end @@ -80,6 +81,11 @@ module IssuableCollections (row_count.to_f / limit).ceil end + # manual / relative_position sorting allows for 100 items on the page + def per_page_for_relative_position + @issuables.per(100) # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + def issuable_finder_for(finder_class) finder_class.new(current_user, finder_options) end @@ -100,6 +106,7 @@ module IssuableCollections if @project options[:project_id] = @project.id + options[:attempt_project_search_optimizations] = true elsif @group options[:group_id] = @group.id options[:include_subgroups] = true @@ -189,15 +196,15 @@ module IssuableCollections end end + # rubocop:disable Gitlab/ModuleWithInstanceVariables def preload_for_collection + common_attributes = [:author, :assignees, :labels, :milestone] @preload_for_collection ||= case collection_type when 'Issue' - [:project, :author, :assignees, :labels, :milestone, project: :namespace] + common_attributes + [:project, project: :namespace] when 'MergeRequest' - [ - :target_project, :author, :assignee, :labels, :milestone, - source_project: :route, head_pipeline: :project, target_project: :namespace, latest_merge_request_diff: :merge_request_diff_commits - ] + common_attributes + [:target_project, source_project: :route, head_pipeline: :project, target_project: :namespace, latest_merge_request_diff: :merge_request_diff_commits] end end + # rubocop:enable Gitlab/ModuleWithInstanceVariables end diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index 57e444319e0..f7137a04437 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -26,7 +26,7 @@ module LfsRequest render( json: { - message: 'Git LFS is not enabled on this GitLab server, contact your admin.', + message: _('Git LFS is not enabled on this GitLab server, contact your admin.'), documentation_url: help_url }, status: :not_implemented @@ -51,7 +51,7 @@ module LfsRequest def render_lfs_forbidden render( json: { - message: 'Access forbidden. Check your access level.', + message: _('Access forbidden. Check your access level.'), documentation_url: help_url }, content_type: CONTENT_TYPE, @@ -62,7 +62,7 @@ module LfsRequest def render_lfs_not_found render( json: { - message: 'Not found.', + message: _('Not found.'), documentation_url: help_url }, content_type: CONTENT_TYPE, diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index 6402e01ddc0..0b2756c0c6a 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -9,7 +9,7 @@ module MembershipActions result = Members::CreateService.new(current_user, create_params).execute(membershipable) if result[:status] == :success - redirect_to members_page_url, notice: 'Users were successfully added.' + redirect_to members_page_url, notice: _('Users were successfully added.') else redirect_to members_page_url, alert: result[:message] end @@ -35,9 +35,16 @@ module MembershipActions respond_to do |format| format.html do - source = source_type == 'group' ? 'group and any subresources' : source_type + message = + begin + case membershipable + when Namespace + _("User was successfully removed from group and any subresources.") + else + _("User was successfully removed from project.") + end + end - message = "User was successfully removed from #{source}." redirect_to members_page_url, notice: message end @@ -49,7 +56,7 @@ module MembershipActions membershipable.request_access(current_user) redirect_to polymorphic_path(membershipable), - notice: 'Your request for access has been queued for review.' + notice: _('Your request for access has been queued for review.') end def approve_access_request @@ -68,9 +75,9 @@ module MembershipActions notice = if member.request? - "Your access request to the #{source_type} has been withdrawn." + _("Your access request to the %{source_type} has been withdrawn.") % { source_type: source_type } else - "You left the \"#{membershipable.human_name}\" #{source_type}." + _("You left the \"%{membershipable_human_name}\" %{source_type}.") % { membershipable_human_name: membershipable.human_name, source_type: source_type } end respond_to do |format| @@ -90,9 +97,9 @@ module MembershipActions if member.invite? member.resend_invite - redirect_to members_page_url, notice: 'The invitation was successfully resent.' + redirect_to members_page_url, notice: _('The invitation was successfully resent.') else - redirect_to members_page_url, alert: 'The invitation has already been accepted.' + redirect_to members_page_url, alert: _('The invitation has already been accepted.') end end @@ -125,6 +132,16 @@ module MembershipActions end def source_type - @source_type ||= membershipable.class.to_s.humanize(capitalize: false) + @source_type ||= + begin + case membershipable + when Namespace + _("group") + when Project + _("project") + else + raise "Unknown membershipable type: #{membershipable}!" + end + end end end diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb index eccbe35577b..8b8b7db72f8 100644 --- a/app/controllers/concerns/milestone_actions.rb +++ b/app/controllers/concerns/milestone_actions.rb @@ -8,7 +8,7 @@ module MilestoneActions format.html { redirect_to milestone_redirect_path } format.json do render json: tabs_json("shared/milestones/_merge_requests_tab", { - merge_requests: @milestone.sorted_merge_requests, # rubocop:disable Gitlab/ModuleWithInstanceVariables + merge_requests: @milestone.sorted_merge_requests(current_user), # rubocop:disable Gitlab/ModuleWithInstanceVariables show_project_name: true }) end @@ -26,16 +26,22 @@ module MilestoneActions end end + # rubocop:disable Gitlab/ModuleWithInstanceVariables def labels respond_to do |format| format.html { redirect_to milestone_redirect_path } format.json do + milestone_labels = @milestone.issue_labels_visible_by_user(current_user) + render json: tabs_json("shared/milestones/_labels_tab", { - labels: @milestone.labels # rubocop:disable Gitlab/ModuleWithInstanceVariables + labels: milestone_labels.map do |label| + label.present(issuable_subject: @milestone.parent) + end }) end end end + # rubocop:enable Gitlab/ModuleWithInstanceVariables private diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index b4fee93713b..f96d1821095 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -48,7 +48,7 @@ module NotesActions respond_to do |format| format.json do json = { - commands_changes: @note.commands_changes + commands_changes: @note.commands_changes&.slice(:emoji_award, :time_estimate, :spend_time) } if @note.persisted? && return_discussion? diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index f72d25fc54c..2a9729b6ffd 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -20,7 +20,7 @@ module PreviewMarkdown body: view_context.markdown(result[:text], markdown_params), references: { users: result[:users], - suggestions: result[:suggestions], + suggestions: SuggestionSerializer.new.represent_diff(result[:suggestions]), commands: view_context.markdown(result[:commands]) } } diff --git a/app/controllers/concerns/project_unauthorized.rb b/app/controllers/concerns/project_unauthorized.rb index f59440dbc59..7238840440f 100644 --- a/app/controllers/concerns/project_unauthorized.rb +++ b/app/controllers/concerns/project_unauthorized.rb @@ -1,10 +1,21 @@ # frozen_string_literal: true module ProjectUnauthorized - extend ActiveSupport::Concern + module ControllerActions + def self.on_routable_not_found + lambda do |routable| + return unless routable.is_a?(Project) - # EE would override this - def project_unauthorized_proc - # no-op + label = routable.external_authorization_classification_label + rejection_reason = nil + + unless ::Gitlab::ExternalAuthorization.access_allowed?(current_user, label) + rejection_reason = ::Gitlab::ExternalAuthorization.rejection_reason(current_user, label) + rejection_reason ||= _('External authorization denied access to this project') + end + + access_denied!(rejection_reason) if rejection_reason + end + end end end diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb index ce36da6b715..18015b1de88 100644 --- a/app/controllers/concerns/renders_notes.rb +++ b/app/controllers/concerns/renders_notes.rb @@ -16,7 +16,7 @@ module RendersNotes private def preload_max_access_for_authors(notes, project) - return nil unless project + return unless project user_ids = notes.map(&:author_id) project.team.max_member_access_for_user_ids(user_ids) diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb index 5624eb3aa45..ff9b0332c97 100644 --- a/app/controllers/concerns/routable_actions.rb +++ b/app/controllers/concerns/routable_actions.rb @@ -3,15 +3,13 @@ module RoutableActions extend ActiveSupport::Concern - def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil, not_found_or_authorized_proc: nil) + def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil) routable = routable_klass.find_by_full_path(requested_full_path, follow_redirects: request.get?) if routable_authorized?(routable, extra_authorization_proc) ensure_canonical_path(routable, requested_full_path) routable else - if not_found_or_authorized_proc - not_found_or_authorized_proc.call(routable) - end + perform_not_found_actions(routable, not_found_actions) route_not_found unless performed? @@ -19,6 +17,18 @@ module RoutableActions end end + def not_found_actions + [ProjectUnauthorized::ControllerActions.on_routable_not_found] + end + + def perform_not_found_actions(routable, actions) + actions.each do |action| + break if performed? + + instance_exec(routable, &action) + end + end + def routable_authorized?(routable, extra_authorization_proc) return false unless routable diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb index c3a1b12af84..a8ffa33f1c7 100644 --- a/app/controllers/concerns/spammable_actions.rb +++ b/app/controllers/concerns/spammable_actions.rb @@ -12,9 +12,9 @@ module SpammableActions def mark_as_spam if SpamService.new(spammable).mark_as_spam! - redirect_to spammable_path, notice: "#{spammable.spammable_entity_type.titlecase} was submitted to Akismet successfully." + redirect_to spammable_path, notice: _("%{spammable_titlecase} was submitted to Akismet successfully.") % { spammable_titlecase: spammable.spammable_entity_type.titlecase } else - redirect_to spammable_path, alert: 'Error with Akismet. Please check the logs for more info.' + redirect_to spammable_path, alert: _('Error with Akismet. Please check the logs for more info.') end end @@ -33,7 +33,7 @@ module SpammableActions ensure_spam_config_loaded! if params[:recaptcha_verification] - flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.' + flash[:alert] = _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.') end respond_to do |format| diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb index 4ec0e94df9a..59f6d3452a3 100644 --- a/app/controllers/concerns/uploads_actions.rb +++ b/app/controllers/concerns/uploads_actions.rb @@ -16,7 +16,7 @@ module UploadsActions end else format.json do - render json: 'Invalid file.', status: :unprocessable_entity + render json: _('Invalid file.'), status: :unprocessable_entity end end end @@ -57,7 +57,7 @@ module UploadsActions render json: authorized rescue SocketError - render json: "Error uploading file", status: :internal_server_error + render json: _("Error uploading file"), status: :internal_server_error end private diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb index 2c4aab67448..2ae500a2fdf 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/confirmations_controller.rb @@ -22,7 +22,7 @@ class ConfirmationsController < Devise::ConfirmationsController after_sign_in(resource) else Gitlab::AppLogger.info("Email Confirmed: username=#{resource.username} email=#{resource.email} ip=#{request.remote_ip}") - flash[:notice] = flash[:notice] + " Please sign in." + flash[:notice] = flash[:notice] + _(" Please sign in.") new_session_path(:user, anchor: 'login-pane') end end diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index b1d224d026f..65d14781d92 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -6,21 +6,22 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } before_action :set_non_archived_param + before_action :projects, only: [:index] before_action :default_sorting skip_cross_project_access_check :index, :starred def index - @projects = load_projects(params.merge(non_public: true)) - respond_to do |format| - format.html + format.html do + render_projects + end format.atom do load_events render layout: 'xml.atom' end format.json do render json: { - html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects }) + html: view_to_html_string("dashboard/projects/_projects", projects: @projects) } end end @@ -37,7 +38,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController format.html format.json do render json: { - html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects }) + html: view_to_html_string("dashboard/projects/_projects", projects: @projects) } end end @@ -46,6 +47,17 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController private + def projects + @projects ||= load_projects(params.merge(non_public: true)) + end + + def render_projects + # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/40260 + Gitlab::GitalyClient.allow_n_plus_1_calls do + render + end + end + def default_sorting params[:sort] ||= 'latest_activity_desc' @sort = params[:sort] diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 3fa582cf25b..f173c263474 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -21,7 +21,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController format.html do redirect_to dashboard_todos_path, status: 302, - notice: 'Todo was successfully marked as done.' + notice: _('Todo was successfully marked as done.') end format.js { head :ok } format.json { render json: todos_counts } @@ -32,7 +32,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController updated_ids = TodoService.new.mark_todos_as_done(@todos, current_user) respond_to do |format| - format.html { redirect_to dashboard_todos_path, status: 302, notice: 'All todos were marked as done.' } + format.html { redirect_to dashboard_todos_path, status: 302, notice: _('All todos were marked as done.') } format.js { head :ok } format.json { render json: todos_counts.merge(updated_ids: updated_ids) } end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 75329b05a6f..1a97b39d3ae 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -46,7 +46,10 @@ class DashboardController < Dashboard::ApplicationController end def check_filters_presence! - @no_filters_set = finder_type.scalar_params.none? { |k| params.key?(k) } + no_scalar_filters_set = finder_type.scalar_params.none? { |k| params.key?(k) } + no_array_filters_set = finder_type.array_params.none? { |k, _| params.key?(k) } + + @no_filters_set = no_scalar_filters_set && no_array_filters_set return unless @no_filters_set diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index f3d76c5a478..ef86d5f981a 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -15,7 +15,7 @@ class Explore::ProjectsController < Explore::ApplicationController format.html format.json do render json: { - html: view_to_html_string("explore/projects/_projects", locals: { projects: @projects }) + html: view_to_html_string("explore/projects/_projects", projects: @projects) } end end @@ -30,7 +30,7 @@ class Explore::ProjectsController < Explore::ApplicationController format.html format.json do render json: { - html: view_to_html_string("explore/projects/_projects", locals: { projects: @projects }) + html: view_to_html_string("explore/projects/_projects", projects: @projects) } end end @@ -44,7 +44,7 @@ class Explore::ProjectsController < Explore::ApplicationController format.html format.json do render json: { - html: view_to_html_string("explore/projects/_projects", locals: { projects: @projects }) + html: view_to_html_string("explore/projects/_projects", projects: @projects) } end end diff --git a/app/controllers/google_api/authorizations_controller.rb b/app/controllers/google_api/authorizations_controller.rb index dd9f5af61b3..ed0995e7ffd 100644 --- a/app/controllers/google_api/authorizations_controller.rb +++ b/app/controllers/google_api/authorizations_controller.rb @@ -2,6 +2,10 @@ module GoogleApi class AuthorizationsController < ApplicationController + include Gitlab::Utils::StrongMemoize + + before_action :validate_session_key! + def callback token, expires_at = GoogleApi::CloudPlatform::Client .new(nil, callback_google_api_auth_url) @@ -11,21 +15,27 @@ module GoogleApi session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = expires_at.to_s - state_redirect_uri = redirect_uri_from_session_key(params[:state]) - - if state_redirect_uri - redirect_to state_redirect_uri - else - redirect_to root_path - end + redirect_to redirect_uri_from_session end private - def redirect_uri_from_session_key(state) - key = GoogleApi::CloudPlatform::Client - .session_key_for_redirect_uri(params[:state]) - session[key] if key + def validate_session_key! + access_denied! unless redirect_uri_from_session.present? + end + + def redirect_uri_from_session + strong_memoize(:redirect_uri_from_session) do + if params[:state].present? + session[session_key_for_redirect_uri(params[:state])] + else + nil + end + end + end + + def session_key_for_redirect_uri(state) + GoogleApi::CloudPlatform::Client.session_key_for_redirect_uri(state) end end end diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 3ef03bc9622..1ce0afac83b 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -3,18 +3,21 @@ class GraphqlController < ApplicationController # Unauthenticated users have access to the API for public data skip_before_action :authenticate_user! - prepend_before_action(only: [:execute]) { authenticate_sessionless_user!(:api) } + + # Allow missing CSRF tokens, this would mean that if a CSRF is invalid or missing, + # the user won't be authenticated but can proceed as an anonymous user. + # + # If a CSRF is valid, the user is authenticated. This makes it easier to play + # around in GraphiQL. + protect_from_forgery with: :null_session, only: :execute before_action :check_graphql_feature_flag! + before_action :authorize_access_api! + before_action(only: [:execute]) { authenticate_sessionless_user!(:api) } def execute - variables = Gitlab::Graphql::Variables.new(params[:variables]).to_h - query = params[:query] - operation_name = params[:operationName] - context = { - current_user: current_user - } - result = GitlabSchema.execute(query, variables: variables, context: context, operation_name: operation_name) + result = multiplex? ? execute_multiplex : execute_query + render json: result end @@ -30,6 +33,48 @@ class GraphqlController < ApplicationController private + def execute_multiplex + GitlabSchema.multiplex(multiplex_queries, context: context) + end + + def execute_query + variables = build_variables(params[:variables]) + operation_name = params[:operationName] + + GitlabSchema.execute(query, variables: variables, context: context, operation_name: operation_name) + end + + def query + params[:query] + end + + def multiplex_queries + params[:_json].map do |single_query_info| + { + query: single_query_info[:query], + variables: build_variables(single_query_info[:variables]), + operation_name: single_query_info[:operationName], + context: context + } + end + end + + def context + @context ||= { current_user: current_user } + end + + def build_variables(variable_info) + Gitlab::Graphql::Variables.new(variable_info).to_h + end + + def multiplex? + params[:_json].present? + end + + def authorize_access_api! + access_denied!("API not accessible for user.") unless can?(current_user, :access_api) + end + # Overridden from the ApplicationController to make the response look like # a GraphQL response. That is nicely picked up in Graphiql. def render_404 diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index 51fdb6c05fb..40b8d5ed72c 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -1,53 +1,16 @@ # frozen_string_literal: true class Groups::BoardsController < Groups::ApplicationController - include BoardsResponses + include BoardsActions include RecordUserLastActivity before_action :assign_endpoint_vars - before_action :boards, only: :index - before_action :redirect_to_recent_board, only: :index - - def index - respond_with_boards - end - - def show - @board = boards.find(params[:id]) - - # add/update the board in the recent visited table - Boards::Visits::CreateService.new(@board.group, current_user).execute(@board) if request.format.html? - - respond_with_board - end private - def boards - @boards ||= Boards::ListService.new(group, current_user).execute - end - def assign_endpoint_vars @boards_endpoint = group_boards_url(group) @namespace_path = group.to_param @labels_endpoint = group_labels_url(group) end - - def serialize_as_json(resource) - resource.as_json(only: [:id]) - end - - def includes_board?(board_id) - boards.any? { |board| board.id == board_id } - end - - def redirect_to_recent_board - return if request.format.json? - - recently_visited = Boards::Visits::LatestService.new(group, current_user).execute - - if recently_visited && includes_board?(recently_visited.board_id) - redirect_to(group_board_path(id: recently_visited.board_id), status: :found) - end - end end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 0bc082246a1..f1d6fb00cfc 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -12,6 +12,7 @@ class Groups::GroupMembersController < Groups::ApplicationController # Authorize before_action :authorize_admin_group_member!, except: admin_not_required_endpoints + skip_before_action :check_two_factor_requirement, only: :leave skip_cross_project_access_check :index, :create, :update, :destroy, :request_access, :approve_access_request, :leave, :resend_invite, :override diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb index dd8fbf7a029..f8e32451b02 100644 --- a/app/controllers/groups/runners_controller.rb +++ b/app/controllers/groups/runners_controller.rb @@ -16,7 +16,7 @@ class Groups::RunnersController < Groups::ApplicationController def update if Ci::UpdateRunnerService.new(@runner).update(runner_params) - redirect_to group_runner_path(@group, @runner), notice: 'Runner was successfully updated.' + redirect_to group_runner_path(@group, @runner), notice: _('Runner was successfully updated.') else render 'edit' end @@ -30,17 +30,17 @@ class Groups::RunnersController < Groups::ApplicationController def resume if Ci::UpdateRunnerService.new(@runner).update(active: true) - redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: 'Runner was successfully updated.' + redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: _('Runner was successfully updated.') else - redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: 'Runner was not updated.' + redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: _('Runner was not updated.') end end def pause if Ci::UpdateRunnerService.new(@runner).update(active: false) - redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: 'Runner was successfully updated.' + redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: _('Runner was successfully updated.') else - redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: 'Runner was not updated.' + redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: _('Runner was not updated.') end end diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index f476f428fdb..c465e622de0 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -13,7 +13,17 @@ module Groups def reset_registration_token @group.reset_runners_token! - flash[:notice] = 'New runners registration token has been generated!' + flash[:notice] = _('GroupSettings|New runners registration token has been generated!') + redirect_to group_settings_ci_cd_path + end + + def update_auto_devops + if auto_devops_service.execute + flash[:notice] = s_('GroupSettings|Auto DevOps pipeline was updated for the group') + else + flash[:alert] = s_("GroupSettings|There was a problem updating Auto DevOps pipeline: %{error_messages}." % { error_messages: group.errors.full_messages }) + end + redirect_to group_settings_ci_cd_path end @@ -29,6 +39,14 @@ module Groups def authorize_admin_group! return render_404 unless can?(current_user, :admin_group, group) end + + def auto_devops_params + params.require(:group).permit(:auto_devops_enabled) + end + + def auto_devops_service + Groups::AutoDevopsService.new(group, current_user, auto_devops_params) + end end end end diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb index 4f641de0357..11e3cfb01e4 100644 --- a/app/controllers/groups/variables_controller.rb +++ b/app/controllers/groups/variables_controller.rb @@ -41,7 +41,7 @@ module Groups end def variable_params_attributes - %i[id key secret_value protected _destroy] + %i[id variable_type key secret_value protected masked _destroy] end def authorize_admin_build! diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 4e50106398a..e936d771502 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -58,11 +58,24 @@ class GroupsController < Groups::ApplicationController def show respond_to do |format| - format.html + format.html do + render_show_html + end format.atom do - load_events - render layout: 'xml.atom' + render_details_view_atom + end + end + end + + def details + respond_to do |format| + format.html do + render_details_html + end + + format.atom do + render_details_view_atom end end end @@ -111,14 +124,27 @@ class GroupsController < Groups::ApplicationController flash[:notice] = "Group '#{@group.name}' was successfully transferred." redirect_to group_path(@group) else - flash.now[:alert] = service.error - render :edit + flash[:alert] = service.error + redirect_to edit_group_path(@group) end end # rubocop: enable CodeReuse/ActiveRecord protected + def render_show_html + render 'groups/show' + end + + def render_details_html + render 'groups/show' + end + + def render_details_view_atom + load_events + render layout: 'xml.atom', template: 'groups/show' + end + # rubocop: disable CodeReuse/ActiveRecord def authorize_create_group! allowed = if params[:parent_id].present? @@ -161,7 +187,8 @@ class GroupsController < Groups::ApplicationController :create_chat_team, :chat_team_name, :require_two_factor_authentication, - :two_factor_grace_period + :two_factor_grace_period, + :project_creation_level ] end @@ -178,8 +205,8 @@ class GroupsController < Groups::ApplicationController .includes(:namespace) @events = EventCollection - .new(@projects, offset: params[:offset].to_i, filter: event_filter) - .to_a + .new(@projects, offset: params[:offset].to_i, filter: event_filter) + .to_a Events::RenderService .new(current_user) diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index a9d6addd4a4..837c26c630a 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -7,7 +7,7 @@ class HelpController < ApplicationController # Taken from Jekyll # https://github.com/jekyll/jekyll/blob/3.5-stable/lib/jekyll/document.rb#L13 - YAML_FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m + YAML_FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m.freeze def index # Remove YAML frontmatter so that it doesn't look weird @@ -22,7 +22,7 @@ class HelpController < ApplicationController end def show - @path = clean_path_info(path_params[:path]) + @path = Rack::Utils.clean_path_info(path_params[:path]) respond_to do |format| format.any(:markdown, :md, :html) do @@ -75,35 +75,4 @@ class HelpController < ApplicationController params end - - PATH_SEPS = Regexp.union(*[::File::SEPARATOR, ::File::ALT_SEPARATOR].compact) - - # Taken from ActionDispatch::FileHandler - # Cleans up the path, to prevent directory traversal outside the doc folder. - def clean_path_info(path_info) - parts = path_info.split(PATH_SEPS) - - clean = [] - - # Walk over each part of the path - parts.each do |part| - # Turn `one//two` or `one/./two` into `one/two`. - next if part.empty? || part == '.' - - if part == '..' - # Turn `one/two/../` into `one` - clean.pop - else - # Add simple folder names to the clean path. - clean << part - end - end - - # If the path was an absolute path (i.e. `/` or `/one/two`), - # add `/` to the front of the clean path. - clean.unshift '/' if parts.empty? || parts.first.empty? - - # Join all the clean path parts by the path separator. - ::File.join(*clean) - end end diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index 2b1395f364f..293d76ea765 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -62,7 +62,7 @@ class Import::BitbucketController < Import::BaseController render json: { errors: project_save_error(project) }, status: :unprocessable_entity end else - render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity + render json: { errors: _('This namespace has already been taken! Please choose another one.') }, status: :unprocessable_entity end end diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb index f333e43b892..f71ea8642cd 100644 --- a/app/controllers/import/bitbucket_server_controller.rb +++ b/app/controllers/import/bitbucket_server_controller.rb @@ -15,8 +15,8 @@ class Import::BitbucketServerController < Import::BaseController # (https://community.atlassian.com/t5/Answers-Developer-Questions/stash-repository-names/qaq-p/499054) # # Bitbucket Server starts personal project names with a tilde. - VALID_BITBUCKET_PROJECT_CHARS = /\A~?[\w\-\.\s]+\z/ - VALID_BITBUCKET_CHARS = /\A[\w\-\.\s]+\z/ + VALID_BITBUCKET_PROJECT_CHARS = /\A~?[\w\-\.\s]+\z/.freeze + VALID_BITBUCKET_CHARS = /\A[\w\-\.\s]+\z/.freeze def new end @@ -25,7 +25,7 @@ class Import::BitbucketServerController < Import::BaseController repo = bitbucket_client.repo(@project_key, @repo_slug) unless repo - return render json: { errors: "Project #{@project_key}/#{@repo_slug} could not be found" }, status: :unprocessable_entity + return render json: { errors: _("Project %{project_repo} could not be found") % { project_repo: "#{@project_key}/#{@repo_slug}" } }, status: :unprocessable_entity end project_name = params[:new_name].presence || repo.name @@ -41,10 +41,10 @@ class Import::BitbucketServerController < Import::BaseController render json: { errors: project_save_error(project) }, status: :unprocessable_entity end else - render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity + render json: { errors: _('This namespace has already been taken! Please choose another one.') }, status: :unprocessable_entity end - rescue BitbucketServer::Connection::ConnectionError => e - render json: { errors: "Unable to connect to server: #{e}" }, status: :unprocessable_entity + rescue BitbucketServer::Connection::ConnectionError => error + render json: { errors: _("Unable to connect to server: %{error}") % { error: error } }, status: :unprocessable_entity end def configure @@ -65,8 +65,8 @@ class Import::BitbucketServerController < Import::BaseController already_added_projects_names = @already_added_projects.pluck(:import_source) @repos.reject! { |repo| already_added_projects_names.include?(repo.browse_url) } - rescue BitbucketServer::Connection::ConnectionError => e - flash[:alert] = "Unable to connect to server: #{e}" + rescue BitbucketServer::Connection::ConnectionError => error + flash[:alert] = _("Unable to connect to server: %{error}") % { error: error } clear_session_data redirect_to new_import_bitbucket_server_path end diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb index 5a439e6de78..a37ba682b91 100644 --- a/app/controllers/import/fogbugz_controller.rb +++ b/app/controllers/import/fogbugz_controller.rb @@ -14,7 +14,7 @@ class Import::FogbugzController < Import::BaseController res = Gitlab::FogbugzImport::Client.new(import_params.symbolize_keys) rescue # If the URI is invalid various errors can occur - return redirect_to new_import_fogbugz_path, alert: 'Could not connect to FogBugz, check your URL' + return redirect_to new_import_fogbugz_path, alert: _('Could not connect to FogBugz, check your URL') end session[:fogbugz_token] = res.get_token session[:fogbugz_uri] = params[:uri] @@ -29,14 +29,14 @@ class Import::FogbugzController < Import::BaseController user_map = params[:users] unless user_map.is_a?(Hash) && user_map.all? { |k, v| !v[:name].blank? } - flash.now[:alert] = 'All users must have a name.' + flash.now[:alert] = _('All users must have a name.') return render 'new_user_map' end session[:fogbugz_user_map] = user_map - flash[:notice] = 'The user map has been saved. Continue by selecting the projects you want to import.' + flash[:notice] = _('The user map has been saved. Continue by selecting the projects you want to import.') redirect_to status_import_fogbugz_path end diff --git a/app/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb index 68ad8650dba..a23b2f8139e 100644 --- a/app/controllers/import/gitea_controller.rb +++ b/app/controllers/import/gitea_controller.rb @@ -46,7 +46,7 @@ class Import::GiteaController < Import::GithubController def provider_auth if session[access_token_key].blank? || provider_url.blank? redirect_to new_import_gitea_url, - alert: 'You need to specify both an Access Token and a Host URL.' + alert: _('You need to specify both an Access Token and a Host URL.') end end diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb index 498de0b07b8..5ec8e9e6fc5 100644 --- a/app/controllers/import/gitlab_controller.rb +++ b/app/controllers/import/gitlab_controller.rb @@ -42,7 +42,7 @@ class Import::GitlabController < Import::BaseController render json: { errors: project_save_error(project) }, status: :unprocessable_entity end else - render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity + render json: { errors: _('This namespace has already been taken! Please choose another one.') }, status: :unprocessable_entity end end diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb index 354fba5d204..89889141be6 100644 --- a/app/controllers/import/gitlab_projects_controller.rb +++ b/app/controllers/import/gitlab_projects_controller.rb @@ -13,7 +13,7 @@ class Import::GitlabProjectsController < Import::BaseController def create unless file_is_valid? - return redirect_back_or_default(options: { alert: "You need to upload a GitLab project export archive (ending in .gz)." }) + return redirect_back_or_default(options: { alert: _("You need to upload a GitLab project export archive (ending in .gz).") }) end @project = ::Projects::GitlabProjectsImportService.new(current_user, project_params).execute @@ -21,7 +21,7 @@ class Import::GitlabProjectsController < Import::BaseController if @project.saved? redirect_to( project_path(@project), - notice: "Project '#{@project.name}' is being imported." + notice: _("Project '%{project_name}' is being imported.") % { project_name: @project.name } ) else redirect_back_or_default(options: { alert: "Project could not be imported: #{@project.errors.full_messages.join(', ')}" }) diff --git a/app/controllers/import/google_code_controller.rb b/app/controllers/import/google_code_controller.rb index 331f06c3dd6..4dddfbcd20d 100644 --- a/app/controllers/import/google_code_controller.rb +++ b/app/controllers/import/google_code_controller.rb @@ -11,18 +11,18 @@ class Import::GoogleCodeController < Import::BaseController dump_file = params[:dump_file] unless dump_file.respond_to?(:read) - return redirect_back_or_default(options: { alert: "You need to upload a Google Takeout archive." }) + return redirect_back_or_default(options: { alert: _("You need to upload a Google Takeout archive.") }) end begin dump = JSON.parse(dump_file.read) rescue - return redirect_back_or_default(options: { alert: "The uploaded file is not a valid Google Takeout archive." }) + return redirect_back_or_default(options: { alert: _("The uploaded file is not a valid Google Takeout archive.") }) end client = Gitlab::GoogleCodeImport::Client.new(dump) unless client.valid? - return redirect_back_or_default(options: { alert: "The uploaded file is not a valid Google Takeout archive." }) + return redirect_back_or_default(options: { alert: _("The uploaded file is not a valid Google Takeout archive.") }) end session[:google_code_dump] = dump @@ -44,13 +44,13 @@ class Import::GoogleCodeController < Import::BaseController begin user_map = JSON.parse(user_map_json) rescue - flash.now[:alert] = "The entered user map is not a valid JSON user map." + flash.now[:alert] = _("The entered user map is not a valid JSON user map.") return render "new_user_map" end unless user_map.is_a?(Hash) && user_map.all? { |k, v| k.is_a?(String) && v.is_a?(String) } - flash.now[:alert] = "The entered user map is not a valid JSON user map." + flash.now[:alert] = _("The entered user map is not a valid JSON user map.") return render "new_user_map" end @@ -62,7 +62,7 @@ class Import::GoogleCodeController < Import::BaseController session[:google_code_user_map] = user_map - flash[:notice] = "The user map has been saved. Continue by selecting the projects you want to import." + flash[:notice] = _("The user map has been saved. Continue by selecting the projects you want to import.") redirect_to status_import_google_code_path end diff --git a/app/controllers/import/phabricator_controller.rb b/app/controllers/import/phabricator_controller.rb new file mode 100644 index 00000000000..d1c04817689 --- /dev/null +++ b/app/controllers/import/phabricator_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class Import::PhabricatorController < Import::BaseController + include ImportHelper + + before_action :verify_import_enabled + + def new + end + + def create + @project = Gitlab::PhabricatorImport::ProjectCreator + .new(current_user, import_params).execute + + if @project&.persisted? + redirect_to @project + else + @name = params[:name] + @path = params[:path] + @errors = @project&.errors&.full_messages || [_("Invalid import params")] + + render :new + end + end + + def verify_import_enabled + render_404 unless phabricator_import_enabled? + end + + private + + def import_params + params.permit(:path, :phabricator_server_url, :api_token, :name, :namespace_id) + end +end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 315d1375e02..a78d87eceea 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -13,9 +13,9 @@ class InvitesController < ApplicationController if member.accept_invite!(current_user) label, path = source_info(member.source) - redirect_to path, notice: "You have been granted #{member.human_access} access to #{label}." + redirect_to path, notice: _("You have been granted %{member_human_access} access to %{label}.") % { member_human_access: member.human_access, label: label } else - redirect_back_or_default(options: { alert: "The invitation could not be accepted." }) + redirect_back_or_default(options: { alert: _("The invitation could not be accepted.") }) end end @@ -30,9 +30,9 @@ class InvitesController < ApplicationController new_user_session_path end - redirect_to path, notice: "You have declined the invitation to join #{label}." + redirect_to path, notice: _("You have declined the invitation to join %{label}.") % { label: label } else - redirect_back_or_default(options: { alert: "The invitation could not be declined." }) + redirect_back_or_default(options: { alert: _("The invitation could not be declined.") }) end end diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index f9008a5b67e..5ecf4f114cf 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -22,7 +22,7 @@ class JwtController < ApplicationController private def authenticate_project_or_user - @authentication_result = Gitlab::Auth::Result.new(nil, nil, :none, Gitlab::Auth.read_authentication_abilities) + @authentication_result = Gitlab::Auth::Result.new(nil, nil, :none, Gitlab::Auth.read_only_authentication_abilities) authenticate_with_http_basic do |login, password| @authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, ip: request.ip) @@ -39,9 +39,9 @@ class JwtController < ApplicationController render json: { errors: [ { code: 'UNAUTHORIZED', - message: "HTTP Basic: Access denied\n" \ - "You must use a personal access token with 'api' scope for Git over HTTP.\n" \ - "You can generate one at #{profile_personal_access_tokens_url}" } + message: _('HTTP Basic: Access denied\n' \ + 'You must use a personal access token with \'api\' scope for Git over HTTP.\n' \ + 'You can generate one at %{profile_personal_access_tokens_url}') % { profile_personal_access_tokens_url: profile_personal_access_tokens_url } } ] }, status: :unauthorized end diff --git a/app/controllers/ldap/omniauth_callbacks_controller.rb b/app/controllers/ldap/omniauth_callbacks_controller.rb index 5e872804448..9a5a45939e0 100644 --- a/app/controllers/ldap/omniauth_callbacks_controller.rb +++ b/app/controllers/ldap/omniauth_callbacks_controller.rb @@ -26,7 +26,7 @@ class Ldap::OmniauthCallbacksController < OmniauthCallbacksController override :fail_login def fail_login(user) - flash[:alert] = 'Access denied for your LDAP account.' + flash[:alert] = _('Access denied for your LDAP account.') redirect_to new_user_session_path end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index cc2bb99f55b..2a8dd997d04 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -3,6 +3,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController include AuthenticatesWithTwoFactor include Devise::Controllers::Rememberable + include AuthHelper protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true @@ -80,11 +81,13 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController end if current_user + return render_403 unless link_provider_allowed?(oauth['provider']) + log_audit_event(current_user, with: oauth['provider']) identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth) - identity_linker.link + link_identity(identity_linker) if identity_linker.changed? redirect_identity_linked @@ -98,16 +101,20 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController end end + def link_identity(identity_linker) + identity_linker.link + end + def redirect_identity_exists redirect_to after_sign_in_path_for(current_user) end def redirect_identity_link_failed(error_message) - redirect_to profile_account_path, notice: "Authentication failed: #{error_message}" + redirect_to profile_account_path, notice: _("Authentication failed: %{error_message}") % { error_message: error_message } end def redirect_identity_linked - redirect_to profile_account_path, notice: 'Authentication method updated' + redirect_to profile_account_path, notice: _('Authentication method updated') end def handle_service_ticket(provider, ticket) @@ -145,10 +152,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController def handle_signup_error label = Gitlab::Auth::OAuth::Provider.label_for(oauth['provider']) - message = ["Signing in using your #{label} account without a pre-existing GitLab account is not allowed."] + message = [_("Signing in using your %{label} account without a pre-existing GitLab account is not allowed.") % { label: label }] if Gitlab::CurrentSettings.allow_signup? - message << "Create a GitLab account first, and then connect it to your #{label} account." + message << _("Create a GitLab account first, and then connect it to your %{label} account.") % { label: label } end flash[:notice] = message.join(' ') @@ -166,14 +173,14 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController end def fail_auth0_login - flash[:alert] = 'Wrong extern UID provided. Make sure Auth0 is configured correctly.' + flash[:alert] = _('Wrong extern UID provided. Make sure Auth0 is configured correctly.') redirect_to new_user_session_path end def handle_disabled_provider label = Gitlab::Auth::OAuth::Provider.label_for(oauth['provider']) - flash[:alert] = "Signing in using #{label} has been disabled" + flash[:alert] = _("Signing in using %{label} has been disabled") % { label: label } redirect_to new_user_session_path end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 28f113b5cbe..77de5cb45c9 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -22,7 +22,7 @@ class PasswordsController < Devise::PasswordsController ).first_or_initialize unless user.reset_password_period_valid? - flash[:alert] = 'Your password reset token has expired.' + flash[:alert] = _('Your password reset token has expired.') redirect_to(new_user_password_url(user_email: user['email'])) end end @@ -52,7 +52,7 @@ class PasswordsController < Devise::PasswordsController end redirect_to after_sending_reset_password_instructions_path_for(resource_name), - alert: "Password authentication is unavailable." + alert: _("Password authentication is unavailable.") end def throttle_reset diff --git a/app/controllers/profiles/accounts_controller.rb b/app/controllers/profiles/accounts_controller.rb index b0d65f284af..b03f4b7435f 100644 --- a/app/controllers/profiles/accounts_controller.rb +++ b/app/controllers/profiles/accounts_controller.rb @@ -14,10 +14,10 @@ class Profiles::AccountsController < Profiles::ApplicationController return render_404 unless identity - if unlink_allowed?(provider) + if unlink_provider_allowed?(provider) identity.destroy else - flash[:alert] = "You are not allowed to unlink your primary login account" + flash[:alert] = _("You are not allowed to unlink your primary login account") end redirect_to profile_account_path diff --git a/app/controllers/profiles/active_sessions_controller.rb b/app/controllers/profiles/active_sessions_controller.rb index efe7ede5efa..c473023cacb 100644 --- a/app/controllers/profiles/active_sessions_controller.rb +++ b/app/controllers/profiles/active_sessions_controller.rb @@ -2,15 +2,6 @@ class Profiles::ActiveSessionsController < Profiles::ApplicationController def index - @sessions = ActiveSession.list(current_user) - end - - def destroy - ActiveSession.destroy(current_user, params[:id]) - - respond_to do |format| - format.html { redirect_to profile_active_sessions_url, status: :found } - format.js { head :ok } - end + @sessions = ActiveSession.list(current_user).reject(&:is_impersonated) end end diff --git a/app/controllers/profiles/chat_names_controller.rb b/app/controllers/profiles/chat_names_controller.rb index 2e78b9e6dc7..80b8279e91e 100644 --- a/app/controllers/profiles/chat_names_controller.rb +++ b/app/controllers/profiles/chat_names_controller.rb @@ -15,9 +15,9 @@ class Profiles::ChatNamesController < Profiles::ApplicationController new_chat_name = current_user.chat_names.new(chat_name_params) if new_chat_name.save - flash[:notice] = "Authorized #{new_chat_name.chat_name}" + flash[:notice] = _("Authorized %{new_chat_name}") % { new_chat_name: new_chat_name.chat_name } else - flash[:alert] = "Could not authorize chat nickname. Try again!" + flash[:alert] = _("Could not authorize chat nickname. Try again!") end delete_chat_name_token @@ -27,7 +27,7 @@ class Profiles::ChatNamesController < Profiles::ApplicationController def deny delete_chat_name_token - flash[:notice] = "Denied authorization of chat nickname #{chat_name_params[:user_name]}." + flash[:notice] = _("Denied authorization of chat nickname %{user_name}.") % { user_name: chat_name_params[:user_name] } redirect_to profile_chat_names_path end @@ -36,9 +36,9 @@ class Profiles::ChatNamesController < Profiles::ApplicationController @chat_name = chat_names.find(params[:id]) if @chat_name.destroy - flash[:notice] = "Deleted chat nickname: #{@chat_name.chat_name}!" + flash[:notice] = _("Deleted chat nickname: %{chat_name}!") % { chat_name: @chat_name.chat_name } else - flash[:alert] = "Could not delete chat nickname #{@chat_name.chat_name}." + flash[:alert] = _("Could not delete chat nickname %{chat_name}.") % { chat_name: @chat_name.chat_name } end redirect_to profile_chat_names_path, status: :found diff --git a/app/controllers/profiles/groups_controller.rb b/app/controllers/profiles/groups_controller.rb new file mode 100644 index 00000000000..c755bcb718a --- /dev/null +++ b/app/controllers/profiles/groups_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Profiles::GroupsController < Profiles::ApplicationController + include RoutableActions + + def update + group = find_routable!(Group, params[:id]) + notification_setting = current_user.notification_settings.find_by(source: group) # rubocop: disable CodeReuse/ActiveRecord + + if notification_setting.update(update_params) + flash[:notice] = "Notification settings for #{group.name} saved" + else + flash[:alert] = "Failed to save new settings for #{group.name}" + end + + redirect_back_or_default(default: profile_notifications_path) + end + + private + + def update_params + params.require(:notification_setting).permit(:notification_email) + end +end diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb index b719b70c56e..617e5bb7cb3 100644 --- a/app/controllers/profiles/notifications_controller.rb +++ b/app/controllers/profiles/notifications_controller.rb @@ -14,9 +14,9 @@ class Profiles::NotificationsController < Profiles::ApplicationController result = Users::UpdateService.new(current_user, user_params.merge(user: current_user)).execute if result[:status] == :success - flash[:notice] = "Notification settings saved" + flash[:notice] = _("Notification settings saved") else - flash[:alert] = "Failed to save new settings" + flash[:alert] = _("Failed to save new settings") end redirect_back_or_default(default: profile_notifications_path) diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb index a0391d677c4..d2787c2e450 100644 --- a/app/controllers/profiles/passwords_controller.rb +++ b/app/controllers/profiles/passwords_controller.rb @@ -14,7 +14,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController def create unless @user.password_automatically_set || @user.valid_password?(user_params[:current_password]) - redirect_to new_profile_password_path, alert: 'You must provide a valid current password' + redirect_to new_profile_password_path, alert: _('You must provide a valid current password') return end @@ -29,7 +29,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController if result[:status] == :success Users::UpdateService.new(current_user, user: @user, password_expires_at: nil).execute - redirect_to root_path, notice: 'Password successfully changed' + redirect_to root_path, notice: _('Password successfully changed') else render :new end @@ -45,24 +45,24 @@ class Profiles::PasswordsController < Profiles::ApplicationController password_attributes[:password_automatically_set] = false unless @user.password_automatically_set || @user.valid_password?(user_params[:current_password]) - redirect_to edit_profile_password_path, alert: 'You must provide a valid current password' + redirect_to edit_profile_password_path, alert: _('You must provide a valid current password') return end result = Users::UpdateService.new(current_user, password_attributes.merge(user: @user)).execute if result[:status] == :success - flash[:notice] = "Password was successfully updated. Please login with it" + flash[:notice] = _('Password was successfully updated. Please login with it') redirect_to new_user_session_path else - @user.reload + @user.reset render 'edit' end end def reset current_user.send_reset_password_instructions - redirect_to edit_profile_password_path, notice: 'We sent you an email with reset password instructions' + redirect_to edit_profile_password_path, notice: _('We sent you an email with reset password instructions') end private diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 4b6ec2697b7..f1c07cd9a1d 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -11,7 +11,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController if @personal_access_token.save PersonalAccessToken.redis_store!(current_user.id, @personal_access_token.token) - redirect_to profile_personal_access_tokens_path, notice: "Your new personal access token has been created." + redirect_to profile_personal_access_tokens_path, notice: _("Your new personal access token has been created.") else set_index_vars render :index @@ -22,9 +22,9 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController @personal_access_token = finder.find(params[:id]) if @personal_access_token.revoke! - flash[:notice] = "Revoked personal access token #{@personal_access_token.name}!" + flash[:notice] = _("Revoked personal access token %{personal_access_token_name}!") % { personal_access_token_name: @personal_access_token.name } else - flash[:alert] = "Could not revoke personal access token #{@personal_access_token.name}." + flash[:alert] = _("Could not revoke personal access token %{personal_access_token_name}.") % { personal_access_token_name: @personal_access_token.name } end redirect_to profile_personal_access_tokens_path @@ -42,7 +42,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def set_index_vars - @scopes = Gitlab::Auth.available_scopes(current_user) + @scopes = Gitlab::Auth.available_scopes_for(current_user) @inactive_personal_access_tokens = finder(state: 'inactive').execute @active_personal_access_tokens = finder(state: 'active').execute.order(:expires_at) diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 0227af2c266..62f98d9e549 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -11,13 +11,13 @@ class Profiles::PreferencesController < Profiles::ApplicationController result = Users::UpdateService.new(current_user, preferences_params.merge(user: user)).execute if result[:status] == :success - flash[:notice] = 'Preferences saved.' + flash[:notice] = _('Preferences saved.') else - flash[:alert] = 'Failed to save preferences.' + flash[:alert] = _('Failed to save preferences.') end rescue ArgumentError => e # Raised when `dashboard` is given an invalid value. - flash[:alert] = "Failed to save preferences (#{e.message})." + flash[:alert] = _("Failed to save preferences (%{error_message}).") % { error_message: e.message } end respond_to do |format| @@ -44,7 +44,9 @@ class Profiles::PreferencesController < Profiles::ApplicationController :project_view, :theme_id, :first_day_of_week, - :preferred_language + :preferred_language, + :time_display_relative, + :time_format_in_24h ] end end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index ba94196b2f9..95b9344c551 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -18,21 +18,16 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController two_factor_authentication_reason( global: lambda do flash.now[:alert] = - 'The global settings require you to enable Two-Factor Authentication for your account.' + _('The global settings require you to enable Two-Factor Authentication for your account.') end, group: lambda do |groups| - group_links = groups.map { |group| view_context.link_to group.full_name, group_path(group) }.to_sentence - - flash.now[:alert] = %{ - The group settings for #{group_links} require you to enable - Two-Factor Authentication for your account. - }.html_safe + flash.now[:alert] = groups_notification(groups) end ) unless two_factor_grace_period_expired? grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours - flash.now[:alert] = flash.now[:alert] + " You need to do this before #{l(grace_period_deadline)}." + flash.now[:alert] = flash.now[:alert] + _(" You need to do this before %{grace_period_deadline}.") % { grace_period_deadline: l(grace_period_deadline) } end end @@ -49,7 +44,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController render 'create' else - @error = 'Invalid pin code' + @error = _('Invalid pin code') @qr_code = build_qr_code setup_u2f_registration render 'show' @@ -63,7 +58,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController if @u2f_registration.persisted? session.delete(:challenges) - redirect_to profile_two_factor_auth_path, notice: "Your U2F device was registered!" + redirect_to profile_two_factor_auth_path, notice: s_("Your U2F device was registered!") else @qr_code = build_qr_code setup_u2f_registration @@ -85,7 +80,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController def skip if two_factor_grace_period_expired? - redirect_to new_profile_two_factor_auth_path, alert: 'Cannot skip two factor authentication setup' + redirect_to new_profile_two_factor_auth_path, alert: s_('Cannot skip two factor authentication setup') else session[:skip_two_factor] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours redirect_to root_path @@ -126,4 +121,12 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController def u2f_registration_params params.require(:u2f_registration).permit(:device_response, :name) end + + def groups_notification(groups) + group_links = groups.map { |group| view_context.link_to group.full_name, group_path(group) }.to_sentence + leave_group_links = groups.map { |group| view_context.link_to (s_("leave %{group_name}") % { group_name: group.full_name }), leave_group_members_path(group), remote: false, method: :delete}.to_sentence + + s_(%{The group settings for %{group_links} require you to enable Two-Factor Authentication for your account. You can %{leave_group_links}.}) + .html_safe % { group_links: group_links.html_safe, leave_group_links: leave_group_links.html_safe } + end end diff --git a/app/controllers/profiles/u2f_registrations_controller.rb b/app/controllers/profiles/u2f_registrations_controller.rb index e6a154fb6aa..866c4dee6e2 100644 --- a/app/controllers/profiles/u2f_registrations_controller.rb +++ b/app/controllers/profiles/u2f_registrations_controller.rb @@ -4,6 +4,6 @@ class Profiles::U2fRegistrationsController < Profiles::ApplicationController def destroy u2f_registration = current_user.u2f_registrations.find(params[:id]) u2f_registration.destroy - redirect_to profile_two_factor_auth_path, status: 302, notice: "Successfully deleted U2F device." + redirect_to profile_two_factor_auth_path, status: 302, notice: _("Successfully deleted U2F device.") end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index b9c52618d4b..1d16ddb1608 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -15,7 +15,7 @@ class ProfilesController < Profiles::ApplicationController result = Users::UpdateService.new(current_user, user_params.merge(user: @user)).execute if result[:status] == :success - message = "Profile was successfully updated" + message = s_("Profiles|Profile was successfully updated") format.html { redirect_back_or_default(default: { action: 'show' }, options: { notice: message }) } format.json { render json: { message: message } } @@ -31,7 +31,7 @@ class ProfilesController < Profiles::ApplicationController user.reset_incoming_email_token! end - flash[:notice] = "Incoming email token was successfully reset" + flash[:notice] = s_("Profiles|Incoming email token was successfully reset") redirect_to profile_personal_access_tokens_path end @@ -41,7 +41,7 @@ class ProfilesController < Profiles::ApplicationController user.reset_feed_token! end - flash[:notice] = 'Feed token was successfully reset' + flash[:notice] = s_('Profiles|Feed token was successfully reset') redirect_to profile_personal_access_tokens_path end @@ -106,6 +106,7 @@ class ProfilesController < Profiles::ApplicationController :organization, :private_profile, :include_private_contributions, + :timezone, status: [:emoji, :message] ) end diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index e0677ce3fbc..80e4f54bbf4 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -3,7 +3,6 @@ class Projects::ApplicationController < ApplicationController include CookiesHelper include RoutableActions - include ProjectUnauthorized include ChecksCollaboration skip_before_action :authenticate_user! @@ -17,12 +16,12 @@ class Projects::ApplicationController < ApplicationController def project return @project if @project - return nil unless params[:project_id] || params[:id] + return unless params[:project_id] || params[:id] path = File.join(params[:namespace_id], params[:project_id] || params[:id]) auth_proc = ->(project) { !project.pending_delete? } - @project = find_routable!(Project, path, extra_authorization_proc: auth_proc, not_found_or_authorized_proc: project_unauthorized_proc) + @project = find_routable!(Project, path, extra_authorization_proc: auth_proc) end def build_canonical_path(project) @@ -88,4 +87,10 @@ class Projects::ApplicationController < ApplicationController def check_issues_available! return render_404 unless @project.feature_available?(:issues, current_user) end + + def allow_gitaly_ref_name_caching + ::Gitlab::GitalyClient.allow_ref_name_caching do + yield + end + end end diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index 9c130af8394..0e3f13045ce 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Projects::AutocompleteSourcesController < Projects::ApplicationController + before_action :authorize_read_milestone!, only: :milestones + def members render json: ::Projects::ParticipantsService.new(@project, current_user).execute(target) end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 77672e7d9fc..b04ffe80db4 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -9,6 +9,8 @@ class Projects::BlobController < Projects::ApplicationController include ActionView::Helpers::SanitizeHelper prepend_before_action :authenticate_user!, only: [:edit] + around_action :allow_gitaly_ref_name_caching, only: [:show] + before_action :require_non_empty_project, except: [:new, :create] before_action :authorize_download_code! @@ -29,7 +31,7 @@ class Projects::BlobController < Projects::ApplicationController end def create - create_commit(Files::CreateService, success_notice: "The file has been successfully created.", + create_commit(Files::CreateService, success_notice: _("The file has been successfully created."), success_path: -> { project_blob_path(@project, File.join(@branch_name, @file_path)) }, failure_view: :new, failure_path: project_new_blob_path(@project, @ref)) @@ -81,7 +83,7 @@ class Projects::BlobController < Projects::ApplicationController end def destroy - create_commit(Files::DeleteService, success_notice: "The file has been successfully deleted.", + create_commit(Files::DeleteService, success_notice: _("The file has been successfully deleted."), success_path: -> { after_delete_path }, failure_view: :show, failure_path: project_blob_path(@project, @id)) @@ -90,65 +92,21 @@ class Projects::BlobController < Projects::ApplicationController def diff apply_diff_view_cookie! - @blob.load_all_data! - @lines = @blob.present.highlight.lines - - @form = UnfoldForm.new(params.to_unsafe_h) - - @lines = @lines[@form.since - 1..@form.to - 1].map(&:html_safe) - - if @form.bottom? - @match_line = '' - else - lines_length = @lines.length - 1 - line = [@form.since, lines_length].join(',') - @match_line = "@@ -#{line}+#{line} @@" - end + @form = Blobs::UnfoldPresenter.new(blob, params.to_unsafe_h) - # We can keep only 'render_diff_lines' from this conditional when + # keep only json rendering when # https://gitlab.com/gitlab-org/gitlab-ce/issues/44988 is done if rendered_for_merge_request? - render_diff_lines + render json: DiffLineSerializer.new.represent(@form.diff_lines) else + @lines = @form.lines + @match_line = @form.match_line_text render layout: false end end private - # Converts a String array to Gitlab::Diff::Line array - def render_diff_lines - @lines.map! do |line| - # These are marked as context lines but are loaded from blobs. - # We also have context lines loaded from diffs in other places. - diff_line = Gitlab::Diff::Line.new(line, nil, nil, nil, nil) - diff_line.rich_text = line - diff_line - end - - add_match_line - - render json: DiffLineSerializer.new.represent(@lines) - end - - def add_match_line - return unless @form.unfold? - - if @form.bottom? && @form.to < @blob.lines.size - old_pos = @form.to - @form.offset - new_pos = @form.to - elsif @form.since != 1 - old_pos = new_pos = @form.since - end - - # Match line is not needed when it reaches the top limit or bottom limit of the file. - return unless new_pos - - @match_line = Gitlab::Diff::Line.new(@match_line, 'match', nil, old_pos, new_pos) - - @form.bottom? ? @lines.push(@match_line) : @lines.unshift(@match_line) - end - def blob @blob ||= @repository.blob_at(@commit.id, @path) @@ -216,8 +174,7 @@ class Projects::BlobController < Projects::ApplicationController end if params[:file].present? - params[:content] = Base64.encode64(params[:file].read) - params[:encoding] = 'base64' + params[:content] = params[:file] end @commit_params = { @@ -231,6 +188,8 @@ class Projects::BlobController < Projects::ApplicationController end def validate_diff_params + return if params[:full] + if [:since, :to, :offset].any? { |key| params[key].blank? } head :ok end diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index 8189b5d182a..95897aaf980 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -1,34 +1,15 @@ # frozen_string_literal: true class Projects::BoardsController < Projects::ApplicationController - include BoardsResponses + include BoardsActions include IssuableCollections before_action :check_issues_available! before_action :authorize_read_board!, only: [:index, :show] - before_action :boards, only: :index before_action :assign_endpoint_vars - before_action :redirect_to_recent_board, only: :index - - def index - respond_with_boards - end - - def show - @board = boards.find(params[:id]) - - # add/update the board in the recent visited table - Boards::Visits::CreateService.new(@board.project, current_user).execute(@board) if request.format.html? - - respond_with_board - end private - def boards - @boards ||= Boards::ListService.new(project, current_user).execute - end - def assign_endpoint_vars @boards_endpoint = project_boards_path(project) @bulk_issues_path = bulk_update_project_issues_path(project) @@ -39,22 +20,4 @@ class Projects::BoardsController < Projects::ApplicationController def authorize_read_board! access_denied! unless can?(current_user, :read_board, project) end - - def serialize_as_json(resource) - resource.as_json(only: [:id]) - end - - def includes_board?(board_id) - boards.any? { |board| board.id == board_id } - end - - def redirect_to_recent_board - return if request.format.json? - - recently_visited = Boards::Visits::LatestService.new(project, current_user).execute - - if recently_visited && includes_board?(recently_visited.board_id) - redirect_to(namespace_project_board_path(id: recently_visited.board_id), status: :found) - end - end end diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 32b7f3207ef..fc708400657 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -25,7 +25,7 @@ class Projects::BranchesController < Projects::ApplicationController @refs_pipelines = @project.ci_pipelines.latest_successful_for_refs(@branches.map(&:name)) @merged_branch_names = repository.merged_branch_names(@branches.map(&:name)) - # n+1: https://gitlab.com/gitlab-org/gitaly/issues/992 + # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/48097 Gitlab::GitalyClient.allow_n_plus_1_calls do @max_commits = @branches.reduce(0) do |memo, branch| diverging_commit_counts = repository.diverging_commit_counts(branch) @@ -53,7 +53,7 @@ class Projects::BranchesController < Projects::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def create - branch_name = sanitize(strip_tags(params[:branch_name])) + branch_name = strip_tags(sanitize(params[:branch_name])) branch_name = Addressable::URI.unescape(branch_name) redirect_to_autodeploy = project.empty_repo? && project.deployment_platform.present? @@ -100,14 +100,14 @@ class Projects::BranchesController < Projects::ApplicationController respond_to do |format| format.html do - flash_type = result[:status] == :error ? :alert : :notice - flash[flash_type] = result[:message] + flash_type = result.error? ? :alert : :notice + flash[flash_type] = result.message redirect_to project_branches_path(@project), status: :see_other end - format.js { head result[:return_code] } - format.json { render json: { message: result[:message] }, status: result[:return_code] } + format.js { head result.http_status } + format.json { render json: { message: result.message }, status: result.http_status } end end @@ -115,14 +115,14 @@ class Projects::BranchesController < Projects::ApplicationController DeleteMergedBranchesService.new(@project, current_user).async_execute redirect_to project_branches_path(@project), - notice: 'Merged branches are being deleted. This can take some time depending on the number of branches. Please refresh the page to see changes.' + notice: _('Merged branches are being deleted. This can take some time depending on the number of branches. Please refresh the page to see changes.') end private def ref if params[:ref] - ref_escaped = sanitize(strip_tags(params[:ref])) + ref_escaped = strip_tags(sanitize(params[:ref])) Addressable::URI.unescape(ref_escaped) else @project.default_branch || 'master' @@ -143,7 +143,7 @@ class Projects::BranchesController < Projects::ApplicationController def redirect_for_legacy_index_sort_or_search # Normalize a legacy URL with redirect if request.format != :json && !params[:state].presence && [:sort, :search, :page].any? { |key| params[key].presence } - redirect_to project_branches_filtered_path(@project, state: 'all'), notice: 'Update your bookmarked URLs as filtered/sorted branches URL has been changed.' + redirect_to project_branches_filtered_path(@project, state: 'all'), notice: _('Update your bookmarked URLs as filtered/sorted branches URL has been changed.') end end diff --git a/app/controllers/projects/clusters/applications_controller.rb b/app/controllers/projects/clusters/applications_controller.rb index c7b6218d007..2a04b007304 100644 --- a/app/controllers/projects/clusters/applications_controller.rb +++ b/app/controllers/projects/clusters/applications_controller.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class Projects::Clusters::ApplicationsController < Clusters::ApplicationsController - include ProjectUnauthorized - prepend_before_action :project private @@ -12,6 +10,6 @@ class Projects::Clusters::ApplicationsController < Clusters::ApplicationsControl end def project - @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]), not_found_or_authorized_proc: project_unauthorized_proc) + @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id])) end end diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb index feda6deeaa6..98cd66cf6f9 100644 --- a/app/controllers/projects/clusters_controller.rb +++ b/app/controllers/projects/clusters_controller.rb @@ -1,11 +1,13 @@ # frozen_string_literal: true class Projects::ClustersController < Clusters::ClustersController - include ProjectUnauthorized - prepend_before_action :project before_action :repository + before_action do + push_frontend_feature_flag(:prometheus_computed_alerts) + end + layout 'project' private @@ -15,7 +17,7 @@ class Projects::ClustersController < Clusters::ClustersController end def project - @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]), not_found_or_authorized_proc: project_unauthorized_proc) + @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id])) end def repository diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index b13c0ae3967..939a09d4fd2 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -65,7 +65,11 @@ class Projects::CommitController < Projects::ApplicationController # rubocop: enable CodeReuse/ActiveRecord def merge_requests - @merge_requests = @commit.merge_requests.map do |mr| + @merge_requests = MergeRequestsFinder.new( + current_user, + project_id: @project.id, + commit_sha: @commit.sha + ).execute.map do |mr| { iid: mr.iid, path: merge_request_path(mr), title: mr.title } end diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb index 2510a31c9b3..f540ccee386 100644 --- a/app/controllers/projects/commits_controller.rb +++ b/app/controllers/projects/commits_controller.rb @@ -7,6 +7,7 @@ class Projects::CommitsController < Projects::ApplicationController include RendersCommits prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } + around_action :allow_gitaly_ref_name_caching before_action :whitelist_query_limiting, except: :commits_root before_action :require_non_empty_project before_action :assign_ref_vars, except: :commits_root diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index 6824a07dc76..514b03e23b5 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -38,7 +38,7 @@ class Projects::DeployKeysController < Projects::ApplicationController def update if deploy_key.update(update_params) - flash[:notice] = 'Deploy key was successfully updated.' + flash[:notice] = _('Deploy key was successfully updated.') redirect_to_repository_settings(@project, anchor: 'js-deploy-keys-settings') else render 'edit' diff --git a/app/controllers/projects/environments/prometheus_api_controller.rb b/app/controllers/projects/environments/prometheus_api_controller.rb new file mode 100644 index 00000000000..f8ef23cd83e --- /dev/null +++ b/app/controllers/projects/environments/prometheus_api_controller.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Projects::Environments::PrometheusApiController < Projects::ApplicationController + before_action :authorize_read_prometheus! + before_action :environment + + def proxy + result = Prometheus::ProxyService.new( + environment, + proxy_method, + proxy_path, + proxy_params + ).execute + + if result.nil? + return render status: :accepted, 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 + end + + private + + def query_context + Gitlab::Prometheus::QueryVariables.call(environment) + end + + def environment + @environment ||= project.environments.find(params[:id]) + end + + def proxy_method + request.method + end + + def proxy_path + params[:proxy_path] + end + + def proxy_params + substitute_query_variables(params).permit! + end + + def substitute_query_variables(params) + query = params[:query] + return params unless query + + params.merge(query: query % query_context) + end +end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index e9cd475a199..e002a4d349b 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -10,6 +10,12 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics] before_action :verify_api_request!, only: :terminal_websocket_authorize 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) + push_frontend_feature_flag(:environment_metrics_show_multiple_dashboards) + push_frontend_feature_flag(:grafana_dashboard_link) + push_frontend_feature_flag(:prometheus_computed_alerts) + end def index @environments = project.environments @@ -114,7 +120,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController terminal = environment.terminals.try(:first) if terminal set_workhorse_internal_api_content_type - render json: Gitlab::Workhorse.terminal_websocket(terminal) + render json: Gitlab::Workhorse.channel_websocket(terminal) else render html: 'Not found', status: :not_found end @@ -131,13 +137,13 @@ class Projects::EnvironmentsController < Projects::ApplicationController end def metrics - # Currently, this acts as a hint to load the metrics details into the cache - # if they aren't there already - @metrics = environment.metrics || {} - respond_to do |format| format.html format.json do + # Currently, this acts as a hint to load the metrics details into the cache + # if they aren't there already + @metrics = environment.metrics || {} + render json: @metrics, status: @metrics.any? ? :ok : :no_content end end @@ -146,13 +152,40 @@ class Projects::EnvironmentsController < Projects::ApplicationController def additional_metrics respond_to do |format| format.json do - additional_metrics = environment.additional_metrics || {} + additional_metrics = environment.additional_metrics(*metrics_params) || {} render json: additional_metrics, status: additional_metrics.any? ? :ok : :no_content end end end + def metrics_dashboard + return render_403 unless Feature.enabled?(:environment_metrics_use_prometheus_endpoint, project) + + if Feature.enabled?(:environment_metrics_show_multiple_dashboards, project) + result = dashboard_finder.find(project, current_user, environment, params[:dashboard]) + + result[:all_dashboards] = project.repository.metrics_dashboard_paths + else + result = dashboard_finder.find(project, current_user, 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 @@ -186,6 +219,14 @@ class Projects::EnvironmentsController < Projects::ApplicationController @environment ||= project.environments.find(params[:id]) end + def metrics_params + params.require([:start, :end]) + end + + def dashboard_finder + Gitlab::Metrics::Dashboard::Finder + end + def search_environment_names return [] unless params[:query] diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb index d439db97252..956093b972b 100644 --- a/app/controllers/projects/git_http_client_controller.rb +++ b/app/controllers/projects/git_http_client_controller.rb @@ -15,6 +15,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController alias_method :authenticated_user, :actor # Git clients will not know what authenticity token to send along + skip_around_action :set_session_storage skip_before_action :verify_authenticity_token skip_before_action :repository before_action :authenticate_user @@ -78,24 +79,28 @@ class Projects::GitHttpClientController < Projects::ApplicationController end def parse_repo_path - @project, @wiki, @redirected_path = Gitlab::RepoPath.parse("#{params[:namespace_id]}/#{params[:project_id]}") + @project, @repo_type, @redirected_path = Gitlab::RepoPath.parse("#{params[:namespace_id]}/#{params[:project_id]}") end def render_missing_personal_access_token render plain: "HTTP Basic: Access denied\n" \ - "You must use a personal access token with 'api' scope for Git over HTTP.\n" \ + "You must use a personal access token with 'read_repository' or 'write_repository' scope for Git over HTTP.\n" \ "You can generate one at #{profile_personal_access_tokens_url}", status: :unauthorized end def repository - wiki? ? project.wiki.repository : project.repository + repo_type.repository_for(project) end def wiki? - parse_repo_path unless defined?(@wiki) + repo_type.wiki? + end + + def repo_type + parse_repo_path unless defined?(@repo_type) - @wiki + @repo_type end def handle_basic_authentication(login, password) diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index 30e436365de..e519cc1f158 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -4,6 +4,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController include WorkhorseRequest 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 @@ -20,6 +21,8 @@ class Projects::GitHttpController < Projects::GitHttpClientController # POST /foo/bar.git/git-upload-pack (git pull) def git_upload_pack + enqueue_fetch_statistics_update + render_ok end @@ -30,6 +33,10 @@ class Projects::GitHttpController < Projects::GitHttpClientController private + def deny_head_requests + head :forbidden if request.head? + end + def download_request? upload_pack? end @@ -48,7 +55,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController def render_ok set_workhorse_internal_api_content_type - render json: Gitlab::Workhorse.git_http_ok(repository, wiki?, user, action_name) + render json: Gitlab::Workhorse.git_http_ok(repository, repo_type, user, action_name) end def render_403(exception) @@ -67,6 +74,13 @@ class Projects::GitHttpController < Projects::GitHttpClientController render plain: exception.message, status: :service_unavailable end + def enqueue_fetch_statistics_update + return if wiki? + return unless project.daily_statistics_enabled? + + ProjectDailyStatisticsWorker.perform_async(project.id) + end + def access @access ||= access_klass.new(access_actor, project, 'http', authentication_abilities: authentication_abilities, @@ -85,7 +99,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController end def access_klass - @access_klass ||= wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess + @access_klass ||= repo_type.access_checker_class end def project_path diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb index c80fce513f6..67d3f49af18 100644 --- a/app/controllers/projects/graphs_controller.rb +++ b/app/controllers/projects/graphs_controller.rb @@ -46,12 +46,8 @@ class Projects::GraphsController < Projects::ApplicationController def get_languages @languages = - if @project.repository_languages.present? - @project.repository_languages.map do |lang| - { value: lang.share, label: lang.name, color: lang.color, highlight: lang.color } - end - else - @project.repository.languages + ::Projects::RepositoryLanguagesService.new(@project, current_user).execute.map do |lang| + { value: lang.share, label: lang.name, color: lang.color, highlight: lang.color } end end diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb index 7c713c19762..dc65f9959db 100644 --- a/app/controllers/projects/group_links_controller.rb +++ b/app/controllers/projects/group_links_controller.rb @@ -13,11 +13,12 @@ class Projects::GroupLinksController < Projects::ApplicationController group = Group.find(params[:link_group_id]) if params[:link_group_id].present? if group - return render_404 unless can?(current_user, :read_group, group) + result = Projects::GroupLinks::CreateService.new(project, current_user, group_link_create_params).execute(group) + return render_404 if result[:http_status] == 404 - Projects::GroupLinks::CreateService.new(project, current_user, group_link_create_params).execute(group) + flash[:alert] = result[:message] if result[:http_status] == 409 else - flash[:alert] = 'Please select a group.' + flash[:alert] = _('Please select a group.') end redirect_to project_project_members_path(project) diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index bc84418b79f..5fa0339f44d 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -32,7 +32,7 @@ class Projects::HooksController < Projects::ApplicationController def update if hook.update(hook_params) - flash[:notice] = 'Hook was successfully updated.' + flash[:notice] = _('Hook was successfully updated.') redirect_to project_settings_integrations_path(@project) else render 'edit' diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb index 8b33fa85c1e..afbf9fd7720 100644 --- a/app/controllers/projects/imports_controller.rb +++ b/app/controllers/projects/imports_controller.rb @@ -2,6 +2,7 @@ class Projects::ImportsController < Projects::ApplicationController include ContinueParams + include ImportUrlParams # Authorize before_action :authorize_admin_project! @@ -14,7 +15,7 @@ class Projects::ImportsController < Projects::ApplicationController def create if @project.update(import_params) - @project.import_state.reload.schedule + @project.import_state.reset.schedule end redirect_to project_import_path(@project) @@ -42,9 +43,9 @@ class Projects::ImportsController < Projects::ApplicationController def finished_notice if @project.forked? - 'The project was successfully forked.' + _('The project was successfully forked.') else - 'The project was successfully imported.' + _('The project was successfully imported.') end end @@ -67,10 +68,12 @@ class Projects::ImportsController < Projects::ApplicationController end def import_params_attributes - [:import_url] + [] end def import_params - params.require(:project).permit(import_params_attributes) + params.require(:project) + .permit(import_params_attributes) + .merge(import_url_params) end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index b9d02a62fc3..b4d89db20c5 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -10,11 +10,11 @@ class Projects::IssuesController < Projects::ApplicationController include SpammableActions include RecordUserLastActivity - def self.issue_except_actions + def issue_except_actions %i[index calendar new create bulk_update import_csv] end - def self.set_issuables_index_only_actions + def set_issuables_index_only_actions %i[index calendar] end @@ -25,9 +25,9 @@ class Projects::IssuesController < Projects::ApplicationController before_action :whitelist_query_limiting, only: [:create, :create_merge_request, :move, :bulk_update] before_action :check_issues_available! - before_action :issue, except: issue_except_actions + before_action :issue, unless: ->(c) { c.issue_except_actions.include?(c.action_name.to_sym) } - before_action :set_issuables_index, only: set_issuables_index_only_actions + before_action :set_issuables_index, if: ->(c) { c.set_issuables_index_only_actions.include?(c.action_name.to_sym) } # Allow write(create) issue before_action :authorize_create_issue!, only: [:new, :create] @@ -39,6 +39,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action :authorize_create_merge_request_from!, only: [:create_merge_request] before_action :authorize_import_issues!, only: [:import_csv] + before_action :authorize_download_code!, only: [:related_branches] before_action :set_suggested_issues_feature_flags, only: [:new] @@ -95,9 +96,9 @@ class Projects::IssuesController < Projects::ApplicationController if service.discussions_to_resolve.count(&:resolved?) > 0 flash[:notice] = if service.discussion_to_resolve_id - "Resolved 1 discussion." + _("Resolved 1 discussion.") else - "Resolved all discussions." + _("Resolved all discussions.") end end @@ -131,18 +132,6 @@ class Projects::IssuesController < Projects::ApplicationController render_conflict_response end - def referenced_merge_requests - @merge_requests, @closed_by_merge_requests = ::Issues::ReferencedMergeRequestsService.new(project, current_user).execute(issue) - - respond_to do |format| - format.json do - render json: { - html: view_to_html_string('projects/issues/_merge_requests') - } - end - end - end - def related_branches @related_branches = Issues::RelatedBranchesService.new(project, current_user).execute(issue) diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index d5ce790e2d9..2a4933e7bc2 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -122,7 +122,7 @@ class Projects::JobsController < Projects::ApplicationController def erase if @build.erase(erased_by: current_user) redirect_to project_job_path(project, @build), - notice: "Job has been successfully erased!" + notice: _("Job has been successfully erased!") else respond_422 end @@ -157,7 +157,7 @@ class Projects::JobsController < Projects::ApplicationController # GET .../terminal.ws : implemented in gitlab-workhorse def terminal_websocket_authorize set_workhorse_internal_api_content_type - render json: Gitlab::Workhorse.terminal_websocket(@build.terminal_specification) + render json: Gitlab::Workhorse.channel_websocket(@build.terminal_specification) end private diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 640038818f2..386a1f00bd2 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -132,7 +132,7 @@ class Projects::LabelsController < Projects::ApplicationController respond_to do |format| format.html do redirect_to(project_labels_path(@project), - notice: 'Failed to promote label due to internal error. Please contact administrators.') + notice: _('Failed to promote label due to internal error. Please contact administrators.')) end format.js end diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb index be40077d389..42c415757f9 100644 --- a/app/controllers/projects/lfs_api_controller.rb +++ b/app/controllers/projects/lfs_api_controller.rb @@ -26,7 +26,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController def deprecated render( json: { - message: 'Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.', + message: _('Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.'), documentation_url: "#{Gitlab.config.gitlab.url}/help" }, status: :not_implemented @@ -62,7 +62,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController else object[:error] = { code: 404, - message: "Object does not exist on the server or you don't have permissions to access it" + message: _("Object does not exist on the server or you don't have permissions to access it") } end end diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 6045ee4e171..f2a6268b3e9 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -7,11 +7,15 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont private - # rubocop: disable CodeReuse/ActiveRecord def merge_request - @issuable = @merge_request ||= @project.merge_requests.includes(author: :status).find_by!(iid: params[:id]) + @issuable = + @merge_request ||= + merge_request_includes(@project.merge_requests).find_by_iid!(params[:id]) + end + + def merge_request_includes(association) + association.includes(:metrics, :assignees, author: :status) # rubocop:disable CodeReuse/ActiveRecord end - # rubocop: enable CodeReuse/ActiveRecord def merge_request_params params.require(:merge_request).permit(merge_request_params_attributes) @@ -20,7 +24,6 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont def merge_request_params_attributes [ :allow_collaboration, - :assignee_id, :description, :force_remove_source_branch, :lock_version, @@ -35,6 +38,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont :title, :discussion_locked, label_ids: [], + assignee_ids: [], update_task: [:index, :checked, :line_number, :line_source] ] end diff --git a/app/controllers/projects/merge_requests/conflicts_controller.rb b/app/controllers/projects/merge_requests/conflicts_controller.rb index 045a4e974fe..011ac9a42f8 100644 --- a/app/controllers/projects/merge_requests/conflicts_controller.rb +++ b/app/controllers/projects/merge_requests/conflicts_controller.rb @@ -16,12 +16,12 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap render json: @conflicts_list elsif @merge_request.can_be_merged? render json: { - message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.', + message: _('The merge conflicts for this merge request have already been resolved. Please return to the merge request.'), type: 'error' } else render json: { - message: 'The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.', + message: _('The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.'), type: 'error' } end @@ -43,7 +43,7 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap return render_404 unless @conflicts_list.can_be_resolved_in_ui? if @merge_request.can_be_merged? - render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' } + render status: :bad_request, json: { message: _('The merge conflicts for this merge request have already been resolved.') } return end @@ -52,7 +52,7 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap .new(merge_request) .execute(current_user, params) - flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.' + flash[:notice] = _('All merge conflicts were resolved. The merge request can now be merged.') render json: { redirect_to: project_merge_request_url(@project, @merge_request, resolved_conflicts: true) } rescue Gitlab::Git::Conflict::Resolver::ResolutionError => e diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 518d41bd3fb..456d2c34768 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -46,8 +46,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic # rubocop: disable CodeReuse/ActiveRecord def commit - return nil unless commit_id = params[:commit_id].presence - return nil unless @merge_request.all_commits.exists?(sha: commit_id) + return unless commit_id = params[:commit_id].presence + return unless @merge_request.all_commits.exists?(sha: commit_id) @commit ||= @project.commit(commit_id) end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 46a44841c31..135117926be 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -16,9 +16,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo 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(:diff_tree_filtering, default_enabled: true) - end + around_action :allow_gitaly_ref_name_caching, only: [:index, :show] def index @merge_requests = @issuables @@ -35,7 +33,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo def show close_merge_request_if_no_source_project - mark_merge_request_mergeable + @merge_request.check_mergeability respond_to do |format| format.html do @@ -100,20 +98,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def test_reports - result = @merge_request.compare_test_reports - - case result[:status] - when :parsing - Gitlab::PollingInterval.set_header(response, interval: 3000) - - render json: '', status: :no_content - when :parsed - render json: result[:data].to_json, status: :ok - when :error - render json: { status_reason: result[:status_reason] }, status: :bad_request - else - render json: { status_reason: 'Unknown error' }, status: :internal_server_error - end + reports_response(@merge_request.compare_test_reports) end def edit @@ -160,14 +145,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo render partial: 'projects/merge_requests/widget/commit_change_content', layout: false end - def cancel_merge_when_pipeline_succeeds - unless @merge_request.can_cancel_merge_when_pipeline_succeeds?(current_user) + def cancel_auto_merge + unless @merge_request.can_cancel_auto_merge?(current_user) return access_denied! end - ::MergeRequests::MergeWhenPipelineSucceedsService - .new(@project, current_user) - .cancel(@merge_request) + AutoMergeService.new(project, current_user).cancel(@merge_request) render json: serialize_widget(@merge_request) end @@ -244,12 +227,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def merge_params_attributes - [:should_remove_source_branch, :commit_message, :squash_commit_message, :squash] + [:should_remove_source_branch, :commit_message, :squash_commit_message, :squash, :auto_merge_strategy] end - def merge_when_pipeline_succeeds_active? - params[:merge_when_pipeline_succeeds].present? && - @merge_request.head_pipeline && @merge_request.head_pipeline.active? + def auto_merge_requested? + # Support params[:merge_when_pipeline_succeeds] during the transition period + params[:auto_merge_strategy].present? || params[:merge_when_pipeline_succeeds].present? end def close_merge_request_if_no_source_project @@ -268,14 +251,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @merge_request.has_no_commits? && !@merge_request.target_branch_exists? end - def mark_merge_request_mergeable - @merge_request.check_if_can_be_merged - end - def merge! - # Disable the CI check if merge_when_pipeline_succeeds is enabled since we have + # Disable the CI check if auto_merge_strategy is specified since we have # to wait until CI completes to know - unless @merge_request.mergeable?(skip_ci_check: merge_when_pipeline_succeeds_active?) + unless @merge_request.mergeable?(skip_ci_check: auto_merge_requested?) return :failed end @@ -289,24 +268,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @merge_request.update(merge_error: nil, squash: merge_params.fetch(:squash, false)) - if params[:merge_when_pipeline_succeeds].present? - return :failed unless @merge_request.actual_head_pipeline - - if @merge_request.actual_head_pipeline.active? - ::MergeRequests::MergeWhenPipelineSucceedsService - .new(@project, current_user, merge_params) - .execute(@merge_request) - - :merge_when_pipeline_succeeds - elsif @merge_request.actual_head_pipeline.success? - # This can be triggered when a user clicks the auto merge button while - # the tests finish at about the same time - @merge_request.merge_async(current_user.id, merge_params) - - :success - else - :failed - end + if auto_merge_requested? + AutoMergeService.new(project, current_user, merge_params) + .execute(merge_request, + params[:auto_merge_strategy] || AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) else @merge_request.merge_async(current_user.id, merge_params) @@ -355,4 +320,19 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo # Also see https://gitlab.com/gitlab-org/gitlab-ce/issues/42441 Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42438') end + + def reports_response(report_comparison) + case report_comparison[:status] + when :parsing + ::Gitlab::PollingInterval.set_header(response, interval: 3000) + + render json: '', status: :no_content + when :parsed + render json: report_comparison[:data].to_json, status: :ok + when :error + render json: { status_reason: report_comparison[:status_reason] }, status: :bad_request + else + render json: { status_reason: 'Unknown error' }, status: :internal_server_error + end + end end diff --git a/app/controllers/projects/mirrors_controller.rb b/app/controllers/projects/mirrors_controller.rb index ab7ab13657a..6c6adc233b7 100644 --- a/app/controllers/projects/mirrors_controller.rb +++ b/app/controllers/projects/mirrors_controller.rb @@ -18,7 +18,7 @@ class Projects::MirrorsController < Projects::ApplicationController result = ::Projects::UpdateService.new(project, current_user, mirror_params).execute if result[:status] == :success - flash[:notice] = 'Mirroring settings were successfully updated.' + flash[:notice] = _('Mirroring settings were successfully updated.') else flash[:alert] = project.errors.full_messages.join(', ').html_safe end @@ -38,7 +38,7 @@ class Projects::MirrorsController < Projects::ApplicationController def update_now if params[:sync_remote] project.update_remote_mirrors - flash[:notice] = "The remote repository is being updated..." + flash[:notice] = _("The remote repository is being updated...") end redirect_to_repository_settings(project, anchor: 'js-push-remote-settings') @@ -81,6 +81,7 @@ class Projects::MirrorsController < Projects::ApplicationController password ssh_known_hosts regenerate_ssh_private_key + _destroy ] ] end diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb index 58b1bc54181..89f21d8dadb 100644 --- a/app/controllers/projects/pages_domains_controller.rb +++ b/app/controllers/projects/pages_domains_controller.rb @@ -65,11 +65,11 @@ class Projects::PagesDomainsController < Projects::ApplicationController private def create_params - params.require(:pages_domain).permit(:key, :certificate, :domain) + params.require(:pages_domain).permit(:key, :certificate, :domain, :auto_ssl_enabled) end def update_params - params.require(:pages_domain).permit(:key, :certificate) + params.require(:pages_domain).permit(:key, :certificate, :auto_ssl_enabled) end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index acf56f0eb6a..72e939a3310 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -50,9 +50,11 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController job_id = RunPipelineScheduleWorker.perform_async(schedule.id, current_user.id) if job_id - flash[:notice] = "Successfully scheduled a pipeline to run. Go to the <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details.".html_safe + pipelines_link_start = "<a href=\"#{project_pipelines_path(@project)}\">" + message = _("Successfully scheduled a pipeline to run. Go to the %{pipelines_link_start}Pipelines page%{pipelines_link_end} for details.") % { pipelines_link_start: pipelines_link_start, pipelines_link_end: "</a>" } + flash[:notice] = message.html_safe else - flash[:alert] = 'Unable to schedule a pipeline to run immediately' + flash[:alert] = _('Unable to schedule a pipeline to run immediately') end redirect_to pipeline_schedules_path(@project) @@ -85,7 +87,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController return unless limiter.throttled?([current_user, schedule], 1) - flash[:alert] = 'You cannot play this scheduled pipeline at the moment. Please wait a minute.' + flash[:alert] = _('You cannot play this scheduled pipeline at the moment. Please wait a minute.') redirect_to pipeline_schedules_path(@project) end @@ -96,7 +98,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController def schedule_params params.require(:schedule) .permit(:description, :cron, :cron_timezone, :ref, :active, - variables_attributes: [:id, :key, :secret_value, :_destroy] ) + variables_attributes: [:id, :variable_type, :key, :secret_value, :_destroy] ) end def authorize_play_pipeline_schedule! diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 6a86f8ca729..db3b7c8b177 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -8,6 +8,8 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_create_pipeline!, only: [:new, :create] before_action :authorize_update_pipeline!, only: [:retry, :cancel] + around_action :allow_gitaly_ref_name_caching, only: [:index, :show] + wrap_parameters Ci::Pipeline POLLING_INTERVAL = 10_000 @@ -31,10 +33,7 @@ class Projects::PipelinesController < Projects::ApplicationController Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) render json: { - pipelines: PipelineSerializer - .new(project: @project, current_user: @current_user) - .with_pagination(request, response) - .represent(@pipelines, disable_coverage: true, preload: true), + pipelines: serialize_pipelines, count: { all: @pipelines_count, running: @running_count, @@ -150,6 +149,13 @@ class Projects::PipelinesController < Projects::ApplicationController private + def serialize_pipelines + PipelineSerializer + .new(project: @project, current_user: @current_user) + .with_pagination(request, response) + .represent(@pipelines, disable_coverage: true, preload: true) + end + def render_show respond_to do |format| format.html do @@ -163,7 +169,7 @@ class Projects::PipelinesController < Projects::ApplicationController end def create_params - params.require(:pipeline).permit(:ref, variables_attributes: %i[key secret_value]) + params.require(:pipeline).permit(:ref, variables_attributes: %i[key variable_type secret_value]) end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb index b97fbe19bbf..b3447812ef2 100644 --- a/app/controllers/projects/refs_controller.rb +++ b/app/controllers/projects/refs_controller.rb @@ -4,6 +4,8 @@ class Projects::RefsController < Projects::ApplicationController include ExtractsPath include TreeHelper + around_action :allow_gitaly_ref_name_caching, only: [:logs_tree] + before_action :require_non_empty_project before_action :validate_ref_id before_action :assign_ref_vars diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb index 4eeaeb860ee..3b4215b766e 100644 --- a/app/controllers/projects/repositories_controller.rb +++ b/app/controllers/projects/repositories_controller.rb @@ -23,7 +23,7 @@ class Projects::RepositoriesController < Projects::ApplicationController append_sha = false if @filename == shortname end - send_git_archive @repository, ref: @ref, format: params[:format], append_sha: append_sha + send_git_archive @repository, ref: @ref, path: params[:path], format: params[:format], append_sha: append_sha rescue => ex logger.error("#{self.class.name}: #{ex}") git_not_found! diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb index 91f40b90aa8..ca62f54813b 100644 --- a/app/controllers/projects/runners_controller.rb +++ b/app/controllers/projects/runners_controller.rb @@ -15,7 +15,7 @@ class Projects::RunnersController < Projects::ApplicationController def update if Ci::UpdateRunnerService.new(@runner).update(runner_params) - redirect_to project_runner_path(@project, @runner), notice: 'Runner was successfully updated.' + redirect_to project_runner_path(@project, @runner), notice: _('Runner was successfully updated.') else render 'edit' end @@ -31,17 +31,17 @@ class Projects::RunnersController < Projects::ApplicationController def resume if Ci::UpdateRunnerService.new(@runner).update(active: true) - redirect_to project_runners_path(@project), notice: 'Runner was successfully updated.' + redirect_to project_runners_path(@project), notice: _('Runner was successfully updated.') else - redirect_to project_runners_path(@project), alert: 'Runner was not updated.' + redirect_to project_runners_path(@project), alert: _('Runner was not updated.') end end def pause if Ci::UpdateRunnerService.new(@runner).update(active: false) - redirect_to project_runners_path(@project), notice: 'Runner was successfully updated.' + redirect_to project_runners_path(@project), notice: _('Runner was successfully updated.') else - redirect_to project_runners_path(@project), alert: 'Runner was not updated.' + redirect_to project_runners_path(@project), alert: _('Runner was not updated.') end end diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb index 39eca10134f..4b0d001fca6 100644 --- a/app/controllers/projects/serverless/functions_controller.rb +++ b/app/controllers/projects/serverless/functions_controller.rb @@ -3,29 +3,20 @@ module Projects module Serverless class FunctionsController < Projects::ApplicationController - include ProjectUnauthorized - before_action :authorize_read_cluster! - INDEX_PRIMING_INTERVAL = 15_000 - INDEX_POLLING_INTERVAL = 60_000 - def index respond_to do |format| format.json do functions = finder.execute - if functions.any? - Gitlab::PollingInterval.set_header(response, interval: INDEX_POLLING_INTERVAL) - render json: serialize_function(functions) - else - Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL) - head :no_content - end + render json: { + knative_installed: finder.knative_installed, + functions: serialize_function(functions) + }.to_json end format.html do - @installed = finder.installed? render end end @@ -33,6 +24,8 @@ module Projects def show @service = serialize_function(finder.service(params[:environment_id], params[:id])) + @prometheus = finder.has_prometheus?(params[:environment_id]) + return not_found if @service.nil? respond_to do |format| @@ -44,10 +37,24 @@ module Projects end end + def metrics + respond_to do |format| + format.json do + metrics = finder.invocation_metrics(params[:environment_id], params[:id]) + + if metrics.nil? + head :no_content + else + render json: metrics + end + end + end + end + private def finder - Projects::Serverless::FunctionsFinder.new(project.clusters) + Projects::Serverless::FunctionsFinder.new(project) end def serialize_function(function) diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index f1c9d0d0f77..e0df51590ae 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -43,20 +43,20 @@ class Projects::ServicesController < Projects::ApplicationController if outcome[:success] {} else - { error: true, message: 'Test failed.', service_response: outcome[:result].to_s, test_failed: true } + { error: true, message: _('Test failed.'), service_response: outcome[:result].to_s, test_failed: true } end else - { error: true, message: 'Validations failed.', service_response: @service.errors.full_messages.join(','), test_failed: false } + { error: true, message: _('Validations failed.'), service_response: @service.errors.full_messages.join(','), test_failed: false } end rescue Gitlab::HTTP::BlockedUrlError => e - { error: true, message: 'Test failed.', service_response: e.message, test_failed: true } + { error: true, message: _('Test failed.'), service_response: e.message, test_failed: true } end def success_message if @service.active? - "#{@service.title} activated." + _("%{service_title} activated.") % { service_title: @service.title } else - "#{@service.title} settings saved, but not activated." + _("%{service_title} settings saved, but not activated.") % { service_title: @service.title } end end diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index f2f63e986bb..1b8d479209b 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -13,13 +13,13 @@ module Projects Projects::UpdateService.new(project, current_user, update_params).tap do |service| result = service.execute if result[:status] == :success - flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated." + flash[:notice] = _("Pipelines settings for '%{project_name}' were successfully updated.") % { project_name: @project.name } run_autodevops_pipeline(service) redirect_to project_settings_ci_cd_path(@project) else - render 'show' + redirect_to project_settings_ci_cd_path(@project), alert: result[:message] end end end @@ -39,7 +39,7 @@ module Projects def reset_registration_token @project.reset_runners_token! - flash[:notice] = 'New runners registration token has been generated!' + flash[:notice] = _('New runners registration token has been generated!') redirect_to namespace_project_settings_ci_cd_path end @@ -50,7 +50,8 @@ module Projects :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] + auto_devops_attributes: [:id, :domain, :enabled, :deploy_strategy], + ci_cd_settings_attributes: [:default_git_depth] ) end @@ -58,7 +59,7 @@ module Projects return unless service.run_auto_devops_pipeline? if @project.empty_repo? - flash[:warning] = "This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch." + flash[:warning] = _("This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch.") return end diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb index 521ec2acebb..b5c77e5bbf4 100644 --- a/app/controllers/projects/settings/operations_controller.rb +++ b/app/controllers/projects/settings/operations_controller.rb @@ -3,9 +3,12 @@ module Projects module Settings class OperationsController < Projects::ApplicationController - before_action :check_license before_action :authorize_update_environment! + before_action do + push_frontend_feature_flag(:grafana_dashboard_link) + end + helper_method :error_tracking_setting def show @@ -14,16 +17,37 @@ module Projects def update result = ::Projects::Operations::UpdateService.new(project, current_user, update_params).execute + render_update_response(result) + end + + private + + # overridden in EE + def render_update_response(result) + respond_to do |format| + format.json do + render_update_json_response(result) + end + end + end + + def render_update_json_response(result) if result[:status] == :success flash[:notice] = _('Your changes have been saved') - redirect_to project_settings_operations_path(@project) + render json: { + status: result[:status] + } else - render 'show' + render( + status: result[:http_status] || :bad_request, + json: { + status: result[:status], + message: result[:message] + } + ) end end - private - def error_tracking_setting @error_tracking_setting ||= project.error_tracking_setting || project.build_error_tracking_setting @@ -35,11 +59,16 @@ module Projects # overridden in EE def permitted_project_params - { error_tracking_setting_attributes: [:enabled, :api_url, :token] } - end + { + metrics_setting_attributes: [:external_dashboard_url], - def check_license - render_404 unless helpers.settings_operations_available? + error_tracking_setting_attributes: [ + :enabled, + :api_host, + :token, + project: [:slug, :name, :organization_slug, :organization_name] + ] + } end end end diff --git a/app/controllers/projects/stages_controller.rb b/app/controllers/projects/stages_controller.rb new file mode 100644 index 00000000000..c8db5b1277f --- /dev/null +++ b/app/controllers/projects/stages_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Projects::StagesController < Projects::PipelinesController + before_action :authorize_update_pipeline! + + def play_manual + ::Ci::PlayManualStageService + .new(@project, current_user, pipeline: pipeline) + .execute(stage) + + respond_to do |format| + format.json do + render json: StageSerializer + .new(project: @project, current_user: @current_user) + .represent(stage) + end + end + end + + private + + def stage + @pipeline_stage ||= pipeline.find_stage_by_name!(params[:stage_name]) + end +end diff --git a/app/controllers/projects/tags/releases_controller.rb b/app/controllers/projects/tags/releases_controller.rb index 334e1847cc8..5e4c601a693 100644 --- a/app/controllers/projects/tags/releases_controller.rb +++ b/app/controllers/projects/tags/releases_controller.rb @@ -12,16 +12,13 @@ class Projects::Tags::ReleasesController < Projects::ApplicationController end def update - # Release belongs to Tag which is not active record object, - # it exists only to save a description to each Tag. - # If description is empty we should destroy the existing record. if release_params[:description].present? release.update(release_params) else release.destroy end - redirect_to project_tag_path(@project, @tag.name) + redirect_to project_tag_path(@project, tag.name) end private @@ -30,11 +27,10 @@ class Projects::Tags::ReleasesController < Projects::ApplicationController @tag ||= @repository.find_tag(params[:tag_id]) end - # rubocop: disable CodeReuse/ActiveRecord def release - @release ||= @project.releases.find_or_initialize_by(tag: @tag.name) + @release ||= Releases::CreateService.new(project, current_user, tag: @tag.name) + .find_or_build_release end - # rubocop: enable CodeReuse/ActiveRecord def release_params params.require(:release).permit(:description) diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index edebfc55c17..7509cc29a76 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -6,6 +6,8 @@ class Projects::TreeController < Projects::ApplicationController include CreatesCommit include ActionView::Helpers::SanitizeHelper + around_action :allow_gitaly_ref_name_caching, only: [:show] + before_action :require_non_empty_project, except: [:new, :create] before_action :assign_ref_vars before_action :assign_dir_vars, only: [:create_dir] @@ -37,7 +39,7 @@ class Projects::TreeController < Projects::ApplicationController def create_dir return render_404 unless @commit_params.values.all? - create_commit(Files::CreateDirService, success_notice: "The directory has been successfully created.", + create_commit(Files::CreateDirService, success_notice: _("The directory has been successfully created."), success_path: project_tree_path(@project, File.join(@branch_name, @dir_name)), failure_path: project_tree_path(@project, @ref)) end diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb index c7b4ebb2b24..284e119ca06 100644 --- a/app/controllers/projects/triggers_controller.rb +++ b/app/controllers/projects/triggers_controller.rb @@ -16,9 +16,9 @@ class Projects::TriggersController < Projects::ApplicationController @trigger = project.triggers.create(trigger_params.merge(owner: current_user)) if @trigger.valid? - flash[:notice] = 'Trigger was created successfully.' + flash[:notice] = _('Trigger was created successfully.') else - flash[:alert] = 'You could not create a new trigger.' + flash[:alert] = _('You could not create a new trigger.') end redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers') @@ -26,9 +26,9 @@ class Projects::TriggersController < Projects::ApplicationController def take_ownership if trigger.update(owner: current_user) - flash[:notice] = 'Trigger was re-assigned.' + flash[:notice] = _('Trigger was re-assigned.') else - flash[:alert] = 'You could not take ownership of trigger.' + flash[:alert] = _('You could not take ownership of trigger.') end redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers') @@ -39,7 +39,7 @@ class Projects::TriggersController < Projects::ApplicationController def update if trigger.update(trigger_params) - redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers'), notice: 'Trigger was successfully updated.' + redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers'), notice: _('Trigger was successfully updated.') else render action: "edit" end @@ -47,9 +47,9 @@ class Projects::TriggersController < Projects::ApplicationController def destroy if trigger.destroy - flash[:notice] = "Trigger removed." + flash[:notice] = _("Trigger removed.") else - flash[:alert] = "Could not remove the trigger." + flash[:alert] = _("Could not remove the trigger.") end redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers'), status: :found diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index bb658bfcc19..646728e8167 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -38,6 +38,6 @@ class Projects::VariablesController < Projects::ApplicationController end def variable_params_attributes - %i[id key secret_value protected _destroy] + %i[id variable_type key secret_value protected masked _destroy] end end diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 88dd111132b..fa5bdbc7d49 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -16,7 +16,10 @@ class Projects::WikisController < Projects::ApplicationController end def pages - @wiki_pages = Kaminari.paginate_array(@project_wiki.pages).page(params[:page]) + @wiki_pages = Kaminari.paginate_array( + @project_wiki.list_pages(sort: params[:sort], direction: params[:direction]) + ).page(params[:page]) + @wiki_entries = WikiPage.group_by_directory(@wiki_pages) end @@ -49,7 +52,7 @@ class Projects::WikisController < Projects::ApplicationController if @page.valid? redirect_to( project_wiki_path(@project, @page), - notice: 'Wiki was successfully updated.' + notice: _('Wiki was successfully updated.') ) else render 'edit' @@ -65,7 +68,7 @@ class Projects::WikisController < Projects::ApplicationController if @page.persisted? redirect_to( project_wiki_path(@project, @page), - notice: 'Wiki was successfully updated.' + notice: _('Wiki was successfully updated.') ) else render action: "edit" @@ -85,7 +88,7 @@ class Projects::WikisController < Projects::ApplicationController else redirect_to( project_wiki_path(@project, :home), - notice: "Page not found" + notice: _("Page not found") ) end end @@ -95,7 +98,7 @@ class Projects::WikisController < Projects::ApplicationController redirect_to project_wiki_path(@project, :home), status: 302, - notice: "Page was successfully deleted" + notice: _("Page was successfully deleted") rescue Gitlab::Git::Wiki::OperationError => e @error = e render 'edit' @@ -115,10 +118,10 @@ class Projects::WikisController < Projects::ApplicationController @sidebar_page = @project_wiki.find_sidebar(params[:version_id]) unless @sidebar_page # Fallback to default sidebar - @sidebar_wiki_entries = WikiPage.group_by_directory(@project_wiki.pages(limit: 15)) + @sidebar_wiki_entries = WikiPage.group_by_directory(@project_wiki.list_pages(limit: 15)) end rescue ProjectWiki::CouldNotCreateWikiError - flash[:notice] = "Could not create Wiki Repository at this time. Please try again later." + flash[:notice] = _("Could not create Wiki Repository at this time. Please try again later.") redirect_to project_path(@project) false end @@ -155,7 +158,7 @@ class Projects::WikisController < Projects::ApplicationController end def set_encoding_error - flash.now[:notice] = "The content of this page is not encoded in UTF-8. Edits can only be made via the Git repository." + flash.now[:notice] = _("The content of this page is not encoded in UTF-8. Edits can only be made via the Git repository.") end def file_blob diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 33c6608d321..12db493978b 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -7,9 +7,12 @@ class ProjectsController < Projects::ApplicationController include PreviewMarkdown include SendFileUpload include RecordUserLastActivity + include ImportUrlParams prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } + around_action :allow_gitaly_ref_name_caching, only: [:index, :show] + before_action :whitelist_query_limiting, only: [:create] before_action :authenticate_user!, except: [:index, :show, :activity, :refs, :resolve] before_action :redirect_git_extension, only: [:show] @@ -34,10 +37,10 @@ class ProjectsController < Projects::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def new - namespace = Namespace.find_by(id: params[:namespace_id]) if params[:namespace_id] - return access_denied! if namespace && !can?(current_user, :create_projects, namespace) + @namespace = Namespace.find_by(id: params[:namespace_id]) if params[:namespace_id] + return access_denied! if @namespace && !can?(current_user, :create_projects, @namespace) - @project = Project.new(namespace_id: namespace&.id) + @project = Project.new(namespace_id: @namespace&.id) end # rubocop: enable CodeReuse/ActiveRecord @@ -47,7 +50,7 @@ class ProjectsController < Projects::ApplicationController end def create - @project = ::Projects::CreateService.new(current_user, project_params).execute + @project = ::Projects::CreateService.new(current_user, project_params(attributes: project_params_create_attributes)).execute if @project.saved? cookies[:issue_board_welcome_hidden] = { path: project_path(@project), value: nil, expires: Time.at(0) } @@ -235,7 +238,7 @@ class ProjectsController < Projects::ApplicationController def toggle_star current_user.toggle_star(@project) - @project.reload + @project.reset render json: { star_count: @project.star_count @@ -328,9 +331,10 @@ class ProjectsController < Projects::ApplicationController end # rubocop: enable CodeReuse/ActiveRecord - def project_params + def project_params(attributes: []) params.require(:project) - .permit(project_params_attributes) + .permit(project_params_attributes + attributes) + .merge(import_url_params) end def project_params_attributes @@ -343,17 +347,17 @@ class ProjectsController < Projects::ApplicationController :container_registry_enabled, :default_branch, :description, + :external_authorization_classification_label, :import_url, :issues_tracker, :issues_tracker_id, :last_activity_at, :lfs_enabled, :name, - :namespace_id, :only_allow_merge_if_all_discussions_are_resolved, :only_allow_merge_if_pipeline_succeeds, - :printing_merge_request_link_enabled, :path, + :printing_merge_request_link_enabled, :public_builds, :request_access_enabled, :runners_token, @@ -375,6 +379,10 @@ class ProjectsController < Projects::ApplicationController ] end + def project_params_create_attributes + [:namespace_id] + end + def custom_import_params {} end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 8b8d87524a8..07b38371ab9 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -4,6 +4,7 @@ class RegistrationsController < Devise::RegistrationsController include Recaptcha::Verify include AcceptsPendingInvitations + prepend_before_action :check_captcha, only: :create before_action :whitelist_query_limiting, only: [:destroy] before_action :ensure_terms_accepted, if: -> { Gitlab::CurrentSettings.current_application_settings.enforce_terms? }, @@ -21,15 +22,10 @@ class RegistrationsController < Devise::RegistrationsController params[resource_name] = params.delete(:"new_#{resource_name}") end - if !Gitlab::Recaptcha.load_configurations! || verify_recaptcha - accept_pending_invitations - super do |new_user| - persist_accepted_terms_if_required(new_user) - end - else - flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.' - flash.delete :recaptcha_error - render action: 'new' + accept_pending_invitations + + super do |new_user| + persist_accepted_terms_if_required(new_user) end rescue Gitlab::Access::AccessDeniedError redirect_to(new_user_session_path) @@ -89,6 +85,17 @@ class RegistrationsController < Devise::RegistrationsController private + def check_captcha + return unless Feature.enabled?(:registrations_recaptcha, default_enabled: true) + return unless Gitlab::Recaptcha.load_configurations! + + return if verify_recaptcha + + flash[:alert] = _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.') + flash.delete :recaptcha_error + render action: 'new' + end + def sign_up_params params.require(:user).permit(:username, :email, :email_confirmation, :name, :password) end diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb index 7b6657e1196..f1b39125a48 100644 --- a/app/controllers/root_controller.rb +++ b/app/controllers/root_controller.rb @@ -15,7 +15,7 @@ class RootController < Dashboard::ProjectsController before_action :redirect_logged_user, if: -> { current_user.present? } def index - # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37434 + # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/40260 Gitlab::GitalyClient.allow_n_plus_1_calls do super end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 1b22907c10f..cb25548c83f 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -14,8 +14,6 @@ class SearchController < ApplicationController layout 'search' def show - search_service = SearchService.new(current_user, params) - @project = search_service.project @group = search_service.group @@ -27,8 +25,10 @@ class SearchController < ApplicationController @show_snippets = search_service.show_snippets? @search_results = search_service.search_results @search_objects = search_service.search_objects + @display_options = search_service.display_options render_commits if @scope == 'commits' + eager_load_user_status if @scope == 'users' check_single_commit_result end @@ -54,6 +54,12 @@ class SearchController < ApplicationController @search_objects = prepare_commits_for_rendering(@search_objects) end + def eager_load_user_status + return if Feature.disabled?(:users_search, default_enabled: true) + + @search_objects = @search_objects.eager_load(:status) # rubocop:disable CodeReuse/ActiveRecord + end + def check_single_commit_result if @search_results.single_commit_result? only_commit = @search_results.objects('commits').first diff --git a/app/controllers/sent_notifications_controller.rb b/app/controllers/sent_notifications_controller.rb index 2b76921ebd8..77757c4a3ef 100644 --- a/app/controllers/sent_notifications_controller.rb +++ b/app/controllers/sent_notifications_controller.rb @@ -16,7 +16,7 @@ class SentNotificationsController < ApplicationController noteable = @sent_notification.noteable noteable.unsubscribe(@sent_notification.recipient, @sent_notification.project) - flash[:notice] = "You have been unsubscribed from this thread." + flash[:notice] = _("You have been unsubscribed from this thread.") if current_user redirect_to noteable_path(noteable) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 4bd7d71e264..a841859621e 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -8,6 +8,8 @@ class SessionsController < Devise::SessionsController include Recaptcha::Verify skip_before_action :check_two_factor_requirement, only: [:destroy] + # replaced with :require_no_authentication_without_flash + skip_before_action :require_no_authentication, only: [:new, :create] prepend_before_action :check_initial_setup, only: [:new] prepend_before_action :authenticate_with_two_factor, @@ -15,6 +17,9 @@ class SessionsController < Devise::SessionsController prepend_before_action :check_captcha, only: [:create] prepend_before_action :store_redirect_uri, only: [:new] prepend_before_action :ldap_servers, only: [:new, :create] + prepend_before_action :require_no_authentication_without_flash, only: [:new, :create] + prepend_before_action :ensure_password_authentication_enabled!, if: :password_based_login?, only: [:create] + before_action :auto_sign_in_with_provider, only: [:new] before_action :load_recaptcha @@ -54,6 +59,14 @@ class SessionsController < Devise::SessionsController private + def require_no_authentication_without_flash + require_no_authentication + + if flash[:alert] == I18n.t('devise.failure.already_authenticated') + flash[:alert] = nil + end + end + def captcha_enabled? request.headers[CAPTCHA_HEADER] && Gitlab::Recaptcha.enabled? end @@ -70,7 +83,7 @@ class SessionsController < Devise::SessionsController increment_failed_login_captcha_counter self.resource = resource_class.new - flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.' + flash[:alert] = _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.') flash.delete :recaptcha_error respond_with_navigational(resource) { render :new } @@ -122,10 +135,18 @@ class SessionsController < Devise::SessionsController end redirect_to edit_user_password_path(reset_password_token: @token), - notice: "Please create a password for your new account." + notice: _("Please create a password for your new account.") end # rubocop: enable CodeReuse/ActiveRecord + def ensure_password_authentication_enabled! + render_403 unless Gitlab::CurrentSettings.password_authentication_enabled_for_web? + end + + def password_based_login? + user_params[:login].present? || user_params[:password].present? + end + def user_params params.require(:user).permit(:login, :password, :remember_me, :otp_attempt, :device_response) end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 519e7439205..5d28635232b 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -28,13 +28,13 @@ class UploadsController < ApplicationController end def find_model - return nil unless params[:id] + return unless params[:id] upload_model_class.find(params[:id]) end def authorize_access! - return nil unless model + return unless model authorized = case model @@ -45,7 +45,7 @@ class UploadsController < ApplicationController when Appearance true else - permission = "read_#{model.class.to_s.underscore}".to_sym + permission = "read_#{model.class.underscore}".to_sym can?(current_user, permission, model) end @@ -54,10 +54,11 @@ class UploadsController < ApplicationController end def authorize_create_access! - return nil unless model + return unless model - # for now we support only personal snippets comments - authorized = can?(current_user, :comment_personal_snippet, model) + # for now we support only personal snippets comments. Only personal_snippet + # is allowed as a model to #create through routing. + authorized = can?(current_user, :create_note, model) render_unauthorized unless authorized end diff --git a/app/finders/admin/runners_finder.rb b/app/finders/admin/runners_finder.rb index fbb1cfc5c66..b2799565f57 100644 --- a/app/finders/admin/runners_finder.rb +++ b/app/finders/admin/runners_finder.rb @@ -11,10 +11,11 @@ class Admin::RunnersFinder < UnionFinder search! filter_by_status! filter_by_runner_type! + filter_by_tag_list! sort! paginate! - @runners + @runners.with_tags end def sort_key @@ -44,6 +45,14 @@ class Admin::RunnersFinder < UnionFinder filter_by!(:type_type, Ci::Runner::AVAILABLE_TYPES) end + def filter_by_tag_list! + tag_list = @params[:tag_name].presence + + if tag_list + @runners = @runners.tagged_with(tag_list) + end + end + def sort! @runners = @runners.order_by(sort_key) end diff --git a/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb b/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb new file mode 100644 index 00000000000..f38c187799c --- /dev/null +++ b/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Autocomplete + module ActsAsTaggableOn + class TagsFinder + LIMIT = 20 + + def initialize(params:) + @params = params + end + + def execute + tags = all_tags + tags = filter_by_name(tags) + limit(tags) + end + + private + + def all_tags + ::ActsAsTaggableOn::Tag.all + end + + def filter_by_name(tags) + return tags unless search + return tags.none if search.empty? + + if search.length >= Gitlab::SQL::Pattern::MIN_CHARS_FOR_PARTIAL_MATCHING + tags.named_like(search) + else + tags.named(search) + end + end + + def limit(tags) + tags.limit(LIMIT) # rubocop: disable CodeReuse/ActiveRecord + end + + def search + @params[:search] + end + end + end +end diff --git a/app/finders/autocomplete/users_finder.rb b/app/finders/autocomplete/users_finder.rb index 45955783be9..ce7d0b8699c 100644 --- a/app/finders/autocomplete/users_finder.rb +++ b/app/finders/autocomplete/users_finder.rb @@ -2,6 +2,8 @@ module Autocomplete class UsersFinder + include Gitlab::Utils::StrongMemoize + # The number of users to display in the results is hardcoded to 20, and # pagination is not supported. This ensures that performance remains # consistent and removes the need for implementing keyset pagination to @@ -31,7 +33,7 @@ module Autocomplete # Include current user if available to filter by "Me" items.unshift(current_user) if prepend_current_user? - if prepend_author? && (author = User.find_by_id(author_id)) + if prepend_author? && author&.active? items.unshift(author) end end @@ -41,6 +43,12 @@ module Autocomplete private + def author + strong_memoize(:author) do + User.find_by_id(author_id) + end + end + # Returns the users based on the input parameters, as an Array. # # This method is separate so it is easier to extend in EE. diff --git a/app/finders/clusters/knative_services_finder.rb b/app/finders/clusters/knative_services_finder.rb new file mode 100644 index 00000000000..7d3b53ef663 --- /dev/null +++ b/app/finders/clusters/knative_services_finder.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true +module Clusters + class KnativeServicesFinder + include ReactiveCaching + include Gitlab::Utils::StrongMemoize + + KNATIVE_STATES = { + 'checking' => 'checking', + 'installed' => 'installed', + 'not_found' => 'not_found' + }.freeze + + self.reactive_cache_key = ->(finder) { finder.model_name } + self.reactive_cache_worker_finder = ->(_id, *cache_args) { from_cache(*cache_args) } + + attr_reader :cluster, :project + + def initialize(cluster, project) + @cluster = cluster + @project = project + end + + def with_reactive_cache_memoized(*cache_args, &block) + strong_memoize(:reactive_cache) do + with_reactive_cache(*cache_args, &block) + end + end + + def clear_cache! + clear_reactive_cache!(*cache_args) + end + + def self.from_cache(cluster_id, project_id) + cluster = Clusters::Cluster.find(cluster_id) + project = ::Project.find(project_id) + + new(cluster, project) + end + + def calculate_reactive_cache(*) + # read_services calls knative_client.discover implicitily. If we stop + # detecting services but still want to detect knative, we'll need to + # explicitily call: knative_client.discover + # + # We didn't create it separately to avoid 2 cluster requests. + ksvc = read_services + pods = knative_client.discovered ? read_pods : [] + { services: ksvc, pods: pods, knative_detected: knative_client.discovered } + end + + def services + return [] unless search_namespace + + cached_data = with_reactive_cache_memoized(*cache_args) { |data| data } + cached_data.to_h.fetch(:services, []) + end + + def cache_args + [cluster.id, project.id] + end + + def service_pod_details(service) + cached_data = with_reactive_cache_memoized(*cache_args) { |data| data } + cached_data.to_h.fetch(:pods, []).select do |pod| + filter_pods(pod, service) + end + end + + def knative_detected + cached_data = with_reactive_cache_memoized(*cache_args) { |data| data } + + knative_state = cached_data.to_h[:knative_detected] + + return KNATIVE_STATES['checking'] if knative_state.nil? + return KNATIVE_STATES['installed'] if knative_state + + KNATIVE_STATES['uninstalled'] + end + + def model_name + self.class.name.underscore.tr('/', '_') + end + + private + + def search_namespace + @search_namespace ||= cluster.kubernetes_namespace_for(project) + end + + def knative_client + cluster.kubeclient.knative_client + end + + def filter_pods(pod, service) + pod["metadata"]["labels"]["serving.knative.dev/service"] == service + end + + def read_services + knative_client.get_services(namespace: search_namespace).as_json + rescue Kubeclient::ResourceNotFoundError + [] + end + + def read_pods + cluster.kubeclient.core_client.get_pods(namespace: search_namespace).as_json + end + + def id + nil + end + end +end diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index 0080123407d..7d419103b1c 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -11,6 +11,7 @@ # parent: Group # all_available: boolean (defaults to true) # min_access_level: integer +# exclude_group_ids: array of integers # # Users with full private access can see all groups. The `owned` and `parent` # params can be used to restrict the groups that are returned. @@ -29,6 +30,7 @@ class GroupsFinder < UnionFinder items = all_groups.map do |item| item = by_parent(item) item = by_custom_attributes(item) + item = exclude_group_ids(item) item end @@ -72,6 +74,12 @@ class GroupsFinder < UnionFinder end # rubocop: enable CodeReuse/ActiveRecord + def exclude_group_ids(groups) + return groups unless params[:exclude_group_ids] + + groups.id_not_in(params[:exclude_group_ids]) + end + # rubocop: disable CodeReuse/ActiveRecord def by_parent(groups) return groups unless params[:parent] diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 5870f158690..50e9418677c 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -29,6 +29,7 @@ # updated_after: datetime # updated_before: datetime # attempt_group_search_optimizations: boolean +# attempt_project_search_optimizations: boolean # class IssuableFinder prepend FinderWithCrossProjectAccess @@ -78,19 +79,20 @@ class IssuableFinder items = init_collection items = filter_items(items) - # This has to be last as we may use a CTE as an optimization fence - # by passing the attempt_group_search_optimizations param and - # enabling the use_cte_for_group_issues_search feature flag + # This has to be last as we use a CTE as an optimization fence + # for counts by passing the force_cte param and enabling the + # attempt_group_search_optimizations feature flag # https://www.postgresql.org/docs/current/static/queries-with.html items = by_search(items) - sort(items) + items = sort(items) + + items end def filter_items(items) items = by_project(items) items = by_group(items) - items = by_subquery(items) items = by_scope(items) items = by_created_at(items) items = by_updated_at(items) @@ -117,8 +119,9 @@ class IssuableFinder # # rubocop: disable CodeReuse/ActiveRecord def count_by_state - count_params = params.merge(state: nil, sort: nil) + count_params = params.merge(state: nil, sort: nil, force_cte: true) finder = self.class.new(current_user, count_params) + counts = Hash.new(0) # Searching by label includes a GROUP BY in the query, but ours will be last @@ -128,6 +131,11 @@ class IssuableFinder # # This does not apply when we are using a CTE for the search, as the labels # GROUP BY is inside the subquery in that case, so we set labels_count to 1. + # + # Groups and projects have separate feature flags to suggest the use + # of a CTE. The CTE will not be used if the sort doesn't support it, + # but will always be used for the counts here as we ignore sorting + # anyway. labels_count = label_names.any? ? label_names.count : 1 labels_count = 1 if use_cte_for_search? @@ -177,7 +185,6 @@ class IssuableFinder @project = project end - # rubocop: disable CodeReuse/ActiveRecord def projects return @projects if defined?(@projects) @@ -185,17 +192,25 @@ class IssuableFinder projects = if current_user && params[:authorized_only].presence && !current_user_related? - current_user.authorized_projects + current_user.authorized_projects(min_access_level) elsif group - finder_options = { include_subgroups: params[:include_subgroups], only_owned: true } - GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute # rubocop: disable CodeReuse/Finder + find_group_projects else - ProjectsFinder.new(current_user: current_user).execute # rubocop: disable CodeReuse/Finder + Project.public_or_visible_to_user(current_user, min_access_level) end - @projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil) + @projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil) # rubocop: disable CodeReuse/ActiveRecord + end + + def find_group_projects + return Project.none unless group + + if params[:include_subgroups] + Project.where(namespace_id: group.self_and_descendants) # rubocop: disable CodeReuse/ActiveRecord + else + group.projects + end.public_or_visible_to_user(current_user, min_access_level) end - # rubocop: enable CodeReuse/ActiveRecord def search params[:search].presence @@ -303,29 +318,35 @@ class IssuableFinder end # rubocop: enable CodeReuse/ActiveRecord - def use_subquery_for_search? - strong_memoize(:use_subquery_for_search) do - attempt_group_search_optimizations? && - Feature.enabled?(:use_subquery_for_group_issues_search, default_enabled: true) - end - end - def use_cte_for_search? strong_memoize(:use_cte_for_search) do - attempt_group_search_optimizations? && - !use_subquery_for_search? && - Feature.enabled?(:use_cte_for_group_issues_search, default_enabled: true) + next false unless search + next false unless Gitlab::Database.postgresql? + # Only simple unsorted & simple sorts can use CTE + next false if params[:sort].present? && !params[:sort].in?(klass.simple_sorts.keys) + + attempt_group_search_optimizations? || attempt_project_search_optimizations? end end private + def force_cte? + !!params[:force_cte] + end + def init_collection klass.all end def attempt_group_search_optimizations? - search && Gitlab::Database.postgresql? && params[:attempt_group_search_optimizations] + params[:attempt_group_search_optimizations] && + Feature.enabled?(:attempt_group_search_optimizations, default_enabled: true) + end + + def attempt_project_search_optimizations? + params[:attempt_project_search_optimizations] && + Feature.enabled?(:attempt_project_search_optimizations, default_enabled: true) end def count_key(value) @@ -398,15 +419,6 @@ class IssuableFinder end # rubocop: enable CodeReuse/ActiveRecord - # Wrap projects and groups in a subquery if the conditions are met. - def by_subquery(items) - if use_subquery_for_search? - klass.where(id: items.select(:id)) # rubocop: disable CodeReuse/ActiveRecord - else - items - end - end - # rubocop: disable CodeReuse/ActiveRecord def by_search(items) return items unless search @@ -436,22 +448,6 @@ class IssuableFinder end # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord - def by_assignee(items) - if filter_by_no_assignee? - items.where(assignee_id: nil) - elsif filter_by_any_assignee? - items.where('assignee_id IS NOT NULL') - elsif assignee - items.where(assignee_id: assignee.id) - elsif assignee_id? || assignee_username? # assignee not found - items.none - else - items - end - end - # rubocop: enable CodeReuse/ActiveRecord - def filter_by_no_assignee? # Assignee_id takes precedence over assignee_username [NONE, FILTER_NONE].include?(params[:assignee_id].to_s.downcase) || params[:assignee_username].to_s == NONE @@ -475,6 +471,20 @@ class IssuableFinder end # rubocop: enable CodeReuse/ActiveRecord + def by_assignee(items) + if filter_by_no_assignee? + items.unassigned + elsif filter_by_any_assignee? + items.assigned + elsif assignee + items.assigned_to(assignee) + elsif assignee_id? || assignee_username? # assignee not found + items.none + else + items + end + end + # rubocop: disable CodeReuse/ActiveRecord def by_milestone(items) if milestones? @@ -486,7 +496,7 @@ class IssuableFinder upcoming_ids = Milestone.upcoming_ids(projects, related_groups) items = items.left_joins_milestones.where(milestone_id: upcoming_ids) elsif filter_by_started_milestone? - items = items.left_joins_milestones.where('milestones.start_date <= NOW()') + items = items.left_joins_milestones.merge(Milestone.started) else items = items.with_milestone(params[:milestone_title]) end @@ -568,4 +578,8 @@ class IssuableFinder scope = params[:scope] scope == 'created_by_me' || scope == 'authored' || scope == 'assigned_to_me' end + + def min_access_level + ProjectFeature.required_minimum_access_level(klass) + end end diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index cb44575d6f1..58a01d598ba 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -48,9 +48,9 @@ class IssuesFinder < IssuableFinder OR (issues.confidential = TRUE AND (issues.author_id = :user_id OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id) - OR issues.project_id IN(:project_ids)))', + OR EXISTS (:authorizations)))', user_id: current_user.id, - project_ids: current_user.authorized_projects(CONFIDENTIAL_ACCESS_LEVEL).select(:id)) + authorizations: current_user.authorizations_for_projects(min_access_level: CONFIDENTIAL_ACCESS_LEVEL, related_project_column: "issues.project_id")) end # rubocop: enable CodeReuse/ActiveRecord @@ -144,18 +144,4 @@ class IssuesFinder < IssuableFinder current_user.blank? end - - def by_assignee(items) - if filter_by_no_assignee? - items.unassigned - elsif filter_by_any_assignee? - items.assigned - elsif assignee - items.assigned_to(assignee) - elsif assignee_id? || assignee_username? # assignee not found - items.none - else - items - end - end end diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb index f90a7868102..917de249104 100644 --- a/app/finders/members_finder.rb +++ b/app/finders/members_finder.rb @@ -9,25 +9,18 @@ class MembersFinder @group = project.group end - # rubocop: disable CodeReuse/ActiveRecord - def execute(include_descendants: false) + def execute(include_descendants: false, include_invited_groups_members: false) project_members = project.project_members project_members = project_members.non_invite unless can?(current_user, :admin_project, project) - if group - group_members = GroupMembersFinder.new(group).execute(include_descendants: include_descendants) # rubocop: disable CodeReuse/Finder - group_members = group_members.non_invite + union_members = group_union_members(include_descendants, include_invited_groups_members) - union = Gitlab::SQL::Union.new([project_members, group_members], remove_duplicates: false) # rubocop: disable Gitlab/Union - - sql = distinct_on(union) - - Member.includes(:user).from("(#{sql}) AS #{Member.table_name}") + if union_members.any? + distinct_union_of_members(union_members << project_members) else project_members end end - # rubocop: enable CodeReuse/ActiveRecord def can?(*args) Ability.allowed?(*args) @@ -35,6 +28,34 @@ class MembersFinder private + def group_union_members(include_descendants, include_invited_groups_members) + [].tap do |members| + members << direct_group_members(include_descendants) if group + members << project_invited_groups_members if include_invited_groups_members + end + end + + def direct_group_members(include_descendants) + GroupMembersFinder.new(group).execute(include_descendants: include_descendants).non_invite # rubocop: disable CodeReuse/Finder + end + + def project_invited_groups_members + invited_groups_ids_including_ancestors = Gitlab::ObjectHierarchy + .new(project.invited_groups) + .base_and_ancestors + .public_or_visible_to_user(current_user) + .select(:id) + + GroupMember.with_source_id(invited_groups_ids_including_ancestors) + end + + def distinct_union_of_members(union_members) + union = Gitlab::SQL::Union.new(union_members, remove_duplicates: false) # rubocop: disable Gitlab/Union + sql = distinct_on(union) + + Member.includes(:user).from([Arel.sql("(#{sql}) AS #{Member.table_name}")]) # rubocop: disable CodeReuse/ActiveRecord + end + def distinct_on(union) # We're interested in a list of members without duplicates by user_id. # We prefer project members over group members, project members should go first. diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index b645011a3c5..29947bc94d5 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -29,7 +29,7 @@ # class MergeRequestsFinder < IssuableFinder def self.scalar_params - @scalar_params ||= super + [:wip] + @scalar_params ||= super + [:wip, :target_branch] end def klass @@ -37,13 +37,21 @@ class MergeRequestsFinder < IssuableFinder end def filter_items(_items) - items = by_source_branch(super) + items = by_commit(super) + items = by_source_branch(items) items = by_wip(items) - by_target_branch(items) + items = by_target_branch(items) + by_source_project_id(items) end private + def by_commit(items) + return items unless params[:commit_sha].presence + + items.by_commit_sha(params[:commit_sha]) + end + def source_branch @source_branch ||= params[:source_branch].presence end @@ -67,6 +75,16 @@ class MergeRequestsFinder < IssuableFinder items.where(target_branch: target_branch) end + def source_project_id + @source_project_id ||= params[:source_project_id].presence + end + + def by_source_project_id(items) + return items unless source_project_id + + items.where(source_project_id: source_project_id) + end + def by_wip(items) if params[:wip] == 'yes' items.where(wip_match(items.arel_table)) diff --git a/app/finders/projects/daily_statistics_finder.rb b/app/finders/projects/daily_statistics_finder.rb new file mode 100644 index 00000000000..912c23107bc --- /dev/null +++ b/app/finders/projects/daily_statistics_finder.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Projects + class DailyStatisticsFinder + attr_reader :project + + def initialize(project) + @project = project + end + + def fetches + ProjectDailyStatistic.of_project(project) + .of_last_30_days + .sorted_by_date_desc + end + + def total_fetch_count + fetches.sum_fetch_count + end + end +end diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb index 2f2816a4a08..ebe50806ca1 100644 --- a/app/finders/projects/serverless/functions_finder.rb +++ b/app/finders/projects/serverless/functions_finder.rb @@ -3,29 +3,59 @@ module Projects module Serverless class FunctionsFinder - def initialize(clusters) - @clusters = clusters + attr_reader :project + + def initialize(project) + @clusters = project.clusters + @project = project end def execute knative_services.flatten.compact end - def installed? - clusters_with_knative_installed.exists? + # Possible return values: Clusters::KnativeServicesFinder::KNATIVE_STATE + def knative_installed + states = @clusters.map do |cluster| + cluster.application_knative + cluster.knative_services_finder(project).knative_detected.tap do |state| + return state if state == ::Clusters::KnativeServicesFinder::KNATIVE_STATES['checking'] # rubocop:disable Cop/AvoidReturnFromBlocks + end + end + + states.any? { |state| state == ::Clusters::KnativeServicesFinder::KNATIVE_STATES['installed'] } end def service(environment_scope, name) knative_service(environment_scope, name)&.first end + def invocation_metrics(environment_scope, name) + return unless prometheus_adapter&.can_query? + + cluster = @clusters.find do |c| + environment_scope == c.environment_scope + end + + func = ::Serverless::Function.new(project, name, cluster.kubernetes_namespace_for(project)) + prometheus_adapter.query(:knative_invocation, func) + end + + def has_prometheus?(environment_scope) + @clusters.any? do |cluster| + environment_scope == cluster.environment_scope && cluster.application_prometheus_available? + end + end + private def knative_service(environment_scope, name) - clusters_with_knative_installed.preload_knative.map do |cluster| + @clusters.map do |cluster| next if environment_scope != cluster.environment_scope - services = cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace) + services = cluster + .knative_services_finder(project) + .services .select { |svc| svc["metadata"]["name"] == name } add_metadata(cluster, services).first unless services.nil? @@ -33,8 +63,11 @@ module Projects end def knative_services - clusters_with_knative_installed.preload_knative.map do |cluster| - services = cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace) + @clusters.map do |cluster| + services = cluster + .knative_services_finder(project) + .services + add_metadata(cluster, services) unless services.nil? end end @@ -45,16 +78,19 @@ module Projects s["cluster_id"] = cluster.id if services.length == 1 - s["podcount"] = cluster.application_knative.service_pod_details( - cluster.platform_kubernetes&.actual_namespace, - s["metadata"]["name"]).length + s["podcount"] = cluster + .knative_services_finder(project) + .service_pod_details(s["metadata"]["name"]) + .length end end end - def clusters_with_knative_installed - @clusters.with_knative_installed + # rubocop: disable CodeReuse/ServiceClass + def prometheus_adapter + @prometheus_adapter ||= ::Prometheus::AdapterService.new(project).prometheus_adapter end + # rubocop: enable CodeReuse/ServiceClass end end end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 93d3c991846..23b731b1aed 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -62,7 +62,7 @@ class ProjectsFinder < UnionFinder collection = by_personal(collection) collection = by_starred(collection) collection = by_trending(collection) - collection = by_visibilty_level(collection) + collection = by_visibility_level(collection) collection = by_tags(collection) collection = by_search(collection) collection = by_archived(collection) @@ -71,12 +71,11 @@ class ProjectsFinder < UnionFinder collection end - # rubocop: disable CodeReuse/ActiveRecord def collection_with_user if owned_projects? current_user.owned_projects elsif min_access_level? - current_user.authorized_projects.where('project_authorizations.access_level >= ?', params[:min_access_level]) + current_user.authorized_projects(params[:min_access_level]) else if private_only? current_user.authorized_projects @@ -85,7 +84,6 @@ class ProjectsFinder < UnionFinder end end end - # rubocop: enable CodeReuse/ActiveRecord # Builds a collection for an anonymous user. def collection_without_user @@ -131,7 +129,7 @@ class ProjectsFinder < UnionFinder end # rubocop: disable CodeReuse/ActiveRecord - def by_visibilty_level(items) + def by_visibility_level(items) params[:visibility_level].present? ? items.where(visibility_level: params[:visibility_level]) : items end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index d3774746cb8..bf29f15642d 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -69,6 +69,8 @@ class SnippetsFinder < UnionFinder base.with_optional_visibility(visibility_from_scope).fresh end + private + # Produces a query that retrieves snippets from multiple projects. # # The resulting query will, depending on the user's permissions, include the diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index 06d26309b5b..2e5bdbd79c8 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -1,13 +1,97 @@ # frozen_string_literal: true class GitlabSchema < GraphQL::Schema + # Currently an IntrospectionQuery has a complexity of 179. + # These values will evolve over time. + DEFAULT_MAX_COMPLEXITY = 200 + AUTHENTICATED_COMPLEXITY = 250 + ADMIN_COMPLEXITY = 300 + + DEFAULT_MAX_DEPTH = 10 + AUTHENTICATED_MAX_DEPTH = 15 + use BatchLoader::GraphQL use Gitlab::Graphql::Authorize use Gitlab::Graphql::Present use Gitlab::Graphql::Connections + use Gitlab::Graphql::GenericTracing + + query_analyzer Gitlab::Graphql::QueryAnalyzers::LoggerAnalyzer.new query(Types::QueryType) default_max_page_size 100 + + max_complexity DEFAULT_MAX_COMPLEXITY + max_depth DEFAULT_MAX_DEPTH + mutation(Types::MutationType) + + class << self + def multiplex(queries, **kwargs) + kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context]) + + queries.each do |query| + query[:max_depth] = max_query_depth(kwargs[:context]) + end + + super(queries, **kwargs) + end + + def execute(query_str = nil, **kwargs) + kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context]) + kwargs[:max_depth] ||= max_query_depth(kwargs[:context]) + + super(query_str, **kwargs) + end + + def id_from_object(object) + unless object.respond_to?(:to_global_id) + # This is an error in our schema and needs to be solved. So raise a + # more meaningfull error message + raise "#{object} does not implement `to_global_id`. "\ + "Include `GlobalID::Identification` into `#{object.class}" + end + + object.to_global_id + end + + def object_from_id(global_id) + gid = GlobalID.parse(global_id) + + unless gid + raise Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid GitLab id." + end + + if gid.model_class < ApplicationRecord + Gitlab::Graphql::Loaders::BatchModelLoader.new(gid.model_class, gid.model_id).find + else + gid.find + end + end + + private + + def max_query_complexity(ctx) + current_user = ctx&.fetch(:current_user, nil) + + if current_user&.admin + ADMIN_COMPLEXITY + elsif current_user + AUTHENTICATED_COMPLEXITY + else + DEFAULT_MAX_COMPLEXITY + end + end + + def max_query_depth(ctx) + current_user = ctx&.fetch(:current_user, nil) + + if current_user + AUTHENTICATED_MAX_DEPTH + else + DEFAULT_MAX_DEPTH + end + end + end end diff --git a/app/graphql/mutations/merge_requests/base.rb b/app/graphql/mutations/merge_requests/base.rb index 7d0cb777ad1..e85d16fc2c5 100644 --- a/app/graphql/mutations/merge_requests/base.rb +++ b/app/graphql/mutations/merge_requests/base.rb @@ -10,7 +10,7 @@ module Mutations required: true, description: "The project the merge request to mutate is in" - argument :iid, GraphQL::ID_TYPE, + argument :iid, GraphQL::STRING_TYPE, required: true, description: "The iid of the merge request to mutate" diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 063def75d38..5b7eb57841c 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -9,5 +9,24 @@ module Resolvers end end end + + def self.resolver_complexity(args, child_complexity:) + complexity = 1 + complexity += 1 if args[:sort] + complexity += 5 if args[:search] + + complexity + end + + def self.complexity_multiplier(args) + # When fetching many items, additional complexity is added to the field + # depending on how many items is fetched. For each item we add 1% of the + # original complexity - this means that loading 100 items (our default + # maxp_age_size limit) doubles the original complexity. + # + # Complexity is not increased when searching by specific ID(s), because + # complexity difference is minimal in this case. + [args[:iid], args[:iids]].any? ? 0 : 0.01 + end end end diff --git a/app/graphql/resolvers/concerns/resolves_pipelines.rb b/app/graphql/resolvers/concerns/resolves_pipelines.rb index 8fd26d85994..a6f82cc8505 100644 --- a/app/graphql/resolvers/concerns/resolves_pipelines.rb +++ b/app/graphql/resolvers/concerns/resolves_pipelines.rb @@ -19,6 +19,16 @@ module ResolvesPipelines description: "Filter pipelines by the sha of the commit they are run for" end + class_methods do + def resolver_complexity(args, child_complexity:) + complexity = super + complexity += 2 if args[:sha] + complexity += 2 if args[:ref] + + complexity + end + end + def resolve_pipelines(project, params = {}) PipelinesFinder.new(project, context[:current_user], params).execute end diff --git a/app/graphql/resolvers/full_path_resolver.rb b/app/graphql/resolvers/full_path_resolver.rb index 0f1a64b6c58..972f318c806 100644 --- a/app/graphql/resolvers/full_path_resolver.rb +++ b/app/graphql/resolvers/full_path_resolver.rb @@ -7,14 +7,14 @@ module Resolvers prepended do argument :full_path, GraphQL::ID_TYPE, required: true, - description: 'The full path of the project or namespace, e.g., "gitlab-org/gitlab-ce"' + description: 'The full path of the project, group or namespace, e.g., "gitlab-org/gitlab-ce"' end def model_by_full_path(model, full_path) BatchLoader.for(full_path).batch(key: model) do |full_paths, loader, args| # `with_route` avoids an N+1 calculating full_path - args[:key].where_full_path_in(full_paths).with_route.each do |project| - loader.call(project.full_path, project) + args[:key].where_full_path_in(full_paths).with_route.each do |model_instance| + loader.call(model_instance.full_path, model_instance) end end end diff --git a/app/graphql/resolvers/group_resolver.rb b/app/graphql/resolvers/group_resolver.rb new file mode 100644 index 00000000000..4260e18829e --- /dev/null +++ b/app/graphql/resolvers/group_resolver.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Resolvers + class GroupResolver < BaseResolver + prepend FullPathResolver + + type Types::GroupType, null: true + + def resolve(full_path:) + model_by_full_path(Group, full_path) + end + end +end diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index b98d8bd1fff..6988b451ec3 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -2,11 +2,11 @@ module Resolvers class IssuesResolver < BaseResolver - argument :iid, GraphQL::ID_TYPE, + argument :iid, GraphQL::STRING_TYPE, required: false, description: 'The IID of the issue, e.g., "1"' - argument :iids, [GraphQL::ID_TYPE], + argument :iids, [GraphQL::STRING_TYPE], required: false, description: 'The list of IIDs of issues, e.g., [1, 2]' argument :state, Types::IssuableStateEnum, @@ -44,6 +44,12 @@ module Resolvers alias_method :project, :object def resolve(**args) + # The project could have been loaded in batch by `BatchLoader`. + # At this point we need the `id` of the project to query for issues, so + # make sure it's loaded and not `nil` before continuing. + project.sync if project.respond_to?(:sync) + return Issue.none if project.nil? + # Will need to be be made group & namespace aware with # https://gitlab.com/gitlab-org/gitlab-ce/issues/54520 args[:project_id] = project.id @@ -51,5 +57,12 @@ module Resolvers IssuesFinder.new(context[:current_user], args).execute end + + def self.resolver_complexity(args, child_complexity:) + complexity = super + complexity += 2 if args[:labelName] + + complexity + end end end diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb index 90795c797ac..b84e60066e1 100644 --- a/app/graphql/resolvers/merge_requests_resolver.rb +++ b/app/graphql/resolvers/merge_requests_resolver.rb @@ -2,11 +2,11 @@ module Resolvers class MergeRequestsResolver < BaseResolver - argument :iid, GraphQL::ID_TYPE, + argument :iid, GraphQL::STRING_TYPE, required: false, description: 'The IID of the merge request, e.g., "1"' - argument :iids, [GraphQL::ID_TYPE], + argument :iids, [GraphQL::STRING_TYPE], required: false, description: 'The list of IIDs of issues, e.g., [1, 2]' diff --git a/app/graphql/resolvers/metadata_resolver.rb b/app/graphql/resolvers/metadata_resolver.rb new file mode 100644 index 00000000000..3a79e6434fb --- /dev/null +++ b/app/graphql/resolvers/metadata_resolver.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Resolvers + class MetadataResolver < BaseResolver + type Types::MetadataType, null: false + + def resolve(**args) + { version: Gitlab::VERSION, revision: Gitlab.revision } + end + end +end diff --git a/app/graphql/resolvers/namespace_projects_resolver.rb b/app/graphql/resolvers/namespace_projects_resolver.rb new file mode 100644 index 00000000000..677ea808aeb --- /dev/null +++ b/app/graphql/resolvers/namespace_projects_resolver.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Resolvers + class NamespaceProjectsResolver < BaseResolver + argument :include_subgroups, GraphQL::BOOLEAN_TYPE, + required: false, + default_value: false, + description: 'Include also subgroup projects' + + type Types::ProjectType, null: true + + alias_method :namespace, :object + + def resolve(include_subgroups:) + # The namespace could have been loaded in batch by `BatchLoader`. + # At this point we need the `id` or the `full_path` of the namespace + # to query for projects, so make sure it's loaded and not `nil` before continuing. + namespace.sync if namespace.respond_to?(:sync) + return Project.none if namespace.nil? + + if include_subgroups + namespace.all_projects.with_route + else + namespace.projects.with_route + end + end + + def self.resolver_complexity(args, child_complexity:) + complexity = super + complexity + 10 + end + end +end diff --git a/app/graphql/resolvers/namespace_resolver.rb b/app/graphql/resolvers/namespace_resolver.rb new file mode 100644 index 00000000000..17b3800d151 --- /dev/null +++ b/app/graphql/resolvers/namespace_resolver.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Resolvers + class NamespaceResolver < BaseResolver + prepend FullPathResolver + + type Types::NamespaceType, null: true + + def resolve(full_path:) + model_by_full_path(Namespace, full_path) + end + end +end diff --git a/app/graphql/resolvers/project_resolver.rb b/app/graphql/resolvers/project_resolver.rb index ac7c9b0ce2e..2132447da5e 100644 --- a/app/graphql/resolvers/project_resolver.rb +++ b/app/graphql/resolvers/project_resolver.rb @@ -9,5 +9,9 @@ module Resolvers def resolve(full_path:) model_by_full_path(Project, full_path) end + + def self.complexity_multiplier(args) + 0 + end end end diff --git a/app/graphql/resolvers/tree_resolver.rb b/app/graphql/resolvers/tree_resolver.rb new file mode 100644 index 00000000000..5aad1c71b40 --- /dev/null +++ b/app/graphql/resolvers/tree_resolver.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Resolvers + class TreeResolver < BaseResolver + argument :path, GraphQL::STRING_TYPE, + required: false, + default_value: '', + description: 'The path to get the tree for. Default value is the root of the repository' + argument :ref, GraphQL::STRING_TYPE, + required: false, + default_value: :head, + description: 'The commit ref to get the tree for. Default value is HEAD' + argument :recursive, GraphQL::BOOLEAN_TYPE, + required: false, + default_value: false, + description: 'Used to get a recursive tree. Default is false' + + alias_method :repository, :object + + def resolve(**args) + return unless repository.exists? + + repository.tree(args[:ref], args[:path], recursive: args[:recursive]) + end + end +end diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index 2b2ea64c00b..dd0d9105df6 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -3,5 +3,47 @@ module Types class BaseField < GraphQL::Schema::Field prepend Gitlab::Graphql::Authorize + + DEFAULT_COMPLEXITY = 1 + + def initialize(*args, **kwargs, &block) + kwargs[:complexity] ||= field_complexity(kwargs[:resolver_class]) + + super(*args, **kwargs, &block) + end + + private + + def field_complexity(resolver_class) + if resolver_class + field_resolver_complexity + else + DEFAULT_COMPLEXITY + end + end + + def field_resolver_complexity + # Complexity can be either integer or proc. If proc is used then it's + # called when computing a query complexity and context and query + # arguments are available for computing complexity. For resolvers we use + # proc because we set complexity depending on arguments and number of + # items which can be loaded. + proc do |ctx, args, child_complexity| + # Resolvers may add extra complexity depending on used arguments + complexity = child_complexity + self.resolver&.try(:resolver_complexity, args, child_complexity: child_complexity).to_i + + field_defn = to_graphql + + if field_defn.connection? + # Resolvers may add extra complexity depending on number of items being loaded. + page_size = field_defn.connection_max_page_size || ctx.schema.default_max_page_size + limit_value = [args[:first], args[:last], page_size].compact.min + multiplier = self.resolver&.try(:complexity_multiplier, args).to_f + complexity += complexity * limit_value * multiplier + end + + complexity.to_i + end + end end end diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb index 82b78abd573..e40059c46bb 100644 --- a/app/graphql/types/base_object.rb +++ b/app/graphql/types/base_object.rb @@ -6,5 +6,10 @@ module Types prepend Gitlab::Graphql::ExposePermissions field_class Types::BaseField + + # All graphql fields exposing an id, should expose a global id. + def id + GitlabSchema.id_from_object(object) + end end end diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb new file mode 100644 index 00000000000..2987354b556 --- /dev/null +++ b/app/graphql/types/ci/detailed_status_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +module Types + module Ci + class DetailedStatusType < BaseObject + graphql_name 'DetailedStatus' + + field :group, GraphQL::STRING_TYPE, null: false + field :icon, GraphQL::STRING_TYPE, null: false + field :favicon, GraphQL::STRING_TYPE, null: false + field :details_path, GraphQL::STRING_TYPE, null: false + field :has_details, GraphQL::BOOLEAN_TYPE, null: false, method: :has_details? + field :label, GraphQL::STRING_TYPE, null: false + field :text, GraphQL::STRING_TYPE, null: false + field :tooltip, GraphQL::STRING_TYPE, null: false, method: :status_tooltip + end + end +end diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb index 2bbffad4563..cff81e5670b 100644 --- a/app/graphql/types/ci/pipeline_type.rb +++ b/app/graphql/types/ci/pipeline_type.rb @@ -3,16 +3,22 @@ module Types module Ci class PipelineType < BaseObject - expose_permissions Types::PermissionTypes::Ci::Pipeline - graphql_name 'Pipeline' + authorize :read_pipeline + + expose_permissions Types::PermissionTypes::Ci::Pipeline + field :id, GraphQL::ID_TYPE, null: false - field :iid, GraphQL::ID_TYPE, null: false + field :iid, GraphQL::STRING_TYPE, null: false field :sha, GraphQL::STRING_TYPE, null: false field :before_sha, GraphQL::STRING_TYPE, null: true field :status, PipelineStatusEnum, null: false + field :detailed_status, + Types::Ci::DetailedStatusType, + null: false, + resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) } field :duration, GraphQL::INT_TYPE, null: true, diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb new file mode 100644 index 00000000000..530aecc2bf9 --- /dev/null +++ b/app/graphql/types/group_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + class GroupType < NamespaceType + graphql_name 'Group' + + authorize :read_group + + expose_permissions Types::PermissionTypes::Group + + field :web_url, GraphQL::STRING_TYPE, null: false + + field :avatar_url, GraphQL::STRING_TYPE, null: true, resolve: -> (group, args, ctx) do + group.avatar_url(only_path: false) + end + + if ::Group.supports_nested_objects? + field :parent, GroupType, + null: true, + resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.parent_id).find } + end + end +end diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 87f6b1f8278..dd5133189dc 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -2,10 +2,12 @@ module Types class IssueType < BaseObject - expose_permissions Types::PermissionTypes::Issue - graphql_name 'Issue' + authorize :read_issue + + expose_permissions Types::PermissionTypes::Issue + present_using IssuePresenter field :iid, GraphQL::ID_TYPE, null: false @@ -13,20 +15,22 @@ module Types field :description, GraphQL::STRING_TYPE, null: true field :state, IssueStateEnum, null: false + field :reference, GraphQL::STRING_TYPE, null: false, method: :to_reference do + argument :full, GraphQL::BOOLEAN_TYPE, required: false, default_value: false + end + field :author, Types::UserType, null: false, - resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find } do - authorize :read_user - end + resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find } - field :assignees, Types::UserType.connection_type, null: true + # Remove complexity when BatchLoader is used + field :assignees, Types::UserType.connection_type, null: true, complexity: 5 - field :labels, Types::LabelType.connection_type, null: true + # Remove complexity when BatchLoader is used + field :labels, Types::LabelType.connection_type, null: true, complexity: 5 field :milestone, Types::MilestoneType, null: true, - resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find } do - authorize :read_milestone - end + resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find } field :due_date, Types::TimeType, null: true field :confidential, GraphQL::BOOLEAN_TYPE, null: false @@ -37,7 +41,9 @@ module Types field :upvotes, GraphQL::INT_TYPE, null: false field :downvotes, GraphQL::INT_TYPE, null: false field :user_notes_count, GraphQL::INT_TYPE, null: false + field :web_path, GraphQL::STRING_TYPE, null: false, method: :issue_path field :web_url, GraphQL::STRING_TYPE, null: false + field :relative_position, GraphQL::INT_TYPE, null: true field :closed_at, Types::TimeType, null: true diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 7827b6e3717..85ac3102442 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -2,14 +2,16 @@ module Types class MergeRequestType < BaseObject + graphql_name 'MergeRequest' + + authorize :read_merge_request + expose_permissions Types::PermissionTypes::MergeRequest present_using MergeRequestPresenter - graphql_name 'MergeRequest' - field :id, GraphQL::ID_TYPE, null: false - field :iid, GraphQL::ID_TYPE, null: false + field :iid, GraphQL::STRING_TYPE, null: false field :title, GraphQL::STRING_TYPE, null: false field :description, GraphQL::STRING_TYPE, null: true field :state, MergeRequestStateEnum, null: false @@ -48,9 +50,7 @@ module Types field :downvotes, GraphQL::INT_TYPE, null: false field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false - field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline do - authorize :read_pipeline - end + field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline field :pipelines, Types::Ci::PipelineType.connection_type, resolver: Resolvers::MergeRequestPipelinesResolver end diff --git a/app/graphql/types/metadata_type.rb b/app/graphql/types/metadata_type.rb new file mode 100644 index 00000000000..2d8bad0614b --- /dev/null +++ b/app/graphql/types/metadata_type.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + class MetadataType < ::Types::BaseObject + graphql_name 'Metadata' + + field :version, GraphQL::STRING_TYPE, null: false + field :revision, GraphQL::STRING_TYPE, null: false + end +end diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb index af31b572c9a..2772fbec86f 100644 --- a/app/graphql/types/milestone_type.rb +++ b/app/graphql/types/milestone_type.rb @@ -4,6 +4,8 @@ module Types class MilestoneType < BaseObject graphql_name 'Milestone' + authorize :read_milestone + field :description, GraphQL::STRING_TYPE, null: true field :title, GraphQL::STRING_TYPE, null: false field :state, GraphQL::STRING_TYPE, null: false diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb new file mode 100644 index 00000000000..f6d91320e50 --- /dev/null +++ b/app/graphql/types/namespace_type.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + class NamespaceType < BaseObject + graphql_name 'Namespace' + + field :id, GraphQL::ID_TYPE, null: false + + field :name, GraphQL::STRING_TYPE, null: false + field :path, GraphQL::STRING_TYPE, null: false + field :full_name, GraphQL::STRING_TYPE, null: false + field :full_path, GraphQL::ID_TYPE, null: false + + field :description, GraphQL::STRING_TYPE, null: true + field :visibility, GraphQL::STRING_TYPE, null: true + field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled? + field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true + + field :projects, + Types::ProjectType.connection_type, + null: false, + resolver: ::Resolvers::NamespaceProjectsResolver + end +end diff --git a/app/graphql/types/permission_types/group.rb b/app/graphql/types/permission_types/group.rb new file mode 100644 index 00000000000..29833993ce6 --- /dev/null +++ b/app/graphql/types/permission_types/group.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module PermissionTypes + class Group < BasePermissionType + graphql_name 'GroupPermissions' + + abilities :read_group + end + end +end diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb new file mode 100644 index 00000000000..62537361918 --- /dev/null +++ b/app/graphql/types/project_statistics_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + class ProjectStatisticsType < BaseObject + graphql_name 'ProjectStatistics' + + field :commit_count, GraphQL::INT_TYPE, null: false + + field :storage_size, GraphQL::INT_TYPE, null: false + field :repository_size, GraphQL::INT_TYPE, null: false + field :lfs_objects_size, GraphQL::INT_TYPE, null: false + field :build_artifacts_size, GraphQL::INT_TYPE, null: false + field :packages_size, GraphQL::INT_TYPE, null: false + field :wiki_size, GraphQL::INT_TYPE, null: true + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index d25c8c8bd90..2236ffa394d 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -2,10 +2,12 @@ module Types class ProjectType < BaseObject - expose_permissions Types::PermissionTypes::Project - graphql_name 'Project' + authorize :read_project + + expose_permissions Types::PermissionTypes::Project + field :id, GraphQL::ID_TYPE, null: false field :full_path, GraphQL::ID_TYPE, null: false @@ -16,7 +18,6 @@ module Types field :description, GraphQL::STRING_TYPE, null: true - field :default_branch, GraphQL::STRING_TYPE, null: true field :tag_list, GraphQL::STRING_TYPE, null: true field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true @@ -59,26 +60,30 @@ module Types end field :import_status, GraphQL::STRING_TYPE, null: true - field :ci_config_path, GraphQL::STRING_TYPE, null: true field :only_allow_merge_if_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true field :printing_merge_request_link_enabled, GraphQL::BOOLEAN_TYPE, null: true + field :namespace, Types::NamespaceType, null: false + field :group, Types::GroupType, null: true + + field :statistics, Types::ProjectStatisticsType, + null: false, + resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(obj.id).find } + + field :repository, Types::RepositoryType, null: false + field :merge_requests, Types::MergeRequestType.connection_type, null: true, - resolver: Resolvers::MergeRequestsResolver do - authorize :read_merge_request - end + resolver: Resolvers::MergeRequestsResolver field :merge_request, Types::MergeRequestType, null: true, - resolver: Resolvers::MergeRequestsResolver.single do - authorize :read_merge_request - end + resolver: Resolvers::MergeRequestsResolver.single field :issues, Types::IssueType.connection_type, @@ -92,7 +97,7 @@ module Types field :pipelines, Types::Ci::PipelineType.connection_type, - null: false, + null: true, resolver: Resolvers::ProjectPipelinesResolver end end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 7c41716b82a..536bdb077ad 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -1,14 +1,30 @@ # frozen_string_literal: true module Types - class QueryType < BaseObject + class QueryType < ::Types::BaseObject graphql_name 'Query' field :project, Types::ProjectType, null: true, resolver: Resolvers::ProjectResolver, - description: "Find a project" do - authorize :read_project + description: "Find a project" + + field :group, Types::GroupType, + null: true, + resolver: Resolvers::GroupResolver, + description: "Find a group" + + field :namespace, Types::NamespaceType, + null: true, + resolver: Resolvers::NamespaceResolver, + description: "Find a namespace" + + field :metadata, Types::MetadataType, + null: true, + resolver: Resolvers::MetadataResolver, + description: 'Metadata about GitLab' do |*args| + + authorize :read_instance_metadata end field :echo, GraphQL::STRING_TYPE, null: false, function: Functions::Echo.new diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb new file mode 100644 index 00000000000..5987467e1ea --- /dev/null +++ b/app/graphql/types/repository_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + class RepositoryType < BaseObject + graphql_name 'Repository' + + authorize :download_code + + field :root_ref, GraphQL::STRING_TYPE, null: true + field :empty, GraphQL::BOOLEAN_TYPE, null: false, method: :empty? + field :exists, GraphQL::BOOLEAN_TYPE, null: false, method: :exists? + field :tree, Types::Tree::TreeType, null: true, resolver: Resolvers::TreeResolver + end +end diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb new file mode 100644 index 00000000000..f2b7d5df2b2 --- /dev/null +++ b/app/graphql/types/tree/blob_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +module Types + module Tree + class BlobType < BaseObject + implements Types::Tree::EntryType + + present_using BlobPresenter + + graphql_name 'Blob' + + field :web_url, GraphQL::STRING_TYPE, null: true + end + end +end diff --git a/app/graphql/types/tree/entry_type.rb b/app/graphql/types/tree/entry_type.rb new file mode 100644 index 00000000000..d8e8642ddb8 --- /dev/null +++ b/app/graphql/types/tree/entry_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +module Types + module Tree + module EntryType + include Types::BaseInterface + + field :id, GraphQL::ID_TYPE, null: false + field :name, GraphQL::STRING_TYPE, null: false + field :type, Tree::TypeEnum, null: false + field :path, GraphQL::STRING_TYPE, null: false + field :flat_path, GraphQL::STRING_TYPE, null: false + end + end +end diff --git a/app/graphql/types/tree/submodule_type.rb b/app/graphql/types/tree/submodule_type.rb new file mode 100644 index 00000000000..cea76dbfd2a --- /dev/null +++ b/app/graphql/types/tree/submodule_type.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +module Types + module Tree + class SubmoduleType < BaseObject + implements Types::Tree::EntryType + + graphql_name 'Submodule' + end + end +end diff --git a/app/graphql/types/tree/tree_entry_type.rb b/app/graphql/types/tree/tree_entry_type.rb new file mode 100644 index 00000000000..23ec2ef0ec2 --- /dev/null +++ b/app/graphql/types/tree/tree_entry_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +module Types + module Tree + class TreeEntryType < BaseObject + implements Types::Tree::EntryType + + present_using TreeEntryPresenter + + graphql_name 'TreeEntry' + description 'Represents a directory' + + field :web_url, GraphQL::STRING_TYPE, null: true + end + end +end diff --git a/app/graphql/types/tree/tree_type.rb b/app/graphql/types/tree/tree_type.rb new file mode 100644 index 00000000000..1ee93ed9542 --- /dev/null +++ b/app/graphql/types/tree/tree_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +module Types + module Tree + class TreeType < BaseObject + graphql_name 'Tree' + + field :trees, Types::Tree::TreeEntryType.connection_type, null: false, resolve: -> (obj, args, ctx) do + Gitlab::Graphql::Representation::TreeEntry.decorate(obj.trees, obj.repository) + end + + field :submodules, Types::Tree::SubmoduleType.connection_type, null: false + + field :blobs, Types::Tree::BlobType.connection_type, null: false, resolve: -> (obj, args, ctx) do + Gitlab::Graphql::Representation::TreeEntry.decorate(obj.blobs, obj.repository) + end + end + end +end diff --git a/app/graphql/types/tree/type_enum.rb b/app/graphql/types/tree/type_enum.rb new file mode 100644 index 00000000000..6560d91e9e5 --- /dev/null +++ b/app/graphql/types/tree/type_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Tree + class TypeEnum < BaseEnum + graphql_name 'EntryType' + description 'Type of a tree entry' + + value 'tree', value: :tree + value 'blob', value: :blob + value 'commit', value: :commit + end + end +end diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index a13e65207df..6b53554314b 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -4,6 +4,8 @@ module Types class UserType < BaseObject graphql_name 'User' + authorize :read_user + present_using UserPresenter field :name, GraphQL::STRING_TYPE, null: false diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index 023e44258b7..c0db9910143 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module AppearancesHelper + include MarkupHelper + def brand_title current_appearance&.title.presence || default_brand_title end @@ -47,7 +49,7 @@ module AppearancesHelper class_names = [] class_names << 'with-performance-bar' if performance_bar_enabled? - render_message(:header_message, class_names) + render_message(:header_message, class_names: class_names) end def footer_message @@ -58,10 +60,10 @@ module AppearancesHelper private - def render_message(field_sym, class_names = []) + def render_message(field_sym, class_names: [], style: message_style) class_names << field_sym.to_s.dasherize - content_tag :div, class: class_names, style: message_style do + content_tag :div, class: class_names, style: style do markdown_field(current_appearance, field_sym) end end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index e635f608237..4469118f065 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -119,6 +119,39 @@ module ApplicationSettingsHelper options_for_select(options, selected) end + def external_authorization_description + _("If enabled, access to projects will be validated on an external service"\ + " using their classification label.") + end + + def external_authorization_timeout_help_text + _("Time in seconds GitLab will wait for a response from the external "\ + "service. When the service does not respond in time, access will be "\ + "denied.") + end + + def external_authorization_url_help_text + _("When leaving the URL blank, classification labels can still be "\ + "specified without disabling cross project features or performing "\ + "external authorization checks.") + end + + def external_authorization_client_certificate_help_text + _("The X509 Certificate to use when mutual TLS is required to communicate "\ + "with the external authorization service. If left blank, the server "\ + "certificate is still validated when accessing over HTTPS.") + end + + def external_authorization_client_key_help_text + _("The private key to use when a client certificate is provided. This value "\ + "is encrypted at rest.") + end + + def external_authorization_client_pass_help_text + _("The passphrase required to decrypt the private key. This is optional "\ + "and the value is encrypted at rest.") + end + def visible_attributes [ :admin_notification_email, @@ -127,6 +160,7 @@ module ApplicationSettingsHelper :akismet_api_key, :akismet_enabled, :allow_local_requests_from_hooks_and_services, + :dns_rebinding_protection_enabled, :archive_builds_in_human_readable, :authorized_keys_enabled, :auto_devops_enabled, @@ -137,6 +171,7 @@ module ApplicationSettingsHelper :default_artifacts_expire_in, :default_branch_protection, :default_group_visibility, + :default_project_creation, :default_project_visibility, :default_projects_limit, :default_snippet_visibility, @@ -237,7 +272,23 @@ module ApplicationSettingsHelper ] end + def external_authorization_service_attributes + [ + :external_auth_client_cert, + :external_auth_client_key, + :external_auth_client_key_pass, + :external_authorization_service_default_label, + :external_authorization_service_enabled, + :external_authorization_service_timeout, + :external_authorization_service_url + ] + end + def expanded_by_default? Rails.env.test? end + + def instance_clusters_enabled? + can?(current_user, :read_cluster, Clusters::Instance.new) + end end diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 2b1d6f49878..076976175a9 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -2,7 +2,7 @@ module AuthHelper PROVIDERS_WITH_ICONS = %w(twitter github gitlab bitbucket google_oauth2 facebook azure_oauth2 authentiq).freeze - LDAP_PROVIDER = /\Aldap/ + LDAP_PROVIDER = /\Aldap/.freeze def ldap_enabled? Gitlab::Auth::LDAP::Config.enabled? @@ -100,8 +100,12 @@ module AuthHelper end # rubocop: enable CodeReuse/ActiveRecord - def unlink_allowed?(provider) - %w(saml cas3).exclude?(provider.to_s) + def unlink_provider_allowed?(provider) + IdentityProviderPolicy.new(current_user, provider).can?(:unlink) + end + + def link_provider_allowed?(provider) + IdentityProviderPolicy.new(current_user, provider).can?(:link) end extend self diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb index 67e7e475920..0f0d5350df6 100644 --- a/app/helpers/auto_devops_helper.rb +++ b/app/helpers/auto_devops_helper.rb @@ -9,4 +9,17 @@ module AutoDevopsHelper !project.repository.gitlab_ci_yml && !project.ci_service end + + def badge_for_auto_devops_scope(auto_devops_receiver) + return unless auto_devops_receiver.auto_devops_enabled? + + case auto_devops_receiver.first_auto_devops_config[:scope] + when :project + nil + when :group + s_('CICD|group enabled') + when :instance + s_('CICD|instance enabled') + end + end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 23d6684a8e6..0d6a6496993 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -19,10 +19,14 @@ module BlobHelper def ide_edit_path(project = @project, ref = @ref, path = @path, options = {}) segments = [ide_path, 'project', project.full_path, 'edit', ref] - segments.concat(['-', path]) if path.present? + segments.concat(['-', encode_ide_path(path)]) if path.present? File.join(segments) end + def encode_ide_path(path) + url_encode(path).gsub('%2F', '/') + end + def edit_blob_button(project = @project, ref = @ref, path = @path, options = {}) return unless blob = readable_blob(options, path, project, ref) @@ -31,12 +35,13 @@ module BlobHelper edit_button_tag(blob, common_classes, _('Edit'), - edit_blob_path(project, ref, path, options), + Feature.enabled?(:web_ide_default) ? ide_edit_path(project, ref, path, options) : edit_blob_path(project, ref, path, options), project, ref) end def ide_edit_button(project = @project, ref = @ref, path = @path, options = {}) + return if Feature.enabled?(:web_ide_default) return unless blob = readable_blob(options, path, project, ref) edit_button_tag(blob, @@ -72,7 +77,7 @@ module BlobHelper project, ref, path, - label: "Replace", + label: _("Replace"), action: "replace", btn_class: "default", modal_type: "upload" @@ -84,7 +89,7 @@ module BlobHelper project, ref, path, - label: "Delete", + label: _("Delete"), action: "delete", btn_class: "remove", modal_type: "remove" @@ -96,14 +101,14 @@ module BlobHelper end def leave_edit_message - "Leave edit mode?\nAll unsaved changes will be lost." + _("Leave edit mode? All unsaved changes will be lost.") end def editing_preview_title(filename) if Gitlab::MarkupHelper.previewable?(filename) - 'Preview' + _('Preview') else - 'Preview changes' + _('Preview changes') end end @@ -183,7 +188,7 @@ module BlobHelper end def copy_file_path_button(file_path) - clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard') + clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent', title: 'Copy file path to clipboard') end def copy_blob_source_button(blob) @@ -196,14 +201,14 @@ module BlobHelper return if blob.empty? return if blob.binary? || blob.stored_externally? - title = 'Open raw' + title = _('Open raw') link_to icon('file-code-o'), blob_raw_path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' } end def download_blob_button(blob) return if blob.empty? - title = 'Download' + title = _('Download') link_to sprite_icon('download'), blob_raw_path(inline: false), download: @path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' } end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index be1e7016a1e..1640f4fc93f 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -69,7 +69,7 @@ module BoardsHelper end def board_sidebar_user_data - dropdown_options = issue_assignees_dropdown_options + dropdown_options = assignees_dropdown_options('issue') { toggle: 'dropdown', diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb index 289cb44f1e8..495c29d3e24 100644 --- a/app/helpers/broadcast_messages_helper.rb +++ b/app/helpers/broadcast_messages_helper.rb @@ -4,7 +4,7 @@ module BroadcastMessagesHelper def broadcast_message(message) return unless message.present? - content_tag :div, class: 'broadcast-message', style: broadcast_message_style(message) do + content_tag :div, dir: 'auto', class: 'broadcast-message', style: broadcast_message_style(message) do icon('bullhorn') << ' ' << render_broadcast_message(message) end end diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb index 3c8caec3fe5..a5fe6bb8f07 100644 --- a/app/helpers/builds_helper.rb +++ b/app/helpers/builds_helper.rb @@ -4,12 +4,12 @@ module BuildsHelper def build_summary(build, skip: false) if build.has_trace? if skip - link_to "View job trace", pipeline_job_url(build.pipeline, build) + link_to _("View job trace"), pipeline_job_url(build.pipeline, build) else build.trace.html(last_lines: 10).html_safe end else - "No job trace" + _("No job trace") end end @@ -31,7 +31,7 @@ module BuildsHelper def build_failed_issue_options { - title: "Job Failed ##{@build.id}", + title: _("Job Failed #%{build_id}") % { build_id: @build.id }, description: project_job_url(@project, @build) } end diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index 494c754e7d5..03adbfa204f 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 to clipboard') 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 923a06a0512..f2b5b82b013 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -16,7 +16,7 @@ module CiStatusHelper label = case status when 'success' 'passed' - when 'success_with_warnings' + when 'success-with-warnings' 'passed with warnings' when 'manual' 'waiting for manual action' @@ -37,7 +37,7 @@ module CiStatusHelper case status when 'success' s_('CiStatusText|passed') - when 'success_with_warnings' + when 'success-with-warnings' s_('CiStatusText|passed') when 'manual' s_('CiStatusText|blocked') @@ -71,7 +71,7 @@ module CiStatusHelper case status when 'success' 'status_success' - when 'success_with_warnings' + when 'success-with-warnings' 'status_warning' when 'failed' 'status_failed' @@ -100,17 +100,6 @@ module CiStatusHelper "pipeline-status/#{pipeline_status.sha}-#{pipeline_status.status}" end - def render_project_pipeline_status(pipeline_status, tooltip_placement: 'left') - project = pipeline_status.project - path = pipelines_project_commit_path(project, pipeline_status.sha, ref: pipeline_status.ref) - - render_status_with_link( - 'commit', - pipeline_status.status, - path, - tooltip_placement: tooltip_placement) - end - def render_commit_status(commit, ref: nil, tooltip_placement: 'left') project = commit.project path = pipelines_project_commit_path(project, commit, ref: ref) @@ -123,14 +112,8 @@ module CiStatusHelper icon_size: 24) end - def render_pipeline_status(pipeline, tooltip_placement: 'left') - project = pipeline.project - path = project_pipeline_path(project, pipeline) - render_status_with_link('pipeline', pipeline.status, path, tooltip_placement: tooltip_placement) - end - def render_status_with_link(type, status, path = nil, tooltip_placement: 'left', cssclass: '', container: 'body', icon_size: 16) - klass = "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}" + klass = "ci-status-link ci-status-icon-#{status.dasherize} d-inline-flex #{cssclass}" title = "#{type.titleize}: #{ci_label_for_status(status)}" data = { toggle: 'tooltip', placement: tooltip_placement, container: container } diff --git a/app/helpers/ci_variables_helper.rb b/app/helpers/ci_variables_helper.rb index e3728804c2a..fc51f00d052 100644 --- a/app/helpers/ci_variables_helper.rb +++ b/app/helpers/ci_variables_helper.rb @@ -12,4 +12,23 @@ module CiVariablesHelper ci_variable_protected_by_default? end end + + def ci_variable_masked?(variable, only_key_value) + if variable && !only_key_value + variable.masked + else + false + end + end + + def ci_variable_type_options + [ + %w(Variable env_var), + %w(File file) + ] + end + + def ci_variable_maskable_regex + Maskable::REGEX.inspect.sub('\\A', '^').sub('\\z', '$').sub(/^\//, '').sub(/\/[a-z]*$/, '').gsub('\/', '/') + end end diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 916dcb1a308..769f75f57c4 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -14,4 +14,10 @@ module ClustersHelper render 'clusters/clusters/gcp_signup_offer_banner' end end + + def has_rbac_enabled?(cluster) + return cluster.platform_kubernetes_rbac? if cluster.platform_kubernetes + + !cluster.provider.legacy_abac? + end end diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb index d90ef8903a7..42732eb93dd 100644 --- a/app/helpers/dashboard_helper.rb +++ b/app/helpers/dashboard_helper.rb @@ -21,6 +21,10 @@ module DashboardHelper links.any? { |link| dashboard_nav_link?(link) } end + def has_start_trial? + false + end + private def get_dashboard_nav_links diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index dedc58f482b..36122d3a22a 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -57,12 +57,6 @@ module EmailsHelper pluralize(valid_length, unit) end - def reset_token_expire_message - link_tag = link_to('request a new one', new_user_password_url(user_email: @user.email)) - "This link is valid for #{password_reset_token_valid_time}. " \ - "After it expires, you can #{link_tag}." - end - def header_logo if current_appearance&.header_logo? image_tag( @@ -91,6 +85,29 @@ module EmailsHelper ].join(';') end + def closure_reason_text(closed_via, format: nil) + case closed_via + when MergeRequest + merge_request = MergeRequest.find(closed_via[:id]).present + + case format + when :html + merge_request_link = link_to(merge_request.to_reference, merge_request.web_url) + _("via merge request %{link}").html_safe % { link: merge_request_link } + else + # If it's not HTML nor text then assume it's text to be safe + _("via merge request %{link}") % { link: "#{merge_request.to_reference} (#{merge_request.web_url})" } + end + when String + # Technically speaking this should be Commit but per + # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/15610#note_163812339 + # we can't deserialize Commit without custom serializer for ActiveJob + _("via %{closed_via}") % { closed_via: closed_via } + else + "" + end + end + # "You are receiving this email because #{reason}" def notification_reason_text(reason) string = case reason @@ -131,4 +148,42 @@ module EmailsHelper project.id.to_s + "." + project_path_as_domain + "." + Gitlab.config.gitlab.host end + + def html_header_message + return unless show_header? + + render_message(:header_message, style: '') + end + + def html_footer_message + return unless show_footer? + + render_message(:footer_message, style: '') + end + + def text_header_message + return unless show_header? + + strip_tags(render_message(:header_message, style: '')) + end + + def text_footer_message + return unless show_footer? + + strip_tags(render_message(:footer_message, style: '')) + end + + private + + def show_footer? + email_header_and_footer_enabled? && current_appearance&.show_footer? + end + + def show_header? + email_header_and_footer_enabled? && current_appearance&.show_header? + end + + def email_header_and_footer_enabled? + current_appearance&.email_header_and_footer_enabled? + end end diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 365b94f5a3e..8002eb08ada 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -30,7 +30,8 @@ module EnvironmentsHelper "environments-endpoint": project_environments_path(project, format: :json), "project-path" => project_path(project), "tags-path" => project_tags_path(project), - "has-metrics" => "#{environment.has_metrics?}" + "has-metrics" => "#{environment.has_metrics?}", + "external-dashboard-url" => project.metrics_setting_external_dashboard_url } end end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 1371e9993b4..e990e425cb6 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -68,7 +68,7 @@ module EventsHelper end def event_preposition(event) - if event.push? || event.commented? || event.target + if event.push_action? || event.commented_action? || event.target "at" elsif event.milestone? "in" @@ -80,11 +80,11 @@ module EventsHelper words << event.author_name words << event_action_name(event) - if event.push? + if event.push_action? words << event.ref_type words << event.ref_name words << "at" - elsif event.commented? + elsif event.commented_action? words << event.note_target_reference words << "at" elsif event.milestone? @@ -121,9 +121,9 @@ module EventsHelper if event.note_target event_note_target_url(event) end - elsif event.push? + elsif event.push_action? push_event_feed_url(event) - elsif event.created_project? + elsif event.created_project_action? project_url(event.project) end end @@ -147,7 +147,7 @@ module EventsHelper def event_feed_summary(event) if event.issue? render "events/event_issue", issue: event.issue - elsif event.push? + elsif event.push_action? render "events/event_push", event: event elsif event.merge_request? render "events/event_merge_request", merge_request: event.merge_request diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 5705ee54cee..f7c7f37cc38 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -4,8 +4,7 @@ module FormHelper def form_errors(model, type: 'form') return unless model.errors.any? - pluralized = 'error'.pluralize(model.errors.count) - headline = "The #{type} contains the following #{pluralized}:" + headline = n_('The %{type} contains the following error:', 'The %{type} contains the following errors:', model.errors.count) % { type: type } content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do content_tag(:h4, headline) << @@ -18,19 +17,19 @@ module FormHelper end end - def issue_assignees_dropdown_options - { + def assignees_dropdown_options(issuable_type) + dropdown_data = { toggle_class: 'js-user-search js-assignee-search js-multiselect js-save-user-data', title: 'Select assignee', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee', - placeholder: 'Search users', + placeholder: _('Search users'), data: { first_user: current_user&.username, null_user: true, current_user: true, - project_id: @project&.id, - field_name: 'issue[assignee_ids][]', + project_id: (@target_project || @project)&.id, + field_name: "#{issuable_type}[assignee_ids][]", default_label: 'Unassigned', 'max-select': 1, 'dropdown-header': 'Assignee', @@ -40,5 +39,36 @@ module FormHelper current_user_info: UserSerializer.new.represent(current_user) } } + + type = issuable_type.to_s + + if type == 'issue' && issue_supports_multiple_assignees? || + type == 'merge_request' && merge_request_supports_multiple_assignees? + dropdown_data = multiple_assignees_dropdown_options(dropdown_data) + end + + dropdown_data + end + + # Overwritten + def issue_supports_multiple_assignees? + false + end + + # Overwritten + def merge_request_supports_multiple_assignees? + false + end + + private + + def multiple_assignees_dropdown_options(options) + new_options = options.dup + + new_options[:title] = 'Select assignee(s)' + new_options[:data][:'dropdown-header'] = 'Assignee(s)' + new_options[:data].delete(:'max-select') + + new_options end end diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb new file mode 100644 index 00000000000..a5d2f76820f --- /dev/null +++ b/app/helpers/groups/group_members_helper.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Groups::GroupMembersHelper + def group_member_select_options + { multiple: true, class: 'input-clamp', scope: :all, email_user: true } + end +end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 4a9ed123161..a3f53ca8dd6 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -4,6 +4,7 @@ module GroupsHelper def group_overview_nav_link_paths %w[ groups#show + groups#details groups#activity groups#subgroups analytics#show @@ -98,7 +99,7 @@ module GroupsHelper end def remove_group_message(group) - _("You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?") % + _("You are going to remove %{group_name}, this will also remove all of its subgroups and projects. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?") % { group_name: group.name } end @@ -117,11 +118,12 @@ module GroupsHelper end def parent_group_options(current_group) - groups = current_user.owned_groups.sort_by(&:human_name).map do |group| + exclude_groups = current_group.self_and_descendants.pluck_primary_key + exclude_groups << current_group.parent_id if current_group.parent_id + groups = GroupsFinder.new(current_user, min_access_level: Gitlab::Access::OWNER, exclude_group_ids: exclude_groups).execute.sort_by(&:human_name).map do |group| { id: group.id, text: group.human_name } end - groups.delete_if { |group| group[:id] == current_group.id } groups.to_json end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index af28e6fcb93..9a12db258d5 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -15,11 +15,14 @@ module IssuablesHelper sidebar_gutter_collapsed? ? _('Expand sidebar') : _('Collapse sidebar') end - def sidebar_assignee_tooltip_label(issuable) - if issuable.assignee - issuable.assignee.name + def assignees_label(issuable, include_value: true) + label = 'Assignee'.pluralize(issuable.assignees.count) + + if include_value + sanitized_list = sanitize_name(issuable.assignee_list) + "#{label}: #{sanitized_list}" else - issuable.allows_multiple_assignees? ? _('Assignee(s)') : _('Assignee') + label end end @@ -191,7 +194,7 @@ module IssuablesHelper output << content_tag(:strong) do author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline") - author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-block d-sm-none") + author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-inline d-sm-none") if status = user_status(issuable.author) author_output << "#{status}".html_safe @@ -277,6 +280,8 @@ module IssuablesHelper initialTaskStatus: issuable.task_status } + data[:hasClosingMergeRequest] = issuable.merge_requests_count != 0 if issuable.is_a?(Issue) + if parent.is_a?(Group) data[:groupPath] = parent.path else diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index bd53add80ca..db4f29cd996 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -5,7 +5,7 @@ module LabelsHelper include ActionView::Helpers::TagHelper def show_label_issuables_link?(label, issuables_type, current_user: nil, project: nil) - return true if label.is_a?(GroupLabel) + return true unless label.project_label? return true unless project project.feature_available?(issuables_type, current_user) @@ -13,9 +13,7 @@ module LabelsHelper # Link to a Label # - # label - Label object to link to - # subject - Project/Group object which will be used as the context for the - # label's link. If omitted, defaults to the label's own group/project. + # label - LabelPresenter object to link to # type - The type of item the link will point to (:issue or # :merge_request). If omitted, defaults to :issue. # block - An optional block that will be passed to `link_to`, forming the @@ -40,81 +38,77 @@ module LabelsHelper # link_to_label(label) { "My Custom Label Text" } # # Returns a String - def link_to_label(label, subject: nil, type: :issue, tooltip: true, css_class: nil, &block) - link = label_filter_path(subject || label.subject, label, type: type) + def link_to_label(label, type: :issue, tooltip: true, css_class: nil, &block) + link = label.filter_path(type: type) if block_given? link_to link, class: css_class, &block else - link_to render_colored_label(label, tooltip: tooltip), link, class: css_class + render_label(label, tooltip: tooltip, link: link, css: css_class) end end - def label_filter_path(subject, label, type: :issue) - case subject - when Group - send("#{type.to_s.pluralize}_group_path", # rubocop:disable GitlabSecurity/PublicSend - subject, - label_name: [label.name]) - when Project - send("namespace_project_#{type.to_s.pluralize}_path", # rubocop:disable GitlabSecurity/PublicSend - subject.namespace, - subject, - label_name: [label.name]) - end - end - - def edit_label_path(label) - case label - when GroupLabel then edit_group_label_path(label.group, label) - when ProjectLabel then edit_project_label_path(label.project, label) - end - end + def render_label(label, tooltip: true, link: nil, css: nil) + # if scoped label is used then EE wraps label tag with scoped label + # doc link + html = render_colored_label(label, tooltip: tooltip) + html = link_to(html, link, class: css) if link - def destroy_label_path(label) - case label - when GroupLabel then group_label_path(label.group, label) - when ProjectLabel then project_label_path(label.project, label) - end + html end - def render_colored_label(label, label_suffix = '', tooltip: true) + def render_colored_label(label, label_suffix: '', tooltip: true, title: nil) text_color = text_color_for_bg(label.color) + title ||= tooltip ? label_tooltip_title(label) : label.name # Intentionally not using content_tag here so that this method can be called # by LabelReferenceFilter span = %(<span class="badge color-label #{"has-tooltip" if tooltip}" ) + - %(style="background-color: #{label.color}; color: #{text_color}" ) + - %(title="#{escape_once(label.description)}" data-container="body">) + + %(data-html="true" style="background-color: #{label.color}; color: #{text_color}" ) + + %(title="#{escape_once(title)}" data-container="body">) + %(#{escape_once(label.name)}#{label_suffix}</span>) span.html_safe end + def label_tooltip_title(label) + label.description + end + def suggested_colors - [ - '#0033CC', - '#428BCA', - '#44AD8E', - '#A8D695', - '#5CB85C', - '#69D100', - '#004E00', - '#34495E', - '#7F8C8D', - '#A295D6', - '#5843AD', - '#8E44AD', - '#FFECDB', - '#AD4363', - '#D10069', - '#CC0033', - '#FF0000', - '#D9534F', - '#D1D100', - '#F0AD4E', - '#AD8D43' - ] + { + '#0033CC' => s_('SuggestedColors|UA blue'), + '#428BCA' => s_('SuggestedColors|Moderate blue'), + '#44AD8E' => s_('SuggestedColors|Lime green'), + '#A8D695' => s_('SuggestedColors|Feijoa'), + '#5CB85C' => s_('SuggestedColors|Slightly desaturated green'), + '#69D100' => s_('SuggestedColors|Bright green'), + '#004E00' => s_('SuggestedColors|Very dark lime green'), + '#34495E' => s_('SuggestedColors|Very dark desaturated blue'), + '#7F8C8D' => s_('SuggestedColors|Dark grayish cyan'), + '#A295D6' => s_('SuggestedColors|Slightly desaturated blue'), + '#5843AD' => s_('SuggestedColors|Dark moderate blue'), + '#8E44AD' => s_('SuggestedColors|Dark moderate violet'), + '#FFECDB' => s_('SuggestedColors|Very pale orange'), + '#AD4363' => s_('SuggestedColors|Dark moderate pink'), + '#D10069' => s_('SuggestedColors|Strong pink'), + '#CC0033' => s_('SuggestedColors|Strong red'), + '#FF0000' => s_('SuggestedColors|Pure red'), + '#D9534F' => s_('SuggestedColors|Soft red'), + '#D1D100' => s_('SuggestedColors|Strong yellow'), + '#F0AD4E' => s_('SuggestedColors|Soft orange'), + '#AD8D43' => s_('SuggestedColors|Dark moderate orange') + } + end + + def render_suggested_colors + colors_html = suggested_colors.map do |color_hex_value, color_name| + link_to('', '#', class: "has-tooltip", style: "background-color: #{color_hex_value}", data: { color: color_hex_value }, title: color_name) + end + + content_tag(:div, class: 'suggest-colors') do + colors_html.join.html_safe + end end def text_color_for_bg(bg_color) @@ -154,10 +148,6 @@ module LabelsHelper end end - def can_subscribe_to_label_in_different_levels?(label) - defined?(@project) && label.is_a?(GroupLabel) - end - def label_subscription_status(label, project) return 'group-level' if label.subscribed?(current_user) return 'project-level' if label.subscribed?(current_user, project) @@ -179,13 +169,6 @@ module LabelsHelper label.subscribed?(current_user, project) ? 'Unsubscribe' : 'Subscribe' end - def label_deletion_confirm_text(label) - case label - when GroupLabel then 'Remove this label? This will affect all projects within the group. Are you sure?' - when ProjectLabel then 'Remove this label? Are you sure?' - end - end - def create_label_title(subject) case subject when Group @@ -220,17 +203,52 @@ module LabelsHelper end def label_status_tooltip(label, status) - type = label.is_a?(ProjectLabel) ? 'project' : 'group' + type = label.project_label? ? 'project' : 'group' level = status.unsubscribed? ? type : status.sub('-level', '') action = status.unsubscribed? ? 'Subscribe' : 'Unsubscribe' "#{action} at #{level} level" end - def labels_sorted_by_title(labels) - labels.sort_by(&:title) + def presented_labels_sorted_by_title(labels, subject) + labels.sort_by(&:title).map { |label| label.present(issuable_subject: subject) } + end + + def label_dropdown_data(project, opts = {}) + { + toggle: "dropdown", + field_name: opts[:field_name] || "label_name[]", + show_no: "true", + show_any: "true", + project_id: project&.try(:id), + namespace_path: project&.try(:namespace)&.try(:full_path), + project_path: project&.try(:path) + }.merge(opts) + end + + def sidebar_label_dropdown_data(issuable_type, issuable_sidebar) + label_dropdown_data(nil, { + default_label: "Labels", + field_name: "#{issuable_type}[label_names][]", + ability_name: issuable_type, + namespace_path: issuable_sidebar[:namespace_path], + project_path: issuable_sidebar[:project_path], + issue_update: issuable_sidebar[:issuable_json_path], + labels: issuable_sidebar[:project_labels_path], + display: 'static' + }) + end + + def label_from_hash(hash) + klass = hash[:group_id] ? GroupLabel : ProjectLabel + + klass.new(hash.slice(:color, :description, :title, :group_id, :project_id)) + end + + def issuable_types + ['issues', 'merge requests'] end # Required for Banzai::Filter::LabelReferenceFilter - module_function :render_colored_label, :text_color_for_bg, :escape_once + module_function :render_colored_label, :text_color_for_bg, :escape_once, :label_tooltip_title end diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index 66f4b7b3f30..dce4168ad7b 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -4,7 +4,7 @@ require 'nokogiri' module MarkupHelper include ActionView::Helpers::TagHelper - include ActionView::Context + include ::Gitlab::ActionViewOutput::Context def plain?(filename) Gitlab::MarkupHelper.plain?(filename) @@ -74,7 +74,7 @@ module MarkupHelper # the tag contents are truncated without removing the closing tag. def first_line_in_markdown(object, attribute, max_chars = nil, options = {}) md = markdown_field(object, attribute, options) - return nil unless md.present? + return unless md.present? tags = %w(a gl-emoji b pre code p span) tags << 'img' if options[:allow_images] @@ -83,7 +83,8 @@ module MarkupHelper text = sanitize( text, tags: tags, - attributes: Rails::Html::WhiteListSanitizer.allowed_attributes + ['style', 'data-src', 'data-name', 'data-unicode-version'] + attributes: Rails::Html::WhiteListSanitizer.allowed_attributes + + %w(style data-src data-name data-unicode-version data-iid data-project-path data-mr-title) ) # since <img> tags are stripped, this can leave empty <a> tags hanging around @@ -241,9 +242,7 @@ module MarkupHelper node.remove if node.name == 'a' && node.content.blank? end - # Use `Loofah` directly instead of `sanitize` - # as we still use the `rails-deprecated_sanitizer` gem - Loofah.fragment(text).scrub!(scrubber).to_s + sanitize text, scrubber: scrubber end def markdown_toolbar_button(options = {}) diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 23d7aa427bb..2de4e92e33e 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -29,7 +29,7 @@ module MergeRequestsHelper def ci_build_details_path(merge_request) build_url = merge_request.source_project.ci_service.build_page(merge_request.diff_head_sha, merge_request.source_branch) - return nil unless build_url + return unless build_url parsed_url = URI.parse(build_url) @@ -92,7 +92,7 @@ module MergeRequestsHelper end def version_index(merge_request_diff) - return nil if @merge_request_diffs.empty? + return if @merge_request_diffs.empty? @merge_request_diffs.size - @merge_request_diffs.index(merge_request_diff) end @@ -103,7 +103,7 @@ module MergeRequestsHelper def merge_params(merge_request) { - merge_when_pipeline_succeeds: true, + auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS, should_remove_source_branch: true, sha: merge_request.diff_head_sha, squash: merge_request.squash @@ -149,7 +149,7 @@ module MergeRequestsHelper def merge_request_source_project_for_project(project = @project) unless can?(current_user, :create_merge_request_in, project) - return nil + return end if can?(current_user, :create_merge_request_from, project) diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index 50aec83b867..c1a04640688 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -45,7 +45,7 @@ module MilestonesHelper when :closed issues.closed else - raise ArgumentError, "invalid milestone state `#{state}`" + raise ArgumentError, _("invalid milestone state `%{state}`") % { state: state } end issues.size @@ -145,8 +145,13 @@ module MilestonesHelper content = [] - content << n_("1 open issue", "%d open issues", issues["opened"]) % issues["opened"] if issues["opened"] - content << n_("1 closed issue", "%d closed issues", issues["closed"]) % issues["closed"] if issues["closed"] + if issues["opened"] + content << n_("1 open issue", "%{issues} open issues", issues["opened"]) % { issues: issues["opened"] } + end + + if issues["closed"] + content << n_("1 closed issue", "%{issues} closed issues", issues["closed"]) % { issues: issues["closed"] } + end content.join('<br />').html_safe end @@ -158,9 +163,9 @@ module MilestonesHelper content = [] - content << n_("1 open merge request", "%d open merge requests", merge_requests.opened.count) % merge_requests.opened.count if merge_requests.opened.any? - content << n_("1 closed merge request", "%d closed merge requests", merge_requests.closed.count) % merge_requests.closed.count if merge_requests.closed.any? - content << n_("1 merged merge request", "%d merged merge requests", merge_requests.merged.count) % merge_requests.merged.count if merge_requests.merged.any? + content << n_("1 open merge request", "%{merge_requests} open merge requests", merge_requests.opened.count) % { merge_requests: merge_requests.opened.count } if merge_requests.opened.any? + content << n_("1 closed merge request", "%{merge_requests} closed merge requests", merge_requests.closed.count) % { merge_requests: merge_requests.closed.count } if merge_requests.closed.any? + content << n_("1 merged merge request", "%{merge_requests} merged merge requests", merge_requests.merged.count) % { merge_requests: merge_requests.merged.count } if merge_requests.merged.any? content.join('<br />').html_safe end @@ -178,15 +183,15 @@ module MilestonesHelper "#{milestone.start_date.to_s(:medium)}–#{milestone.due_date.to_s(:medium)}" elsif milestone.due_date if milestone.due_date.past? - "expired on #{milestone.due_date.to_s(:medium)}" + _("expired on %{milestone_due_date}") % { milestone_due_date: milestone.due_date.strftime('%b %-d, %Y') } else - "expires on #{milestone.due_date.to_s(:medium)}" + _("expires on %{milestone_due_date}") % { milestone_due_date: milestone.due_date.strftime('%b %-d, %Y') } end elsif milestone.start_date if milestone.start_date.past? - "started on #{milestone.start_date.to_s(:medium)}" + _("started on %{milestone_start_date}") % { milestone_start_date: milestone.start_date.strftime('%b %-d, %Y') } else - "starts on #{milestone.start_date.to_s(:medium)}" + _("starts on %{milestone_start_date}") % { milestone_start_date: milestone.start_date.strftime('%b %-d, %Y') } end end end diff --git a/app/helpers/mirror_helper.rb b/app/helpers/mirror_helper.rb index 65c7cd82832..921c79ab771 100644 --- a/app/helpers/mirror_helper.rb +++ b/app/helpers/mirror_helper.rb @@ -7,4 +7,8 @@ module MirrorHelper project_mirror_endpoint: project_mirror_path(@project, :json) } end + + def mirror_lfs_sync_message + _('The Git LFS objects will <strong>not</strong> be synced.').html_safe + end end diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index ea3bcfc791a..572d68cb4a3 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -49,6 +49,13 @@ module NamespacesHelper end end + def namespaces_options_with_developer_maintainer_access(options = {}) + selected = options.delete(:selected) || :current_user + options[:groups] = current_user.manageable_groups_with_routes(include_groups_with_developer_maintainer_access: true) + + namespaces_options(selected, options) + end + private # Many importers create a temporary Group, so use the real diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 05da5ebdb22..a57ba5f3a4f 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -58,6 +58,14 @@ module NavHelper current_path?('milestones#show') end + def admin_monitoring_nav_links + %w(system_info background_jobs logs health_check requests_profiles) + end + + def group_issues_sub_menu_items + %w(groups#issues labels#index milestones#index boards#index boards#show) + end + private def get_header_links diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index aaf38cbfe70..2e31a5e2ed4 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -122,21 +122,15 @@ module NotesHelper end def new_form_url - return nil unless @snippet.is_a?(PersonalSnippet) + return unless @snippet.is_a?(PersonalSnippet) snippet_notes_path(@snippet) end def can_create_note? - issuable = @issue || @merge_request + noteable = @issue || @merge_request || @snippet || @project - if @snippet.is_a?(PersonalSnippet) - can?(current_user, :comment_personal_snippet, @snippet) - elsif issuable - can?(current_user, :create_note, issuable) - else - can?(current_user, :create_note, @project) - end + can?(current_user, :create_note, noteable) end def initial_notes_data(autocomplete) diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 5318ab4ddef..11b9cf22142 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -93,4 +93,15 @@ module NotificationsHelper s_(event.to_s.humanize) end end + + def notification_setting_icon(notification_setting) + sprite_icon( + notification_setting.disabled? ? "notifications-off" : "notifications", + css_class: "icon notifications-icon js-notifications-icon" + ) + end + + def show_unsubscribe_title?(noteable) + can?(current_user, "read_#{noteable.to_ability_name}".to_sym, noteable) + end end diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb index 5038dcf9746..ec1d8577f36 100644 --- a/app/helpers/page_layout_helper.rb +++ b/app/helpers/page_layout_helper.rb @@ -1,3 +1,4 @@ +# coding: utf-8 # frozen_string_literal: true module PageLayoutHelper @@ -36,7 +37,7 @@ module PageLayoutHelper if description.present? @page_description = description.squish elsif @page_description.present? - sanitize(@page_description, tags: []).truncate_words(30) + sanitize(@page_description.truncate_words(30), tags: []) end end diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index eed529f93db..766508b6609 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -46,7 +46,8 @@ module PreferencesHelper def first_day_of_week_choices [ [_('Sunday'), 0], - [_('Monday'), 1] + [_('Monday'), 1], + [_('Saturday'), 6] ] end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index c400302cda3..8dee842a22d 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -169,7 +169,7 @@ module ProjectsHelper translation.html_safe end - def project_list_cache_key(project) + def project_list_cache_key(project, pipeline_status: true) key = [ project.route.cache_key, project.cache_key, @@ -179,10 +179,11 @@ module ProjectsHelper Gitlab::CurrentSettings.cache_key, "cross-project:#{can?(current_user, :read_cross_project)}", max_project_member_access_cache_key(project), + pipeline_status, 'v2.6' ] - key << pipeline_status_cache_key(project.pipeline_status) if project.pipeline_status.has_status? + key << pipeline_status_cache_key(project.pipeline_status) if pipeline_status && project.pipeline_status.has_status? key end @@ -238,8 +239,11 @@ module ProjectsHelper end # rubocop: enable CodeReuse/ActiveRecord + # TODO: Remove this method when removing the feature flag + # https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/11209#note_162234863 + # make sure to remove from the EE specific controller as well: ee/app/controllers/ee/dashboard/projects_controller.rb def show_projects?(projects, params) - !!(params[:personal] || params[:name] || any_projects?(projects)) + Feature.enabled?(:project_list_filter_bar) || !!(params[:personal] || params[:name] || any_projects?(projects)) end def push_to_create_project_command(user = current_user) @@ -284,13 +288,74 @@ module ProjectsHelper can?(current_user, :read_environment, @project) end + def error_tracking_setting_project_json + setting = @project.error_tracking_setting + + return if setting.blank? || setting.project_slug.blank? || + setting.organization_slug.blank? + + { + name: setting.project_name, + organization_name: setting.organization_name, + organization_slug: setting.organization_slug, + slug: setting.project_slug + }.to_json + end + + def directory? + @path.present? + end + + def external_classification_label_help_message + default_label = ::Gitlab::CurrentSettings.current_application_settings + .external_authorization_service_default_label + + s_( + "ExternalAuthorizationService|When no classification label is set the "\ + "default label `%{default_label}` will be used." + ) % { default_label: default_label } + end + + def can_import_members? + Ability.allowed?(current_user, :admin_project_member, @project) + end + + def project_can_be_shared? + !membership_locked? || @project.allowed_to_share_with_group? + end + + def membership_locked? + false + end + + def share_project_description(project) + share_with_group = project.allowed_to_share_with_group? + share_with_members = !membership_locked? + + description = + if share_with_group && share_with_members + _("You can invite a new member to <strong>%{project_name}</strong> or invite another group.") + elsif share_with_group + _("You can invite another group to <strong>%{project_name}</strong>.") + elsif share_with_members + _("You can invite a new member to <strong>%{project_name}</strong>.") + end + + description.html_safe % { project_name: project.name } + end + + def metrics_external_dashboard_url + @project.metrics_setting_external_dashboard_url + end + private def get_project_nav_tabs(project, current_user) nav_tabs = [:home] - if !project.empty_repo? && can?(current_user, :download_code, project) - nav_tabs << [:files, :commits, :network, :graphs, :forks, :releases] + unless project.empty_repo? + nav_tabs << [:files, :commits, :network, :graphs, :forks] if can?(current_user, :download_code, project) + nav_tabs << :releases if can?(current_user, :read_release, project) end if project.repo_exists? && can?(current_user, :read_merge_request, project) @@ -350,7 +415,8 @@ module ProjectsHelper blobs: :download_code, commits: :download_code, merge_requests: :read_merge_request, - notes: [:read_merge_request, :download_code, :read_issue, :read_project_snippet] + notes: [:read_merge_request, :download_code, :read_issue, :read_project_snippet], + members: :read_project_member ) end @@ -594,4 +660,8 @@ module ProjectsHelper project.builds_enabled? && !project.repository.gitlab_ci_yml end + + def vue_file_list_enabled? + Gitlab::Graphql.enabled? && Feature.enabled?(:vue_file_list, @project) + end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 0ee76a51f7d..4594f5a31b9 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -30,13 +30,18 @@ module SearchHelper to = collection.offset_value + collection.to_a.size count = collection.total_count - "Showing #{from} - #{to} of #{count} #{scope.humanize(capitalize: false)} for \"#{term}\"" + s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for \"%{term}\"") % { from: from, to: to, count: count, scope: scope.humanize(capitalize: false), term: term } end - def find_project_for_result_blob(result) + def find_project_for_result_blob(projects, result) @project end + # Used in EE + def blob_projects(results) + nil + end + def parse_search_result(result) result end @@ -45,36 +50,40 @@ module SearchHelper filename end + def search_service + @search_service ||= ::SearchService.new(current_user, params) + end + private # Autocomplete results for various settings pages def default_autocomplete [ - { category: "Settings", label: "User settings", url: profile_path }, - { category: "Settings", label: "SSH Keys", url: profile_keys_path }, - { category: "Settings", label: "Dashboard", url: root_path } + { category: "Settings", label: _("User settings"), url: profile_path }, + { category: "Settings", label: _("SSH Keys"), url: profile_keys_path }, + { category: "Settings", label: _("Dashboard"), url: root_path } ] end # Autocomplete results for settings pages, for admins def default_autocomplete_admin [ - { category: "Settings", label: "Admin Section", url: admin_root_path } + { category: "Settings", label: _("Admin Section"), url: admin_root_path } ] end # Autocomplete results for internal help pages def help_autocomplete [ - { category: "Help", label: "API Help", url: help_page_path("api/README") }, - { category: "Help", label: "Markdown Help", url: help_page_path("user/markdown") }, - { category: "Help", label: "Permissions Help", url: help_page_path("user/permissions") }, - { category: "Help", label: "Public Access Help", url: help_page_path("public_access/public_access") }, - { category: "Help", label: "Rake Tasks Help", url: help_page_path("raketasks/README") }, - { category: "Help", label: "SSH Keys Help", url: help_page_path("ssh/README") }, - { category: "Help", label: "System Hooks Help", url: help_page_path("system_hooks/system_hooks") }, - { category: "Help", label: "Webhooks Help", url: help_page_path("user/project/integrations/webhooks") }, - { category: "Help", label: "Workflow Help", url: help_page_path("workflow/README") } + { category: "Help", label: _("API Help"), url: help_page_path("api/README") }, + { category: "Help", label: _("Markdown Help"), url: help_page_path("user/markdown") }, + { category: "Help", label: _("Permissions Help"), url: help_page_path("user/permissions") }, + { category: "Help", label: _("Public Access Help"), url: help_page_path("public_access/public_access") }, + { category: "Help", label: _("Rake Tasks Help"), url: help_page_path("raketasks/README") }, + { category: "Help", label: _("SSH Keys Help"), url: help_page_path("ssh/README") }, + { category: "Help", label: _("System Hooks Help"), url: help_page_path("system_hooks/system_hooks") }, + { category: "Help", label: _("Webhooks Help"), url: help_page_path("user/project/integrations/webhooks") }, + { category: "Help", label: _("Workflow Help"), url: help_page_path("workflow/README") } ] end @@ -84,16 +93,16 @@ module SearchHelper ref = @ref || @project.repository.root_ref [ - { category: "In this project", label: "Files", url: project_tree_path(@project, ref) }, - { category: "In this project", label: "Commits", url: project_commits_path(@project, ref) }, - { category: "In this project", label: "Network", url: project_network_path(@project, ref) }, - { category: "In this project", label: "Graph", url: project_graph_path(@project, ref) }, - { category: "In this project", label: "Issues", url: project_issues_path(@project) }, - { category: "In this project", label: "Merge Requests", url: project_merge_requests_path(@project) }, - { category: "In this project", label: "Milestones", url: project_milestones_path(@project) }, - { category: "In this project", label: "Snippets", url: project_snippets_path(@project) }, - { category: "In this project", label: "Members", url: project_project_members_path(@project) }, - { category: "In this project", label: "Wiki", url: project_wikis_path(@project) } + { category: "In this project", label: _("Files"), url: project_tree_path(@project, ref) }, + { category: "In this project", label: _("Commits"), url: project_commits_path(@project, ref) }, + { category: "In this project", label: _("Network"), url: project_network_path(@project, ref) }, + { category: "In this project", label: _("Graph"), url: project_graph_path(@project, ref) }, + { category: "In this project", label: _("Issues"), url: project_issues_path(@project) }, + { category: "In this project", label: _("Merge Requests"), url: project_merge_requests_path(@project) }, + { category: "In this project", label: _("Milestones"), url: project_milestones_path(@project) }, + { category: "In this project", label: _("Snippets"), url: project_snippets_path(@project) }, + { category: "In this project", label: _("Members"), url: project_project_members_path(@project) }, + { category: "In this project", label: _("Wiki"), url: project_wikis_path(@project) } ] else [] @@ -119,7 +128,7 @@ module SearchHelper # rubocop: disable CodeReuse/ActiveRecord def projects_autocomplete(term, limit = 5) current_user.authorized_projects.order_id_desc.search_by_title(term) - .sorted_by_stars.non_archived.limit(limit).map do |p| + .sorted_by_stars_desc.non_archived.limit(limit).map do |p| { category: "Projects", id: p.id, @@ -153,7 +162,7 @@ module SearchHelper opts = { id: "filtered-search-#{type}", - placeholder: 'Search or filter results...', + placeholder: _('Search or filter results...'), data: { 'username-params' => UserSerializer.new.represent(@users) }, @@ -201,4 +210,14 @@ module SearchHelper def limited_count(count, limit = 1000) count > limit ? "#{limit}+" : count end + + def search_tabs?(tab) + return false if Feature.disabled?(:users_search, default_enabled: true) + + if @project + project_search_tabs?(:members) + else + can?(current_user, :read_users_list) + end + end end diff --git a/app/helpers/sidekiq_helper.rb b/app/helpers/sidekiq_helper.rb index 32bf3526571..6326d98461e 100644 --- a/app/helpers/sidekiq_helper.rb +++ b/app/helpers/sidekiq_helper.rb @@ -8,7 +8,7 @@ module SidekiqHelper (?<state>[DIEKNRSTVWXZNLpsl\+<>/\d]+)\s+ (?<start>.+?)\s+ (?<command>(?:ruby\d+:\s+)?sidekiq.*\].*) - \z}x + \z}x.freeze def parse_sidekiq_ps(line) match = line.strip.match(SIDEKIQ_PS_REGEXP) diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 07ec129dea3..26692934456 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -3,40 +3,48 @@ module SortingHelper def sort_options_hash { - sort_value_created_date => sort_title_created_date, - sort_value_downvotes => sort_title_downvotes, - sort_value_due_date => sort_title_due_date, - sort_value_due_date_later => sort_title_due_date_later, - sort_value_due_date_soon => sort_title_due_date_soon, - sort_value_label_priority => sort_title_label_priority, - sort_value_largest_group => sort_title_largest_group, - sort_value_largest_repo => sort_title_largest_repo, - sort_value_milestone => sort_title_milestone, - sort_value_milestone_later => sort_title_milestone_later, - sort_value_milestone_soon => sort_title_milestone_soon, - sort_value_name => sort_title_name, - sort_value_name_desc => sort_title_name_desc, - sort_value_oldest_created => sort_title_oldest_created, - sort_value_oldest_signin => sort_title_oldest_signin, - sort_value_oldest_updated => sort_title_oldest_updated, - sort_value_recently_created => sort_title_recently_created, - sort_value_recently_signin => sort_title_recently_signin, - sort_value_recently_updated => sort_title_recently_updated, - sort_value_popularity => sort_title_popularity, - sort_value_priority => sort_title_priority, - sort_value_upvotes => sort_title_upvotes, - sort_value_contacted_date => sort_title_contacted_date + sort_value_created_date => sort_title_created_date, + sort_value_downvotes => sort_title_downvotes, + sort_value_due_date => sort_title_due_date, + sort_value_due_date_later => sort_title_due_date_later, + sort_value_due_date_soon => sort_title_due_date_soon, + sort_value_label_priority => sort_title_label_priority, + sort_value_largest_group => sort_title_largest_group, + sort_value_largest_repo => sort_title_largest_repo, + sort_value_milestone => sort_title_milestone, + sort_value_milestone_later => sort_title_milestone_later, + sort_value_milestone_soon => sort_title_milestone_soon, + sort_value_name => sort_title_name, + sort_value_name_desc => sort_title_name_desc, + sort_value_oldest_created => sort_title_oldest_created, + sort_value_oldest_signin => sort_title_oldest_signin, + sort_value_oldest_updated => sort_title_oldest_updated, + sort_value_recently_created => sort_title_recently_created, + sort_value_recently_signin => sort_title_recently_signin, + sort_value_recently_updated => sort_title_recently_updated, + sort_value_popularity => sort_title_popularity, + sort_value_priority => sort_title_priority, + sort_value_upvotes => sort_title_upvotes, + sort_value_contacted_date => sort_title_contacted_date, + sort_value_relative_position => sort_title_relative_position } end def projects_sort_options_hash + Feature.enabled?(:project_list_filter_bar) && !current_controller?('admin/projects') ? projects_sort_common_options_hash : old_projects_sort_options_hash + end + + # TODO: Simplify these sorting options + # https://gitlab.com/gitlab-org/gitlab-ce/issues/60798 + # https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/11209#note_162234858 + def old_projects_sort_options_hash options = { sort_value_latest_activity => sort_title_latest_activity, sort_value_name => sort_title_name, sort_value_oldest_activity => sort_title_oldest_activity, sort_value_oldest_created => sort_title_oldest_created, sort_value_recently_created => sort_title_recently_created, - sort_value_most_stars => sort_title_most_stars + sort_value_stars_desc => sort_title_most_stars } if current_controller?('admin/projects') @@ -46,6 +54,41 @@ module SortingHelper options end + def projects_sort_common_options_hash + { + sort_value_latest_activity => sort_title_latest_activity, + sort_value_recently_created => sort_title_created_date, + sort_value_name => sort_title_name, + sort_value_stars_desc => sort_title_stars + } + end + + def projects_sort_option_titles + { + sort_value_latest_activity => sort_title_latest_activity, + sort_value_recently_created => sort_title_created_date, + sort_value_name => sort_title_name, + sort_value_stars_desc => sort_title_stars, + sort_value_oldest_activity => sort_title_latest_activity, + sort_value_oldest_created => sort_title_created_date, + sort_value_name_desc => sort_title_name, + sort_value_stars_asc => sort_title_stars + } + end + + def projects_reverse_sort_options_hash + { + sort_value_latest_activity => sort_value_oldest_activity, + sort_value_recently_created => sort_value_oldest_created, + sort_value_name => sort_value_name_desc, + sort_value_stars_desc => sort_value_stars_asc, + sort_value_oldest_activity => sort_value_latest_activity, + sort_value_oldest_created => sort_value_recently_created, + sort_value_name_desc => sort_value_name, + sort_value_stars_asc => sort_value_stars_desc + } + end + def groups_sort_options_hash { sort_value_name => sort_title_name, @@ -59,7 +102,7 @@ module SortingHelper def subgroups_sort_options_hash groups_sort_options_hash.merge( - sort_value_most_stars => sort_title_most_stars + sort_value_stars_desc => sort_title_most_stars ) end @@ -142,7 +185,9 @@ module SortingHelper { sort_value_oldest_created => sort_value_created_date, sort_value_oldest_updated => sort_value_recently_updated, - sort_value_milestone_later => sort_value_milestone + sort_value_milestone_later => sort_value_milestone, + sort_value_due_date_later => sort_value_due_date, + sort_value_least_popular => sort_value_popularity } end @@ -151,7 +196,11 @@ module SortingHelper sort_value_created_date => sort_value_oldest_created, sort_value_recently_created => sort_value_oldest_created, sort_value_recently_updated => sort_value_oldest_updated, - sort_value_milestone => sort_value_milestone_later + sort_value_milestone => sort_value_milestone_later, + sort_value_due_date => sort_value_due_date_later, + sort_value_due_date_soon => sort_value_due_date_later, + sort_value_popularity => sort_value_least_popular, + sort_value_most_popular => sort_value_least_popular }.merge(issuable_sort_option_overrides) end @@ -170,6 +219,8 @@ module SortingHelper end end + # TODO: dedupicate issuable and project sort direction + # https://gitlab.com/gitlab-org/gitlab-ce/issues/60798 def issuable_sort_direction_button(sort_value) link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort' reverse_sort = issuable_reverse_sort_order_hash[sort_value] @@ -181,7 +232,23 @@ module SortingHelper link_class += ' disabled' end - link_to(reverse_url, type: 'button', class: link_class, title: 'Sort direction') do + link_to(reverse_url, type: 'button', class: link_class, title: s_('SortOptions|Sort direction')) do + sprite_icon("sort-#{issuable_sort_icon_suffix(sort_value)}", size: 16) + end + end + + def project_sort_direction_button(sort_value) + link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort' + reverse_sort = projects_reverse_sort_options_hash[sort_value] + + if reverse_sort + reverse_url = filter_projects_path(sort: reverse_sort) + else + reverse_url = '#' + link_class += ' disabled' + end + + link_to(reverse_url, type: 'button', class: link_class, title: s_('SortOptions|Sort direction')) do sprite_icon("sort-#{issuable_sort_icon_suffix(sort_value)}", size: 16) end end @@ -319,6 +386,10 @@ module SortingHelper s_('SortOptions|Most stars') end + def sort_title_stars + s_('SortOptions|Stars') + end + def sort_title_oldest_last_activity s_('SortOptions|Oldest last activity') end @@ -327,6 +398,10 @@ module SortingHelper s_('SortOptions|Recent last activity') end + def sort_title_relative_position + s_('SortOptions|Manual') + end + # Values. def sort_value_access_level_asc 'access_level_asc' @@ -420,6 +495,14 @@ module SortingHelper 'popularity' end + def sort_value_most_popular + 'popularity_desc' + end + + def sort_value_least_popular + 'popularity_asc' + end + def sort_value_priority 'priority' end @@ -452,10 +535,14 @@ module SortingHelper 'contacted_asc' end - def sort_value_most_stars + def sort_value_stars_desc 'stars_desc' end + def sort_value_stars_asc + 'stars_asc' + end + def sort_value_oldest_last_activity 'last_activity_on_asc' end @@ -463,4 +550,8 @@ module SortingHelper def sort_value_recently_last_activity 'last_activity_on_desc' end + + def sort_value_relative_position + 'relative_position' + end end diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb index be8761db562..ecf37bae6b3 100644 --- a/app/helpers/storage_helper.rb +++ b/app/helpers/storage_helper.rb @@ -2,8 +2,21 @@ module StorageHelper def storage_counter(size_in_bytes) + return s_('StorageSize|Unknown') unless size_in_bytes + precision = size_in_bytes < 1.megabyte ? 0 : 1 number_to_human_size(size_in_bytes, delimiter: ',', precision: precision, significant: false) end + + def storage_counters_details(statistics) + counters = { + counter_repositories: storage_counter(statistics.repository_size), + counter_wikis: storage_counter(statistics.wiki_size), + counter_build_artifacts: storage_counter(statistics.build_artifacts_size), + counter_lfs_objects: storage_counter(statistics.lfs_objects_size) + } + + _("%{counter_repositories} repositories, %{counter_wikis} wikis, %{counter_build_artifacts} build artifacts, %{counter_lfs_objects} LFS") % counters + end end diff --git a/app/helpers/tracking_helper.rb b/app/helpers/tracking_helper.rb new file mode 100644 index 00000000000..51ea79d1ddd --- /dev/null +++ b/app/helpers/tracking_helper.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module TrackingHelper + def tracking_attrs(label, event, property) + {} # CE has no tracking features + end +end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index e2879bfdcf1..4690b6ffbe1 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -86,17 +86,17 @@ module TreeHelper end def edit_in_new_fork_notice_now - "You're not allowed to make changes to this project directly." + - " A fork of this project is being created that you can make changes in, so you can submit a merge request." + _("You're not allowed to make changes to this project directly. "\ + "A fork of this project is being created that you can make changes in, so you can submit a merge request.") end def edit_in_new_fork_notice - "You're not allowed to make changes to this project directly." + - " A fork of this project has been created that you can make changes in, so you can submit a merge request." + _("You're not allowed to make changes to this project directly. "\ + "A fork of this project has been created that you can make changes in, so you can submit a merge request.") end def edit_in_new_fork_notice_action(action) - edit_in_new_fork_notice + " Try to #{action} this file again." + edit_in_new_fork_notice + _(" Try to %{action} this file again.") % { action: action } end def commit_in_fork_help @@ -136,18 +136,9 @@ module TreeHelper end # returns the relative path of the first subdir that doesn't have only one directory descendant - # rubocop: disable CodeReuse/ActiveRecord def flatten_tree(root_path, tree) - return tree.flat_path.sub(%r{\A#{Regexp.escape(root_path)}/}, '') if tree.flat_path.present? - - subtree = Gitlab::Git::Tree.where(@repository, @commit.id, tree.path) - if subtree.count == 1 && subtree.first.dir? - return tree_join(tree.name, flatten_tree(root_path, subtree.first)) - else - return tree.name - end + tree.flat_path.sub(%r{\A#{Regexp.escape(root_path)}/}, '') end - # rubocop: enable CodeReuse/ActiveRecord def selected_branch @branch_name || tree_edit_branch diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index 1ad7bb81784..5d658d35107 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -17,6 +17,9 @@ module UserCalloutsHelper render 'shared/flash_user_callout', flash_type: flash_type, message: message, feature_name: feature_name end + def render_dashboard_gold_trial(user) + end + private def user_dismissed?(feature_name) diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index 712f0f808dd..b318b27992a 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -42,11 +42,11 @@ module VisibilityLevelHelper def group_visibility_level_description(level) case level when Gitlab::VisibilityLevel::PRIVATE - "The group and its projects can only be viewed by members." + _("The group and its projects can only be viewed by members.") when Gitlab::VisibilityLevel::INTERNAL - "The group and any internal projects can be viewed by any logged in user." + _("The group and any internal projects can be viewed by any logged in user.") when Gitlab::VisibilityLevel::PUBLIC - "The group and any public projects can be viewed without any authentication." + _("The group and any public projects can be viewed without any authentication.") end end @@ -54,20 +54,20 @@ module VisibilityLevelHelper case level when Gitlab::VisibilityLevel::PRIVATE if snippet.is_a? ProjectSnippet - "The snippet is visible only to project members." + _("The snippet is visible only to project members.") else - "The snippet is visible only to me." + _("The snippet is visible only to me.") end when Gitlab::VisibilityLevel::INTERNAL - "The snippet is visible to any logged in user." + _("The snippet is visible to any logged in user.") when Gitlab::VisibilityLevel::PUBLIC - "The snippet can be accessed without any authentication." + _("The snippet can be accessed without any authentication.") end end def restricted_visibility_level_description(level) level_name = Gitlab::VisibilityLevel.level_name(level) - "#{level_name.capitalize} visibility has been restricted by the administrator." + _("%{level_name} visibility has been restricted by the administrator.") % { level_name: level_name.capitalize } end def disallowed_visibility_level_description(level, form_model) @@ -165,8 +165,46 @@ module VisibilityLevelHelper !form_model.visibility_level_allowed?(level) end + # Visibility level can be restricted in two ways: + # + # 1. The group permissions (e.g. a subgroup is private, which requires + # all projects to be private) + # 2. The global allowed visibility settings, set by the admin + def selected_visibility_level(form_model, requested_level) + requested_level = + if requested_level.present? + requested_level.to_i + else + default_project_visibility + end + + [requested_level, max_allowed_visibility_level(form_model)].min + end + private + def max_allowed_visibility_level(form_model) + # First obtain the maximum visibility for the project or group + current_level = max_allowed_visibility_level_by_model(form_model) + + # Now limit this by the global setting + Gitlab::VisibilityLevel.closest_allowed_level(current_level) + end + + def max_allowed_visibility_level_by_model(form_model) + current_level = Gitlab::VisibilityLevel::PRIVATE + + Gitlab::VisibilityLevel.values.sort.each do |value| + if disallowed_visibility_level?(form_model, value) + break + else + current_level = value + end + end + + current_level + end + def visibility_level_errors_for_group(group, level_name) group_name = link_to group.name, group_path(group) change_visiblity = link_to 'change the visibility', edit_group_path(group) diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb index 647f34e57ed..edd48f82729 100644 --- a/app/helpers/wiki_helper.rb +++ b/app/helpers/wiki_helper.rb @@ -47,4 +47,24 @@ module WikiHelper def wiki_attachment_upload_url expose_url(api_v4_projects_wikis_attachments_path(id: @project.id)) end + + def wiki_sort_controls(project, sort, direction) + sort ||= ProjectWiki::TITLE_ORDER + link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort' + reversed_direction = direction == 'desc' ? 'asc' : 'desc' + icon_class = direction == 'desc' ? 'highest' : 'lowest' + + link_to(project_wikis_pages_path(project, sort: sort, direction: reversed_direction), + type: 'button', class: link_class, title: _('Sort direction')) do + sprite_icon("sort-#{icon_class}", size: 16) + end + end + + def wiki_sort_title(key) + if key == ProjectWiki::CREATED_AT_ORDER + s_("Wiki|Created date") + else + s_("Wiki|Title") + end + end end diff --git a/app/mailers/abuse_report_mailer.rb b/app/mailers/abuse_report_mailer.rb index e032f568913..e0aa66e6de3 100644 --- a/app/mailers/abuse_report_mailer.rb +++ b/app/mailers/abuse_report_mailer.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class AbuseReportMailer < BaseMailer + layout 'empty_mailer' + + helper EmailsHelper + def notify(abuse_report_id) return unless deliverable? diff --git a/app/mailers/devise_mailer.rb b/app/mailers/devise_mailer.rb index 7aa75ee30e6..cbaf53fced1 100644 --- a/app/mailers/devise_mailer.rb +++ b/app/mailers/devise_mailer.rb @@ -7,6 +7,7 @@ class DeviseMailer < Devise::Mailer layout 'mailer/devise' helper EmailsHelper + helper ApplicationHelper protected diff --git a/app/mailers/email_rejection_mailer.rb b/app/mailers/email_rejection_mailer.rb index 45fc5a6c383..d743533b1bc 100644 --- a/app/mailers/email_rejection_mailer.rb +++ b/app/mailers/email_rejection_mailer.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class EmailRejectionMailer < BaseMailer + layout 'empty_mailer' + + helper EmailsHelper + def rejection(reason, original_raw, can_retry = false) @reason = reason @original_message = Mail::Message.new(original_raw) diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 654ae211310..f3a3203f7ad 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -30,8 +30,8 @@ module Emails end # rubocop: enable CodeReuse/ActiveRecord - def closed_issue_email(recipient_id, issue_id, updated_by_user_id, reason = nil) - setup_issue_mail(issue_id, recipient_id) + def closed_issue_email(recipient_id, issue_id, updated_by_user_id, reason: nil, closed_via: nil) + setup_issue_mail(issue_id, recipient_id, closed_via: closed_via) @updated_by = User.find(updated_by_user_id) mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason)) @@ -74,6 +74,7 @@ module Emails @new_issue = new_issue @new_project = new_issue.project + @can_access_project = recipient.can?(:read_project, @new_project) mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason)) end @@ -82,7 +83,7 @@ module Emails @project = Project.find(project_id) @results = results - mail(to: @user.notification_email, subject: subject('Imported issues')) do |format| + mail(to: recipient(@user.id, @project.group), subject: subject('Imported issues')) do |format| format.html { render layout: 'mailer' } format.text { render layout: 'mailer' } end @@ -90,10 +91,11 @@ module Emails private - def setup_issue_mail(issue_id, recipient_id) + def setup_issue_mail(issue_id, recipient_id, closed_via: nil) @issue = Issue.find(issue_id) @project = @issue.project @target_url = project_issue_url(@project, @issue) + @closed_via = closed_via @sent_notification = SentNotification.record(@issue, recipient_id, reply_key) end @@ -101,7 +103,7 @@ module Emails def issue_thread_options(sender_id, recipient_id, reason) { from: sender(sender_id), - to: recipient(recipient_id), + to: recipient(recipient_id, @project.group), subject: subject("#{@issue.title} (##{@issue.iid})"), 'X-GitLab-NotificationReason' => reason } diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 91dfdf58982..2bfa59774d7 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -58,9 +58,8 @@ module Emails @member_source_type = member_source_type @member_source = member_source_class.find(source_id) @invite_email = invite_email - inviter = User.find(created_by_id) - mail(to: inviter.notification_email, + mail(to: recipient(created_by_id, member_source_type == 'Project' ? @member_source.group : @member_source), subject: subject('Invitation declined')) end diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 9ba8f92fcbf..864f9e2975a 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -24,10 +24,12 @@ module Emails end # rubocop: disable CodeReuse/ActiveRecord - def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_id, updated_by_user_id, reason = nil) + def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_ids, updated_by_user_id, reason = nil) setup_merge_request_mail(merge_request_id, recipient_id) - @previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id + @previous_assignees = [] + @previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any? + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) end # rubocop: enable CodeReuse/ActiveRecord @@ -56,14 +58,14 @@ module Emails })) end - def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil) + def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason: nil, closed_via: nil) setup_merge_request_mail(merge_request_id, recipient_id) @updated_by = User.find(updated_by_user_id) mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) end - def merged_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil) + def merged_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason: nil, closed_via: nil) setup_merge_request_mail(merge_request_id, recipient_id) mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) @@ -108,7 +110,7 @@ module Emails def merge_request_thread_options(sender_id, recipient_id, reason = nil) { from: sender(sender_id), - to: recipient(recipient_id), + to: recipient(recipient_id, @project.group), subject: subject("#{@merge_request.title} (#{@merge_request.to_reference})"), 'X-GitLab-NotificationReason' => reason } diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb index 1b3c1f9a8a9..70d296fe3b8 100644 --- a/app/mailers/emails/notes.rb +++ b/app/mailers/emails/notes.rb @@ -51,7 +51,7 @@ module Emails def note_thread_options(recipient_id) { from: sender(@note.author_id), - to: recipient(recipient_id), + to: recipient(recipient_id, @group), subject: subject("#{@note.noteable.title} (#{@note.noteable.reference_link_text})") } end diff --git a/app/mailers/emails/pages_domains.rb b/app/mailers/emails/pages_domains.rb index ce449237ef6..2d390666f65 100644 --- a/app/mailers/emails/pages_domains.rb +++ b/app/mailers/emails/pages_domains.rb @@ -7,7 +7,7 @@ module Emails @project = domain.project mail( - to: recipient.notification_email, + to: recipient(recipient.id, @project.group), subject: subject("GitLab Pages domain '#{domain.domain}' has been enabled") ) end @@ -17,7 +17,7 @@ module Emails @project = domain.project mail( - to: recipient.notification_email, + to: recipient(recipient.id, @project.group), subject: subject("GitLab Pages domain '#{domain.domain}' has been disabled") ) end @@ -27,7 +27,7 @@ module Emails @project = domain.project mail( - to: recipient.notification_email, + to: recipient(recipient.id, @project.group), subject: subject("Verification succeeded for GitLab Pages domain '#{domain.domain}'") ) end @@ -37,7 +37,7 @@ module Emails @project = domain.project mail( - to: recipient.notification_email, + to: recipient(recipient.id, @project.group), subject: subject("ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'") ) end diff --git a/app/mailers/emails/pipelines.rb b/app/mailers/emails/pipelines.rb index 31e183640ad..fb57c0da34d 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.first + @merge_request = pipeline.merge_requests_as_head_pipeline.first add_headers # We use bcc here because we don't want to generate this emails for a diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 2500622caa7..f81f76f67f7 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -7,20 +7,20 @@ module Emails @project = Project.find project_id @target_url = project_url(@project) @old_path_with_namespace = old_path_with_namespace - mail(to: @user.notification_email, + mail(to: recipient(user_id, @project.group), subject: subject("Project was moved")) end def project_was_exported_email(current_user, project) @project = project - mail(to: current_user.notification_email, + mail(to: recipient(current_user.id, project.group), subject: subject("Project was exported")) end def project_was_not_exported_email(current_user, project, errors) @project = project @errors = errors - mail(to: current_user.notification_email, + mail(to: recipient(current_user.id, @project.group), subject: subject("Project export error")) end @@ -28,7 +28,7 @@ module Emails @project = project @user = user - mail(to: user.notification_email, subject: subject("Project cleanup has completed")) + mail(to: recipient(user.id, project.group), subject: subject("Project cleanup has completed")) end def repository_cleanup_failure_email(project, user, error) @@ -36,7 +36,7 @@ module Emails @user = user @error = error - mail(to: user.notification_email, subject: subject("Project cleanup failure")) + mail(to: recipient(user.id, project.group), subject: subject("Project cleanup failure")) end def repository_push_email(project_id, opts = {}) diff --git a/app/mailers/emails/remote_mirrors.rb b/app/mailers/emails/remote_mirrors.rb index 2018eb7260b..2d8137843ec 100644 --- a/app/mailers/emails/remote_mirrors.rb +++ b/app/mailers/emails/remote_mirrors.rb @@ -6,7 +6,7 @@ module Emails @remote_mirror = RemoteMirrorFinder.new(id: remote_mirror_id).execute @project = @remote_mirror.project - mail(to: recipient(recipient_id), subject: subject('Remote mirror update failed')) + mail(to: recipient(recipient_id, @project.group), subject: subject('Remote mirror update failed')) end end end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index efa1233b434..576caea4c10 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -4,6 +4,7 @@ class Notify < BaseMailer include ActionDispatch::Routing::PolymorphicRoutes include GitlabRoutingHelper include EmailsHelper + include IssuablesHelper include Emails::Issues include Emails::MergeRequests @@ -24,6 +25,7 @@ class Notify < BaseMailer helper MembersHelper helper AvatarsHelper helper GitlabRoutingHelper + helper IssuablesHelper def test_email(recipient_email, subject, body) mail(to: recipient_email, @@ -71,12 +73,22 @@ class Notify < BaseMailer # Look up a User by their ID and return their email address # - # recipient_id - User ID + # recipient_id - User ID + # notification_group - The parent group of the notification # # Returns a String containing the User's email address. - def recipient(recipient_id) + def recipient(recipient_id, notification_group = nil) @current_user = User.find(recipient_id) - @current_user.notification_email + group_notification_email = nil + + if notification_group + notification_settings = notification_group.notification_settings_for(@current_user, hierarchy_order: :asc) + group_notification_email = notification_settings.find { |n| n.notification_email.present? }&.notification_email + end + + # Return group-specific email address if present, otherwise return global + # email address + group_notification_email || @current_user.notification_email end # Formats arguments into a String suitable for use as an email subject diff --git a/app/mailers/repository_check_mailer.rb b/app/mailers/repository_check_mailer.rb index 145169be8a6..a24d3476d0e 100644 --- a/app/mailers/repository_check_mailer.rb +++ b/app/mailers/repository_check_mailer.rb @@ -2,6 +2,10 @@ class RepositoryCheckMailer < BaseMailer # rubocop: disable CodeReuse/ActiveRecord + layout 'empty_mailer' + + helper EmailsHelper + def notify(failed_count) @message = if failed_count == 1 diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index 1b78fd04ebb..a3a1748142f 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class AbuseReport < ActiveRecord::Base +class AbuseReport < ApplicationRecord include CacheMarkdownField cache_markdown_field :message, pipeline: :single_line diff --git a/app/models/active_session.rb b/app/models/active_session.rb index 0d9c6a4a1f0..f355b02c428 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -5,7 +5,8 @@ class ActiveSession attr_accessor :created_at, :updated_at, :session_id, :ip_address, - :browser, :os, :device_name, :device_type + :browser, :os, :device_name, :device_type, + :is_impersonated def current?(session) return false if session_id.nil? || session.id.nil? @@ -31,7 +32,8 @@ class ActiveSession device_type: client.device_type, created_at: user.current_sign_in_at || timestamp, updated_at: timestamp, - session_id: session_id + session_id: session_id, + is_impersonated: request.session[:impersonator_id].present? ) redis.pipelined do @@ -51,7 +53,7 @@ class ActiveSession def self.list(user) Gitlab::Redis::SharedState.with do |redis| - cleaned_up_lookup_entries(redis, user.id).map do |entry| + cleaned_up_lookup_entries(redis, user).map do |entry| # rubocop:disable Security/MarshalLoad Marshal.load(entry) # rubocop:enable Security/MarshalLoad @@ -76,7 +78,7 @@ class ActiveSession def self.cleanup(user) Gitlab::Redis::SharedState.with do |redis| - cleaned_up_lookup_entries(redis, user.id) + cleaned_up_lookup_entries(redis, user) end end @@ -88,25 +90,52 @@ class ActiveSession "#{Gitlab::Redis::SharedState::USER_SESSIONS_LOOKUP_NAMESPACE}:#{user_id}" end - def self.cleaned_up_lookup_entries(redis, user_id) - lookup_key = lookup_key_name(user_id) + def self.list_sessions(user) + sessions_from_ids(session_ids_for_user(user)) + end - session_ids = redis.smembers(lookup_key) + def self.session_ids_for_user(user) + Gitlab::Redis::SharedState.with do |redis| + redis.smembers(lookup_key_name(user.id)) + end + end - entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) } - return [] if entry_keys.empty? + def self.sessions_from_ids(session_ids) + return [] if session_ids.empty? - entries = redis.mget(entry_keys) + Gitlab::Redis::SharedState.with do |redis| + session_keys = session_ids.map { |session_id| "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" } - session_ids_and_entries = session_ids.zip(entries) + redis.mget(session_keys).compact.map do |raw_session| + # rubocop:disable Security/MarshalLoad + Marshal.load(raw_session) + # rubocop:enable Security/MarshalLoad + end + end + end + + def self.raw_active_session_entries(session_ids, user_id) + return [] if session_ids.empty? + + Gitlab::Redis::SharedState.with do |redis| + entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) } + + redis.mget(entry_keys) + end + end + + def self.cleaned_up_lookup_entries(redis, user) + session_ids = session_ids_for_user(user) + entries = raw_active_session_entries(session_ids, user.id) # remove expired keys. # only the single key entries are automatically expired by redis, the # lookup entries in the set need to be removed manually. + session_ids_and_entries = session_ids.zip(entries) session_ids_and_entries.reject { |_session_id, entry| entry }.each do |session_id, _entry| - redis.srem(lookup_key, session_id) + redis.srem(lookup_key_name(user.id), session_id) end - session_ids_and_entries.select { |_session_id, entry| entry }.map { |_session_id, entry| entry } + entries.compact end end diff --git a/app/models/appearance.rb b/app/models/appearance.rb index b9ad676ca47..2815a117f7f 100644 --- a/app/models/appearance.rb +++ b/app/models/appearance.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Appearance < ActiveRecord::Base +class Appearance < ApplicationRecord include CacheableAttributes include CacheMarkdownField include ObjectStorage::BackgroundMove @@ -20,6 +20,7 @@ class Appearance < ActiveRecord::Base default_value_for :message_background_color, '#E75E40' default_value_for :message_font_color, '#FFFFFF' + default_value_for :email_header_and_footer_enabled, false mount_uploader :logo, AttachmentUploader mount_uploader :header_logo, AttachmentUploader diff --git a/app/models/application_record.rb b/app/models/application_record.rb index a3d662d8250..0979d03f6e6 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -3,10 +3,33 @@ class ApplicationRecord < ActiveRecord::Base self.abstract_class = true + alias_method :reset, :reload + def self.id_in(ids) where(id: ids) end + def self.id_not_in(ids) + where.not(id: ids) + end + + def self.pluck_primary_key + where(nil).pluck(self.primary_key) + end + + def self.safe_ensure_unique(retries: 0) + transaction(requires_new: true) do + yield + end + rescue ActiveRecord::RecordNotUnique + if retries > 0 + retries -= 1 + retry + end + + false + end + def self.safe_find_or_create_by!(*args) safe_find_or_create_by(*args).tap do |record| record.validate! unless record.persisted? @@ -14,10 +37,12 @@ class ApplicationRecord < ActiveRecord::Base end def self.safe_find_or_create_by(*args) - transaction(requires_new: true) do + safe_ensure_unique(retries: 1) do find_or_create_by(*args) end - rescue ActiveRecord::RecordNotUnique - retry + end + + def self.underscore + Gitlab::SafeRequestStore.fetch("model:#{self}:underscore") { self.to_s.underscore } end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index daadf9427ba..bbe2d2e8fd4 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -1,26 +1,20 @@ # frozen_string_literal: true -class ApplicationSetting < ActiveRecord::Base +class ApplicationSetting < ApplicationRecord include CacheableAttributes include CacheMarkdownField include TokenAuthenticatable include IgnorableColumn include ChronicDurationAttribute - add_authentication_token_field :runners_registration_token, encrypted: true, fallback: true + add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption, default_enabled: true) ? :optional : :required } add_authentication_token_field :health_check_access_token - DOMAIN_LIST_SEPARATOR = %r{\s*[,;]\s* # comma or semicolon, optionally surrounded by whitespace - | # or - \s # any whitespace character - | # or - [\r\n] # any number of newline characters - }x - - # Setting a key restriction to `-1` means that all keys of this type are - # forbidden. - FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN - SUPPORTED_KEY_TYPES = %i[rsa dsa ecdsa ed25519].freeze + # Include here so it can override methods from + # `add_authentication_token_field` + # We don't prepend for now because otherwise we'll need to + # fix a lot of tests using allow_any_instance_of + include ApplicationSettingImplementation serialize :restricted_visibility_levels # rubocop:disable Cop/ActiveRecordSerialize serialize :import_sources # rubocop:disable Cop/ActiveRecordSerialize @@ -42,8 +36,6 @@ class ApplicationSetting < ActiveRecord::Base cache_markdown_field :shared_runners_text, pipeline: :plain_markdown cache_markdown_field :after_sign_up_text - attr_accessor :domain_whitelist_raw, :domain_blacklist_raw - default_value_for :id, 1 chronic_duration_attr_writer :archive_builds_in_human_readable, :archive_builds_in_seconds @@ -56,20 +48,20 @@ class ApplicationSetting < ActiveRecord::Base validates :home_page_url, allow_blank: true, - url: true, + addressable_url: true, if: :home_page_url_column_exists? validates :help_page_support_url, allow_blank: true, - url: true, + addressable_url: true, if: :help_page_support_url_column_exists? validates :after_sign_out_path, allow_blank: true, - url: true + addressable_url: true validates :admin_notification_email, - email: true, + devise_email: true, allow_blank: true validates :two_factor_grace_period, @@ -206,7 +198,7 @@ class ApplicationSetting < ActiveRecord::Base validates_each :restricted_visibility_levels do |record, attr, value| value&.each do |level| unless Gitlab::VisibilityLevel.options.value?(level) - record.errors.add(attr, "'#{level}' is not a valid visibility level") + record.errors.add(attr, _("'%{level}' is not a valid visibility level") % { level: level }) end end end @@ -214,13 +206,63 @@ class ApplicationSetting < ActiveRecord::Base validates_each :import_sources do |record, attr, value| value&.each do |source| unless Gitlab::ImportSources.options.value?(source) - record.errors.add(attr, "'#{source}' is not a import source") + record.errors.add(attr, _("'%{source}' is not a import source") % { source: source }) end end end validate :terms_exist, if: :enforce_terms? + validates :external_authorization_service_default_label, + presence: true, + if: :external_authorization_service_enabled + + validates :external_authorization_service_url, + addressable_url: true, allow_blank: true, + if: :external_authorization_service_enabled + + validates :external_authorization_service_timeout, + numericality: { greater_than: 0, less_than_or_equal_to: 10 }, + if: :external_authorization_service_enabled + + validates :external_auth_client_key, + presence: true, + if: -> (setting) { setting.external_auth_client_cert.present? } + + validates :lets_encrypt_notification_email, + devise_email: true, + format: { without: /@example\.(com|org|net)\z/, + message: N_("Let's Encrypt does not accept emails on example.com") }, + allow_blank: true + + validates :lets_encrypt_notification_email, + presence: true, + if: :lets_encrypt_terms_of_service_accepted? + + validates_with X509CertificateCredentialsValidator, + certificate: :external_auth_client_cert, + pkey: :external_auth_client_key, + pass: :external_auth_client_key_pass, + if: -> (setting) { setting.external_auth_client_cert.present? } + + attr_encrypted :external_auth_client_key, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm', + encode: true + + attr_encrypted :external_auth_client_key_pass, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm', + encode: true + + attr_encrypted :lets_encrypt_private_key, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm', + encode: true + before_validation :ensure_uuid! before_validation :strip_sentry_values @@ -232,265 +274,12 @@ class ApplicationSetting < ActiveRecord::Base end after_commit :expire_performance_bar_allowed_user_ids_cache, if: -> { previous_changes.key?('performance_bar_allowed_group_id') } - def self.defaults - { - after_sign_up_text: nil, - akismet_enabled: false, - allow_local_requests_from_hooks_and_services: false, - authorized_keys_enabled: true, # TODO default to false if the instance is configured to use AuthorizedKeysCommand - container_registry_token_expire_delay: 5, - default_artifacts_expire_in: '30 days', - default_branch_protection: Settings.gitlab['default_branch_protection'], - default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'], - default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'], - default_projects_limit: Settings.gitlab['default_projects_limit'], - default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'], - disabled_oauth_sign_in_sources: [], - domain_whitelist: Settings.gitlab['domain_whitelist'], - dsa_key_restriction: 0, - ecdsa_key_restriction: 0, - ed25519_key_restriction: 0, - first_day_of_week: 0, - gitaly_timeout_default: 55, - gitaly_timeout_fast: 10, - gitaly_timeout_medium: 30, - gravatar_enabled: Settings.gravatar['enabled'], - help_page_hide_commercial_content: false, - help_page_text: nil, - hide_third_party_offers: false, - housekeeping_bitmaps_enabled: true, - housekeeping_enabled: true, - housekeeping_full_repack_period: 50, - housekeeping_gc_period: 200, - housekeeping_incremental_repack_period: 10, - import_sources: Settings.gitlab['import_sources'], - max_artifacts_size: Settings.artifacts['max_size'], - max_attachment_size: Settings.gitlab['max_attachment_size'], - mirror_available: true, - password_authentication_enabled_for_git: true, - password_authentication_enabled_for_web: Settings.gitlab['signin_enabled'], - performance_bar_allowed_group_id: nil, - rsa_key_restriction: 0, - plantuml_enabled: false, - plantuml_url: nil, - polling_interval_multiplier: 1, - project_export_enabled: true, - recaptcha_enabled: false, - repository_checks_enabled: true, - repository_storages: ['default'], - require_two_factor_authentication: false, - restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'], - session_expire_delay: Settings.gitlab['session_expire_delay'], - send_user_confirmation_email: false, - shared_runners_enabled: Settings.gitlab_ci['shared_runners_enabled'], - shared_runners_text: nil, - sign_in_text: nil, - signup_enabled: Settings.gitlab['signup_enabled'], - terminal_max_session_time: 0, - throttle_authenticated_api_enabled: false, - throttle_authenticated_api_period_in_seconds: 3600, - throttle_authenticated_api_requests_per_period: 7200, - throttle_authenticated_web_enabled: false, - throttle_authenticated_web_period_in_seconds: 3600, - throttle_authenticated_web_requests_per_period: 7200, - throttle_unauthenticated_enabled: false, - throttle_unauthenticated_period_in_seconds: 3600, - throttle_unauthenticated_requests_per_period: 3600, - two_factor_grace_period: 48, - unique_ips_limit_enabled: false, - unique_ips_limit_per_user: 10, - unique_ips_limit_time_window: 3600, - usage_ping_enabled: Settings.gitlab['usage_ping_enabled'], - instance_statistics_visibility_private: false, - user_default_external: false, - user_default_internal_regex: nil, - user_show_add_ssh_key_message: true, - usage_stats_set_by_user_id: nil, - diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES, - commit_email_hostname: default_commit_email_hostname, - protected_ci_variables: false, - local_markdown_version: 0 - } - end - - def self.default_commit_email_hostname - "users.noreply.#{Gitlab.config.gitlab.host}" - end - def self.create_from_defaults - build_from_defaults.tap(&:save) - end - - def self.human_attribute_name(attr, _options = {}) - if attr == :default_artifacts_expire_in - 'Default artifacts expiration' - else + transaction(requires_new: true) do super end - end - - def home_page_url_column_exists? - ::Gitlab::Database.cached_column_exists?(:application_settings, :home_page_url) - end - - def help_page_support_url_column_exists? - ::Gitlab::Database.cached_column_exists?(:application_settings, :help_page_support_url) - end - - def disabled_oauth_sign_in_sources=(sources) - sources = (sources || []).map(&:to_s) & Devise.omniauth_providers.map(&:to_s) - super(sources) - end - - def domain_whitelist_raw - self.domain_whitelist&.join("\n") - end - - def domain_blacklist_raw - self.domain_blacklist&.join("\n") - end - - def domain_whitelist_raw=(values) - self.domain_whitelist = [] - self.domain_whitelist = values.split(DOMAIN_LIST_SEPARATOR) - self.domain_whitelist.reject! { |d| d.empty? } - self.domain_whitelist - end - - def domain_blacklist_raw=(values) - self.domain_blacklist = [] - self.domain_blacklist = values.split(DOMAIN_LIST_SEPARATOR) - self.domain_blacklist.reject! { |d| d.empty? } - self.domain_blacklist - end - - def domain_blacklist_file=(file) - self.domain_blacklist_raw = file.read - end - - def repository_storages - Array(read_attribute(:repository_storages)) - end - - def commit_email_hostname - super.presence || self.class.default_commit_email_hostname - end - - def default_project_visibility=(level) - super(Gitlab::VisibilityLevel.level_value(level)) - end - - def default_snippet_visibility=(level) - super(Gitlab::VisibilityLevel.level_value(level)) - end - - def default_group_visibility=(level) - super(Gitlab::VisibilityLevel.level_value(level)) - end - - def restricted_visibility_levels=(levels) - super(levels&.map { |level| Gitlab::VisibilityLevel.level_value(level) }) - end - - def strip_sentry_values - sentry_dsn.strip! if sentry_dsn.present? - clientside_sentry_dsn.strip! if clientside_sentry_dsn.present? - end - - def performance_bar_allowed_group - Group.find_by_id(performance_bar_allowed_group_id) - end - - # Return true if the Performance Bar is enabled for a given group - def performance_bar_enabled - performance_bar_allowed_group_id.present? - end - - # Choose one of the available repository storage options. Currently all have - # equal weighting. - def pick_repository_storage - repository_storages.sample - end - - def runners_registration_token - ensure_runners_registration_token! - end - - def health_check_access_token - ensure_health_check_access_token! - end - - def usage_ping_can_be_configured? - Settings.gitlab.usage_ping_enabled - end - - def usage_ping_enabled - usage_ping_can_be_configured? && super - end - - def allowed_key_types - SUPPORTED_KEY_TYPES.select do |type| - key_restriction_for(type) != FORBIDDEN_KEY_VALUE - end - end - - def key_restriction_for(type) - attr_name = "#{type}_key_restriction" - - has_attribute?(attr_name) ? public_send(attr_name) : FORBIDDEN_KEY_VALUE # rubocop:disable GitlabSecurity/PublicSend - end - - def allow_signup? - signup_enabled? && password_authentication_enabled_for_web? - end - - def password_authentication_enabled? - password_authentication_enabled_for_web? || password_authentication_enabled_for_git? - end - - def user_default_internal_regex_enabled? - user_default_external? && user_default_internal_regex.present? - end - - def user_default_internal_regex_instance - Regexp.new(user_default_internal_regex, Regexp::IGNORECASE) - end - - delegate :terms, to: :latest_terms, allow_nil: true - def latest_terms - @latest_terms ||= Term.latest - end - - def reset_memoized_terms - @latest_terms = nil - latest_terms - end - - def archive_builds_older_than - archive_builds_in_seconds.seconds.ago if archive_builds_in_seconds - end - - private - - def ensure_uuid! - return if uuid? - - self.uuid = SecureRandom.uuid - end - - def check_repository_storages - invalid = repository_storages - Gitlab.config.repositories.storages.keys - errors.add(:repository_storages, "can't include: #{invalid.join(", ")}") unless - invalid.empty? - end - - def terms_exist - return unless enforce_terms? - - errors.add(:terms, "You need to set terms to be enforced") unless terms.present? - end - - def expire_performance_bar_allowed_user_ids_cache - Gitlab::PerformanceBar.expire_allowed_user_ids_cache + rescue ActiveRecord::RecordNotUnique + # We already have an ApplicationSetting record, so just return it. + current_without_cache end end diff --git a/app/models/application_setting/term.rb b/app/models/application_setting/term.rb index 498701ba22b..723540c9b91 100644 --- a/app/models/application_setting/term.rb +++ b/app/models/application_setting/term.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class ApplicationSetting - class Term < ActiveRecord::Base + class Term < ApplicationRecord include CacheMarkdownField has_many :term_agreements diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb new file mode 100644 index 00000000000..904d650ef96 --- /dev/null +++ b/app/models/application_setting_implementation.rb @@ -0,0 +1,299 @@ +# frozen_string_literal: true + +module ApplicationSettingImplementation + extend ActiveSupport::Concern + + DOMAIN_LIST_SEPARATOR = %r{\s*[,;]\s* # comma or semicolon, optionally surrounded by whitespace + | # or + \s # any whitespace character + | # or + [\r\n] # any number of newline characters + }x.freeze + + # Setting a key restriction to `-1` means that all keys of this type are + # forbidden. + FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN + SUPPORTED_KEY_TYPES = %i[rsa dsa ecdsa ed25519].freeze + + class_methods do + def defaults + { + after_sign_up_text: nil, + akismet_enabled: false, + allow_local_requests_from_hooks_and_services: false, + dns_rebinding_protection_enabled: true, + authorized_keys_enabled: true, # TODO default to false if the instance is configured to use AuthorizedKeysCommand + container_registry_token_expire_delay: 5, + default_artifacts_expire_in: '30 days', + default_branch_protection: Settings.gitlab['default_branch_protection'], + default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'], + default_project_creation: Settings.gitlab['default_project_creation'], + default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'], + default_projects_limit: Settings.gitlab['default_projects_limit'], + default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'], + disabled_oauth_sign_in_sources: [], + domain_whitelist: Settings.gitlab['domain_whitelist'], + dsa_key_restriction: 0, + ecdsa_key_restriction: 0, + ed25519_key_restriction: 0, + first_day_of_week: 0, + gitaly_timeout_default: 55, + gitaly_timeout_fast: 10, + gitaly_timeout_medium: 30, + gravatar_enabled: Settings.gravatar['enabled'], + help_page_hide_commercial_content: false, + help_page_text: nil, + hide_third_party_offers: false, + housekeeping_bitmaps_enabled: true, + housekeeping_enabled: true, + housekeeping_full_repack_period: 50, + housekeeping_gc_period: 200, + housekeeping_incremental_repack_period: 10, + import_sources: Settings.gitlab['import_sources'], + max_artifacts_size: Settings.artifacts['max_size'], + max_attachment_size: Settings.gitlab['max_attachment_size'], + mirror_available: true, + password_authentication_enabled_for_git: true, + password_authentication_enabled_for_web: Settings.gitlab['signin_enabled'], + performance_bar_allowed_group_id: nil, + rsa_key_restriction: 0, + plantuml_enabled: false, + plantuml_url: nil, + polling_interval_multiplier: 1, + project_export_enabled: true, + recaptcha_enabled: false, + repository_checks_enabled: true, + repository_storages: ['default'], + require_two_factor_authentication: false, + restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'], + session_expire_delay: Settings.gitlab['session_expire_delay'], + send_user_confirmation_email: false, + shared_runners_enabled: Settings.gitlab_ci['shared_runners_enabled'], + shared_runners_text: nil, + sign_in_text: nil, + signup_enabled: Settings.gitlab['signup_enabled'], + terminal_max_session_time: 0, + throttle_authenticated_api_enabled: false, + throttle_authenticated_api_period_in_seconds: 3600, + throttle_authenticated_api_requests_per_period: 7200, + throttle_authenticated_web_enabled: false, + throttle_authenticated_web_period_in_seconds: 3600, + throttle_authenticated_web_requests_per_period: 7200, + throttle_unauthenticated_enabled: false, + throttle_unauthenticated_period_in_seconds: 3600, + throttle_unauthenticated_requests_per_period: 3600, + two_factor_grace_period: 48, + unique_ips_limit_enabled: false, + unique_ips_limit_per_user: 10, + unique_ips_limit_time_window: 3600, + usage_ping_enabled: Settings.gitlab['usage_ping_enabled'], + instance_statistics_visibility_private: false, + user_default_external: false, + user_default_internal_regex: nil, + user_show_add_ssh_key_message: true, + usage_stats_set_by_user_id: nil, + diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES, + commit_email_hostname: default_commit_email_hostname, + protected_ci_variables: false, + local_markdown_version: 0 + } + end + + def default_commit_email_hostname + "users.noreply.#{Gitlab.config.gitlab.host}" + end + + def create_from_defaults + build_from_defaults.tap(&:save) + end + + def human_attribute_name(attr, _options = {}) + if attr == :default_artifacts_expire_in + 'Default artifacts expiration' + else + super + end + end + end + + def home_page_url_column_exists? + ::Gitlab::Database.cached_column_exists?(:application_settings, :home_page_url) + end + + def help_page_support_url_column_exists? + ::Gitlab::Database.cached_column_exists?(:application_settings, :help_page_support_url) + end + + def disabled_oauth_sign_in_sources=(sources) + sources = (sources || []).map(&:to_s) & Devise.omniauth_providers.map(&:to_s) + super(sources) + end + + def domain_whitelist_raw + self.domain_whitelist&.join("\n") + end + + def domain_blacklist_raw + self.domain_blacklist&.join("\n") + end + + def domain_whitelist_raw=(values) + self.domain_whitelist = [] + self.domain_whitelist = values.split(DOMAIN_LIST_SEPARATOR) + self.domain_whitelist.reject! { |d| d.empty? } + self.domain_whitelist + end + + def domain_blacklist_raw=(values) + self.domain_blacklist = [] + self.domain_blacklist = values.split(DOMAIN_LIST_SEPARATOR) + self.domain_blacklist.reject! { |d| d.empty? } + self.domain_blacklist + end + + def domain_blacklist_file=(file) + self.domain_blacklist_raw = file.read + end + + def repository_storages + Array(read_attribute(:repository_storages)) + end + + def commit_email_hostname + super.presence || self.class.default_commit_email_hostname + end + + def default_project_visibility=(level) + super(Gitlab::VisibilityLevel.level_value(level)) + end + + def default_snippet_visibility=(level) + super(Gitlab::VisibilityLevel.level_value(level)) + end + + def default_group_visibility=(level) + super(Gitlab::VisibilityLevel.level_value(level)) + end + + def restricted_visibility_levels=(levels) + super(levels&.map { |level| Gitlab::VisibilityLevel.level_value(level) }) + end + + def strip_sentry_values + sentry_dsn.strip! if sentry_dsn.present? + clientside_sentry_dsn.strip! if clientside_sentry_dsn.present? + end + + def sentry_enabled + Gitlab.config.sentry.enabled || read_attribute(:sentry_enabled) + end + + def sentry_dsn + Gitlab.config.sentry.dsn || read_attribute(:sentry_dsn) + end + + def clientside_sentry_enabled + Gitlab.config.sentry.enabled || read_attribute(:clientside_sentry_enabled) + end + + def clientside_sentry_dsn + Gitlab.config.sentry.clientside_dsn || read_attribute(:clientside_sentry_dsn) + end + + def performance_bar_allowed_group + Group.find_by_id(performance_bar_allowed_group_id) + end + + # Return true if the Performance Bar is enabled for a given group + def performance_bar_enabled + performance_bar_allowed_group_id.present? + end + + # Choose one of the available repository storage options. Currently all have + # equal weighting. + def pick_repository_storage + repository_storages.sample + end + + def runners_registration_token + ensure_runners_registration_token! + end + + def health_check_access_token + ensure_health_check_access_token! + end + + def usage_ping_can_be_configured? + Settings.gitlab.usage_ping_enabled + end + + def usage_ping_enabled + usage_ping_can_be_configured? && super + end + + def allowed_key_types + SUPPORTED_KEY_TYPES.select do |type| + key_restriction_for(type) != FORBIDDEN_KEY_VALUE + end + end + + def key_restriction_for(type) + attr_name = "#{type}_key_restriction" + + has_attribute?(attr_name) ? public_send(attr_name) : FORBIDDEN_KEY_VALUE # rubocop:disable GitlabSecurity/PublicSend + end + + def allow_signup? + signup_enabled? && password_authentication_enabled_for_web? + end + + def password_authentication_enabled? + password_authentication_enabled_for_web? || password_authentication_enabled_for_git? + end + + def user_default_internal_regex_enabled? + user_default_external? && user_default_internal_regex.present? + end + + def user_default_internal_regex_instance + Regexp.new(user_default_internal_regex, Regexp::IGNORECASE) + end + + delegate :terms, to: :latest_terms, allow_nil: true + def latest_terms + @latest_terms ||= ApplicationSetting::Term.latest + end + + def reset_memoized_terms + @latest_terms = nil # rubocop:disable Gitlab/ModuleWithInstanceVariables + latest_terms + end + + def archive_builds_older_than + archive_builds_in_seconds.seconds.ago if archive_builds_in_seconds + end + + private + + def ensure_uuid! + return if uuid? + + self.uuid = SecureRandom.uuid + end + + def check_repository_storages + invalid = repository_storages - Gitlab.config.repositories.storages.keys + errors.add(:repository_storages, "can't include: #{invalid.join(", ")}") unless + invalid.empty? + end + + def terms_exist + return unless enforce_terms? + + errors.add(:terms, "You need to set terms to be enforced") unless terms.present? + end + + def expire_performance_bar_allowed_user_ids_cache + Gitlab::PerformanceBar.expire_allowed_user_ids_cache + end +end diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 8508c88d406..6ef2914ac11 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class AuditEvent < ActiveRecord::Base +class AuditEvent < ApplicationRecord serialize :details, Hash # rubocop:disable Cop/ActiveRecordSerialize belongs_to :user, foreign_key: :author_id diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index ddc516ccb60..e26162f6151 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class AwardEmoji < ActiveRecord::Base +class AwardEmoji < ApplicationRecord DOWNVOTE_NAME = "thumbsdown".freeze UPVOTE_NAME = "thumbsup".freeze diff --git a/app/models/badge.rb b/app/models/badge.rb index f016654206b..50299cd6652 100644 --- a/app/models/badge.rb +++ b/app/models/badge.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Badge < ActiveRecord::Base +class Badge < ApplicationRecord include FromUnion # This structure sets the placeholders that the urls @@ -22,7 +22,7 @@ class Badge < ActiveRecord::Base scope :order_created_at_asc, -> { reorder(created_at: :asc) } - validates :link_url, :image_url, url: { protocols: %w(http https) } + validates :link_url, :image_url, addressable_url: true validates :type, presence: true def rendered_link_url(project = nil) diff --git a/app/models/blob.rb b/app/models/blob.rb index c5766eb0327..d528bef8b19 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -110,7 +110,7 @@ class Blob < SimpleDelegator end def load_all_data! - # Endpoint needed: gitlab-org/gitaly#756 + # Endpoint needed: https://gitlab.com/gitlab-org/gitaly/issues/756 Gitlab::GitalyClient.allow_n_plus_1_calls do super(project.repository) if project end diff --git a/app/models/board.rb b/app/models/board.rb index 758a71d6903..e08db764f65 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Board < ActiveRecord::Base +class Board < ApplicationRecord belongs_to :group belongs_to :project diff --git a/app/models/board_group_recent_visit.rb b/app/models/board_group_recent_visit.rb index 92abbb67222..2f1cd830791 100644 --- a/app/models/board_group_recent_visit.rb +++ b/app/models/board_group_recent_visit.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Tracks which boards in a specific group a user has visited -class BoardGroupRecentVisit < ActiveRecord::Base +class BoardGroupRecentVisit < ApplicationRecord belongs_to :user belongs_to :group belongs_to :board @@ -10,7 +10,7 @@ class BoardGroupRecentVisit < ActiveRecord::Base validates :group, presence: true validates :board, presence: true - scope :by_user_group, -> (user, group) { where(user: user, group: group).order(:updated_at) } + scope :by_user_group, -> (user, group) { where(user: user, group: group) } def self.visited!(user, board) visit = find_or_create_by(user: user, group: board.group, board: board) @@ -19,7 +19,10 @@ class BoardGroupRecentVisit < ActiveRecord::Base retry end - def self.latest(user, group) - by_user_group(user, group).last + def self.latest(user, group, count: nil) + visits = by_user_group(user, group).order(updated_at: :desc) + visits = visits.preload(:board) if count && count > 1 + + visits.first(count) end end diff --git a/app/models/board_project_recent_visit.rb b/app/models/board_project_recent_visit.rb index 7cffff906d8..236d88e909c 100644 --- a/app/models/board_project_recent_visit.rb +++ b/app/models/board_project_recent_visit.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Tracks which boards in a specific project a user has visited -class BoardProjectRecentVisit < ActiveRecord::Base +class BoardProjectRecentVisit < ApplicationRecord belongs_to :user belongs_to :project belongs_to :board @@ -10,7 +10,7 @@ class BoardProjectRecentVisit < ActiveRecord::Base validates :project, presence: true validates :board, presence: true - scope :by_user_project, -> (user, project) { where(user: user, project: project).order(:updated_at) } + scope :by_user_project, -> (user, project) { where(user: user, project: project) } def self.visited!(user, board) visit = find_or_create_by(user: user, project: board.project, board: board) @@ -19,7 +19,10 @@ class BoardProjectRecentVisit < ActiveRecord::Base retry end - def self.latest(user, project) - by_user_project(user, project).last + def self.latest(user, project, count: nil) + visits = by_user_project(user, project).order(updated_at: :desc) + visits = visits.preload(:board) if count && count > 1 + + visits.first(count) end end diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb index 2d237383e60..18fe2a9624f 100644 --- a/app/models/broadcast_message.rb +++ b/app/models/broadcast_message.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -class BroadcastMessage < ActiveRecord::Base +class BroadcastMessage < ApplicationRecord include CacheMarkdownField include Sortable - cache_markdown_field :message, pipeline: :broadcast_message + cache_markdown_field :message, pipeline: :broadcast_message, whitelisted: true validates :message, presence: true validates :starts_at, presence: true diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb index 03b0af53046..0041595baba 100644 --- a/app/models/chat_name.rb +++ b/app/models/chat_name.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ChatName < ActiveRecord::Base +class ChatName < ApplicationRecord LAST_USED_AT_INTERVAL = 1.hour belongs_to :service diff --git a/app/models/chat_team.rb b/app/models/chat_team.rb index 4e724f9adf7..52b5a7b4a91 100644 --- a/app/models/chat_team.rb +++ b/app/models/chat_team.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ChatTeam < ActiveRecord::Base +class ChatTeam < ApplicationRecord validates :team_id, presence: true validates :namespace, uniqueness: true diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 5450d40ea95..644716ba8e7 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -3,8 +3,11 @@ module Ci class Bridge < CommitStatus include Ci::Processable + include Ci::Contextable + include Ci::PipelineDelegator include Importable include AfterCommitQueue + include HasRef include Gitlab::Utils::StrongMemoize belongs_to :project @@ -37,11 +40,11 @@ module Ci false end - def expanded_environment_name + def runnable? + false end - def predefined_variables - raise NotImplementedError + def expanded_environment_name end def execute_hooks diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index c902e49ee6d..89cc082d0bc 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -2,9 +2,10 @@ module Ci class Build < CommitStatus - prepend ArtifactMigratable include Ci::Processable include Ci::Metadatable + include Ci::Contextable + include Ci::PipelineDelegator include TokenAuthenticatable include AfterCommitQueue include ObjectStorage::BackgroundMove @@ -18,6 +19,11 @@ module Ci BuildArchivedError = Class.new(StandardError) ignore_column :commands + ignore_column :artifacts_file + ignore_column :artifacts_metadata + ignore_column :artifacts_file_store + ignore_column :artifacts_metadata_store + ignore_column :artifacts_size belongs_to :project, inverse_of: :builds belongs_to :runner @@ -25,7 +31,8 @@ module Ci belongs_to :erased_by, class_name: 'User' RUNNER_FEATURES = { - upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? } + upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? }, + refspecs: -> (build) { build.merge_request_ref? } }.freeze has_one :deployment, as: :deployable, class_name: 'Deployment' @@ -46,7 +53,6 @@ module Ci delegate :terminal_specification, to: :runner_session, allow_nil: true delegate :gitlab_deploy_token, to: :project delegate :trigger_short_token, to: :trigger_request, allow_nil: true - delegate :merge_request?, to: :pipeline ## # Since Gitlab 11.5, deployments records started being created right after @@ -80,8 +86,7 @@ module Ci scope :unstarted, ->() { where(runner_id: nil) } scope :ignore_failures, ->() { where(allow_failure: false) } scope :with_artifacts_archive, ->() do - where('(artifacts_file IS NOT NULL AND artifacts_file <> ?) OR EXISTS (?)', - '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive) + where('EXISTS (?)', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive) end scope :with_existing_job_artifacts, ->(query) do @@ -96,15 +101,15 @@ module Ci where('NOT EXISTS (?)', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').trace) end - scope :with_test_reports, ->() do - with_existing_job_artifacts(Ci::JobArtifact.test_reports) + scope :with_reports, ->(reports_scope) do + with_existing_job_artifacts(reports_scope) .eager_load_job_artifacts end scope :eager_load_job_artifacts, -> { includes(:job_artifacts) } - scope :with_artifacts_stored_locally, -> { with_artifacts_archive.where(artifacts_file_store: [nil, LegacyArtifactUploader::Store::LOCAL]) } - scope :with_archived_trace_stored_locally, -> { with_archived_trace.where(artifacts_file_store: [nil, LegacyArtifactUploader::Store::LOCAL]) } + scope :with_artifacts_stored_locally, -> { with_existing_job_artifacts(Ci::JobArtifact.archive.with_files_stored_locally) } + scope :with_archived_trace_stored_locally, -> { with_existing_job_artifacts(Ci::JobArtifact.trace.with_files_stored_locally) } scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } @@ -132,14 +137,12 @@ module Ci where("EXISTS (?)", matcher) end - mount_uploader :legacy_artifacts_file, LegacyArtifactUploader, mount_on: :artifacts_file - mount_uploader :legacy_artifacts_metadata, LegacyArtifactUploader, mount_on: :artifacts_metadata + scope :queued_before, ->(time) { where(arel_table[:queued_at].lt(time)) } acts_as_taggable - add_authentication_token_field :token, encrypted: true, fallback: true + add_authentication_token_field :token, encrypted: :optional - before_save :update_artifacts_size, if: :artifacts_file_changed? before_save :ensure_token before_destroy { unscoped_project } @@ -147,9 +150,6 @@ module Ci run_after_commit { BuildHooksWorker.perform_async(build.id) } end - after_save :update_project_statistics_after_save, if: :artifacts_size_changed? - after_destroy :update_project_statistics_after_destroy, unless: :project_destroyed? - class << self # This is needed for url_for to work, # as the controller is JobsController @@ -171,6 +171,10 @@ module Ci end state_machine :status do + event :enqueue do + transition [:created, :skipped, :manual, :scheduled] => :preparing, if: :any_unmet_prerequisites? + end + event :actionize do transition created: :manual end @@ -184,8 +188,12 @@ module Ci end event :enqueue_scheduled do + transition scheduled: :preparing, if: ->(build) do + build.scheduled_at&.past? && build.any_unmet_prerequisites? + end + transition scheduled: :pending, if: ->(build) do - build.scheduled_at && build.scheduled_at < Time.now + build.scheduled_at&.past? && !build.any_unmet_prerequisites? end end @@ -203,6 +211,12 @@ module Ci end end + after_transition any => [:preparing] do |build| + build.run_after_commit do + Ci::BuildPrepareWorker.perform_async(id) + end + end + after_transition any => [:pending] do |build| build.run_after_commit do BuildQueueWorker.perform_async(id) @@ -289,6 +303,10 @@ module Ci self.name == 'pages' end + def runnable? + true + end + def archived? return true if degenerated? @@ -350,6 +368,14 @@ module Ci !retried? end + def any_unmet_prerequisites? + prerequisites.present? + end + + def prerequisites + Gitlab::Ci::Build::Prerequisite::Factory.new(self).unmet + end + def expanded_environment_name return unless has_environment? @@ -398,46 +424,6 @@ module Ci options&.dig(:environment, :on_stop) end - # A slugified version of the build ref, suitable for inclusion in URLs and - # domain names. Rules: - # - # * Lowercased - # * Anything not matching [a-z0-9-] is replaced with a - - # * Maximum length is 63 bytes - # * First/Last Character is not a hyphen - def ref_slug - Gitlab::Utils.slugify(ref.to_s) - end - - ## - # Variables in the environment name scope. - # - def scoped_variables(environment: expanded_environment_name) - Gitlab::Ci::Variables::Collection.new.tap do |variables| - variables.concat(predefined_variables) - variables.concat(project.predefined_variables) - variables.concat(pipeline.predefined_variables) - variables.concat(runner.predefined_variables) if runner - variables.concat(project.deployment_variables(environment: environment)) if environment - variables.concat(yaml_variables) - variables.concat(user_variables) - variables.concat(secret_group_variables) - variables.concat(secret_project_variables(environment: environment)) - variables.concat(trigger_request.user_variables) if trigger_request - variables.concat(pipeline.variables) - variables.concat(pipeline.pipeline_schedule.job_variables) if pipeline.pipeline_schedule - end - end - - ## - # Variables that do not depend on the environment name. - # - def simple_variables - strong_memoize(:simple_variables) do - scoped_variables(environment: nil).to_runner_variables - end - end - ## # All variables, including persisted environment variables. # @@ -451,12 +437,46 @@ module Ci end end - ## - # Regular Ruby hash of scoped variables, without duplicates that are - # possible to be present in an array of hashes returned from `variables`. - # - def scoped_variables_hash - scoped_variables.to_hash + CI_REGISTRY_USER = 'gitlab-ci-token'.freeze + + def persisted_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless persisted? + + variables + .concat(pipeline.persisted_variables) + .append(key: 'CI_JOB_ID', value: id.to_s) + .append(key: 'CI_JOB_URL', value: Gitlab::Routing.url_helpers.project_job_url(project, self)) + .append(key: 'CI_JOB_TOKEN', value: token.to_s, public: false, masked: true) + .append(key: 'CI_BUILD_ID', value: id.to_s) + .append(key: 'CI_BUILD_TOKEN', value: token.to_s, public: false, masked: true) + .append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER) + .append(key: 'CI_REGISTRY_PASSWORD', value: token.to_s, public: false, masked: true) + .append(key: 'CI_REPOSITORY_URL', value: repo_url.to_s, public: false) + .concat(deploy_token_variables) + end + end + + def persisted_environment_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless persisted? && persisted_environment.present? + + variables.concat(persisted_environment.predefined_variables) + + # Here we're passing unexpanded environment_url for runner to expand, + # and we need to make sure that CI_ENVIRONMENT_NAME and + # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded. + variables.append(key: 'CI_ENVIRONMENT_URL', value: environment_url) if environment_url + end + end + + def deploy_token_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless gitlab_deploy_token + + variables.append(key: 'CI_DEPLOY_USER', value: gitlab_deploy_token.username) + variables.append(key: 'CI_DEPLOY_PASSWORD', value: gitlab_deploy_token.token, public: false, masked: true) + end end def features @@ -511,6 +531,26 @@ module Ci trace.exist? end + def artifacts_file + job_artifacts_archive&.file + end + + def artifacts_size + job_artifacts_archive&.size + end + + def artifacts_metadata + job_artifacts_metadata&.file + end + + def artifacts? + !artifacts_expired? && artifacts_file&.exists? + end + + def artifacts_metadata? + artifacts? && artifacts_metadata&.exists? + end + def has_job_artifacts? job_artifacts.any? end @@ -579,14 +619,12 @@ module Ci # and use that for `ExpireBuildInstanceArtifactsWorker`? def erase_erasable_artifacts! job_artifacts.erasable.destroy_all # rubocop: disable DestroyAll - erase_old_artifacts! end def erase(opts = {}) return false unless erasable? job_artifacts.destroy_all # rubocop: disable DestroyAll - erase_old_artifacts! erase_trace! update_erased!(opts[:erased_by]) end @@ -624,37 +662,13 @@ module Ci end def artifacts_file_for_type(type) - file = job_artifacts.find_by(file_type: Ci::JobArtifact.file_types[type])&.file - # TODO: to be removed once legacy artifacts is removed - file ||= legacy_artifacts_file if type == :archive - file + job_artifacts.find_by(file_type: Ci::JobArtifact.file_types[type])&.file end def coverage_regex super || project.try(:build_coverage_regex) end - def user_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - break variables if user.blank? - - variables.append(key: 'GITLAB_USER_ID', value: user.id.to_s) - variables.append(key: 'GITLAB_USER_EMAIL', value: user.email) - variables.append(key: 'GITLAB_USER_LOGIN', value: user.username) - variables.append(key: 'GITLAB_USER_NAME', value: user.name) - end - end - - def secret_group_variables - return [] unless project.group - - project.group.ci_variables_for(git_ref, project) - end - - def secret_project_variables(environment: persisted_environment) - project.ci_variables_for(ref: git_ref, environment: environment) - end - def steps [Gitlab::Ci::Build::Step.from_commands(self), Gitlab::Ci::Build::Step.from_after_script(self)].compact @@ -755,9 +769,13 @@ module Ci end end + def report_artifacts + job_artifacts.with_reports + end + # Virtual deployment status depending on the environment status. def deployment_status - return nil unless starts_environment? + return unless starts_environment? if success? return successful_deployment_status @@ -770,13 +788,6 @@ module Ci private - def erase_old_artifacts! - # TODO: To be removed once we get rid of - remove_artifacts_file! - remove_artifacts_metadata! - save - end - def successful_deployment_status if deployment&.last? :last @@ -798,10 +809,6 @@ module Ci job_artifacts.select { |artifact| artifact.file_type.in?(report_types) } end - def update_artifacts_size - self.artifacts_size = legacy_artifacts_file&.size - end - def erase_trace! trace.erase! end @@ -814,89 +821,6 @@ module Ci @unscoped_project ||= Project.unscoped.find_by(id: project_id) end - CI_REGISTRY_USER = 'gitlab-ci-token'.freeze - - def persisted_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - break variables unless persisted? - - variables - .concat(pipeline.persisted_variables) - .append(key: 'CI_JOB_ID', value: id.to_s) - .append(key: 'CI_JOB_URL', value: Gitlab::Routing.url_helpers.project_job_url(project, self)) - .append(key: 'CI_JOB_TOKEN', value: token.to_s, public: false) - .append(key: 'CI_BUILD_ID', value: id.to_s) - .append(key: 'CI_BUILD_TOKEN', value: token.to_s, public: false) - .append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER) - .append(key: 'CI_REGISTRY_PASSWORD', value: token.to_s, public: false) - .append(key: 'CI_REPOSITORY_URL', value: repo_url.to_s, public: false) - .concat(deploy_token_variables) - end - end - - def predefined_variables # rubocop:disable Metrics/AbcSize - Gitlab::Ci::Variables::Collection.new.tap do |variables| - variables.append(key: 'CI', value: 'true') - variables.append(key: 'GITLAB_CI', value: 'true') - variables.append(key: 'GITLAB_FEATURES', value: project.licensed_features.join(',')) - variables.append(key: 'CI_SERVER_NAME', value: 'GitLab') - variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION) - variables.append(key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s) - variables.append(key: 'CI_SERVER_VERSION_MINOR', value: Gitlab.version_info.minor.to_s) - variables.append(key: 'CI_SERVER_VERSION_PATCH', value: Gitlab.version_info.patch.to_s) - variables.append(key: 'CI_SERVER_REVISION', value: Gitlab.revision) - variables.append(key: 'CI_JOB_NAME', value: name) - variables.append(key: 'CI_JOB_STAGE', value: stage) - variables.append(key: 'CI_COMMIT_SHA', value: sha) - variables.append(key: 'CI_COMMIT_SHORT_SHA', value: short_sha) - variables.append(key: 'CI_COMMIT_BEFORE_SHA', value: before_sha) - variables.append(key: 'CI_COMMIT_REF_NAME', value: ref) - variables.append(key: 'CI_COMMIT_REF_SLUG', value: ref_slug) - variables.append(key: "CI_COMMIT_TAG", value: ref) if tag? - variables.append(key: "CI_PIPELINE_TRIGGERED", value: 'true') if trigger_request - 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.concat(legacy_variables) - end - end - - def legacy_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - variables.append(key: 'CI_BUILD_REF', value: sha) - variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha) - variables.append(key: 'CI_BUILD_REF_NAME', value: ref) - variables.append(key: 'CI_BUILD_REF_SLUG', value: ref_slug) - variables.append(key: 'CI_BUILD_NAME', value: name) - variables.append(key: 'CI_BUILD_STAGE', value: stage) - variables.append(key: "CI_BUILD_TAG", value: ref) if tag? - variables.append(key: "CI_BUILD_TRIGGERED", value: 'true') if trigger_request - variables.append(key: "CI_BUILD_MANUAL", value: 'true') if action? - end - end - - def persisted_environment_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - break variables unless persisted? && persisted_environment.present? - - variables.concat(persisted_environment.predefined_variables) - - # Here we're passing unexpanded environment_url for runner to expand, - # and we need to make sure that CI_ENVIRONMENT_NAME and - # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded. - variables.append(key: 'CI_ENVIRONMENT_URL', value: environment_url) if environment_url - end - end - - def deploy_token_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - break variables unless gitlab_deploy_token - - variables.append(key: 'CI_DEPLOY_USER', value: gitlab_deploy_token.username) - variables.append(key: 'CI_DEPLOY_PASSWORD', value: gitlab_deploy_token.token, public: false) - end - end - def environment_url options&.dig(:environment, :url) || persisted_environment&.external_url end @@ -919,21 +843,5 @@ module Ci pipeline.config_processor.build_attributes(name) end - - def update_project_statistics_after_save - update_project_statistics(read_attribute(:artifacts_size).to_i - artifacts_size_was.to_i) - end - - def update_project_statistics_after_destroy - update_project_statistics(-artifacts_size) - end - - def update_project_statistics(difference) - ProjectStatistics.increment_statistic(project_id, :build_artifacts_size, difference) - end - - def project_destroyed? - project.pending_delete? - end end end diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index cd8eb774cf5..f281cbd1d6f 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -3,7 +3,7 @@ module Ci # The purpose of this class is to store Build related data that can be disposed. # Data that should be persisted forever, should be stored with Ci::Build model. - class BuildMetadata < ActiveRecord::Base + class BuildMetadata < ApplicationRecord extend Gitlab::Ci::Model include Presentable include ChronicDurationAttribute diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb index 457d7eeab6a..997bf298025 100644 --- a/app/models/ci/build_runner_session.rb +++ b/app/models/ci/build_runner_session.rb @@ -3,22 +3,34 @@ module Ci # The purpose of this class is to store Build related runner session. # Data will be removed after transitioning from running to any state. - class BuildRunnerSession < ActiveRecord::Base + class BuildRunnerSession < ApplicationRecord extend Gitlab::Ci::Model + TERMINAL_SUBPROTOCOL = 'terminal.gitlab.com'.freeze + self.table_name = 'ci_builds_runner_session' belongs_to :build, class_name: 'Ci::Build', inverse_of: :runner_session validates :build, presence: true - validates :url, url: { protocols: %w(https) } + validates :url, addressable_url: { schemes: %w(https) } def terminal_specification - return {} unless url.present? + wss_url = Gitlab::UrlHelpers.as_wss(self.url) + return {} unless wss_url.present? + + wss_url = "#{wss_url}/exec" + channel_specification(wss_url, TERMINAL_SUBPROTOCOL) + end + + private + + def channel_specification(url, subprotocol) + return {} if subprotocol.blank? || url.blank? { - subprotocols: ['terminal.gitlab.com'].freeze, - url: "#{url}/exec".sub("https://", "wss://"), + subprotocols: Array(subprotocol), + url: url, headers: { Authorization: [authorization.presence] }.compact, ca_pem: certificate.presence } diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 33e61cd2111..0a7a0e0772b 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class BuildTraceChunk < ActiveRecord::Base + class BuildTraceChunk < ApplicationRecord include FastDestroyAll include ::Gitlab::ExclusiveLeaseHelpers extend Gitlab::Ci::Model @@ -115,7 +115,7 @@ module Ci current_data = get_data unless current_data&.bytesize.to_i == CHUNK_SIZE - raise FailedToPersistDataError, 'Data is not fullfilled in a bucket' + raise FailedToPersistDataError, 'Data is not fulfilled in a bucket' end old_store_class = self.class.get_store_class(data_store) diff --git a/app/models/ci/build_trace_section.rb b/app/models/ci/build_trace_section.rb index a4bee59c83b..8be42eb48d6 100644 --- a/app/models/ci/build_trace_section.rb +++ b/app/models/ci/build_trace_section.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class BuildTraceSection < ActiveRecord::Base + class BuildTraceSection < ApplicationRecord extend Gitlab::Ci::Model belongs_to :build, class_name: 'Ci::Build' diff --git a/app/models/ci/build_trace_section_name.rb b/app/models/ci/build_trace_section_name.rb index cbdf3c4b673..c065cfea14e 100644 --- a/app/models/ci/build_trace_section_name.rb +++ b/app/models/ci/build_trace_section_name.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class BuildTraceSectionName < ActiveRecord::Base + class BuildTraceSectionName < ApplicationRecord extend Gitlab::Ci::Model belongs_to :project diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb index 492d1d0329e..0e50265c7ba 100644 --- a/app/models/ci/group_variable.rb +++ b/app/models/ci/group_variable.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true module Ci - class GroupVariable < ActiveRecord::Base + class GroupVariable < ApplicationRecord extend Gitlab::Ci::Model include HasVariable include Presentable + include Maskable belongs_to :group, class_name: "::Group" diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 789bb293811..f80e98e5bca 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true module Ci - class JobArtifact < ActiveRecord::Base + class JobArtifact < ApplicationRecord include AfterCommitQueue include ObjectStorage::BackgroundMove + include UpdateProjectStatistics extend Gitlab::Ci::Model NotSupportedAdapterError = Class.new(StandardError) @@ -21,14 +22,19 @@ module Ci container_scanning: 'gl-container-scanning-report.json', dast: 'gl-dast-report.json', license_management: 'gl-license-management-report.json', - performance: 'performance.json' + performance: 'performance.json', + metrics: 'metrics.txt' }.freeze - TYPE_AND_FORMAT_PAIRS = { + INTERNAL_TYPES = { archive: :zip, metadata: :gzip, - trace: :raw, + trace: :raw + }.freeze + + REPORT_TYPES = { junit: :gzip, + metrics: :gzip, # All these file formats use `raw` as we need to store them uncompressed # for Frontend to fetch the files and do analysis @@ -42,6 +48,8 @@ module Ci performance: :raw }.freeze + TYPE_AND_FORMAT_PAIRS = INTERNAL_TYPES.merge(REPORT_TYPES).freeze + belongs_to :project belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id @@ -50,10 +58,10 @@ module Ci validates :file_format, presence: true, unless: :trace?, on: :create validate :valid_file_format?, unless: :trace?, on: :create before_save :set_size, if: :file_changed? - after_save :update_project_statistics_after_save, if: :size_changed? - after_destroy :update_project_statistics_after_destroy, unless: :project_destroyed? - after_save :update_file_store, if: :file_changed? + update_project_statistics project_statistics_name: :build_artifacts_size + + after_save :update_file_store, if: :saved_change_to_file? scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) } @@ -63,6 +71,10 @@ module Ci where(file_type: types) end + scope :with_reports, -> do + with_file_types(REPORT_TYPES.keys.map(&:to_s)) + end + scope :test_reports, -> do with_file_types(TEST_REPORT_FILE_TYPES) end @@ -88,14 +100,15 @@ module Ci dast: 8, ## EE-specific codequality: 9, ## EE-specific license_management: 10, ## EE-specific - performance: 11 ## EE-specific + performance: 11, ## EE-specific + metrics: 12 ## EE-specific } enum file_format: { raw: 1, zip: 2, gzip: 3 - } + }, _suffix: true # `file_location` indicates where actual files are stored. # Ideally, actual files should be stored in the same directory, and use the same @@ -173,18 +186,6 @@ module Ci self.size = file.size end - def update_project_statistics_after_save - update_project_statistics(size.to_i - size_was.to_i) - end - - def update_project_statistics_after_destroy - update_project_statistics(-self.size.to_i) - end - - def update_project_statistics(difference) - ProjectStatistics.increment_statistic(project_id, :build_artifacts_size, difference) - end - def project_destroyed? # Use job.project to avoid extra DB query for project job.project.pending_delete? diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb index 96dbc7b6895..930c8a71453 100644 --- a/app/models/ci/legacy_stage.rb +++ b/app/models/ci/legacy_stage.rb @@ -58,5 +58,9 @@ module Ci statuses.latest.failed_but_allowed.any? end end + + def manual_playable? + %[manual scheduled skipped].include?(status.to_s) + end end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index eb15347b4e1..3727a9861aa 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class Pipeline < ActiveRecord::Base + class Pipeline < ApplicationRecord extend Gitlab::Ci::Model include HasStatus include Importable @@ -12,6 +12,11 @@ module Ci include AtomicInternalId include EnumWithNil include HasRef + include ShaAttribute + include FromUnion + + sha_attribute :source_sha + sha_attribute :target_sha belongs_to :project, inverse_of: :all_pipelines belongs_to :user @@ -35,7 +40,7 @@ module Ci # Merge requests for which the current pipeline is running against # the merge request's latest commit. - has_many :merge_requests, foreign_key: "head_pipeline_id" + has_many :merge_requests_as_head_pipeline, foreign_key: "head_pipeline_id", class_name: 'MergeRequest' has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build' has_many :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build' @@ -56,9 +61,9 @@ module Ci validates :sha, presence: { unless: :importing? } validates :ref, presence: { unless: :importing? } - validates :merge_request, presence: { if: :merge_request? } - validates :merge_request, absence: { unless: :merge_request? } - validates :tag, inclusion: { in: [false], if: :merge_request? } + validates :merge_request, presence: { if: :merge_request_event? } + validates :merge_request, absence: { unless: :merge_request_event? } + validates :tag, inclusion: { in: [false], if: :merge_request_event? } validates :status, presence: { unless: :importing? } validate :valid_commit_sha, unless: :importing? validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create @@ -77,10 +82,14 @@ module Ci state_machine :status, initial: :created do event :enqueue do - transition [:created, :skipped, :scheduled] => :pending + transition [:created, :preparing, :skipped, :scheduled] => :pending transition [:success, :failed, :canceled] => :running end + event :prepare do + transition any - [:preparing] => :preparing + end + event :run do transition any - [:running] => :running end @@ -113,7 +122,7 @@ module Ci # Do not add any operations to this state_machine # Create a separate worker for each new operation - before_transition [:created, :pending] => :running do |pipeline| + before_transition [:created, :preparing, :pending] => :running do |pipeline| pipeline.started_at = Time.now end @@ -136,7 +145,7 @@ module Ci end end - after_transition [:created, :pending] => :running do |pipeline| + after_transition [:created, :preparing, :pending] => :running do |pipeline| pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) } end @@ -144,7 +153,7 @@ module Ci pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) } end - after_transition [:created, :pending, :running] => :success do |pipeline| + after_transition [:created, :preparing, :pending, :running] => :success do |pipeline| pipeline.run_after_commit { PipelineSuccessWorker.perform_async(pipeline.id) } end @@ -157,6 +166,16 @@ module Ci end end + after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline| + pipeline.run_after_commit do + pipeline.all_merge_requests.each do |merge_request| + next unless merge_request.auto_merge_enabled? + + AutoMergeProcessWorker.perform_async(merge_request.id) + end + end + end + after_transition any => [:success, :failed] do |pipeline| pipeline.run_after_commit do PipelineNotificationWorker.perform_async(pipeline.id) @@ -175,20 +194,34 @@ module Ci scope :sort_by_merge_request_pipelines, -> do sql = 'CASE ci_pipelines.source WHEN (?) THEN 0 ELSE 1 END, ci_pipelines.id DESC' - query = ActiveRecord::Base.send(:sanitize_sql_array, [sql, sources[:merge_request]]) # rubocop:disable GitlabSecurity/PublicSend + query = ApplicationRecord.send(:sanitize_sql_array, [sql, sources[:merge_request_event]]) # rubocop:disable GitlabSecurity/PublicSend order(query) end scope :for_user, -> (user) { where(user: user) } + 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 :triggered_by_merge_request, -> (merge_request) do + where(source: :merge_request_event, merge_request: merge_request) + end + + scope :detached_merge_request_pipelines, -> (merge_request, sha) do + triggered_by_merge_request(merge_request).for_sha(sha) + end + + scope :merge_request_pipelines, -> (merge_request, source_sha) do + triggered_by_merge_request(merge_request).for_source_sha(source_sha) + end - scope :for_merge_request, -> (merge_request, ref, sha) do - ## - # We have to filter out unrelated MR pipelines. - # When merge request is empty, it selects general pipelines, such as push sourced pipelines. - # When merge request is matched, it selects MR pipelines. - where(merge_request: [nil, merge_request], ref: ref, sha: sha) - .sort_by_merge_request_pipelines + scope :triggered_for_branch, -> (ref) do + where(source: branch_pipeline_sources).where(ref: ref, tag: false) + end + + scope :with_reports, -> (reports_scope) do + where('EXISTS (?)', ::Ci::Build.latest.with_reports(reports_scope).where('ci_pipelines.id=ci_builds.commit_id').select(1)) end # Returns the pipelines in descending order (= newest first), optionally @@ -278,8 +311,8 @@ module Ci sources.reject { |source| source == "external" }.values end - def self.latest_for_merge_request(merge_request, ref, sha) - for_merge_request(merge_request, ref, sha).first + def self.branch_pipeline_sources + @branch_pipeline_sources ||= sources.reject { |source| source == 'merge_request_event' }.values end def self.ci_sources_values @@ -397,10 +430,6 @@ module Ci @commit ||= Commit.lazy(project, sha) end - def branch? - super && !merge_request? - end - def stuck? pending_builds.any?(&:stuck?) end @@ -446,9 +475,9 @@ module Ci end def latest? - return false unless ref && commit.present? + return false unless git_ref && commit.present? - project.commit(ref) == commit + project.commit(git_ref) == commit end def retried @@ -582,6 +611,7 @@ module Ci retry_optimistic_lock(self) do case latest_builds_status.to_s when 'created' then nil + when 'preparing' then prepare when 'pending' then enqueue when 'running' then run when 'success' then succeed @@ -622,8 +652,11 @@ module Ci variables.append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s) variables.append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s) variables.append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s) + variables.append(key: 'CI_COMMIT_REF_PROTECTED', value: (!!protected_ref?).to_s) - if merge_request? && merge_request + if merge_request_event? && merge_request + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', value: source_sha.to_s) + variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_SHA', value: target_sha.to_s) variables.concat(merge_request.predefined_variables) end end @@ -651,10 +684,10 @@ module Ci # All the merge requests for which the current pipeline runs/ran against def all_merge_requests @all_merge_requests ||= - if merge_request? - project.merge_requests.where(id: merge_request_id) + if merge_request_event? + MergeRequest.where(id: merge_request_id) else - project.merge_requests.where(source_branch: ref) + MergeRequest.where(source_project_id: project_id, source_branch: ref) end end @@ -668,16 +701,16 @@ module Ci # We purposely cast the builds to an Array here. Because we always use the # rows if there are more than 0 this prevents us from having to run two # queries: one to get the count and one to get the rows. - @latest_builds_with_artifacts ||= builds.latest.with_artifacts_archive.to_a + @latest_builds_with_artifacts ||= builds.latest.with_artifacts_not_expired.to_a end - def has_test_reports? - complete? && builds.latest.with_test_reports.any? + def has_reports?(reports_scope) + complete? && builds.latest.with_reports(reports_scope).exists? end def test_reports Gitlab::Ci::Reports::TestReports.new.tap do |test_reports| - builds.latest.with_test_reports.each do |build| + builds.latest.with_reports(Ci::JobArtifact.test_reports).each do |build| build.collect_test_reports!(test_reports) end end @@ -696,7 +729,7 @@ module Ci # * nil: Modified path can not be evaluated def modified_paths strong_memoize(:modified_paths) do - if merge_request? + if merge_request_event? merge_request.modified_paths elsif branch_updated? push_details.modified_paths @@ -708,6 +741,50 @@ module Ci ref == project.default_branch end + def triggered_by_merge_request? + merge_request_event? && merge_request_id.present? + end + + def detached_merge_request_pipeline? + triggered_by_merge_request? && target_sha.nil? + end + + def legacy_detached_merge_request_pipeline? + detached_merge_request_pipeline? && !merge_request_ref? + end + + def merge_request_pipeline? + triggered_by_merge_request? && target_sha.present? + end + + def merge_request_ref? + MergeRequest.merge_request_ref?(ref) + end + + def matches_sha_or_source_sha?(sha) + self.sha == sha || self.source_sha == sha + end + + def triggered_by?(current_user) + user == current_user + end + + def source_ref + if triggered_by_merge_request? + merge_request.source_branch + else + ref + end + end + + def source_ref_slug + Gitlab::Utils.slugify(source_ref.to_s) + end + + def find_stage_by_name!(name) + stages.find_by!(name: name) + end + private def ci_yaml_from_repo @@ -739,16 +816,18 @@ module Ci end def git_ref - if merge_request? - ## - # In the future, we're going to change this ref to - # merge request's merged reference, such as "refs/merge-requests/:iid/merge". - # In order to do that, we have to update GitLab-Runner's source pulling - # logic. - # See https://gitlab.com/gitlab-org/gitlab-runner/merge_requests/1092 - Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s - else - super + strong_memoize(:git_ref) do + if merge_request_event? + ## + # In the future, we're going to change this ref to + # merge request's merged reference, such as "refs/merge-requests/:iid/merge". + # In order to do that, we have to update GitLab-Runner's source pulling + # logic. + # See https://gitlab.com/gitlab-org/gitlab-runner/merge_requests/1092 + Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s + else + super + end end end diff --git a/app/models/ci/pipeline_chat_data.rb b/app/models/ci/pipeline_chat_data.rb index 8d37500fec5..65466a8c6f8 100644 --- a/app/models/ci/pipeline_chat_data.rb +++ b/app/models/ci/pipeline_chat_data.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class PipelineChatData < ActiveRecord::Base + class PipelineChatData < ApplicationRecord self.table_name = 'ci_pipeline_chat_data' belongs_to :chat_name diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb index 4be4fdb1ff2..571c4271475 100644 --- a/app/models/ci/pipeline_enums.rb +++ b/app/models/ci/pipeline_enums.rb @@ -23,7 +23,7 @@ module Ci api: 5, external: 6, chat: 8, - merge_request: 10 + merge_request_event: 10 } end diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index 1c1f203bdb2..c40ad39be61 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true module Ci - class PipelineSchedule < ActiveRecord::Base + class PipelineSchedule < ApplicationRecord extend Gitlab::Ci::Model include Importable include IgnorableColumn + include StripAttribute ignore_column :deleted_at @@ -22,11 +23,17 @@ module Ci before_save :set_next_run_at + strip_attributes :cron + scope :active, -> { where(active: true) } scope :inactive, -> { where(active: false) } + scope :runnable_schedules, -> { active.where("next_run_at < ?", Time.now) } + scope :preloaded, -> { preload(:owner, :project) } accepts_nested_attributes_for :variables, allow_destroy: true + alias_attribute :real_next_run, :next_run_at + def owned_by?(current_user) owner == current_user end @@ -43,8 +50,14 @@ module Ci update_attribute(:active, false) end + ## + # The `next_run_at` column is set to the actual execution date of `PipelineScheduleWorker`. + # This way, a schedule like `*/1 * * * *` won't be triggered in a short interval + # when PipelineScheduleWorker runs irregularly by Sidekiq Memory Killer. def set_next_run_at - self.next_run_at = Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now) + self.next_run_at = Gitlab::Ci::CronParser.new(Settings.cron_jobs['pipeline_schedule_worker']['cron'], + Time.zone.name) + .next_time_from(ideal_next_run_at) end def schedule_next_run! @@ -53,15 +66,14 @@ module Ci update_attribute(:next_run_at, nil) # update without validation end - def real_next_run( - worker_cron: Settings.cron_jobs['pipeline_schedule_worker']['cron'], - worker_time_zone: Time.zone.name) - Gitlab::Ci::CronParser.new(worker_cron, worker_time_zone) - .next_time_from(next_run_at) - end - def job_variables variables&.map(&:to_runner_variable) || [] end + + private + + def ideal_next_run_at + Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now) + end end end diff --git a/app/models/ci/pipeline_schedule_variable.rb b/app/models/ci/pipeline_schedule_variable.rb index fbb9987cab2..be6e5e76c31 100644 --- a/app/models/ci/pipeline_schedule_variable.rb +++ b/app/models/ci/pipeline_schedule_variable.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class PipelineScheduleVariable < ActiveRecord::Base + class PipelineScheduleVariable < ApplicationRecord extend Gitlab::Ci::Model include HasVariable diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb index 08514d6af4e..51a6272e1ff 100644 --- a/app/models/ci/pipeline_variable.rb +++ b/app/models/ci/pipeline_variable.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class PipelineVariable < ActiveRecord::Base + class PipelineVariable < ApplicationRecord extend Gitlab::Ci::Model include HasVariable diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 5aae31de6e2..07d00503861 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class Runner < ActiveRecord::Base + class Runner < ApplicationRecord extend Gitlab::Ci::Model include Gitlab::SQL::Pattern include IgnorableColumn @@ -10,7 +10,7 @@ module Ci include FromUnion include TokenAuthenticatable - add_authentication_token_field :token, encrypted: true, migrating: true + add_authentication_token_field :token, encrypted: -> { Feature.enabled?(:ci_runners_tokens_optional_encryption, default_enabled: true) ? :optional : :required } enum access_level: { not_protected: 0, @@ -97,6 +97,7 @@ module Ci scope :order_contacted_at_asc, -> { order(contacted_at: :asc) } scope :order_created_at_desc, -> { order(created_at: :desc) } + scope :with_tags, -> { preload(:tags) } validate :tag_constraints validates :access_level, presence: true diff --git a/app/models/ci/runner_namespace.rb b/app/models/ci/runner_namespace.rb index 22b80b98551..6903e8a21a1 100644 --- a/app/models/ci/runner_namespace.rb +++ b/app/models/ci/runner_namespace.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class RunnerNamespace < ActiveRecord::Base + class RunnerNamespace < ApplicationRecord extend Gitlab::Ci::Model belongs_to :runner, inverse_of: :runner_namespaces, validate: true diff --git a/app/models/ci/runner_project.rb b/app/models/ci/runner_project.rb index 1a718d24141..f5bd50dc5a3 100644 --- a/app/models/ci/runner_project.rb +++ b/app/models/ci/runner_project.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class RunnerProject < ActiveRecord::Base + class RunnerProject < ApplicationRecord extend Gitlab::Ci::Model belongs_to :runner, inverse_of: :runner_projects diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 0389945191e..d90339d90dc 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class Stage < ActiveRecord::Base + class Stage < ApplicationRecord extend Gitlab::Ci::Model include Importable include HasStatus @@ -39,10 +39,14 @@ module Ci state_machine :status, initial: :created do event :enqueue do - transition created: :pending + transition [:created, :preparing] => :pending transition [:success, :failed, :canceled, :skipped] => :running end + event :prepare do + transition any - [:preparing] => :preparing + end + event :run do transition any - [:running] => :running end @@ -76,6 +80,7 @@ module Ci retry_optimistic_lock(self) do case statuses.latest.status when 'created' then nil + when 'preparing' then prepare when 'pending' then enqueue when 'running' then run when 'success' then succeed @@ -115,5 +120,9 @@ module Ci .new(self, current_user) .fabricate! end + + def manual_playable? + blocked? || skipped? + end end end diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 637148c4ce4..8927bb9bc18 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class Trigger < ActiveRecord::Base + class Trigger < ApplicationRecord extend Gitlab::Ci::Model include IgnorableColumn include Presentable diff --git a/app/models/ci/trigger_request.rb b/app/models/ci/trigger_request.rb index 0b52c690e93..5daf3dd192d 100644 --- a/app/models/ci/trigger_request.rb +++ b/app/models/ci/trigger_request.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class TriggerRequest < ActiveRecord::Base + class TriggerRequest < ApplicationRecord extend Gitlab::Ci::Model belongs_to :trigger diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index 524d79014f8..a77bbef0fca 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true module Ci - class Variable < ActiveRecord::Base + class Variable < ApplicationRecord extend Gitlab::Ci::Model include HasVariable include Presentable + include Maskable belongs_to :project diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb index c758577815a..d6a7d1d2bdd 100644 --- a/app/models/clusters/applications/cert_manager.rb +++ b/app/models/clusters/applications/cert_manager.rb @@ -2,7 +2,7 @@ module Clusters module Applications - class CertManager < ActiveRecord::Base + class CertManager < ApplicationRecord VERSION = 'v0.5.2'.freeze self.table_name = 'clusters_applications_cert_managers' @@ -24,6 +24,12 @@ module Clusters 'stable/cert-manager' end + # We will implement this in future MRs. + # Need to reverse postinstall step + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: 'certmanager', diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb index 423071ec024..a83d06c4b00 100644 --- a/app/models/clusters/applications/helm.rb +++ b/app/models/clusters/applications/helm.rb @@ -4,7 +4,7 @@ require 'openssl' module Clusters module Applications - class Helm < ActiveRecord::Base + class Helm < ApplicationRecord self.table_name = 'clusters_applications_helm' attr_encrypted :ca_key, @@ -29,6 +29,13 @@ module Clusters self.status = 'installable' if cluster&.platform_kubernetes_active? end + # We will implement this in future MRs. + # Basically we need to check all other applications are not installed + # first. + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InitCommand.new( name: name, diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index 7c15aaa4825..a1023f44049 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -2,7 +2,7 @@ module Clusters module Applications - class Ingress < ActiveRecord::Base + class Ingress < ApplicationRecord VERSION = '1.1.2'.freeze self.table_name = 'clusters_applications_ingress' @@ -35,6 +35,13 @@ module Clusters 'stable/nginx-ingress' end + # We will implement this in future MRs. + # Basically we need to check all dependent applications are not installed + # first. + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, @@ -48,6 +55,7 @@ module Clusters def schedule_status_update return unless installed? return if external_ip + return if external_hostname ClusterWaitForIngressIpAddressWorker.perform_async(name, id) end diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb index 421a923d386..4aaa1f941e5 100644 --- a/app/models/clusters/applications/jupyter.rb +++ b/app/models/clusters/applications/jupyter.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true +require 'securerandom' + module Clusters module Applications - class Jupyter < ActiveRecord::Base - VERSION = 'v0.6'.freeze + class Jupyter < ApplicationRecord + VERSION = '0.9-174bbd5'.freeze self.table_name = 'clusters_applications_jupyter' @@ -18,8 +20,10 @@ module Clusters def set_initial_status return unless not_installable? + return unless cluster&.application_ingress_available? - if cluster&.application_ingress_available? && cluster.application_ingress.external_ip + ingress = cluster.application_ingress + if ingress.external_ip || ingress.external_hostname self.status = 'installable' end end @@ -36,6 +40,12 @@ module Clusters content_values.to_yaml end + # Will be addressed in future MRs + # We need to investigate and document what will be permanently deleted. + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, @@ -51,6 +61,10 @@ module Clusters "http://#{hostname}/hub/oauth_callback" end + def oauth_scopes + 'api read_repository write_repository' + end + private def specification @@ -72,24 +86,41 @@ module Clusters "secretToken" => secret_token }, "auth" => { + "state" => { + "cryptoKey" => crypto_key + }, "gitlab" => { "clientId" => oauth_application.uid, "clientSecret" => oauth_application.secret, - "callbackUrl" => callback_url + "callbackUrl" => callback_url, + "gitlabProjectIdWhitelist" => [project_id] } }, "singleuser" => { "extraEnv" => { - "GITLAB_CLUSTER_ID" => cluster.id + "GITLAB_CLUSTER_ID" => cluster.id.to_s, + "GITLAB_HOST" => gitlab_host } } } end + def crypto_key + @crypto_key ||= SecureRandom.hex(32) + end + + def project_id + cluster&.project&.id + end + def gitlab_url Gitlab.config.gitlab.url end + def gitlab_host + Gitlab.config.gitlab.host + end + def content_values YAML.load_file(chart_values_file).deep_merge!(specification) end diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index 8d79b041b64..d5a3bd62e3d 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -2,8 +2,8 @@ module Clusters module Applications - class Knative < ActiveRecord::Base - VERSION = '0.2.2'.freeze + class Knative < ApplicationRecord + VERSION = '0.5.0'.freeze REPOSITORY = 'https://storage.googleapis.com/triggermesh-charts'.freeze METRICS_CONFIG = 'https://storage.googleapis.com/triggermesh-charts/istio-metrics.yaml'.freeze FETCH_IP_ADDRESS_DELAY = 30.seconds @@ -15,9 +15,6 @@ module Clusters include ::Clusters::Concerns::ApplicationVersion include ::Clusters::Concerns::ApplicationData include AfterCommitQueue - include ReactiveCaching - - self.reactive_cache_key = ->(knative) { [knative.class.model_name.singular, knative.id] } def set_initial_status return unless not_installable? @@ -41,8 +38,6 @@ module Clusters scope :for_cluster, -> (cluster) { where(cluster: cluster) } - after_save :clear_reactive_cache! - def chart 'knative/knative' end @@ -51,6 +46,12 @@ module Clusters { "domain" => hostname }.to_yaml end + # Handled in a new issue: + # https://gitlab.com/gitlab-org/gitlab-ce/issues/59369 + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, @@ -66,59 +67,17 @@ module Clusters def schedule_status_update return unless installed? return if external_ip + return if external_hostname ClusterWaitForIngressIpAddressWorker.perform_async(name, id) end - def client - cluster.kubeclient.knative_client - end - - def services - with_reactive_cache do |data| - data[:services] - end - end - - def calculate_reactive_cache - { services: read_services, pods: read_pods } - end - def ingress_service - cluster.kubeclient.get_service('knative-ingressgateway', 'istio-system') - end - - def services_for(ns: namespace) - return [] unless services - return [] unless ns - - services.select do |service| - service.dig('metadata', 'namespace') == ns - end - end - - def service_pod_details(ns, service) - with_reactive_cache do |data| - data[:pods].select { |pod| filter_pods(pod, ns, service) } - end + cluster.kubeclient.get_service('istio-ingressgateway', 'istio-system') end private - def read_pods - cluster.kubeclient.core_client.get_pods.as_json - end - - def filter_pods(pod, namespace, service) - pod["metadata"]["namespace"] == namespace && pod["metadata"]["labels"]["serving.knative.dev/service"] == service - end - - def read_services - client.get_services.as_json - rescue Kubeclient::ResourceNotFoundError - [] - end - def install_knative_metrics ["kubectl apply -f #{METRICS_CONFIG}"] if cluster.application_prometheus_available? end diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index fa7ce363531..a6b7617b830 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -2,7 +2,7 @@ module Clusters module Applications - class Prometheus < ActiveRecord::Base + class Prometheus < ApplicationRecord include PrometheusAdapter VERSION = '6.7.3' @@ -16,10 +16,12 @@ module Clusters default_value_for :version, VERSION + after_destroy :disable_prometheus_integration + state_machine :status do after_transition any => [:installed] do |application| application.cluster.projects.each do |project| - project.find_or_initialize_service('prometheus').update(active: true) + project.find_or_initialize_service('prometheus').update!(active: true) end end end @@ -47,6 +49,14 @@ module Clusters ) end + def uninstall_command + Gitlab::Kubernetes::Helm::DeleteCommand.new( + name: name, + rbac: cluster.platform_kubernetes_rbac?, + files: files + ) + end + def upgrade_command(values) ::Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, @@ -82,6 +92,12 @@ module Clusters private + def disable_prometheus_integration + cluster.projects.each do |project| + project.prometheus_service&.update!(active: false) + end + end + def kube_client cluster&.kubeclient&.core_client end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 941551dadaa..db7fd8524c2 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -2,8 +2,8 @@ module Clusters module Applications - class Runner < ActiveRecord::Base - VERSION = '0.2.0'.freeze + class Runner < ApplicationRecord + VERSION = '0.5.2'.freeze self.table_name = 'clusters_applications_runners' @@ -13,7 +13,7 @@ module Clusters include ::Clusters::Concerns::ApplicationData belongs_to :runner, class_name: 'Ci::Runner', foreign_key: :runner_id - delegate :project, to: :cluster + delegate :project, :group, to: :cluster default_value_for :version, VERSION @@ -29,6 +29,13 @@ module Clusters content_values.to_yaml end + # Need to investigate if pipelines run by this runner will stop upon the + # executor pod stopping + # I.e.run a pipeline, and uninstall runner while pipeline is running + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, @@ -55,12 +62,19 @@ module Clusters end def runner_create_params - { + attributes = { name: 'kubernetes-cluster', - runner_type: :project_type, - tag_list: %w(kubernetes cluster), - projects: [project] + runner_type: cluster.cluster_type, + tag_list: %w[kubernetes cluster] } + + if cluster.group_type? + attributes[:groups] = [group] + elsif cluster.project_type? + attributes[:projects] = [project] + end + + attributes end def gitlab_url diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index be3e6a05e1e..e1d6b2a802b 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -1,22 +1,26 @@ # frozen_string_literal: true module Clusters - class Cluster < ActiveRecord::Base + class Cluster < ApplicationRecord include Presentable include Gitlab::Utils::StrongMemoize include FromUnion + include ReactiveCaching self.table_name = 'clusters' + self.reactive_cache_key = -> (cluster) { [cluster.class.model_name.singular, cluster.id] } + PROJECT_ONLY_APPLICATIONS = { + Applications::Jupyter.application_name => Applications::Jupyter, + Applications::Knative.application_name => Applications::Knative + }.freeze APPLICATIONS = { Applications::Helm.application_name => Applications::Helm, Applications::Ingress.application_name => Applications::Ingress, Applications::CertManager.application_name => Applications::CertManager, - Applications::Prometheus.application_name => Applications::Prometheus, Applications::Runner.application_name => Applications::Runner, - Applications::Jupyter.application_name => Applications::Jupyter, - Applications::Knative.application_name => Applications::Knative - }.freeze + Applications::Prometheus.application_name => Applications::Prometheus + }.merge(PROJECT_ONLY_APPLICATIONS).freeze DEFAULT_ENVIRONMENT = '*'.freeze KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN'.freeze @@ -43,7 +47,6 @@ module Clusters has_one :application_knative, class_name: 'Clusters::Applications::Knative' has_many :kubernetes_namespaces - has_one :kubernetes_namespace, -> { order(id: :desc) }, class_name: 'Clusters::KubernetesNamespace' accepts_nested_attributes_for :provider_gcp, update_only: true accepts_nested_attributes_for :platform_kubernetes, update_only: true @@ -56,6 +59,8 @@ module Clusters validate :no_groups, unless: :group_type? validate :no_projects, unless: :project_type? + after_save :clear_reactive_cache! + delegate :status, to: :provider, allow_nil: true delegate :status_reason, to: :provider, allow_nil: true delegate :on_creation?, to: :provider, allow_nil: true @@ -67,8 +72,10 @@ module Clusters delegate :available?, to: :application_prometheus, prefix: true, allow_nil: true delegate :available?, to: :application_knative, prefix: true, allow_nil: true delegate :external_ip, to: :application_ingress, prefix: true, allow_nil: true + delegate :external_hostname, to: :application_ingress, prefix: true, allow_nil: true alias_attribute :base_domain, :domain + alias_attribute :provided_by_user?, :user? enum cluster_type: { instance_type: 1, @@ -90,6 +97,7 @@ module Clusters 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 :managed, -> { where(managed: true) } scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) } @@ -103,29 +111,35 @@ module Clusters scope :preload_knative, -> { preload( - :kubernetes_namespace, + :kubernetes_namespaces, :platform_kubernetes, :application_knative ) } def self.ancestor_clusters_for_clusterable(clusterable, hierarchy_order: :asc) + return [] if clusterable.is_a?(Instance) + hierarchy_groups = clusterable.ancestors_upto(hierarchy_order: hierarchy_order).eager_load(:clusters) hierarchy_groups = hierarchy_groups.merge(current_scope) if current_scope - hierarchy_groups.flat_map(&:clusters) + hierarchy_groups.flat_map(&:clusters) + Instance.new.clusters end def status_name - if provider - provider.status_name - else - :created + provider&.status_name || connection_status.presence || :created + end + + def connection_status + with_reactive_cache do |data| + data[:connection_status] end end - def created? - status_name == :created + def calculate_reactive_cache + return unless enabled? + + { connection_status: retrieve_connection_status } end def applications @@ -148,10 +162,6 @@ module Clusters return platform_kubernetes if kubernetes? end - def managed? - !user? - end - def all_projects if project_type? projects @@ -176,20 +186,24 @@ module Clusters end alias_method :group, :first_group + def instance + Instance.new if instance_type? + end + def kubeclient platform_kubernetes.kubeclient if kubernetes? end + def kubernetes_namespace_for(project) + find_or_initialize_kubernetes_namespace_for_project(project).namespace + end + def find_or_initialize_kubernetes_namespace_for_project(project) - if project_type? - kubernetes_namespaces.find_or_initialize_by( - project: project, - cluster_project: cluster_project - ) - else - kubernetes_namespaces.find_or_initialize_by( - project: project - ) + attributes = { project: project } + attributes[:cluster_project] = cluster_project if project_type? + + kubernetes_namespaces.find_or_initialize_by(attributes).tap do |namespace| + namespace.set_defaults end end @@ -198,7 +212,7 @@ module Clusters end def kube_ingress_domain - @kube_ingress_domain ||= domain.presence || instance_domain || legacy_auto_devops_domain + @kube_ingress_domain ||= domain.presence || instance_domain end def predefined_variables @@ -209,12 +223,43 @@ module Clusters end end + def knative_services_finder(project) + @knative_services_finder ||= KnativeServicesFinder.new(self, project) + end + private def instance_domain @instance_domain ||= Gitlab::CurrentSettings.auto_devops_domain end + def retrieve_connection_status + kubeclient.core_client.discover + rescue *Gitlab::Kubernetes::Errors::CONNECTION + :unreachable + rescue *Gitlab::Kubernetes::Errors::AUTHENTICATION + :authentication_failure + rescue Kubeclient::HttpError => e + kubeclient_error_status(e.message) + rescue => e + Gitlab::Sentry.track_acceptable_exception(e, extra: { cluster_id: id }) + + :unknown_failure + else + :connected + end + + # KubeClient uses the same error class + # For connection errors (eg. timeout) and + # for Kubernetes errors. + def kubeclient_error_status(message) + if message&.match?(/timed out|timeout/i) + :unreachable + else + :authentication_failure + end + end + # To keep backward compatibility with AUTO_DEVOPS_DOMAIN # environment variable, we need to ensure KUBE_INGRESS_BASE_DOMAIN # is set if AUTO_DEVOPS_DOMAIN is set on any of the following options: diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb index 683b45331f6..4514498b84b 100644 --- a/app/models/clusters/concerns/application_core.rb +++ b/app/models/clusters/concerns/application_core.rb @@ -18,6 +18,16 @@ module Clusters self.status = 'installable' if cluster&.application_helm_available? end + def can_uninstall? + allowed_to_uninstall? + end + + # All new applications should uninstall by default + # Override if there's dependencies that needs to be uninstalled first + def allowed_to_uninstall? + true + end + def self.application_name self.to_s.demodulize.underscore end @@ -30,6 +40,12 @@ module Clusters # Override if you need extra data synchronized # from K8s after installation end + + def update_command + install_command.tap do |command| + command.version = version + end + end end end end diff --git a/app/models/clusters/concerns/application_data.rb b/app/models/clusters/concerns/application_data.rb index 52498f123ff..3479fea415e 100644 --- a/app/models/clusters/concerns/application_data.rb +++ b/app/models/clusters/concerns/application_data.rb @@ -3,48 +3,52 @@ module Clusters module Concerns module ApplicationData - extend ActiveSupport::Concern + def uninstall_command + Gitlab::Kubernetes::Helm::DeleteCommand.new( + name: name, + rbac: cluster.platform_kubernetes_rbac?, + files: files + ) + end - included do - def repository - nil - end + def repository + nil + end - def values - File.read(chart_values_file) - end + def values + File.read(chart_values_file) + end - def files - @files ||= begin - files = { 'values.yaml': values } + def files + @files ||= begin + files = { 'values.yaml': values } - files.merge!(certificate_files) if cluster.application_helm.has_ssl? + files.merge!(certificate_files) if cluster.application_helm.has_ssl? - files - end + files end + end - private + private - def certificate_files - { - 'ca.pem': ca_cert, - 'cert.pem': helm_cert.cert_string, - 'key.pem': helm_cert.key_string - } - end + def certificate_files + { + 'ca.pem': ca_cert, + 'cert.pem': helm_cert.cert_string, + 'key.pem': helm_cert.key_string + } + end - def ca_cert - cluster.application_helm.ca_cert - end + def ca_cert + cluster.application_helm.ca_cert + end - def helm_cert - @helm_cert ||= cluster.application_helm.issue_client_cert - end + def helm_cert + @helm_cert ||= cluster.application_helm.issue_client_cert + end - def chart_values_file - "#{Rails.root}/vendor/#{name}/values.yaml" - end + def chart_values_file + "#{Rails.root}/vendor/#{name}/values.yaml" end end end diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb index 1273ed83abe..54a3dda6d75 100644 --- a/app/models/clusters/concerns/application_status.rb +++ b/app/models/clusters/concerns/application_status.rb @@ -25,9 +25,11 @@ module Clusters state :updating, value: 4 state :updated, value: 5 state :update_errored, value: 6 + state :uninstalling, value: 7 + state :uninstall_errored, value: 8 event :make_scheduled do - transition [:installable, :errored, :installed, :updated, :update_errored] => :scheduled + transition [:installable, :errored, :installed, :updated, :update_errored, :uninstall_errored] => :scheduled end event :make_installing do @@ -40,8 +42,9 @@ module Clusters end event :make_errored do - transition any - [:updating] => :errored + transition any - [:updating, :uninstalling] => :errored transition [:updating] => :update_errored + transition [:uninstalling] => :uninstall_errored end event :make_updating do @@ -52,6 +55,10 @@ module Clusters transition any => :update_errored end + event :make_uninstalling do + transition [:scheduled] => :uninstalling + end + before_transition any => [:scheduled] do |app_status, _| app_status.status_reason = nil end @@ -65,7 +72,7 @@ module Clusters app_status.status_reason = nil end - before_transition any => [:update_errored] do |app_status, transition| + before_transition any => [:update_errored, :uninstall_errored] do |app_status, transition| status_reason = transition.args.first app_status.status_reason = status_reason if status_reason end diff --git a/app/models/clusters/group.rb b/app/models/clusters/group.rb index 2b08a9e47f0..27f39b53579 100644 --- a/app/models/clusters/group.rb +++ b/app/models/clusters/group.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Clusters - class Group < ActiveRecord::Base + class Group < ApplicationRecord self.table_name = 'cluster_groups' belongs_to :cluster, class_name: 'Clusters::Cluster' diff --git a/app/models/clusters/instance.rb b/app/models/clusters/instance.rb new file mode 100644 index 00000000000..d8a888d53ba --- /dev/null +++ b/app/models/clusters/instance.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Clusters + class Instance + def clusters + Clusters::Cluster.instance_type + end + + def feature_available?(feature) + ::Feature.enabled?(feature, default_enabled: true) + end + + def self.enabled? + ::Feature.enabled?(:instance_clusters, default_enabled: true) + end + end +end diff --git a/app/models/clusters/kubernetes_namespace.rb b/app/models/clusters/kubernetes_namespace.rb index 73da6cb37d7..b0c4900546e 100644 --- a/app/models/clusters/kubernetes_namespace.rb +++ b/app/models/clusters/kubernetes_namespace.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Clusters - class KubernetesNamespace < ActiveRecord::Base + class KubernetesNamespace < ApplicationRecord include Gitlab::Kubernetes self.table_name = 'clusters_kubernetes_namespaces' @@ -37,7 +37,7 @@ module Clusters variables .append(key: 'KUBE_SERVICE_ACCOUNT', value: service_account_name.to_s) .append(key: 'KUBE_NAMESPACE', value: namespace.to_s) - .append(key: 'KUBE_TOKEN', value: service_account_token.to_s, public: false) + .append(key: 'KUBE_TOKEN', value: service_account_token.to_s, public: false, masked: true) .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true) end end diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 46d0898014e..9b951578aee 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -2,7 +2,7 @@ module Clusters module Platforms - class Kubernetes < ActiveRecord::Base + class Kubernetes < ApplicationRecord include Gitlab::Kubernetes include ReactiveCaching include EnumWithNil @@ -41,7 +41,7 @@ module Clusters validate :no_namespace, unless: :allow_user_defined_namespace? # We expect to be `active?` only when enabled and cluster is created (the api_url is assigned) - validates :api_url, url: true, presence: true + validates :api_url, public_url: true, presence: true validates :token, presence: true validates :ca_cert, certificate: true, allow_blank: true, if: :ca_cert_changed? @@ -52,11 +52,14 @@ module Clusters alias_attribute :ca_pem, :ca_cert - delegate :project, to: :cluster, allow_nil: true delegate :enabled?, to: :cluster, allow_nil: true - delegate :managed?, to: :cluster, allow_nil: true + delegate :provided_by_user?, to: :cluster, allow_nil: true delegate :allow_user_defined_namespace?, to: :cluster, allow_nil: true - delegate :kubernetes_namespace, to: :cluster + + # This is just to maintain compatibility with KubernetesService, which + # will be removed in https://gitlab.com/gitlab-org/gitlab-ce/issues/39217. + # It can be removed once KubernetesService is gone. + delegate :kubernetes_namespace_for, to: :cluster, allow_nil: true alias_method :active?, :enabled? @@ -68,14 +71,6 @@ module Clusters default_value_for :authorization_type, :rbac - def actual_namespace - if namespace.present? - namespace - else - default_namespace - end - end - def predefined_variables(project:) Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'KUBE_URL', value: api_url) @@ -88,16 +83,19 @@ module Clusters if kubernetes_namespace = cluster.kubernetes_namespaces.has_service_account_token.find_by(project: project) variables.concat(kubernetes_namespace.predefined_variables) - elsif cluster.project_type? - # From 11.5, every Clusters::Project should have at least one - # Clusters::KubernetesNamespace, so once migration has been completed, - # this 'else' branch will be removed. For more information, please see - # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22433 + elsif cluster.project_type? || !cluster.managed? + # As of 11.11 a user can create a cluster that they manage themselves, + # which replicates the existing project-level cluster behaviour. + # Once we have marked all project-level clusters that make use of this + # behaviour as "unmanaged", we can remove the `cluster.project_type?` + # check here. + project_namespace = cluster.kubernetes_namespace_for(project) + variables .append(key: 'KUBE_URL', value: api_url) - .append(key: 'KUBE_TOKEN', value: token, public: false) - .append(key: 'KUBE_NAMESPACE', value: actual_namespace) - .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true) + .append(key: 'KUBE_TOKEN', value: token, public: false, masked: true) + .append(key: 'KUBE_NAMESPACE', value: project_namespace) + .append(key: 'KUBECONFIG', value: kubeconfig(project_namespace), public: false, file: true) end variables.concat(cluster.predefined_variables) @@ -110,8 +108,10 @@ module Clusters # short time later def terminals(environment) with_reactive_cache do |data| - pods = filter_by_label(data[:pods], app: environment.slug) - terminals = pods.flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) }.compact + project = environment.project + + pods = filter_by_project_environment(data[:pods], project.full_path_slug, environment.slug) + terminals = pods.flat_map { |pod| terminals_for_pod(api_url, cluster.kubernetes_namespace_for(project), pod) }.compact terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) } end end @@ -119,7 +119,7 @@ module Clusters # Caches resources in the namespace so other calls don't need to block on # network access def calculate_reactive_cache - return unless enabled? && project && !project.pending_delete? + return unless enabled? # We may want to cache extra things in the future { pods: read_pods } @@ -131,33 +131,16 @@ module Clusters private - def kubeconfig + def kubeconfig(namespace) to_kubeconfig( url: api_url, - namespace: actual_namespace, + namespace: namespace, token: token, ca_pem: ca_pem) end - def default_namespace - kubernetes_namespace&.namespace.presence || fallback_default_namespace - end - - # DEPRECATED - # - # On 11.4 Clusters::KubernetesNamespace was introduced, this model will allow to - # have multiple namespaces per project. This method will be removed after migration - # has been completed. - def fallback_default_namespace - return unless project - - slug = "#{project.path}-#{project.id}".downcase - Gitlab::NamespaceSanitizer.sanitize(slug) - end - def build_kube_client! raise "Incomplete settings" unless api_url - raise "No namespace" if cluster.project_type? && actual_namespace.empty? # can probably remove this line once we remove #actual_namespace unless (username && password) || token raise "Either username/password or token is required to access API" @@ -173,9 +156,13 @@ module Clusters # Returns a hash of all pods in the namespace def read_pods - kubeclient = build_kube_client! + # TODO: The project lookup here should be moved (to environment?), + # which will enable reading pods from the correct namespace for group + # and instance clusters. + # This will be done in https://gitlab.com/gitlab-org/gitlab-ce/issues/61156 + return [] unless cluster.project_type? - kubeclient.get_pods(namespace: actual_namespace).as_json + kubeclient.get_pods(namespace: cluster.kubernetes_namespace_for(cluster.first_project)).as_json rescue Kubeclient::ResourceNotFoundError [] end @@ -219,7 +206,7 @@ module Clusters end def prevent_modification - return unless managed? + return if provided_by_user? if api_url_changed? || token_changed? || ca_pem_changed? errors.add(:base, _('Cannot modify managed Kubernetes cluster')) @@ -230,7 +217,7 @@ module Clusters end def update_kubernetes_namespace - return unless namespace_changed? + return unless saved_change_to_namespace? run_after_commit do ClusterConfigureWorker.perform_async(cluster_id) diff --git a/app/models/clusters/project.rb b/app/models/clusters/project.rb index 15092b1c9d2..e0bf60164ba 100644 --- a/app/models/clusters/project.rb +++ b/app/models/clusters/project.rb @@ -1,13 +1,12 @@ # frozen_string_literal: true module Clusters - class Project < ActiveRecord::Base + class Project < ApplicationRecord self.table_name = 'cluster_projects' belongs_to :cluster, class_name: 'Clusters::Cluster' belongs_to :project, class_name: '::Project' has_many :kubernetes_namespaces, class_name: 'Clusters::KubernetesNamespace', foreign_key: :cluster_project_id - has_one :kubernetes_namespace, -> { order(id: :desc) }, class_name: 'Clusters::KubernetesNamespace', foreign_key: :cluster_project_id end end diff --git a/app/models/clusters/providers/gcp.rb b/app/models/clusters/providers/gcp.rb index 16b59cd9d14..390748bf252 100644 --- a/app/models/clusters/providers/gcp.rb +++ b/app/models/clusters/providers/gcp.rb @@ -2,7 +2,7 @@ module Clusters module Providers - class Gcp < ActiveRecord::Base + class Gcp < ApplicationRecord self.table_name = 'cluster_providers_gcp' belongs_to :cluster, inverse_of: :provider_gcp, class_name: 'Clusters::Cluster' diff --git a/app/models/commit.rb b/app/models/commit.rb index f412d252e5c..fa0bf36ba49 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -13,6 +13,7 @@ class Commit include StaticModel include Presentable include ::Gitlab::Utils::StrongMemoize + include CacheMarkdownField attr_mentionable :safe_message, pipeline: :single_line @@ -37,13 +38,9 @@ class Commit # Used by GFM to match and present link extensions on node texts and hrefs. LINK_EXTENSION_PATTERN = /(patch)/.freeze - def banzai_render_context(field) - pipeline = field == :description ? :commit_description : :single_line - context = { pipeline: pipeline, project: self.project } - context[:author] = self.author if self.author - - context - end + cache_markdown_field :title, pipeline: :single_line + cache_markdown_field :full_title, pipeline: :single_line + cache_markdown_field :description, pipeline: :commit_description class << self def decorate(commits, project) diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb index a9a2e9c81eb..e8df46e1cc3 100644 --- a/app/models/commit_collection.rb +++ b/app/models/commit_collection.rb @@ -20,18 +20,51 @@ class CommitCollection commits.each(&block) end - def authors - emails = without_merge_commits.map(&:author_email).uniq + def committers + emails = without_merge_commits.map(&:committer_email).uniq User.by_any_email(emails) end def without_merge_commits strong_memoize(:without_merge_commits) do - commits.reject(&:merge_commit?) + # `#enrich!` the collection to ensure all commits contain + # the necessary parent data + enrich!.commits.reject(&:merge_commit?) end end + def unenriched + commits.reject(&:gitaly_commit?) + end + + def fully_enriched? + unenriched.empty? + end + + # Batch load any commits that are not backed by full gitaly data, and + # replace them in the collection. + def enrich! + # A project is needed in order to fetch data from gitaly. Projects + # can be absent from commits in certain rare situations (like when + # viewing a MR of a deleted fork). In these cases, assume that the + # enriched data is not needed. + return self if project.blank? || fully_enriched? + + # Batch load full Commits from the repository + # and map to a Hash of id => Commit + replacements = Hash[unenriched.map do |c| + [c.id, Commit.lazy(project, c.id)] + 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 diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb index 094747ee48d..08ca86bc902 100644 --- a/app/models/commit_range.rb +++ b/app/models/commit_range.rb @@ -28,12 +28,12 @@ class CommitRange # The beginning and ending refs can be named or SHAs, and # the range notation can be double- or triple-dot. - REF_PATTERN = /[0-9a-zA-Z][0-9a-zA-Z_.-]*[0-9a-zA-Z\^]/ - PATTERN = /#{REF_PATTERN}\.{2,3}#{REF_PATTERN}/ + REF_PATTERN = /[0-9a-zA-Z][0-9a-zA-Z_.-]*[0-9a-zA-Z\^]/.freeze + PATTERN = /#{REF_PATTERN}\.{2,3}#{REF_PATTERN}/.freeze # In text references, the beginning and ending refs can only be SHAs # between 7 and 40 hex characters. - STRICT_PATTERN = /\h{7,40}\.{2,3}\h{7,40}/ + STRICT_PATTERN = /\h{7,40}\.{2,3}\h{7,40}/.freeze def self.reference_prefix '@' @@ -134,25 +134,25 @@ class CommitRange end def sha_from - return nil unless @commit_from + return unless @commit_from @commit_from.id end def sha_to - return nil unless @commit_to + return unless @commit_to @commit_to.id end def sha_start - return nil unless sha_from + return unless sha_from exclude_start? ? sha_from + '^' : sha_from end def commit_start - return nil unless sha_start + return unless sha_start if exclude_start? @commit_start ||= project.commit(sha_start) diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 7f6562b63e5..be6f3e9c5b0 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class CommitStatus < ActiveRecord::Base +class CommitStatus < ApplicationRecord include HasStatus include Importable include AfterCommitQueue @@ -66,7 +66,10 @@ class CommitStatus < ActiveRecord::Base end event :enqueue do - transition [:created, :skipped, :manual, :scheduled] => :pending + # A CommitStatus will never have prerequisites, but this event + # is shared by Ci::Build, which cannot progress unless prerequisites + # are satisfied. + transition [:created, :preparing, :skipped, :manual, :scheduled] => :pending, unless: :any_unmet_prerequisites? end event :run do @@ -74,26 +77,26 @@ class CommitStatus < ActiveRecord::Base end event :skip do - transition [:created, :pending] => :skipped + transition [:created, :preparing, :pending] => :skipped end event :drop do - transition [:created, :pending, :running, :scheduled] => :failed + transition [:created, :preparing, :pending, :running, :scheduled] => :failed end event :success do - transition [:created, :pending, :running] => :success + transition [:created, :preparing, :pending, :running] => :success end event :cancel do - transition [:created, :pending, :running, :manual, :scheduled] => :canceled + transition [:created, :preparing, :pending, :running, :manual, :scheduled] => :canceled end - before_transition [:created, :skipped, :manual, :scheduled] => :pending do |commit_status| + before_transition [:created, :preparing, :skipped, :manual, :scheduled] => :pending do |commit_status| commit_status.queued_at = Time.now end - before_transition [:created, :pending] => :running do |commit_status| + before_transition [:created, :preparing, :pending] => :running do |commit_status| commit_status.started_at = Time.now end @@ -137,7 +140,7 @@ class CommitStatus < ActiveRecord::Base end def locking_enabled? - status_changed? + will_save_change_to_status? end def before_sha @@ -180,6 +183,10 @@ class CommitStatus < ActiveRecord::Base false end + def any_unmet_prerequisites? + false + end + def auto_canceled? canceled? && auto_canceled_by_id? end diff --git a/app/models/commit_status_enums.rb b/app/models/commit_status_enums.rb index 152105d9429..45e08fa18fe 100644 --- a/app/models/commit_status_enums.rb +++ b/app/models/commit_status_enums.rb @@ -14,7 +14,8 @@ module CommitStatusEnums runner_unsupported: 6, stale_schedule: 7, job_execution_timeout: 8, - archived_failure: 9 + archived_failure: 9, + unmet_prerequisites: 10 } end end diff --git a/app/models/concerns/artifact_migratable.rb b/app/models/concerns/artifact_migratable.rb deleted file mode 100644 index cbd63ba8876..00000000000 --- a/app/models/concerns/artifact_migratable.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -# Adapter class to unify the interface between mounted uploaders and the -# Ci::Artifact model -# Meant to be prepended so the interface can stay the same -module ArtifactMigratable - def artifacts_file - job_artifacts_archive&.file || legacy_artifacts_file - end - - def artifacts_metadata - job_artifacts_metadata&.file || legacy_artifacts_metadata - end - - def artifacts? - !artifacts_expired? && artifacts_file.exists? - end - - def artifacts_metadata? - artifacts? && artifacts_metadata.exists? - end - - def artifacts_file_changed? - job_artifacts_archive&.file_changed? || attribute_changed?(:artifacts_file) - end - - def remove_artifacts_file! - if job_artifacts_archive - job_artifacts_archive.destroy - else - remove_legacy_artifacts_file! - end - end - - def remove_artifacts_metadata! - if job_artifacts_metadata - job_artifacts_metadata.destroy - else - remove_legacy_artifacts_metadata! - end - end - - def artifacts_size - read_attribute(:artifacts_size).to_i + job_artifacts.sum(:size).to_i - end -end diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index 4e15b60ccd1..dc1735a7e48 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -7,7 +7,7 @@ # # For example, let's generate internal ids for Issue per Project: # ``` -# class Issue < ActiveRecord::Base +# class Issue < ApplicationRecord # has_internal_id :iid, scope: :project, init: ->(s) { s.project.issues.maximum(:iid) } # end # ``` @@ -53,6 +53,20 @@ module AtomicInternalId value 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 + + if InternalId.reset(self, scope_attrs, usage, value) + write_attribute(column, nil) + end + end + + read_attribute(column) + end end end end diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index 4687ec7d166..80278e07e65 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -91,7 +91,8 @@ module Avatarable private def retrieve_upload_from_batch(identifier) - BatchLoader.for(identifier: identifier, model: self).batch(key: self.class) do |upload_params, loader, args| + BatchLoader.for(identifier: identifier, model: self) + .batch(key: self.class, cache: true, replace_methods: false) do |upload_params, loader, args| model_class = args[:key] paths = upload_params.flat_map do |params| params[:model].upload_paths(params[:identifier]) diff --git a/app/models/concerns/blob_language_from_git_attributes.rb b/app/models/concerns/blob_language_from_git_attributes.rb index 70213d22147..56e1276a220 100644 --- a/app/models/concerns/blob_language_from_git_attributes.rb +++ b/app/models/concerns/blob_language_from_git_attributes.rb @@ -5,7 +5,7 @@ module BlobLanguageFromGitAttributes extend ActiveSupport::Concern def language_from_gitattributes - return nil unless project + return unless project repository = project.repository repository.gitattribute(path, 'gitlab-language') diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 1a8570b80c3..42203a5f214 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -7,40 +7,15 @@ # cache_markdown_field :foo # cache_markdown_field :bar # cache_markdown_field :baz, pipeline: :single_line +# cache_markdown_field :baz, whitelisted: true # # Corresponding foo_html, bar_html and baz_html fields should exist. module CacheMarkdownField extend ActiveSupport::Concern - # Increment this number every time the renderer changes its output - CACHE_COMMONMARK_VERSION_START = 10 - CACHE_COMMONMARK_VERSION = 14 - # changes to these attributes cause the cache to be invalidates INVALIDATED_BY = %w[author project].freeze - # Knows about the relationship between markdown and html field names, and - # stores the rendering contexts for the latter - class FieldData - def initialize - @data = {} - end - - delegate :[], :[]=, to: :@data - - def markdown_fields - @data.keys - end - - def html_field(markdown_field) - "#{markdown_field}_html" - end - - def html_fields - markdown_fields.map {|field| html_field(field) } - end - end - def skip_project_check? false end @@ -76,24 +51,22 @@ module CacheMarkdownField end.to_h updates['cached_markdown_version'] = latest_cached_markdown_version - updates.each {|html_field, data| write_attribute(html_field, data) } + updates.each { |field, data| write_markdown_field(field, data) } end def refresh_markdown_cache! updates = refresh_markdown_cache - return unless persisted? && Gitlab::Database.read_write? - - update_columns(updates) + save_markdown(updates) end def cached_html_up_to_date?(markdown_field) - html_field = cached_markdown_fields.html_field(markdown_field) + return false if cached_html_for(markdown_field).nil? && __send__(markdown_field).present? # rubocop:disable GitlabSecurity/PublicSend - return false if cached_html_for(markdown_field).nil? && !__send__(markdown_field).nil? # rubocop:disable GitlabSecurity/PublicSend + html_field = cached_markdown_fields.html_field(markdown_field) - markdown_changed = attribute_changed?(markdown_field) || false - html_changed = attribute_changed?(html_field) || false + markdown_changed = markdown_field_changed?(markdown_field) + html_changed = markdown_field_changed?(html_field) latest_cached_markdown_version == cached_markdown_version && (html_changed || markdown_changed == html_changed) @@ -108,21 +81,21 @@ module CacheMarkdownField end def cached_html_for(markdown_field) - raise ArgumentError.new("Unknown field: #{field}") unless + raise ArgumentError.new("Unknown field: #{markdown_field}") unless cached_markdown_fields.markdown_fields.include?(markdown_field) __send__(cached_markdown_fields.html_field(markdown_field)) # rubocop:disable GitlabSecurity/PublicSend end def latest_cached_markdown_version - @latest_cached_markdown_version ||= (CacheMarkdownField::CACHE_COMMONMARK_VERSION << 16) | local_version + @latest_cached_markdown_version ||= (Gitlab::MarkdownCache::CACHE_COMMONMARK_VERSION << 16) | local_version end def local_version # because local_markdown_version is stored in application_settings which # uses cached_markdown_version too, we check explicitly to avoid # endless loop - return local_markdown_version if has_attribute?(:local_markdown_version) + return local_markdown_version if respond_to?(:has_attribute?) && has_attribute?(:local_markdown_version) settings = Gitlab::CurrentSettings.current_application_settings @@ -141,27 +114,14 @@ module CacheMarkdownField included do cattr_reader :cached_markdown_fields do - FieldData.new + Gitlab::MarkdownCache::FieldData.new end - # Always exclude _html fields from attributes (including serialization). - # They contain unredacted HTML, which would be a security issue - alias_method :attributes_before_markdown_cache, :attributes - def attributes - attrs = attributes_before_markdown_cache - - attrs.delete('cached_markdown_version') - - cached_markdown_fields.html_fields.each do |field| - attrs.delete(field) - end - - attrs + if self < ActiveRecord::Base + include Gitlab::MarkdownCache::ActiveRecord::Extension + else + prepend Gitlab::MarkdownCache::Redis::Extension end - - # Using before_update here conflicts with elasticsearch-model somehow - before_create :refresh_markdown_cache, if: :invalidated_markdown_cache? - before_update :refresh_markdown_cache, if: :invalidated_markdown_cache? end class_methods do @@ -179,10 +139,8 @@ module CacheMarkdownField # The HTML becomes invalid if any dependent fields change. For now, assume # author and project invalidate the cache in all circumstances. define_method(invalidation_method) do - changed_fields = changed_attributes.keys - invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY] - invalidations.delete(markdown_field.to_s) if changed_fields.include?("#{markdown_field}_html") - + invalidations = changed_markdown_fields & [markdown_field.to_s, *INVALIDATED_BY] + invalidations.delete(markdown_field.to_s) if changed_markdown_fields.include?("#{markdown_field}_html") !invalidations.empty? || !cached_html_up_to_date?(markdown_field) end end diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb new file mode 100644 index 00000000000..e1d5ce7f7d4 --- /dev/null +++ b/app/models/concerns/ci/contextable.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module Ci + ## + # This module implements methods that provide context in form of + # essential CI/CD variables that can be used by a build / bridge job. + # + module Contextable + ## + # Variables in the environment name scope. + # + def scoped_variables(environment: expanded_environment_name) + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.concat(predefined_variables) + variables.concat(project.predefined_variables) + variables.concat(pipeline.predefined_variables) + variables.concat(runner.predefined_variables) if runnable? && runner + variables.concat(project.deployment_variables(environment: environment)) if environment + variables.concat(yaml_variables) + variables.concat(user_variables) + variables.concat(secret_group_variables) + variables.concat(secret_project_variables(environment: environment)) + variables.concat(trigger_request.user_variables) if trigger_request + variables.concat(pipeline.variables) + variables.concat(pipeline.pipeline_schedule.job_variables) if pipeline.pipeline_schedule + end + end + + ## + # Regular Ruby hash of scoped variables, without duplicates that are + # possible to be present in an array of hashes returned from `variables`. + # + def scoped_variables_hash + scoped_variables.to_hash + end + + ## + # Variables that do not depend on the environment name. + # + def simple_variables + strong_memoize(:simple_variables) do + scoped_variables(environment: nil).to_runner_variables + end + end + + def user_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables if user.blank? + + variables.append(key: 'GITLAB_USER_ID', value: user.id.to_s) + variables.append(key: 'GITLAB_USER_EMAIL', value: user.email) + variables.append(key: 'GITLAB_USER_LOGIN', value: user.username) + variables.append(key: 'GITLAB_USER_NAME', value: user.name) + end + end + + def predefined_variables # rubocop:disable Metrics/AbcSize + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI', value: 'true') + variables.append(key: 'GITLAB_CI', value: 'true') + variables.append(key: 'GITLAB_FEATURES', value: project.licensed_features.join(',')) + variables.append(key: 'CI_SERVER_NAME', value: 'GitLab') + variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION) + variables.append(key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s) + variables.append(key: 'CI_SERVER_VERSION_MINOR', value: Gitlab.version_info.minor.to_s) + variables.append(key: 'CI_SERVER_VERSION_PATCH', value: Gitlab.version_info.patch.to_s) + variables.append(key: 'CI_SERVER_REVISION', value: Gitlab.revision) + variables.append(key: 'CI_JOB_NAME', value: name) + variables.append(key: 'CI_JOB_STAGE', value: stage) + variables.append(key: 'CI_COMMIT_SHA', value: sha) + variables.append(key: 'CI_COMMIT_SHORT_SHA', value: short_sha) + variables.append(key: 'CI_COMMIT_BEFORE_SHA', value: before_sha) + variables.append(key: 'CI_COMMIT_REF_NAME', value: source_ref) + variables.append(key: 'CI_COMMIT_REF_SLUG', value: source_ref_slug) + variables.append(key: "CI_COMMIT_TAG", value: ref) if tag? + variables.append(key: "CI_PIPELINE_TRIGGERED", value: 'true') if trigger_request + 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.concat(legacy_variables) + end + end + + def legacy_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_BUILD_REF', value: sha) + variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha) + variables.append(key: 'CI_BUILD_REF_NAME', value: source_ref) + variables.append(key: 'CI_BUILD_REF_SLUG', value: source_ref_slug) + variables.append(key: 'CI_BUILD_NAME', value: name) + variables.append(key: 'CI_BUILD_STAGE', value: stage) + variables.append(key: "CI_BUILD_TAG", value: ref) if tag? + variables.append(key: "CI_BUILD_TRIGGERED", value: 'true') if trigger_request + variables.append(key: "CI_BUILD_MANUAL", value: 'true') if action? + end + end + + def secret_group_variables + return [] unless project.group + + project.group.ci_variables_for(git_ref, project) + end + + def secret_project_variables(environment: persisted_environment) + project.ci_variables_for(ref: git_ref, environment: environment) + end + end +end diff --git a/app/models/concerns/ci/pipeline_delegator.rb b/app/models/concerns/ci/pipeline_delegator.rb new file mode 100644 index 00000000000..dbc5ed1bc9a --- /dev/null +++ b/app/models/concerns/ci/pipeline_delegator.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +## +# This module is mainly used by child associations of `Ci::Pipeline` that needs to look up +# single source of truth. For example, `Ci::Build` has `git_ref` method, which behaves +# slightly different from `Ci::Pipeline`'s `git_ref`. This is very confusing as +# the system could behave differently time to time. +# We should have a single interface in `Ci::Pipeline` and access the method always. +module Ci + module PipelineDelegator + extend ActiveSupport::Concern + + included do + delegate :merge_request_event?, + :merge_request_ref?, + :source_ref, + :source_ref_slug, + :legacy_detached_merge_request_pipeline?, to: :pipeline + end + end +end diff --git a/app/models/concerns/ci/processable.rb b/app/models/concerns/ci/processable.rb index 1c78b1413a8..268fa8ec692 100644 --- a/app/models/concerns/ci/processable.rb +++ b/app/models/concerns/ci/processable.rb @@ -23,5 +23,9 @@ module Ci def expanded_environment_name raise NotImplementedError end + + def scoped_variables_hash + raise NotImplementedError + end end end diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb index 0107af5f8ec..9ac0d612db3 100644 --- a/app/models/concerns/deployment_platform.rb +++ b/app/models/concerns/deployment_platform.rb @@ -14,6 +14,7 @@ module DeploymentPlatform def find_deployment_platform(environment) find_cluster_platform_kubernetes(environment: environment) || find_group_cluster_platform_kubernetes_with_feature_guard(environment: environment) || + find_instance_cluster_platform_kubernetes_with_feature_guard(environment: environment) || find_kubernetes_service_integration || build_cluster_and_deployment_platform end @@ -36,6 +37,18 @@ module DeploymentPlatform .first&.platform_kubernetes end + def find_instance_cluster_platform_kubernetes_with_feature_guard(environment: nil) + return unless Clusters::Instance.enabled? + + find_instance_cluster_platform_kubernetes(environment: environment) + end + + # EE would override this and utilize environment argument + def find_instance_cluster_platform_kubernetes(environment: nil) + Clusters::Instance.new.clusters.enabled.default_environment + .first&.platform_kubernetes + end + def find_kubernetes_service_integration services.deployment.reorder(nil).find_by(active: true) end diff --git a/app/models/concerns/deprecated_assignee.rb b/app/models/concerns/deprecated_assignee.rb new file mode 100644 index 00000000000..7f12ce39c96 --- /dev/null +++ b/app/models/concerns/deprecated_assignee.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +# This module handles backward compatibility for import/export of Merge Requests after +# multiple assignees feature was introduced. Also, it handles the scenarios where +# the #26496 background migration hasn't finished yet. +# Ideally, most of this code should be removed at #59457. +module DeprecatedAssignee + extend ActiveSupport::Concern + + def assignee_ids=(ids) + nullify_deprecated_assignee + super + end + + def assignees=(users) + nullify_deprecated_assignee + super + end + + def assignee_id=(id) + self.assignee_ids = Array(id) + end + + def assignee=(user) + self.assignees = Array(user) + end + + def assignee + assignees.first + end + + def assignee_id + assignee_ids.first + end + + def assignee_ids + if Gitlab::Database.read_only? && pending_assignees_population? + return Array(deprecated_assignee_id) + end + + update_assignees_relation + super + end + + def assignees + if Gitlab::Database.read_only? && pending_assignees_population? + return User.where(id: deprecated_assignee_id) + end + + update_assignees_relation + super + end + + private + + # This will make the background migration process quicker (#26496) as it'll have less + # assignee_id rows to look through. + def nullify_deprecated_assignee + return unless persisted? && Gitlab::Database.read_only? + + update_column(:assignee_id, nil) + end + + # This code should be removed in the clean-up phase of the + # background migration (#59457). + def pending_assignees_population? + persisted? && deprecated_assignee_id && merge_request_assignees.empty? + end + + # If there's an assignee_id and no relation, it means the background + # migration at #26496 didn't reach this merge request yet. + # This code should be removed in the clean-up phase of the + # background migration (#59457). + def update_assignees_relation + if pending_assignees_population? + transaction do + merge_request_assignees.create!(user_id: deprecated_assignee_id, merge_request_id: id) + update_column(:assignee_id, nil) + end + end + end + + def deprecated_assignee_id + read_attribute(:assignee_id) + end +end diff --git a/app/models/concerns/feature_gate.rb b/app/models/concerns/feature_gate.rb index 3f84de54ad5..bb095f113e2 100644 --- a/app/models/concerns/feature_gate.rb +++ b/app/models/concerns/feature_gate.rb @@ -2,7 +2,7 @@ module FeatureGate def flipper_id - return nil if new_record? + return if new_record? "#{self.class.name}:#{id}" end diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb index 05cd4265133..cfffd845e43 100644 --- a/app/models/concerns/group_descendant.rb +++ b/app/models/concerns/group_descendant.rb @@ -22,7 +22,7 @@ module GroupDescendant return [] if descendants.empty? unless descendants.all? { |hierarchy| hierarchy.is_a?(GroupDescendant) } - raise ArgumentError.new('element is not a hierarchy') + raise ArgumentError.new(_('element is not a hierarchy')) end all_hierarchies = descendants.map do |descendant| @@ -56,7 +56,7 @@ module GroupDescendant end if parent.nil? && hierarchy_top.present? - raise ArgumentError.new('specified top is not part of the tree') + raise ArgumentError.new(_('specified top is not part of the tree')) end if parent && parent != hierarchy_top diff --git a/app/models/concerns/has_ref.rb b/app/models/concerns/has_ref.rb index d7089294efc..fa0cf5ddfd2 100644 --- a/app/models/concerns/has_ref.rb +++ b/app/models/concerns/has_ref.rb @@ -1,10 +1,13 @@ # frozen_string_literal: true +## +# We will disable `ref` and `sha` attributes in `Ci::Build` in the future +# and remove this module in favor of Ci::PipelineDelegator. module HasRef extend ActiveSupport::Concern def branch? - !tag? + !tag? && !merge_request_event? end def git_ref @@ -14,4 +17,15 @@ module HasRef Gitlab::Git::TAG_REF_PREFIX + ref.to_s end end + + # A slugified version of the build ref, suitable for inclusion in URLs and + # domain names. Rules: + # + # * Lowercased + # * Anything not matching [a-z0-9-] is replaced with a - + # * Maximum length is 63 bytes + # * First/Last Character is not a hyphen + def ref_slug + Gitlab::Utils.slugify(ref.to_s) + end end diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb index 0d2be4c61ab..78bcce2f592 100644 --- a/app/models/concerns/has_status.rb +++ b/app/models/concerns/has_status.rb @@ -5,14 +5,14 @@ module HasStatus DEFAULT_STATUS = 'created'.freeze BLOCKED_STATUS = %w[manual scheduled].freeze - AVAILABLE_STATUSES = %w[created pending running success failed canceled skipped manual scheduled].freeze + AVAILABLE_STATUSES = %w[created preparing pending running success failed canceled skipped manual scheduled].freeze STARTED_STATUSES = %w[running success failed skipped manual scheduled].freeze - ACTIVE_STATUSES = %w[pending running].freeze + ACTIVE_STATUSES = %w[preparing pending running].freeze COMPLETED_STATUSES = %w[success failed canceled skipped].freeze - ORDERED_STATUSES = %w[failed pending running manual scheduled canceled success skipped created].freeze + ORDERED_STATUSES = %w[failed preparing pending running manual scheduled canceled success skipped created].freeze STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3, failed: 4, canceled: 5, skipped: 6, manual: 7, - scheduled: 8 }.freeze + scheduled: 8, preparing: 9 }.freeze UnknownStatusError = Class.new(StandardError) @@ -26,6 +26,7 @@ module HasStatus success = scope_relevant.success.select('count(*)').to_sql manual = scope_relevant.manual.select('count(*)').to_sql scheduled = scope_relevant.scheduled.select('count(*)').to_sql + preparing = scope_relevant.preparing.select('count(*)').to_sql pending = scope_relevant.pending.select('count(*)').to_sql running = scope_relevant.running.select('count(*)').to_sql skipped = scope_relevant.skipped.select('count(*)').to_sql @@ -37,12 +38,14 @@ module HasStatus WHEN (#{builds})=(#{skipped}) THEN 'skipped' WHEN (#{builds})=(#{success}) THEN 'success' WHEN (#{builds})=(#{created}) THEN 'created' + WHEN (#{builds})=(#{preparing}) THEN 'preparing' WHEN (#{builds})=(#{success})+(#{skipped}) THEN 'success' WHEN (#{builds})=(#{success})+(#{skipped})+(#{canceled}) THEN 'canceled' WHEN (#{builds})=(#{created})+(#{skipped})+(#{pending}) THEN 'pending' WHEN (#{running})+(#{pending})>0 THEN 'running' WHEN (#{manual})>0 THEN 'manual' WHEN (#{scheduled})>0 THEN 'scheduled' + WHEN (#{preparing})>0 THEN 'preparing' WHEN (#{created})>0 THEN 'running' ELSE 'failed' END)" @@ -63,6 +66,10 @@ module HasStatus def all_state_names state_machines.values.flat_map(&:states).flat_map { |s| s.map(&:name) } end + + def completed_statuses + COMPLETED_STATUSES.map(&:to_sym) + end end included do @@ -70,6 +77,7 @@ module HasStatus state_machine :status, initial: :created do state :created, value: 'created' + state :preparing, value: 'preparing' state :pending, value: 'pending' state :running, value: 'running' state :failed, value: 'failed' @@ -81,6 +89,7 @@ module HasStatus end scope :created, -> { where(status: 'created') } + scope :preparing, -> { where(status: 'preparing') } scope :relevant, -> { where(status: AVAILABLE_STATUSES - ['created']) } scope :running, -> { where(status: 'running') } scope :pending, -> { where(status: 'pending') } @@ -90,14 +99,14 @@ module HasStatus scope :skipped, -> { where(status: 'skipped') } scope :manual, -> { where(status: 'manual') } scope :scheduled, -> { where(status: 'scheduled') } - scope :alive, -> { where(status: [:created, :pending, :running]) } + scope :alive, -> { where(status: [:created, :preparing, :pending, :running]) } scope :created_or_pending, -> { where(status: [:created, :pending]) } scope :running_or_pending, -> { where(status: [:running, :pending]) } scope :finished, -> { where(status: [:success, :failed, :canceled]) } scope :failed_or_canceled, -> { where(status: [:failed, :canceled]) } scope :cancelable, -> do - where(status: [:running, :pending, :created, :scheduled]) + where(status: [:running, :preparing, :pending, :created, :scheduled]) end end diff --git a/app/models/concerns/has_variable.rb b/app/models/concerns/has_variable.rb index dfbe413a878..b4e99569071 100644 --- a/app/models/concerns/has_variable.rb +++ b/app/models/concerns/has_variable.rb @@ -4,6 +4,11 @@ module HasVariable extend ActiveSupport::Concern included do + enum variable_type: { + env_var: 1, + file: 2 + } + validates :key, presence: true, length: { maximum: 255 }, @@ -21,9 +26,9 @@ module HasVariable def key=(new_key) super(new_key.to_s.strip) end + end - def to_runner_variable - { key: key, value: value, public: false } - end + def to_runner_variable + { key: key, value: value, public: false, file: file? } end end diff --git a/app/models/concerns/ignorable_column.rb b/app/models/concerns/ignorable_column.rb index 5c1f7dfcd2a..3bec44dc79b 100644 --- a/app/models/concerns/ignorable_column.rb +++ b/app/models/concerns/ignorable_column.rb @@ -5,7 +5,7 @@ # # Example: # -# class User < ActiveRecord::Base +# class User < ApplicationRecord # include IgnorableColumn # # ignore_column :updated_at diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 429a63f83cc..127430cc68f 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -23,12 +23,13 @@ module Issuable include Sortable include CreatedAtFilterable include UpdatedAtFilterable + include IssuableStates include ClosedAtFilterable # This object is used to gather issuable meta data for displaying # upvotes, downvotes, notes and closing merge requests count for issues and merge requests # lists avoiding n+1 queries and improving performance. - IssuableMeta = Struct.new(:upvotes, :downvotes, :notes_count, :merge_requests_count) + IssuableMeta = Struct.new(:upvotes, :downvotes, :user_notes_count, :merge_requests_count) included do cache_markdown_field :title, pipeline: :single_line @@ -36,8 +37,8 @@ module Issuable redact_field :description - belongs_to :author, class_name: "User" - belongs_to :updated_by, class_name: "User" + belongs_to :author, class_name: 'User' + belongs_to :updated_by, class_name: 'User' belongs_to :last_edited_by, class_name: 'User' belongs_to :milestone @@ -66,15 +67,9 @@ module Issuable allow_nil: true, prefix: true - delegate :name, - :email, - :public_email, - to: :assignee, - allow_nil: true, - prefix: true - validates :author, presence: true validates :title, presence: true, length: { maximum: 255 } + validate :milestone_is_valid scope :authored, ->(user) { where(author_id: user) } scope :recent, -> { reorder(id: :desc) } @@ -86,6 +81,19 @@ module Issuable scope :only_opened, -> { with_state(:opened) } scope :closed, -> { with_state(:closed) } + # rubocop:disable GitlabSecurity/SqlInjection + # The `to_ability_name` method is not an user input. + scope :assigned, -> do + where("EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE #{to_ability_name}_id = #{to_ability_name}s.id)") + end + scope :unassigned, -> do + where("NOT EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE #{to_ability_name}_id = #{to_ability_name}s.id)") + end + scope :assigned_to, ->(u) do + where("EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE user_id = ? AND #{to_ability_name}_id = #{to_ability_name}s.id)", u.id) + end + # rubocop:enable GitlabSecurity/SqlInjection + scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") } scope :order_milestone_due_desc, -> { left_joins_milestones.reorder('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC') } scope :order_milestone_due_asc, -> { left_joins_milestones.reorder('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC') } @@ -102,13 +110,14 @@ module Issuable participant :author participant :notes_with_associations + participant :assignees strip_attributes :title # We want to use optimistic lock for cases when only title or description are involved # http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html def locking_enabled? - title_changed? || description_changed? + will_save_change_to_title? || will_save_change_to_description? end def allows_multiple_assignees? @@ -118,6 +127,12 @@ module Issuable def has_multiple_assignees? assignees.count > 1 end + + private + + def milestone_is_valid + errors.add(:milestone_id, message: "is invalid") if milestone_id.present? && !milestone_available? + end end class_methods do @@ -132,6 +147,15 @@ module Issuable fuzzy_search(query, [:title]) end + # Available state values persisted in 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 + end + # Searches for records with a matching title or description. # # This method uses ILIKE on PostgreSQL and LIKE on MySQL. @@ -151,6 +175,10 @@ module Issuable fuzzy_search(query, matched_columns) end + def simple_sorts + super.except('name_asc', 'name_desc') + end + def sort_by_attribute(method, excluded_labels: []) sorted = case method.to_s @@ -245,6 +273,14 @@ module Issuable end end + def milestone_available? + project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group) + end + + def assignee_or_author?(user) + author_id == user.id || assignees.exists?(user.id) + end + def today? Date.today == created_at.to_date end @@ -289,11 +325,7 @@ module Issuable end if old_assignees != assignees - if self.is_a?(Issue) - changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)] - else - changes[:assignee] = [old_assignees&.first&.hook_attrs, assignee&.hook_attrs] - end + changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)] end if self.respond_to?(:total_time_spent) @@ -330,10 +362,18 @@ module Issuable def card_attributes { 'Author' => author.try(:name), - 'Assignee' => assignee.try(:name) + 'Assignee' => assignee_list } end + def assignee_list + assignees.map(&:name).to_sentence + end + + def assignee_username_list + assignees.map(&:username).to_sentence + end + def notes_with_associations # If A has_many Bs, and B has_many Cs, and you do # `A.includes(b: :c).each { |a| a.b.includes(:c) }`, sadly ActiveRecord diff --git a/app/models/concerns/issuable_states.rb b/app/models/concerns/issuable_states.rb new file mode 100644 index 00000000000..b722c541580 --- /dev/null +++ b/app/models/concerns/issuable_states.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +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-ce/issues/51789 for more information + included do + before_save :set_state_id + end + + def set_state_id + return if state.nil? || state.empty? + + # 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) + + self.state_id = self.class.available_states[state] + end +end diff --git a/app/models/concerns/maskable.rb b/app/models/concerns/maskable.rb new file mode 100644 index 00000000000..e0f2c41b836 --- /dev/null +++ b/app/models/concerns/maskable.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Maskable + extend ActiveSupport::Concern + + # * Single line + # * No escape characters + # * No variables + # * No spaces + # * Minimal length of 8 characters from the Base64 alphabets (RFC4648) + # * Absolutely no fun is allowed + REGEX = /\A[a-zA-Z0-9_+=\/-]{8,}\z/.freeze + + included do + validates :masked, inclusion: { in: [true, false] } + validates :value, format: { with: REGEX }, if: :masked? + end + + def to_runner_variable + super.merge(masked: masked?) + end +end diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index 055ffe04646..3deb86da6cf 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -1,28 +1,20 @@ # frozen_string_literal: true module Milestoneish - def closed_items_count(user) - memoize_per_user(user, :closed_items_count) do - (count_issues_by_state(user)['closed'] || 0) + merge_requests.closed_and_merged.size - end - end - - def total_items_count(user) - memoize_per_user(user, :total_items_count) do - total_issues_count(user) + merge_requests.size - end - end - def total_issues_count(user) count_issues_by_state(user).values.sum end + def closed_issues_count(user) + count_issues_by_state(user)['closed'].to_i + end + def complete?(user) - total_items_count(user) > 0 && total_items_count(user) == closed_items_count(user) + total_issues_count(user) > 0 && total_issues_count(user) == closed_issues_count(user) end def percent_complete(user) - ((closed_items_count(user) * 100) / total_items_count(user)).abs + closed_issues_count(user) * 100 / total_issues_count(user) rescue ZeroDivisionError 0 end @@ -46,12 +38,31 @@ module Milestoneish end end + def issue_participants_visible_by_user(user) + User.joins(:issue_assignees) + .where('issue_assignees.issue_id' => issues_visible_to_user(user).select(:id)) + .distinct + end + + def issue_labels_visible_by_user(user) + Label.joins(:label_links) + .where('label_links.target_id' => issues_visible_to_user(user).select(:id), 'label_links.target_type' => 'Issue') + .distinct + end + def sorted_issues(user) issues_visible_to_user(user).preload_associations.sort_by_attribute('label_priority') end - def sorted_merge_requests - merge_requests.sort_by_attribute('label_priority') + def sorted_merge_requests(user) + merge_requests_visible_to_user(user).sort_by_attribute('label_priority') + end + + def merge_requests_visible_to_user(user) + memoize_per_user(user, :merge_requests_visible_to_user) do + MergeRequestsFinder.new(user, issues_finder_params) + .execute.where(milestone_id: milestoneish_id) + end end def upcoming? diff --git a/app/models/concerns/mirror_authentication.rb b/app/models/concerns/mirror_authentication.rb index e3e1a0441f8..948094221e5 100644 --- a/app/models/concerns/mirror_authentication.rb +++ b/app/models/concerns/mirror_authentication.rb @@ -79,7 +79,7 @@ module MirrorAuthentication end def ssh_public_key - return nil if ssh_private_key.blank? + return if ssh_private_key.blank? comment = "git@#{::Gitlab.config.gitlab.host}" ::SSHKey.new(ssh_private_key, comment: comment).ssh_public_key diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index 3c74034b527..4b428b0af83 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -3,16 +3,26 @@ module Noteable extend ActiveSupport::Concern - # `Noteable` class names that support resolvable notes. - RESOLVABLE_TYPES = %w(MergeRequest).freeze - class_methods do # `Noteable` class names that support replying to individual notes. def replyable_types %w(Issue MergeRequest) end + + # `Noteable` class names that support resolvable notes. + def resolvable_types + %w(MergeRequest) + end end + # The timestamp of the note (e.g. the :created_at or :updated_at attribute if provided via + # API call) + def system_note_timestamp + @system_note_timestamp || Time.now # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + + attr_writer :system_note_timestamp + def base_class_name self.class.base_class.name end @@ -28,7 +38,7 @@ module Noteable end def supports_resolvable_notes? - RESOLVABLE_TYPES.include?(base_class_name) + self.class.resolvable_types.include?(base_class_name) end def supports_discussions? @@ -123,3 +133,5 @@ module Noteable ) end end + +Noteable.extend(Noteable::ClassMethods) diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb index 614c3242874..b140fca9b83 100644 --- a/app/models/concerns/participable.rb +++ b/app/models/concerns/participable.rb @@ -7,7 +7,7 @@ # # Usage: # -# class Issue < ActiveRecord::Base +# class Issue < ApplicationRecord # include Participable # # # ... diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb index a29e80fe0c1..258c819f243 100644 --- a/app/models/concerns/prometheus_adapter.rb +++ b/app/models/concerns/prometheus_adapter.rb @@ -36,7 +36,7 @@ module PrometheusAdapter def calculate_reactive_cache(query_class_name, *args) return unless prometheus_client - data = Kernel.const_get(query_class_name).new(prometheus_client_wrapper).query(*args) + data = Object.const_get(query_class_name, false).new(prometheus_client_wrapper).query(*args) { success: true, data: data, @@ -51,7 +51,7 @@ module PrometheusAdapter end def build_query_args(*args) - args.map(&:id) + args.map { |arg| arg.respond_to?(:id) ? arg.id : arg } end end end diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index de77ca3e963..1e09cd89550 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -7,7 +7,7 @@ # # Example of use: # -# class Foo < ActiveRecord::Base +# class Foo < ApplicationRecord # include ReactiveCaching # # self.reactive_cache_key = ->(thing) { ["foo", thing.id] } @@ -29,6 +29,40 @@ # However, it will enqueue a background worker to call `#calculate_reactive_cache` # and set an initial cache lifetime of ten minutes. # +# The background worker needs to find or generate the object on which +# `with_reactive_cache` was called. +# The default behaviour can be overridden by defining a custom +# `reactive_cache_worker_finder`. +# Otherwise the background worker will use the class name and primary key to get +# the object using the ActiveRecord find_by method. +# +# class Bar +# include ReactiveCaching +# +# self.reactive_cache_key = ->() { ["bar", "thing"] } +# self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } +# +# def self.from_cache(var1, var2) +# # This method will be called by the background worker with "bar1" and +# # "bar2" as arguments. +# new(var1, var2) +# end +# +# def initialize(var1, var2) +# # ... +# end +# +# def calculate_reactive_cache +# # Expensive operation here. The return value of this method is cached +# end +# +# def result +# with_reactive_cache("bar1", "bar2") do |data| +# # ... +# end +# end +# end +# # Each time the background job completes, it stores the return value of # `#calculate_reactive_cache`. It is also re-enqueued to run again after # `reactive_cache_refresh_interval`, so keeping the stored value up to date. @@ -52,6 +86,7 @@ module ReactiveCaching class_attribute :reactive_cache_key class_attribute :reactive_cache_lifetime class_attribute :reactive_cache_refresh_interval + class_attribute :reactive_cache_worker_finder # defaults self.reactive_cache_lease_timeout = 2.minutes @@ -59,6 +94,10 @@ module ReactiveCaching self.reactive_cache_refresh_interval = 1.minute self.reactive_cache_lifetime = 10.minutes + self.reactive_cache_worker_finder = ->(id, *_args) do + find_by(primary_key => id) + end + def calculate_reactive_cache(*args) raise NotImplementedError end @@ -69,7 +108,7 @@ module ReactiveCaching def with_reactive_cache(*args, &blk) unless within_reactive_cache_lifetime?(*args) refresh_reactive_cache!(*args) - return nil + return end keep_alive_reactive_cache!(*args) diff --git a/app/models/concerns/redactable.rb b/app/models/concerns/redactable.rb index 5ad96d6cc46..53ae300ee2d 100644 --- a/app/models/concerns/redactable.rb +++ b/app/models/concerns/redactable.rb @@ -10,7 +10,7 @@ module Redactable extend ActiveSupport::Concern - UNSUBSCRIBE_PATTERN = %r{/sent_notifications/\h{32}/unsubscribe} + UNSUBSCRIBE_PATTERN = %r{/sent_notifications/\h{32}/unsubscribe}.freeze class_methods do def redact_field(field) diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb index 58143a32fdc..4a506146de3 100644 --- a/app/models/concerns/referable.rb +++ b/app/models/concerns/referable.rb @@ -73,6 +73,7 @@ module Referable (?<url> #{Regexp.escape(Gitlab.config.gitlab.url)} \/#{Project.reference_pattern} + (?:\/\-)? \/#{Regexp.escape(route)} \/#{pattern} (?<path> diff --git a/app/models/concerns/resolvable_note.rb b/app/models/concerns/resolvable_note.rb index 16ea330701d..2d2d5fb7168 100644 --- a/app/models/concerns/resolvable_note.rb +++ b/app/models/concerns/resolvable_note.rb @@ -12,7 +12,7 @@ module ResolvableNote validates :resolved_by, presence: true, if: :resolved? # Keep this scope in sync with `#potentially_resolvable?` - scope :potentially_resolvable, -> { where(type: RESOLVABLE_TYPES).where(noteable_type: Noteable::RESOLVABLE_TYPES) } + scope :potentially_resolvable, -> { where(type: RESOLVABLE_TYPES).where(noteable_type: Noteable.resolvable_types) } # Keep this scope in sync with `#resolvable?` scope :resolvable, -> { potentially_resolvable.user } diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb index e51b4e22c96..70ac873a030 100644 --- a/app/models/concerns/sha_attribute.rb +++ b/app/models/concerns/sha_attribute.rb @@ -16,6 +16,8 @@ module ShaAttribute # the column is the correct type. In production it should behave like any other attribute. # See https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/5502 for more discussion def validate_binary_column_exists!(name) + return unless database_exists? + unless table_exists? warn "WARNING: sha_attribute #{name.inspect} is invalid since the table doesn't exist - you may need to run database migrations" return @@ -35,5 +37,13 @@ module ShaAttribute Gitlab::AppLogger.error "ShaAttribute initialization: #{error.message}" raise end + + def database_exists? + ApplicationRecord.connection + + true + rescue + false + end end end diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb index 29e48f0c5f7..df1a9e3fe6e 100644 --- a/app/models/concerns/sortable.rb +++ b/app/models/concerns/sortable.rb @@ -21,19 +21,21 @@ module Sortable class_methods do def order_by(method) - case method.to_s - when 'created_asc' then order_created_asc - when 'created_date' then order_created_desc - when 'created_desc' then order_created_desc - when 'id_asc' then order_id_asc - when 'id_desc' then order_id_desc - when 'name_asc' then order_name_asc - when 'name_desc' then order_name_desc - when 'updated_asc' then order_updated_asc - when 'updated_desc' then order_updated_desc - else - all - end + simple_sorts.fetch(method.to_s, -> { all }).call + end + + def simple_sorts + { + 'created_asc' => -> { order_created_asc }, + 'created_date' => -> { order_created_desc }, + 'created_desc' => -> { order_created_desc }, + 'id_asc' => -> { order_id_asc }, + 'id_desc' => -> { order_id_desc }, + 'name_asc' => -> { order_name_asc }, + 'name_desc' => -> { order_name_desc }, + 'updated_asc' => -> { order_updated_asc }, + 'updated_desc' => -> { order_updated_desc } + } end private diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb index 498996f4f80..a15dc19e07a 100644 --- a/app/models/concerns/storage/legacy_namespace.rb +++ b/app/models/concerns/storage/legacy_namespace.rb @@ -13,20 +13,20 @@ module Storage raise Gitlab::UpdatePathError.new("Namespace #{name} (#{id}) cannot be moved because at least one project (e.g. #{proj_with_tags.name} (#{proj_with_tags.id})) has tags in container registry") end - parent_was = if parent_changed? && parent_id_was.present? - Namespace.find(parent_id_was) # raise NotFound early if needed + parent_was = if saved_change_to_parent? && parent_id_before_last_save.present? + Namespace.find(parent_id_before_last_save) # raise NotFound early if needed end move_repositories - if parent_changed? + if saved_change_to_parent? former_parent_full_path = parent_was&.full_path parent_full_path = parent&.full_path Gitlab::UploadsTransfer.new.move_namespace(path, former_parent_full_path, parent_full_path) Gitlab::PagesTransfer.new.move_namespace(path, former_parent_full_path, parent_full_path) else - Gitlab::UploadsTransfer.new.rename_namespace(full_path_was, full_path) - Gitlab::PagesTransfer.new.rename_namespace(full_path_was, full_path) + Gitlab::UploadsTransfer.new.rename_namespace(full_path_before_last_save, full_path) + Gitlab::PagesTransfer.new.rename_namespace(full_path_before_last_save, full_path) end # If repositories moved successfully we need to @@ -38,7 +38,7 @@ module Storage write_projects_repository_config rescue => e # Raise if development/test environment, else just notify Sentry - Gitlab::Sentry.track_exception(e, extra: { full_path_was: full_path_was, full_path: full_path, action: 'move_dir' }) + Gitlab::Sentry.track_exception(e, extra: { full_path_before_last_save: full_path_before_last_save, full_path: full_path, action: 'move_dir' }) end true # false would cancel later callbacks but not rollback @@ -57,14 +57,14 @@ module Storage # Move the namespace directory in all storages used by member projects repository_storages.each do |repository_storage| # Ensure old directory exists before moving it - gitlab_shell.add_namespace(repository_storage, full_path_was) + gitlab_shell.add_namespace(repository_storage, full_path_before_last_save) # Ensure new directory exists before moving it (if there's a parent) gitlab_shell.add_namespace(repository_storage, parent.full_path) if parent - unless gitlab_shell.mv_namespace(repository_storage, full_path_was, full_path) + unless gitlab_shell.mv_namespace(repository_storage, full_path_before_last_save, full_path) - Rails.logger.error "Exception moving path #{repository_storage} from #{full_path_was} to #{full_path}" + Rails.logger.error "Exception moving path #{repository_storage} from #{full_path_before_last_save} to #{full_path}" # if we cannot move namespace directory we should rollback # db changes in order to prevent out of sync between db and fs @@ -104,11 +104,5 @@ module Storage end end end - - def remove_legacy_exports! - legacy_export_path = File.join(Gitlab::ImportExport.storage_path, full_path_was) - - FileUtils.rm_rf(legacy_export_path) - end end end diff --git a/app/models/concerns/strip_attribute.rb b/app/models/concerns/strip_attribute.rb index c9f5ba7793d..8f6a6244dd3 100644 --- a/app/models/concerns/strip_attribute.rb +++ b/app/models/concerns/strip_attribute.rb @@ -6,7 +6,7 @@ # # Usage: # -# class Milestone < ActiveRecord::Base +# class Milestone < ApplicationRecord # strip_attributes :title # end # diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb index f147ce8ad6b..b42adad94ba 100644 --- a/app/models/concerns/taskable.rb +++ b/app/models/concerns/taskable.rb @@ -19,7 +19,7 @@ module Taskable \s+ # whitespace prefix has to be always presented for a list item (\[\s\]|\[[xX]\]) # checkbox (\s.+) # followed by whitespace and some text. - }x + }x.freeze def self.get_tasks(content) content.to_s.scan(ITEM_PATTERN).map do |checkbox, label| @@ -75,4 +75,11 @@ module Taskable def task_status_short task_status(short: true) end + + def task_completion_status + @task_completion_status ||= { + count: tasks.summary.item_count, + completed_count: tasks.summary.complete_count + } + end end diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb index f5bb559ceda..8c769be0489 100644 --- a/app/models/concerns/token_authenticatable.rb +++ b/app/models/concerns/token_authenticatable.rb @@ -26,34 +26,41 @@ module TokenAuthenticatable end end - define_method(token_field) do + mod = token_authenticatable_module + + mod.define_method(token_field) do strategy.get_token(self) end - define_method("set_#{token_field}") do |token| + mod.define_method("set_#{token_field}") do |token| strategy.set_token(self, token) end - define_method("ensure_#{token_field}") do + mod.define_method("ensure_#{token_field}") do strategy.ensure_token(self) end # Returns a token, but only saves when the database is in read & write mode - define_method("ensure_#{token_field}!") do + mod.define_method("ensure_#{token_field}!") do strategy.ensure_token!(self) end # Resets the token, but only saves when the database is in read & write mode - define_method("reset_#{token_field}!") do + mod.define_method("reset_#{token_field}!") do strategy.reset_token!(self) end - define_method("#{token_field}_matches?") do |other_token| + mod.define_method("#{token_field}_matches?") do |other_token| token = read_attribute(token_field) token.present? && ActiveSupport::SecurityUtils.variable_size_secure_compare(other_token, token) end end + def token_authenticatable_module + @token_authenticatable_module ||= + const_set(:TokenAuthenticatable, Module.new).tap(&method(:include)) + end + def token_authenticatable_fields @token_authenticatable_fields ||= [] end diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb index 01fb194281a..aafd0b538a3 100644 --- a/app/models/concerns/token_authenticatable_strategies/base.rb +++ b/app/models/concerns/token_authenticatable_strategies/base.rb @@ -39,25 +39,9 @@ module TokenAuthenticatableStrategies instance.save! if Gitlab::Database.read_write? end - def fallback? - unless options[:fallback].in?([true, false, nil]) - raise ArgumentError, 'fallback: needs to be a boolean value!' - end - - options[:fallback] == true - end - - def migrating? - unless options[:migrating].in?([true, false, nil]) - raise ArgumentError, 'migrating: needs to be a boolean value!' - end - - options[:migrating] == true - end - def self.fabricate(model, field, options) if options[:digest] && options[:encrypted] - raise ArgumentError, 'Incompatible options set!' + raise ArgumentError, _('Incompatible options set!') end if options[:digest] diff --git a/app/models/concerns/token_authenticatable_strategies/encrypted.rb b/app/models/concerns/token_authenticatable_strategies/encrypted.rb index 152491aa6e9..4728cb658dc 100644 --- a/app/models/concerns/token_authenticatable_strategies/encrypted.rb +++ b/app/models/concerns/token_authenticatable_strategies/encrypted.rb @@ -2,28 +2,18 @@ module TokenAuthenticatableStrategies class Encrypted < Base - def initialize(*) - super - - if migrating? && fallback? - raise ArgumentError, '`fallback` and `migrating` options are not compatible!' - end - end - def find_token_authenticatable(token, unscoped = false) return if token.blank? - if fully_encrypted? - return find_by_encrypted_token(token, unscoped) - end - - if fallback? + if required? + find_by_encrypted_token(token, unscoped) + elsif optional? find_by_encrypted_token(token, unscoped) || find_by_plaintext_token(token, unscoped) elsif migrating? find_by_plaintext_token(token, unscoped) else - raise ArgumentError, 'Unknown encryption phase!' + raise ArgumentError, _("Unknown encryption strategy: %{encrypted_strategy}!") % { encrypted_strategy: encrypted_strategy } end end @@ -41,8 +31,8 @@ module TokenAuthenticatableStrategies return super if instance.has_attribute?(encrypted_field) - if fully_encrypted? - raise ArgumentError, 'Using encrypted strategy when encrypted field is missing!' + if required? + raise ArgumentError, _('Using required encryption strategy when encrypted field is missing!') else insecure_strategy.ensure_token(instance) end @@ -53,8 +43,7 @@ module TokenAuthenticatableStrategies encrypted_token = instance.read_attribute(encrypted_field) token = Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token) - - token || (insecure_strategy.get_token(instance) if fallback?) + token || (insecure_strategy.get_token(instance) if optional?) end def set_token(instance, token) @@ -62,16 +51,35 @@ module TokenAuthenticatableStrategies instance[encrypted_field] = Gitlab::CryptoHelper.aes256_gcm_encrypt(token) instance[token_field] = token if migrating? - instance[token_field] = nil if fallback? + instance[token_field] = nil if optional? token end - def fully_encrypted? - !migrating? && !fallback? + def required? + encrypted_strategy == :required + end + + def migrating? + encrypted_strategy == :migrating + end + + def optional? + encrypted_strategy == :optional end protected + def encrypted_strategy + value = options[:encrypted] + value = value.call if value.is_a?(Proc) + + unless value.in?([:required, :optional, :migrating]) + raise ArgumentError, _('encrypted: needs to be a :required, :optional or :migrating!') + end + + value + end + def find_by_plaintext_token(token, unscoped) insecure_strategy.find_token_authenticatable(token, unscoped) end @@ -89,7 +97,7 @@ module TokenAuthenticatableStrategies def token_set?(instance) raw_token = instance.read_attribute(encrypted_field) - unless fully_encrypted? + unless required? raw_token ||= insecure_strategy.get_token(instance) end diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb new file mode 100644 index 00000000000..1f881249322 --- /dev/null +++ b/app/models/concerns/update_project_statistics.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +# This module is providing helpers for updating `ProjectStatistics` with `after_save` and `before_destroy` hooks. +# +# It deals with `ProjectStatistics.increment_statistic` making sure not to update statistics on a cascade delete from the +# project, and keeping track of value deltas on each save. It updates the DB only when a change is needed. +# +# Example: +# +# module Ci +# class JobArtifact < ApplicationRecord +# include UpdateProjectStatistics +# +# update_project_statistics project_statistics_name: :build_artifacts_size +# end +# end +# +# Expectation: +# +# - `statistic_attribute` must be an ActiveRecord attribute +# - The model must implement `project` and `project_id`. i.e. direct Project relationship or delegation +# +module UpdateProjectStatistics + extend ActiveSupport::Concern + + class_methods do + attr_reader :project_statistics_name, :statistic_attribute + + # Configure the model to update `project_statistics_name` on ProjectStatistics, + # when `statistic_attribute` changes + # + # - project_statistics_name: A column of `ProjectStatistics` to update + # - statistic_attribute: An attribute of the current model, default to `size` + # + def update_project_statistics(project_statistics_name:, statistic_attribute: :size) + @project_statistics_name = project_statistics_name + @statistic_attribute = statistic_attribute + + after_save(:update_project_statistics_after_save, if: :update_project_statistics_attribute_changed?) + after_destroy(:update_project_statistics_after_destroy, unless: :project_destroyed?) + end + + private :update_project_statistics + end + + included do + private + + def update_project_statistics_after_save + attr = self.class.statistic_attribute + delta = read_attribute(attr).to_i - attribute_before_last_save(attr).to_i + + update_project_statistics(delta) + end + + def update_project_statistics_attribute_changed? + saved_change_to_attribute?(self.class.statistic_attribute) + end + + def update_project_statistics_after_destroy + update_project_statistics(-read_attribute(self.class.statistic_attribute).to_i) + end + + def project_destroyed? + project.pending_delete? + end + + def update_project_statistics(delta) + ProjectStatistics.increment_statistic(project_id, self.class.project_statistics_name, delta) + end + end +end diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index cf057d774cf..39e12ac2b06 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ContainerRepository < ActiveRecord::Base +class ContainerRepository < ApplicationRecord include Gitlab::Utils::StrongMemoize belongs_to :project diff --git a/app/models/conversational_development_index/metric.rb b/app/models/conversational_development_index/metric.rb index c54537572d6..b91123be87e 100644 --- a/app/models/conversational_development_index/metric.rb +++ b/app/models/conversational_development_index/metric.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ConversationalDevelopmentIndex - class Metric < ActiveRecord::Base + class Metric < ApplicationRecord include Presentable self.table_name = 'conversational_development_index_metrics' diff --git a/app/models/deploy_keys_project.rb b/app/models/deploy_keys_project.rb index 71fd02fac86..15906ed8e06 100644 --- a/app/models/deploy_keys_project.rb +++ b/app/models/deploy_keys_project.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class DeployKeysProject < ActiveRecord::Base +class DeployKeysProject < ApplicationRecord belongs_to :project belongs_to :deploy_key, inverse_of: :deploy_keys_projects diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb index e3524305346..b0e570f52ba 100644 --- a/app/models/deploy_token.rb +++ b/app/models/deploy_token.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class DeployToken < ActiveRecord::Base +class DeployToken < ApplicationRecord include Expirable include TokenAuthenticatable include PolicyActor diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 811e623b7f7..92c7311014a 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Deployment < ActiveRecord::Base +class Deployment < ApplicationRecord include AtomicInternalId include IidRoutes include AfterCommitQueue @@ -47,6 +47,12 @@ class Deployment < ActiveRecord::Base Deployments::SuccessWorker.perform_async(id) end end + + after_transition any => [:success, :failed, :canceled] do |deployment| + deployment.run_after_commit do + Deployments::FinishedWorker.perform_async(id) + end + end end enum status: { @@ -78,6 +84,19 @@ class Deployment < ActiveRecord::Base Commit.truncate_sha(sha) end + def cluster + platform = project.deployment_platform(environment: environment.name) + + if platform.present? && platform.respond_to?(:cluster) + platform.cluster + end + end + + def execute_hooks + deployment_data = Gitlab::DataBuilder::Deployment.build(self) + project.execute_services(deployment_data, :deployment_hooks) + end + def last? self == environment.last_deployment end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 279603496b0..f75c32633b1 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -15,7 +15,9 @@ class DiffNote < Note validates :original_position, presence: true validates :position, presence: true validates :line_code, presence: true, line_code: true, if: :on_text? - validates :noteable_type, inclusion: { in: noteable_types } + # We need to evaluate the `noteable` types when running the validation since + # EE might have added a type when the module was prepended + validates :noteable_type, inclusion: { in: -> (_note) { noteable_types } } validate :positions_complete validate :verify_supported validate :diff_refs_match_commit, if: :for_commit? @@ -41,6 +43,14 @@ class DiffNote < Note create_note_diff_file(creation_params) end + # Returns the diff file from `position` + def latest_diff_file + strong_memoize(:latest_diff_file) do + position.diff_file(repository) + end + end + + # Returns the diff file from `original_position` def diff_file strong_memoize(:diff_file) do enqueue_diff_file_creation_job if should_create_diff_file? @@ -67,10 +77,10 @@ class DiffNote < Note end def supports_suggestion? - return false unless noteable.supports_suggestion? && on_text? + return false unless noteable&.supports_suggestion? && on_text? # We don't want to trigger side-effects of `diff_file` call. - return false unless file = fetch_diff_file - return false unless line = file.line_for_position(self.original_position) + return false unless file = latest_diff_file + return false unless line = file.line_for_position(self.position) line&.suggestible? end @@ -80,7 +90,7 @@ class DiffNote < Note end def banzai_render_context(field) - super.merge(suggestions_filter_enabled: supports_suggestion?) + super.merge(suggestions_filter_enabled: true) end private @@ -103,7 +113,7 @@ class DiffNote < Note if note_diff_file diff = Gitlab::Git::Diff.new(note_diff_file.to_hash) Gitlab::Diff::File.new(diff, - repository: project.repository, + repository: repository, diff_refs: original_position.diff_refs) elsif created_at_diff?(noteable.diff_refs) # We're able to use the already persisted diffs (Postgres) if we're @@ -114,7 +124,7 @@ class DiffNote < Note # `Diff::FileCollection::MergeRequestDiff`. noteable.diffs(original_position.diff_options).diff_files.first else - original_position.diff_file(self.project.repository) + original_position.diff_file(repository) end # Since persisted diff files already have its content "unfolded" @@ -129,7 +139,7 @@ class DiffNote < Note end def set_line_code - self.line_code = self.position.line_code(self.project.repository) + self.line_code = self.position.line_code(repository) end def verify_supported @@ -163,6 +173,10 @@ class DiffNote < Note shas << self.position.head_sha end - project.repository.keep_around(*shas) + repository.keep_around(*shas) + end + + def repository + noteable.respond_to?(:repository) ? noteable.repository : project.repository end end diff --git a/app/models/email.rb b/app/models/email.rb index 3ce6e792fa8..0ddaa049c3b 100644 --- a/app/models/email.rb +++ b/app/models/email.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -class Email < ActiveRecord::Base +class Email < ApplicationRecord include Sortable include Gitlab::SQL::Pattern belongs_to :user validates :user_id, presence: true - validates :email, presence: true, uniqueness: true, email: true + validates :email, presence: true, uniqueness: true, devise_email: true validate :unique_email, if: ->(email) { email.email_changed? } scope :confirmed, -> { where.not(confirmed_at: nil) } diff --git a/app/models/environment.rb b/app/models/environment.rb index 1fc088b12ae..aff20dae09b 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -class Environment < ActiveRecord::Base +class Environment < ApplicationRecord include Gitlab::Utils::StrongMemoize # Used to generate random suffixes for the slug - LETTERS = 'a'..'z' - NUMBERS = '0'..'9' + LETTERS = ('a'..'z').freeze + NUMBERS = ('0'..'9').freeze SUFFIX_CHARS = LETTERS.to_a + NUMBERS.to_a belongs_to :project, required: true @@ -35,7 +35,7 @@ class Environment < ActiveRecord::Base validates :external_url, length: { maximum: 255 }, allow_nil: true, - url: true + addressable_url: true delegate :stop_action, :manual_actions, to: :last_deployment, allow_nil: true @@ -119,7 +119,7 @@ class Environment < ActiveRecord::Base def first_deployment_for(commit_sha) ref = project.repository.ref_name_for_sha(ref_path, commit_sha) - return nil unless ref + return unless ref deployment_iid = ref.split('/').last deployments.find_by(iid: deployment_iid) @@ -130,7 +130,7 @@ class Environment < ActiveRecord::Base end def formatted_external_url - return nil unless external_url + return unless external_url external_url.gsub(%r{\A.*?://}, '') end @@ -155,11 +155,11 @@ class Environment < ActiveRecord::Base end def has_terminals? - project.deployment_platform.present? && available? && last_deployment.present? + deployment_platform.present? && available? && last_deployment.present? end def terminals - project.deployment_platform.terminals(self) if has_terminals? + deployment_platform.terminals(self) if has_terminals? end def has_metrics? @@ -170,8 +170,10 @@ class Environment < ActiveRecord::Base prometheus_adapter.query(:environment, self) if has_metrics? end - def additional_metrics - prometheus_adapter.query(:additional_metrics_environment, self) if has_metrics? + def additional_metrics(*args) + return unless has_metrics? + + prometheus_adapter.query(:additional_metrics_environment, self, *args.map(&:to_f)) end # rubocop: disable CodeReuse/ServiceClass @@ -243,6 +245,10 @@ class Environment < ActiveRecord::Base self.environment_type || self.name end + def name_without_type + @name_without_type ||= name.delete_prefix("#{environment_type}/") + end + def deployment_platform strong_memoize(:deployment_platform) do project.deployment_platform(environment: self.name) diff --git a/app/models/epic.rb b/app/models/epic.rb index ccd10593434..3693db1de33 100644 --- a/app/models/epic.rb +++ b/app/models/epic.rb @@ -2,7 +2,7 @@ # Placeholder class for model that is implemented in EE # It reserves '&' as a reference prefix, but the table does not exists in CE -class Epic < ActiveRecord::Base +class Epic < ApplicationRecord def self.link_reference_pattern nil end diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index 57283a78ea9..0b4fef5eac1 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -1,20 +1,34 @@ # frozen_string_literal: true module ErrorTracking - class ProjectErrorTrackingSetting < ActiveRecord::Base + class ProjectErrorTrackingSetting < ApplicationRecord + include Gitlab::Utils::StrongMemoize include ReactiveCaching + SENTRY_API_ERROR_TYPE_MISSING_KEYS = 'missing_keys_in_sentry_response' + SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE = 'non_20x_response_from_sentry' + + API_URL_PATH_REGEXP = %r{ + \A + (?<prefix>/api/0/projects/+) + (?: + (?<organization>[^/]+)/+ + (?<project>[^/]+)/* + )? + \z + }x.freeze + self.reactive_cache_key = ->(setting) { [setting.class.model_name.singular, setting.project_id] } belongs_to :project - validates :api_url, length: { maximum: 255 }, public_url: true, url: { enforce_sanitization: true, ascii_only: true }, allow_nil: true + validates :api_url, length: { maximum: 255 }, public_url: { enforce_sanitization: true, ascii_only: true }, allow_nil: true - validates :api_url, presence: true, if: :enabled + validates :api_url, presence: { message: 'is a required field' }, if: :enabled validate :validate_api_url_path, if: :enabled - validates :token, presence: true, if: :enabled + validates :token, presence: { message: 'is a required field' }, if: :enabled attr_encrypted :token, mode: :per_attribute_iv, @@ -23,6 +37,11 @@ module ErrorTracking after_save :clear_reactive_cache! + def api_url=(value) + super + clear_memoization(:api_url_slugs) + end + def project_name super || project_name_from_slug end @@ -40,6 +59,8 @@ module ErrorTracking end def self.build_api_url_from(api_host:, project_slug:, organization_slug:) + return if api_host.blank? + uri = Addressable::URI.parse("#{api_host}/api/0/projects/#{organization_slug}/#{project_slug}/") uri.path = uri.path.squeeze('/') @@ -72,7 +93,9 @@ module ErrorTracking { issues: sentry_client.list_issues(**opts.symbolize_keys) } end rescue Sentry::Client::Error => e - { error: e.message } + { error: e.message, error_type: SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE } + rescue Sentry::Client::MissingKeysError => e + { error: e.message, error_type: SENTRY_API_ERROR_TYPE_MISSING_KEYS } end # http://HOST/api/0/projects/ORG/PROJECT @@ -100,34 +123,39 @@ module ErrorTracking end def project_slug_from_api_url - extract_slug(:project) + api_url_slug(:project) end def organization_slug_from_api_url - extract_slug(:organization) + api_url_slug(:organization) end - def extract_slug(capture) + def api_url_slug(capture) + slugs = strong_memoize(:api_url_slugs) { extract_api_url_slugs || {} } + slugs[capture] + end + + def extract_api_url_slugs return if api_url.blank? begin url = Addressable::URI.parse(api_url) rescue Addressable::URI::InvalidURIError - return nil + return end - @slug_match ||= url.path.match(%r{^/api/0/projects/+(?<organization>[^/]+)/+(?<project>[^/|$]+)}) || {} - @slug_match[capture] + url.path.match(API_URL_PATH_REGEXP) end def validate_api_url_path return if api_url.blank? - begin - unless Addressable::URI.parse(api_url).path.starts_with?('/api/0/projects') - errors.add(:api_url, 'path needs to start with /api/0/projects') - end - rescue Addressable::URI::InvalidURIError + unless api_url_slug(:prefix) + return errors.add(:api_url, 'is invalid') + end + + unless api_url_slug(:organization) + errors.add(:project, 'is a required field') end end end diff --git a/app/models/event.rb b/app/models/event.rb index 6a35bca72c5..738080eb584 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Event < ActiveRecord::Base +class Event < ApplicationRecord include Sortable include IgnorableColumn include FromUnion @@ -68,7 +68,7 @@ class Event < ActiveRecord::Base # Callbacks after_create :reset_project_activity - after_create :set_last_repository_updated_at, if: :push? + after_create :set_last_repository_updated_at, if: :push_action? after_create :track_user_interacted_projects # Scopes @@ -138,11 +138,11 @@ class Event < ActiveRecord::Base # rubocop:disable Metrics/CyclomaticComplexity # rubocop:disable Metrics/PerceivedComplexity def visible_to_user?(user = nil) - if push? || commit_note? + if push_action? || commit_note? Ability.allowed?(user, :download_code, project) elsif membership_changed? Ability.allowed?(user, :read_project, project) - elsif created_project? + elsif created_project_action? Ability.allowed?(user, :read_project, project) elsif issue? || issue_note? Ability.allowed?(user, :read_issue, note? ? note_target : target) @@ -173,56 +173,56 @@ class Event < ActiveRecord::Base target.try(:title) end - def created? + def created_action? action == CREATED end - def push? + def push_action? false end - def merged? + def merged_action? action == MERGED end - def closed? + def closed_action? action == CLOSED end - def reopened? + def reopened_action? action == REOPENED end - def joined? + def joined_action? action == JOINED end - def left? + def left_action? action == LEFT end - def expired? + def expired_action? action == EXPIRED end - def destroyed? + def destroyed_action? action == DESTROYED end - def commented? + def commented_action? action == COMMENTED end def membership_changed? - joined? || left? || expired? + joined_action? || left_action? || expired_action? end - def created_project? - created? && !target && target_type.nil? + def created_project_action? + created_action? && !target && target_type.nil? end def created_target? - created? && target + created_action? && target end def milestone? @@ -258,23 +258,23 @@ class Event < ActiveRecord::Base end def action_name - if push? + if push_action? push_action_name - elsif closed? + elsif closed_action? "closed" - elsif merged? + elsif merged_action? "accepted" - elsif joined? + elsif joined_action? 'joined' - elsif left? + elsif left_action? 'left' - elsif expired? + elsif expired_action? 'removed due to membership expiration from' - elsif destroyed? + elsif destroyed_action? 'destroyed' - elsif commented? + elsif commented_action? "commented on" - elsif created_project? + elsif created_project_action? created_project_action_name else "opened" @@ -337,7 +337,7 @@ class Event < ActiveRecord::Base end def body? - if push? + if push_action? push_with_commits? elsif note? true diff --git a/app/models/fork_network.rb b/app/models/fork_network.rb index 1b9bf93cbbc..0323a8d222a 100644 --- a/app/models/fork_network.rb +++ b/app/models/fork_network.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ForkNetwork < ActiveRecord::Base +class ForkNetwork < ApplicationRecord belongs_to :root_project, class_name: 'Project' has_many :fork_network_members has_many :projects, through: :fork_network_members diff --git a/app/models/fork_network_member.rb b/app/models/fork_network_member.rb index 36c66f21b0b..f18c306cf91 100644 --- a/app/models/fork_network_member.rb +++ b/app/models/fork_network_member.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ForkNetworkMember < ActiveRecord::Base +class ForkNetworkMember < ApplicationRecord belongs_to :fork_network belongs_to :project belongs_to :forked_from_project, class_name: 'Project' diff --git a/app/models/generic_commit_status.rb b/app/models/generic_commit_status.rb index 3028bf21301..8a768b3a2c0 100644 --- a/app/models/generic_commit_status.rb +++ b/app/models/generic_commit_status.rb @@ -3,7 +3,7 @@ class GenericCommitStatus < CommitStatus before_validation :set_default_values - validates :target_url, url: true, + validates :target_url, addressable_url: true, length: { maximum: 255 }, allow_nil: true diff --git a/app/models/global_label.rb b/app/models/global_label.rb index c5b2492bbf6..7c020dd3b3d 100644 --- a/app/models/global_label.rb +++ b/app/models/global_label.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true class GlobalLabel + include Presentable + attr_accessor :title, :labels alias_attribute :name, :title - delegate :color, :text_color, :description, to: :@first_label + delegate :color, :text_color, :description, :scoped_label?, to: :@first_label def for_display @first_label @@ -23,4 +25,8 @@ class GlobalLabel @labels = labels @first_label = labels.find { |lbl| lbl.description.present? } || labels.first end + + def present(attributes) + super(attributes.merge(presenter_class: ::LabelPresenter)) + end end diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index fd17745b035..59f5a7703e2 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -8,7 +8,9 @@ class GlobalMilestone attr_reader :milestone alias_attribute :name, :title - delegate :title, :state, :due_date, :start_date, :participants, :project, :group, :expires_at, :closed?, :iid, :group_milestone?, :safe_title, :milestoneish_id, to: :milestone + delegate :title, :state, :due_date, :start_date, :participants, :project, + :group, :expires_at, :closed?, :iid, :group_milestone?, :safe_title, + :milestoneish_id, :parent, to: :milestone def to_hash { diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 077afffd358..116beac5c2a 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class GpgKey < ActiveRecord::Base +class GpgKey < ApplicationRecord KEY_PREFIX = '-----BEGIN PGP PUBLIC KEY BLOCK-----'.freeze KEY_SUFFIX = '-----END PGP PUBLIC KEY BLOCK-----'.freeze diff --git a/app/models/gpg_key_subkey.rb b/app/models/gpg_key_subkey.rb index 440b588bc78..110bf451136 100644 --- a/app/models/gpg_key_subkey.rb +++ b/app/models/gpg_key_subkey.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class GpgKeySubkey < ActiveRecord::Base +class GpgKeySubkey < ApplicationRecord include ShaAttribute sha_attribute :keyid diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb index 7f9ff7bbda6..46cac1d41bb 100644 --- a/app/models/gpg_signature.rb +++ b/app/models/gpg_signature.rb @@ -38,6 +38,15 @@ class GpgSignature < ApplicationRecord .safe_find_or_create_by!(commit_sha: attributes[:commit_sha]) end + # Find commits that are lacking a signature in the database at present + def self.unsigned_commit_shas(commit_shas) + return [] if commit_shas.empty? + + signed = GpgSignature.where(commit_sha: commit_shas).pluck(:commit_sha) + + commit_shas - signed + end + def gpg_key=(model) case model when GpgKey diff --git a/app/models/group.rb b/app/models/group.rb index 52f503404af..cdb4e6e87f6 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -56,12 +56,12 @@ class Group < Namespace validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 } - add_authentication_token_field :runners_token, encrypted: true, migrating: true + add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:groups_tokens_optional_encryption, default_enabled: true) ? :optional : :required } after_create :post_create_hook after_destroy :post_destroy_hook after_save :update_two_factor_requirement - after_update :path_changed_hook, if: :path_changed? + after_update :path_changed_hook, if: :saved_change_to_path? class << self def sort_by_attribute(method) @@ -126,10 +126,20 @@ class Group < Namespace # Overrides notification_settings has_many association # This allows to apply notification settings from parent groups # to child groups and projects. - def notification_settings + def notification_settings(hierarchy_order: nil) source_type = self.class.base_class.name + settings = NotificationSetting.where(source_type: source_type, source_id: self_and_ancestors_ids) - NotificationSetting.where(source_type: source_type, source_id: self_and_ancestors_ids) + return settings unless hierarchy_order && self_and_ancestors_ids.length > 1 + + settings + .joins("LEFT JOIN (#{self_and_ancestors(hierarchy_order: hierarchy_order).to_sql}) AS ordered_groups ON notification_settings.source_id = ordered_groups.id") + .select('notification_settings.*, ordered_groups.depth AS depth') + .order("ordered_groups.depth #{hierarchy_order}") + end + + def notification_settings_for(user, hierarchy_order: nil) + notification_settings(hierarchy_order: hierarchy_order).where(user: user) end def to_reference(_from = nil, full: nil) @@ -228,22 +238,21 @@ class Group < Namespace def has_owner?(user) return false unless user - members_with_parents.owners.where(user_id: user).any? + members_with_parents.owners.exists?(user_id: user) end def has_maintainer?(user) return false unless user - members_with_parents.maintainers.where(user_id: user).any? + members_with_parents.maintainers.exists?(user_id: user) end # @deprecated alias_method :has_master?, :has_maintainer? # Check if user is a last owner of the group. - # Parent owners are ignored for nested groups. def last_owner?(user) - owners.include?(user) && owners.size == 1 + has_owner?(user) && members_with_parents.owners.size == 1 end def ldap_synced? @@ -405,10 +414,14 @@ class Group < Namespace Feature.enabled?(:group_clusters, root_ancestor, default_enabled: true) end + def project_creation_level + super || ::Gitlab::CurrentSettings.default_project_creation + end + private def update_two_factor_requirement - return unless require_two_factor_authentication_changed? || two_factor_grace_period_changed? + return unless saved_change_to_require_two_factor_authentication? || saved_change_to_two_factor_grace_period? users.find_each(&:update_two_factor_requirement) end diff --git a/app/models/group_custom_attribute.rb b/app/models/group_custom_attribute.rb index 22f14885657..5ac6e5f2550 100644 --- a/app/models/group_custom_attribute.rb +++ b/app/models/group_custom_attribute.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class GroupCustomAttribute < ActiveRecord::Base +class GroupCustomAttribute < ApplicationRecord belongs_to :group validates :group, :key, :value, presence: true diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 1a8662db9fb..daf7ff4b771 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class WebHook < ActiveRecord::Base +class WebHook < ApplicationRecord include Sortable attr_encrypted :token, diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb index 2d9f7594e8c..cfb1f3ec63b 100644 --- a/app/models/hooks/web_hook_log.rb +++ b/app/models/hooks/web_hook_log.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class WebHookLog < ActiveRecord::Base +class WebHookLog < ApplicationRecord belongs_to :web_hook serialize :request_headers, Hash # rubocop:disable Cop/ActiveRecordSerialize diff --git a/app/models/identity.rb b/app/models/identity.rb index acdde4f296b..1cbd50205ed 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Identity < ActiveRecord::Base +class Identity < ApplicationRecord include Sortable include CaseSensitivity @@ -13,6 +13,7 @@ class Identity < ActiveRecord::Base before_save :ensure_normalized_extern_uid, if: :extern_uid_changed? after_destroy :clear_user_synced_attributes, if: :user_synced_attributes_metadata_from_provider? + scope :for_user, ->(user) { where(user: user) } scope :with_provider, ->(provider) { where(provider: provider) } scope :with_extern_uid, ->(provider, extern_uid) do iwhere(extern_uid: normalize_uid(provider, extern_uid)).with_provider(provider) diff --git a/app/models/identity/uniqueness_scopes.rb b/app/models/identity/uniqueness_scopes.rb index 674b735903f..ce68371ae87 100644 --- a/app/models/identity/uniqueness_scopes.rb +++ b/app/models/identity/uniqueness_scopes.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Identity < ActiveRecord::Base +class Identity < ApplicationRecord # This module and method are defined in a separate file to allow EE to # redefine the `scopes` method before it is used in the `Identity` model. module UniquenessScopes diff --git a/app/models/import_export_upload.rb b/app/models/import_export_upload.rb index f0cc5aafcd4..60f5491849a 100644 --- a/app/models/import_export_upload.rb +++ b/app/models/import_export_upload.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ImportExportUpload < ActiveRecord::Base +class ImportExportUpload < ApplicationRecord include WithUploads include ObjectStorage::BackgroundMove diff --git a/app/models/individual_note_discussion.rb b/app/models/individual_note_discussion.rb index b4a661ae5b4..d926e39f96e 100644 --- a/app/models/individual_note_discussion.rb +++ b/app/models/individual_note_discussion.rb @@ -14,7 +14,7 @@ class IndividualNoteDiscussion < Discussion end def can_convert_to_discussion? - noteable.supports_replying_to_individual_notes? && Feature.enabled?(:reply_to_individual_notes) + noteable.supports_replying_to_individual_notes? end def convert_to_discussion!(save: false) diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb index 11289887e00..a9b1962f24c 100644 --- a/app/models/instance_configuration.rb +++ b/app/models/instance_configuration.rb @@ -39,7 +39,7 @@ class InstanceConfiguration def gitlab_ci Settings.gitlab_ci .to_h - .merge(artifacts_max_size: { value: Settings.artifacts.max_size&.megabytes, + .merge(artifacts_max_size: { value: Gitlab::CurrentSettings.max_artifacts_size.megabytes, default: 100.megabytes }) end diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb index e75c6eb2331..237401899db 100644 --- a/app/models/internal_id.rb +++ b/app/models/internal_id.rb @@ -15,7 +15,7 @@ # In order to leverage InternalId for other usages, the idea is to # * Add `usage` value to enum # * (Optionally) add columns to `internal_ids` if needed for scope. -class InternalId < ActiveRecord::Base +class InternalId < ApplicationRecord belongs_to :project belongs_to :namespace @@ -55,7 +55,8 @@ class InternalId < ActiveRecord::Base def track_greatest(subject, scope, usage, new_value, init) return new_value unless available? - InternalIdGenerator.new(subject, scope, usage, init).track_greatest(new_value) + InternalIdGenerator.new(subject, scope, usage) + .track_greatest(init, new_value) end def generate_next(subject, scope, usage, init) @@ -63,7 +64,15 @@ class InternalId < ActiveRecord::Base # This can be the case in other (unrelated) migration specs return (init.call(subject) || 0) + 1 unless available? - InternalIdGenerator.new(subject, scope, usage, init).generate + InternalIdGenerator.new(subject, scope, usage) + .generate(init) + end + + def reset(subject, scope, usage, value) + return false unless available? + + InternalIdGenerator.new(subject, scope, usage) + .reset(value) end # Flushing records is generally safe in a sense that those @@ -78,12 +87,16 @@ class InternalId < ActiveRecord::Base end def available? - @available_flag ||= ActiveRecord::Migrator.current_version >= REQUIRED_SCHEMA_VERSION # rubocop:disable Gitlab/PredicateMemoization + return true unless Rails.env.test? + + Gitlab::SafeRequestStore.fetch(:internal_ids_available_flag) do + ActiveRecord::Migrator.current_version >= REQUIRED_SCHEMA_VERSION + end end # Flushes cached information about schema def reset_column_information - @available_flag = nil + Gitlab::SafeRequestStore[:internal_ids_available_flag] = nil super end end @@ -103,14 +116,11 @@ class InternalId < ActiveRecord::Base # subject: The instance we're generating an internal id for. Gets passed to init if called. # scope: Attributes that define the scope for id generation. # usage: Symbol to define the usage of the internal id, see InternalId.usages - # init: Block that gets called to initialize InternalId record if not present - # Make sure to not throw exceptions in the absence of records (if this is expected). - attr_reader :subject, :scope, :init, :scope_attrs, :usage + attr_reader :subject, :scope, :scope_attrs, :usage - def initialize(subject, scope, usage, init) + def initialize(subject, scope, usage) @subject = subject @scope = scope - @init = init @usage = usage raise ArgumentError, 'Scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty? @@ -121,23 +131,40 @@ class InternalId < ActiveRecord::Base end # Generates next internal id and returns it - def generate + # init: Block that gets called to initialize InternalId record if not present + # Make sure to not throw exceptions in the absence of records (if this is expected). + def generate(init) subject.transaction do # Create a record in internal_ids if one does not yet exist # and increment its last value # # Note this will acquire a ROW SHARE lock on the InternalId record - (lookup || create_record).increment_and_save! + (lookup || create_record(init)).increment_and_save! end end + # Reset tries to rewind to `value-1`. This will only succeed, + # if `value` stored in database is equal to `last_value`. + # value: The expected last_value to decrement + def reset(value) + return false unless value + + updated = + InternalId + .where(**scope, usage: usage_value) + .where(last_value: value) + .update_all('last_value = last_value - 1') + + updated > 0 + end + # Create a record in internal_ids if one does not yet exist # and set its new_value if it is higher than the current last_value # # Note this will acquire a ROW SHARE lock on the InternalId record - def track_greatest(new_value) + def track_greatest(init, new_value) subject.transaction do - (lookup || create_record).track_greatest_and_save!(new_value) + (lookup || create_record(init)).track_greatest_and_save!(new_value) end end @@ -158,7 +185,7 @@ class InternalId < ActiveRecord::Base # was faster in doing this, we'll realize once we hit the unique key constraint # violation. We can safely roll-back the nested transaction and perform # a lookup instead to retrieve the record. - def create_record + def create_record(init) subject.transaction(requires_new: true) do InternalId.create!( **scope, diff --git a/app/models/issue.rb b/app/models/issue.rb index 0b46e949052..6da6fbe55cb 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -2,7 +2,7 @@ require 'carrierwave/orm/activerecord' -class Issue < ActiveRecord::Base +class Issue < ApplicationRecord include AtomicInternalId include IidRoutes include Issuable @@ -49,10 +49,6 @@ class Issue < ActiveRecord::Base scope :in_projects, ->(project_ids) { where(project_id: project_ids) } - scope :assigned, -> { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') } - scope :unassigned, -> { where('NOT EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') } - scope :assigned_to, ->(u) { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = ? AND issue_id = issues.id)', u.id)} - scope :with_due_date, -> { where.not(due_date: nil) } scope :without_due_date, -> { where(due_date: nil) } scope :due_before, ->(date) { where('issues.due_date < ?', date) } @@ -62,8 +58,10 @@ class Issue < ActiveRecord::Base scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') } scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') } scope :order_closest_future_date, -> { reorder('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC') } + scope :order_relative_position_asc, -> { reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')) } scope :preload_associations, -> { preload(:labels, project: :namespace) } + scope :with_api_entity_associations, -> { preload(:timelogs, :assignees, :author, :notes, :labels, project: [:route, { namespace: :route }] ) } scope :public_only, -> { where(confidential: false) } scope :confidential_only, -> { where(confidential: true) } @@ -74,8 +72,6 @@ class Issue < ActiveRecord::Base attr_spammable :title, spam_title: true attr_spammable :description, spam_description: true - participant :assignees - state_machine :state, initial: :opened do event :close do transition [:opened] => :closed @@ -89,7 +85,7 @@ class Issue < ActiveRecord::Base state :closed before_transition any => :closed do |issue| - issue.closed_at = Time.zone.now + issue.closed_at = issue.system_note_timestamp end before_transition closed: :opened do |issue| @@ -135,9 +131,10 @@ class Issue < ActiveRecord::Base def self.sort_by_attribute(method, excluded_labels: []) case method.to_s when 'closest_future_date' then order_closest_future_date - when 'due_date' then order_due_date_asc - when 'due_date_asc' then order_due_date_asc - when 'due_date_desc' then order_due_date_desc + when 'due_date' then order_due_date_asc + when 'due_date_asc' then order_due_date_asc + when 'due_date_desc' then order_due_date_desc + when 'relative_position' then order_relative_position_asc else super end @@ -154,22 +151,6 @@ class Issue < ActiveRecord::Base Gitlab::HookData::IssueBuilder.new(self).build end - # Returns a Hash of attributes to be used for Twitter card metadata - def card_attributes - { - 'Author' => author.try(:name), - 'Assignee' => assignee_list - } - end - - def assignee_or_author?(user) - author_id == user.id || assignees.exists?(user.id) - end - - def assignee_list - assignees.map(&:name).to_sentence - end - # `from` argument can be a Namespace or Project. def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" @@ -229,7 +210,13 @@ class Issue < ActiveRecord::Base def visible_to_user?(user = nil) return false unless project && project.feature_available?(:issues, user) - user ? readable_by?(user) : publicly_visible? + return publicly_visible? unless user + + return false unless readable_by?(user) + + user.full_private_access? || + ::Gitlab::ExternalAuthorization.access_allowed?( + user, project.external_authorization_classification_label) end def check_for_spam? @@ -263,6 +250,10 @@ class Issue < ActiveRecord::Base end # rubocop: enable CodeReuse/ServiceClass + def merge_requests_count + merge_requests_closing_issues.count + end + private def ensure_metrics @@ -293,7 +284,7 @@ class Issue < ActiveRecord::Base # Returns `true` if this Issue is visible to everybody. def publicly_visible? - project.public? && !confidential? + project.public? && !confidential? && !::Gitlab::ExternalAuthorization.enabled? end def expire_etag_cache diff --git a/app/models/issue/metrics.rb b/app/models/issue/metrics.rb index 0f5ee957ec9..8010cbc3d78 100644 --- a/app/models/issue/metrics.rb +++ b/app/models/issue/metrics.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Issue::Metrics < ActiveRecord::Base +class Issue::Metrics < ApplicationRecord belongs_to :issue def record! diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb index 400c0256945..fbd9be1fb43 100644 --- a/app/models/issue_assignee.rb +++ b/app/models/issue_assignee.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class IssueAssignee < ActiveRecord::Base +class IssueAssignee < ApplicationRecord belongs_to :issue belongs_to :assignee, class_name: "User", foreign_key: :user_id end diff --git a/app/models/key.rb b/app/models/key.rb index 8f93418b88b..8aa25924c28 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -2,7 +2,7 @@ require 'digest/md5' -class Key < ActiveRecord::Base +class Key < ApplicationRecord include AfterCommitQueue include Sortable @@ -59,6 +59,11 @@ class Key < ActiveRecord::Base "key-#{id}" end + # EE overrides this + def can_delete? + true + end + # rubocop: disable CodeReuse/ServiceClass def update_last_used_at Keys::LastUsedService.new(self).execute diff --git a/app/models/label.rb b/app/models/label.rb index 1c3db3eb35d..e9085e8bd25 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Label < ActiveRecord::Base +class Label < ApplicationRecord include CacheMarkdownField include Referable include Subscribable @@ -8,6 +8,7 @@ class Label < ActiveRecord::Base include OptionallySearch include Sortable include FromUnion + include Presentable cache_markdown_field :description, pipeline: :single_line @@ -126,6 +127,17 @@ class Label < ActiveRecord::Base fuzzy_search(query, [:title, :description]) end + # Override Gitlab::SQL::Pattern.min_chars_for_partial_matching as + # label queries are never global, and so will not use a trigram + # index. That means we can have just one character in the LIKE. + def self.min_chars_for_partial_matching + 1 + end + + def self.by_ids(ids) + where(id: ids) + end + def open_issues_count(user = nil) issues_count(user, state: 'opened') end @@ -222,6 +234,10 @@ class Label < ActiveRecord::Base attributes end + def present(attributes) + super(attributes.merge(presenter_class: ::LabelPresenter)) + end + private def issues_count(user, params = {}) diff --git a/app/models/label_link.rb b/app/models/label_link.rb index 1d93a55e8e9..ffc0afd8e85 100644 --- a/app/models/label_link.rb +++ b/app/models/label_link.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class LabelLink < ActiveRecord::Base +class LabelLink < ApplicationRecord include Importable belongs_to :target, polymorphic: true, inverse_of: :label_links # rubocop:disable Cop/PolymorphicAssociations diff --git a/app/models/label_note.rb b/app/models/label_note.rb index 680952cf421..d6814f4a948 100644 --- a/app/models/label_note.rb +++ b/app/models/label_note.rb @@ -81,7 +81,7 @@ class LabelNote < Note deleted = label_refs.count - existing_refs.count deleted_str = deleted == 0 ? nil : "#{deleted} deleted" - return nil unless refs_str || deleted_str + return unless refs_str || deleted_str label_list_str = [refs_str, deleted_str].compact.join(' + ') suffix = 'label'.pluralize(deleted > 0 ? deleted : existing_refs.count) diff --git a/app/models/label_priority.rb b/app/models/label_priority.rb index 8ed8bb7577f..8f8f36efbfe 100644 --- a/app/models/label_priority.rb +++ b/app/models/label_priority.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class LabelPriority < ActiveRecord::Base +class LabelPriority < ApplicationRecord belongs_to :project belongs_to :label diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb index 00dec6bb92b..e2c75bc7ee9 100644 --- a/app/models/legacy_diff_note.rb +++ b/app/models/legacy_diff_note.rb @@ -73,7 +73,7 @@ class LegacyDiffNote < Note private def find_diff - return nil unless noteable + return unless noteable return @diff if defined?(@diff) @diff = noteable.raw_diffs(Commit.max_diff_options).find do |d| diff --git a/app/models/lfs_file_lock.rb b/app/models/lfs_file_lock.rb index 431d37e12e9..624b1d02e1a 100644 --- a/app/models/lfs_file_lock.rb +++ b/app/models/lfs_file_lock.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class LfsFileLock < ActiveRecord::Base +class LfsFileLock < ApplicationRecord belongs_to :project belongs_to :user diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index 69c563545bb..5245dbc8d15 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class LfsObject < ActiveRecord::Base +class LfsObject < ApplicationRecord include AfterCommitQueue include ObjectStorage::BackgroundMove @@ -13,7 +13,7 @@ class LfsObject < ActiveRecord::Base mount_uploader :file, LfsObjectUploader - after_save :update_file_store, if: :file_changed? + after_save :update_file_store, if: :saved_change_to_file? def update_file_store # The file.object_store is set during `uploader.store!` diff --git a/app/models/lfs_objects_project.rb b/app/models/lfs_objects_project.rb index 353602800d7..f9afb18c1d7 100644 --- a/app/models/lfs_objects_project.rb +++ b/app/models/lfs_objects_project.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class LfsObjectsProject < ActiveRecord::Base +class LfsObjectsProject < ApplicationRecord belongs_to :project belongs_to :lfs_object diff --git a/app/models/list.rb b/app/models/list.rb index 682af761ba0..17b1a8510cf 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class List < ActiveRecord::Base +class List < ApplicationRecord belongs_to :board belongs_to :label diff --git a/app/models/member.rb b/app/models/member.rb index 8e071a8ff21..c7583434148 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Member < ActiveRecord::Base +class Member < ApplicationRecord include AfterCommitQueue include Sortable include Importable @@ -28,7 +28,7 @@ class Member < ActiveRecord::Base presence: { if: :invite? }, - email: { + devise_email: { allow_nil: true }, uniqueness: { @@ -80,6 +80,8 @@ class Member < ActiveRecord::Base scope :owners_and_masters, -> { owners_and_maintainers } # @deprecated scope :with_user, -> (user) { where(user: user) } + scope :with_source_id, ->(source_id) { where(source_id: source_id) } + scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) } scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) } scope :order_recent_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'DESC')) } @@ -446,10 +448,10 @@ class Member < ActiveRecord::Base end def higher_access_level_than_group - if highest_group_member && highest_group_member.access_level >= access_level + if highest_group_member && highest_group_member.access_level > access_level error_parameters = { access: highest_group_member.human_access, group_name: highest_group_member.group.name } - errors.add(:access_level, s_("should be higher than %{access} inherited membership from group %{group_name}") % error_parameters) + errors.add(:access_level, s_("should be greater than or equal to %{access} inherited membership from group %{group_name}") % error_parameters) end end end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 2c9e1ba1d80..4cba69069bb 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -12,7 +12,9 @@ class GroupMember < Member validates :source_type, format: { with: /\ANamespace\z/ } default_scope { where(source_type: SOURCE_TYPE) } - scope :in_groups, ->(groups) { where(source_id: groups.select(:id)) } + scope :of_groups, ->(groups) { where(source_id: groups.select(:id)) } + + scope :count_users_by_group_id, -> { joins(:user).group(:source_id).count } after_create :update_two_factor_requirement, unless: :invite? after_destroy :update_two_factor_requirement, unless: :invite? @@ -53,7 +55,7 @@ class GroupMember < Member end def post_update_hook - if access_level_changed? + if saved_change_to_access_level? run_after_commit { notification_service.update_group_member(self) } end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 5372c6084f4..c64e2669b6a 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -111,7 +111,7 @@ class ProjectMember < Member end def post_update_hook - if access_level_changed? + if saved_change_to_access_level? run_after_commit { notification_service.update_project_member(self) } end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 75fca96ce0a..4fcaac75655 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class MergeRequest < ActiveRecord::Base +class MergeRequest < ApplicationRecord include AtomicInternalId include IidRoutes include Issuable @@ -16,6 +16,7 @@ class MergeRequest < ActiveRecord::Base include LabelEventable include ReactiveCaching include FromUnion + include DeprecatedAssignee self.reactive_cache_key = ->(model) { [model.project.id, model.iid] } self.reactive_cache_refresh_interval = 10.minutes @@ -65,13 +66,15 @@ class MergeRequest < ActiveRecord::Base dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :cached_closes_issues, through: :merge_requests_closing_issues, source: :issue - has_many :merge_request_pipelines, foreign_key: 'merge_request_id', class_name: 'Ci::Pipeline' + has_many :pipelines_for_merge_request, foreign_key: 'merge_request_id', class_name: 'Ci::Pipeline' + has_many :suggestions, through: :notes - belongs_to :assignee, class_name: "User" + has_many :merge_request_assignees + has_many :assignees, class_name: "User", through: :merge_request_assignees serialize :merge_params, Hash # rubocop:disable Cop/ActiveRecordSerialize - after_create :ensure_merge_request_diff, unless: :importing? + after_create :ensure_merge_request_diff after_update :clear_memoized_shas after_update :reload_diff_if_branch_changed after_save :ensure_metrics @@ -162,7 +165,7 @@ class MergeRequest < ActiveRecord::Base validates :source_branch, presence: true validates :target_project, presence: true validates :target_branch, presence: true - validates :merge_user, presence: true, if: :merge_when_pipeline_succeeds?, unless: :importing? + validates :merge_user, presence: true, if: :auto_merge_enabled?, unless: :importing? validate :validate_branches, unless: [:allow_broken, :importing?, :closed_without_fork?] validate :validate_fork, unless: :closed_without_fork? validate :validate_target_project, on: :create @@ -181,18 +184,44 @@ class MergeRequest < ActiveRecord::Base end scope :join_project, -> { joins(:target_project) } scope :references_project, -> { references(:target_project) } - scope :assigned, -> { where("assignee_id IS NOT NULL") } - scope :unassigned, -> { where("assignee_id IS NULL") } - scope :assigned_to, ->(u) { where(assignee_id: u.id)} - - participant :assignee + scope :with_api_entity_associations, -> { + preload(:assignees, :author, :notes, :labels, :milestone, :timelogs, + latest_merge_request_diff: [:merge_request_diff_commits], + metrics: [:latest_closed_by, :merged_by], + target_project: [:route, { namespace: :route }], + source_project: [:route, { namespace: :route }]) + } after_save :keep_around_commit + alias_attribute :project, :target_project + alias_attribute :project_id, :target_project_id + alias_attribute :auto_merge_enabled, :merge_when_pipeline_succeeds + def self.reference_prefix '!' 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 + # sort by updated_at of merge request: + # + # ['master', 'develop', 'production'] + # + # limit - The maximum number of target branch to return. + def self.recent_target_branches(limit: 100) + group(:target_branch) + .select(:target_branch) + .reorder('MAX(merge_requests.updated_at) DESC') + .limit(limit) + .pluck(:target_branch) + end + def rebase_in_progress? strong_memoize(:rebase_in_progress) do # The source project can be deleted @@ -206,7 +235,7 @@ class MergeRequest < ActiveRecord::Base # branch head commit, for example checking if a merge request can be merged. # For more information check: https://gitlab.com/gitlab-org/gitlab-ce/issues/40004 def actual_head_pipeline - head_pipeline&.sha == diff_head_sha ? head_pipeline : nil + head_pipeline&.matches_sha_or_source_sha?(diff_head_sha) ? head_pipeline : nil end def merge_pipeline @@ -286,12 +315,8 @@ class MergeRequest < ActiveRecord::Base work_in_progress?(title) ? title : "WIP: #{title}" end - def commit_authors - @commit_authors ||= commits.authors - end - - def authors - User.from_union([commit_authors, User.where(id: self.author_id)]) + def committers + @committers ||= commits.committers end # Verifies if title has changed not taking into account WIP prefix @@ -304,31 +329,6 @@ class MergeRequest < ActiveRecord::Base Gitlab::HookData::MergeRequestBuilder.new(self).build end - # Returns a Hash of attributes to be used for Twitter card metadata - def card_attributes - { - 'Author' => author.try(:name), - 'Assignee' => assignee.try(:name) - } - end - - # These method are needed for compatibility with issues to not mess view and other code - def assignees - Array(assignee) - end - - def assignee_ids - Array(assignee_id) - end - - def assignee_ids=(ids) - write_attribute(:assignee_id, ids.last) - end - - def assignee_or_author?(user) - author_id == user.id || assignee_id == user.id - end - # `from` argument can be a Namespace or Project. def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" @@ -392,7 +392,7 @@ class MergeRequest < ActiveRecord::Base def merge_participants participants = [author] - if merge_when_pipeline_succeeds? && !participants.include?(merge_user) + if auto_merge_enabled? && !participants.include?(merge_user) participants << merge_user end @@ -582,11 +582,15 @@ class MergeRequest < ActiveRecord::Base end def validate_branches + return unless target_project && source_project + if target_project == source_project && target_branch == source_branch errors.add :branch_conflict, "You can't use same project/branch for source and target" return end + [:source_branch, :target_branch].each { |attr| validate_branch_name(attr) } + if opened? similar_mrs = target_project .merge_requests @@ -607,6 +611,16 @@ class MergeRequest < ActiveRecord::Base end end + def validate_branch_name(attr) + return unless changes_include?(attr) + + branch = read_attribute(attr) + + return unless branch + + errors.add(attr) unless Gitlab::GitRefValidator.validate_merge_request_branch(branch) + end + def validate_target_project return true if target_project.merge_requests_enabled? @@ -699,7 +713,7 @@ class MergeRequest < ActiveRecord::Base end def reload_diff_if_branch_changed - if (source_branch_changed? || target_branch_changed?) && + if (saved_change_to_source_branch? || saved_change_to_target_branch?) && (source_branch_head && target_branch_head) reload_diff end @@ -711,19 +725,16 @@ class MergeRequest < ActiveRecord::Base MergeRequests::ReloadDiffsService.new(self, current_user).execute end - # rubocop: enable CodeReuse/ServiceClass - def check_if_can_be_merged - return unless self.class.state_machines[:merge_status].check_state?(merge_status) && Gitlab::Database.read_write? - - can_be_merged = - !broken? && project.repository.can_be_merged?(diff_head_sha, target_branch) + def check_mergeability + MergeRequests::MergeabilityCheckService.new(self).execute + end + # rubocop: enable CodeReuse/ServiceClass - if can_be_merged - mark_as_mergeable - else - mark_as_unmergeable - end + # Returns boolean indicating the merge_status should be rechecked in order to + # switch to either can_be_merged or cannot_be_merged. + def recheck_merge_status? + self.class.state_machines[:merge_status].check_state?(merge_status) end def merge_event @@ -749,7 +760,7 @@ class MergeRequest < ActiveRecord::Base def mergeable?(skip_ci_check: false) return false unless mergeable_state?(skip_ci_check: skip_ci_check) - check_if_can_be_merged + check_mergeability can_be_merged? && !should_be_rebased? end @@ -772,7 +783,7 @@ class MergeRequest < ActiveRecord::Base project.ff_merge_must_be_possible? && !ff_merge_possible? end - def can_cancel_merge_when_pipeline_succeeds?(current_user) + def can_cancel_auto_merge?(current_user) can_be_merged_by?(current_user) || self.author == current_user end @@ -791,6 +802,16 @@ class MergeRequest < ActiveRecord::Base Gitlab::Utils.to_boolean(merge_params['force_remove_source_branch']) end + def auto_merge_strategy + return unless auto_merge_enabled? + + merge_params['auto_merge_strategy'] || AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS + end + + def auto_merge_strategy=(strategy) + merge_params['auto_merge_strategy'] = strategy + end + def remove_source_branch? should_remove_source_branch? || force_remove_source_branch? end @@ -807,15 +828,6 @@ class MergeRequest < ActiveRecord::Base end def related_notes - # Fetch comments only from last 100 commits - commits_for_notes_limit = 100 - commit_ids = commit_shas.take(commits_for_notes_limit) - - commit_notes = Note - .except(:order) - .where(project_id: [source_project_id, target_project_id]) - .for_commit_id(commit_ids) - # We're using a UNION ALL here since this results in better performance # compared to using OR statements. We're using UNION ALL since the queries # used won't produce any duplicates (e.g. a note for a commit can't also be @@ -827,6 +839,16 @@ class MergeRequest < ActiveRecord::Base alias_method :discussion_notes, :related_notes + def commit_notes + # Fetch comments only from last 100 commits + commit_ids = commit_shas.take(100) + + Note + .user + .where(project_id: [source_project_id, target_project_id]) + .for_commit_id(commit_ids) + end + def mergeable_discussions_state? return true unless project.only_allow_merge_if_all_discussions_are_resolved? @@ -837,10 +859,6 @@ class MergeRequest < ActiveRecord::Base target_project != source_project end - def project - target_project - end - # If the merge request closes any issues, save this information in the # `MergeRequestsClosingIssues` model. This is a performance optimization. # Calculating this information for a number of merge requests requires @@ -966,20 +984,6 @@ class MergeRequest < ActiveRecord::Base end end - def reset_merge_when_pipeline_succeeds - return unless merge_when_pipeline_succeeds? - - self.merge_when_pipeline_succeeds = false - self.merge_user = nil - if merge_params - merge_params.delete('should_remove_source_branch') - merge_params.delete('commit_message') - merge_params.delete('squash_commit_message') - end - - self.save - end - # Return array of possible target branches # depends on target project of MR def target_branches @@ -1049,6 +1053,16 @@ class MergeRequest < ActiveRecord::Base @environments[current_user] end + ## + # This method is for looking for active environments which created via pipelines for merge requests. + # Since deployments run on a merge request ref (e.g. `refs/merge-requests/:iid/head`), + # we cannot look up environments with source branch name. + def environments + return Environment.none unless actual_head_pipeline&.triggered_by_merge_request? + + actual_head_pipeline.environments + end + def state_human_name if merged? "Merged" @@ -1073,10 +1087,24 @@ class MergeRequest < ActiveRecord::Base target_project.repository.fetch_source_branch!(source_project.repository, source_branch, ref_path) end + # Returns the current merge-ref HEAD commit. + # + def merge_ref_head + project.repository.commit(merge_ref_path) + end + def ref_path "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/head" end + def merge_ref_path + "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/merge" + end + + def self.merge_request_ref?(ref) + ref.start_with?("refs/#{Repository::REF_MERGE_REQUEST}/") + end + def in_locked_state begin lock_mr @@ -1113,12 +1141,18 @@ class MergeRequest < ActiveRecord::Base diverged_commits_count > 0 end - def all_pipelines(shas: all_commit_shas) + def all_pipelines return Ci::Pipeline.none unless source_project - @all_pipelines ||= - source_project.ci_pipelines - .for_merge_request(self, source_branch, all_commit_shas) + shas = all_commit_shas + + strong_memoize(:all_pipelines) do + Ci::Pipeline.from_union( + [source_project.ci_pipelines.merge_request_pipelines(self, shas), + source_project.ci_pipelines.detached_merge_request_pipelines(self, shas), + source_project.ci_pipelines.triggered_for_branch(source_branch).for_sha(shas)], + remove_duplicates: false).sort_by_merge_request_pipelines + end end def update_head_pipeline @@ -1128,47 +1162,24 @@ class MergeRequest < ActiveRecord::Base end end - def merge_request_pipeline_exists? - merge_request_pipelines.exists?(sha: diff_head_sha) - end - def has_test_reports? - actual_head_pipeline&.has_test_reports? + actual_head_pipeline&.has_reports?(Ci::JobArtifact.test_reports) end def predefined_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'CI_MERGE_REQUEST_ID', value: id.to_s) variables.append(key: 'CI_MERGE_REQUEST_IID', value: iid.to_s) - - variables.append(key: 'CI_MERGE_REQUEST_REF_PATH', - value: ref_path.to_s) - - variables.append(key: 'CI_MERGE_REQUEST_PROJECT_ID', - value: project.id.to_s) - - variables.append(key: 'CI_MERGE_REQUEST_PROJECT_PATH', - value: project.full_path) - - variables.append(key: 'CI_MERGE_REQUEST_PROJECT_URL', - value: project.web_url) - - variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME', - value: target_branch.to_s) - - if source_project - variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_ID', - value: source_project.id.to_s) - - variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_PATH', - value: source_project.full_path) - - variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_URL', - value: source_project.web_url) - - variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME', - value: source_branch.to_s) - end + variables.append(key: 'CI_MERGE_REQUEST_REF_PATH', value: ref_path.to_s) + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_ID', value: project.id.to_s) + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_PATH', value: project.full_path) + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_URL', value: project.web_url) + variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME', value: target_branch.to_s) + variables.append(key: 'CI_MERGE_REQUEST_TITLE', value: title) + variables.append(key: 'CI_MERGE_REQUEST_ASSIGNEES', value: assignee_username_list) if assignees.any? + variables.append(key: 'CI_MERGE_REQUEST_MILESTONE', value: milestone.title) if milestone + variables.append(key: 'CI_MERGE_REQUEST_LABELS', value: label_names.join(',')) if labels.present? + variables.concat(source_project_variables) end end @@ -1300,7 +1311,7 @@ class MergeRequest < ActiveRecord::Base end def has_commits? - merge_request_diff && commits_count > 0 + merge_request_diff && commits_count.to_i > 0 end def has_no_commits? @@ -1369,10 +1380,20 @@ class MergeRequest < ActiveRecord::Base source_project.repository.squash_in_progress?(id) end + def find_actual_head_pipeline + all_pipelines.for_sha_or_source_sha(diff_head_sha).first + end + private - def find_actual_head_pipeline - source_project&.ci_pipelines - &.latest_for_merge_request(self, source_branch, diff_head_sha) + def source_project_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless source_project + + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_ID', value: source_project.id.to_s) + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_PATH', value: source_project.full_path) + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_URL', value: source_project.web_url) + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME', value: source_branch.to_s) + end end end diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb index 65e94a97b0a..05f8e18a2c1 100644 --- a/app/models/merge_request/metrics.rb +++ b/app/models/merge_request/metrics.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class MergeRequest::Metrics < ActiveRecord::Base +class MergeRequest::Metrics < ApplicationRecord belongs_to :merge_request belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id belongs_to :latest_closed_by, class_name: 'User' diff --git a/app/models/merge_request_assignee.rb b/app/models/merge_request_assignee.rb new file mode 100644 index 00000000000..f0e6be51b7f --- /dev/null +++ b/app/models/merge_request_assignee.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class MergeRequestAssignee < ApplicationRecord + belongs_to :merge_request + belongs_to :assignee, class_name: "User", foreign_key: :user_id +end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index e286a4e57f2..f45bd0e03de 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class MergeRequestDiff < ActiveRecord::Base +class MergeRequestDiff < ApplicationRecord include Sortable include Importable include ManualInverseAssociation @@ -12,6 +12,10 @@ class MergeRequestDiff < ActiveRecord::Base # Don't display more than 100 commits at once COMMITS_SAFE_SIZE = 100 + # Applies to closed or merged MRs when determining whether to migrate their + # diffs to external storage + EXTERNAL_DIFF_CUTOFF = 7.days.freeze + belongs_to :merge_request manual_inverse_association :merge_request, :merge_request_diff @@ -22,6 +26,8 @@ class MergeRequestDiff < ActiveRecord::Base has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) } + validates :base_commit_sha, :head_commit_sha, :start_commit_sha, sha: true + state_machine :state, initial: :empty do event :clean do transition any => :without_files @@ -45,7 +51,86 @@ class MergeRequestDiff < ActiveRecord::Base joins(:merge_request_diff_commits).where(merge_request_diff_commits: { sha: sha }).reorder(nil) end + scope :by_project_id, -> (project_id) do + joins(:merge_request).where(merge_requests: { target_project_id: project_id }) + end + scope :recent, -> { order(id: :desc).limit(100) } + scope :files_in_database, -> { where(stored_externally: [false, nil]) } + + scope :not_latest_diffs, -> do + merge_requests = MergeRequest.arel_table + mr_diffs = arel_table + + join_condition = merge_requests[:id].eq(mr_diffs[:merge_request_id]) + .and(mr_diffs[:id].not_eq(merge_requests[:latest_merge_request_diff_id])) + + arel_join = mr_diffs.join(merge_requests).on(join_condition) + joins(arel_join.join_sources) + end + + scope :old_merged_diffs, -> (before) do + merge_requests = MergeRequest.arel_table + mr_metrics = MergeRequest::Metrics.arel_table + mr_diffs = arel_table + + mr_join = mr_diffs + .join(merge_requests) + .on(mr_diffs[:merge_request_id].eq(merge_requests[:id])) + + metrics_join_condition = mr_diffs[:merge_request_id] + .eq(mr_metrics[:merge_request_id]) + .and(mr_metrics[:merged_at].not_eq(nil)) + + metrics_join = mr_diffs.join(mr_metrics).on(metrics_join_condition) + + condition = MergeRequest.arel_table[:state].eq(:merged) + .and(MergeRequest::Metrics.arel_table[:merged_at].lteq(before)) + .and(MergeRequest::Metrics.arel_table[:merged_at].not_eq(nil)) + + joins(metrics_join.join_sources, mr_join.join_sources).where(condition) + end + + scope :old_closed_diffs, -> (before) do + condition = MergeRequest.arel_table[:state].eq(:closed) + .and(MergeRequest::Metrics.arel_table[:latest_closed_at].lteq(before)) + + joins(merge_request: :metrics).where(condition) + end + + def self.ids_for_external_storage_migration(limit:) + # No point doing any work unless the feature is enabled + return [] unless Gitlab.config.external_diffs.enabled + + case Gitlab.config.external_diffs.when + when 'always' + files_in_database.limit(limit).pluck(:id) + when 'outdated' + # Outdated is too complex to be a single SQL query, so split into three + before = EXTERNAL_DIFF_CUTOFF.ago + + ids = files_in_database + .old_merged_diffs(before) + .limit(limit) + .pluck(:id) + + return ids if ids.size >= limit + + ids += files_in_database + .old_closed_diffs(before) + .limit(limit - ids.size) + .pluck(:id) + + return ids if ids.size >= limit + + ids + files_in_database + .not_latest_diffs + .limit(limit - ids.size) + .pluck(:id) + else + [] + end + end mount_uploader :external_diff, ExternalDiffUploader @@ -53,7 +138,7 @@ class MergeRequestDiff < ActiveRecord::Base # It allows you to override variables like head_commit_sha before getting diff. after_create :save_git_content, unless: :importing? - after_save :update_external_diff_store, if: :external_diff_changed? + after_save :update_external_diff_store, if: -> { !importing? && saved_change_to_external_diff? } def self.find_by_diff_refs(diff_refs) find_by(start_commit_sha: diff_refs.start_sha, head_commit_sha: diff_refs.head_sha, base_commit_sha: diff_refs.base_sha) @@ -73,7 +158,14 @@ class MergeRequestDiff < ActiveRecord::Base ensure_commit_shas save_commits save_diffs + + # Another set of `after_save` hooks will be called here when we update the record save + # We need to reset so that dirty tracking is reset when running the original set + # of `after_save` hooks that come after this `after_create` hook. Otherwise, the + # hooks that run when an attribute was changed are run twice. + reset + keep_around_commits end @@ -267,7 +359,7 @@ class MergeRequestDiff < ActiveRecord::Base has_attribute?(:external_diff_store) end - def external_diff_changed? + def saved_change_to_external_diff? super if has_attribute?(:external_diff) end @@ -284,32 +376,39 @@ class MergeRequestDiff < ActiveRecord::Base return yield(@external_diff_file) if @external_diff_file external_diff.open do |file| - begin - @external_diff_file = file + @external_diff_file = file - yield(@external_diff_file) - ensure - @external_diff_file = nil - end + yield(@external_diff_file) + ensure + @external_diff_file = nil end end - private + # Transactionally migrate the current merge_request_diff_files entries to + # external storage. If external storage isn't an option for this diff, the + # method is a no-op. + def migrate_files_to_external_storage! + return if stored_externally? || !use_external_diff? || merge_request_diff_files.count == 0 - def create_merge_request_diff_files(diffs) - rows = - if has_attribute?(:external_diff) && Gitlab.config.external_diffs.enabled - build_external_merge_request_diff_files(diffs) - else - build_merge_request_diff_files(diffs) - end + rows = build_merge_request_diff_files(merge_request_diff_files) - # Faster inserts - Gitlab::Database.bulk_insert('merge_request_diff_files', rows) + transaction do + MergeRequestDiffFile.where(merge_request_diff_id: id).delete_all + create_merge_request_diff_files(rows) + save! + end + + merge_request_diff_files.reset end - def build_external_merge_request_diff_files(diffs) - rows = build_merge_request_diff_files(diffs) + private + + def encode_in_base64?(diff_text) + (diff_text.encoding == Encoding::BINARY && !diff_text.ascii_only?) || + diff_text.include?("\0") + end + + def build_external_merge_request_diff_files(rows) tempfile = build_external_diff_tempfile(rows) self.external_diff = tempfile @@ -320,16 +419,21 @@ class MergeRequestDiff < ActiveRecord::Base tempfile&.unlink end + def create_merge_request_diff_files(rows) + rows = build_external_merge_request_diff_files(rows) if use_external_diff? + + # Faster inserts + Gitlab::Database.bulk_insert('merge_request_diff_files', rows) + end + def build_external_diff_tempfile(rows) Tempfile.open(external_diff.filename) do |file| - rows.inject(0) do |offset, row| + rows.each do |row| data = row.delete(:diff) - row[:external_diff_offset] = offset - row[:external_diff_size] = data.size + row[:external_diff_offset] = file.pos + row[:external_diff_size] = data.bytesize file.write(data) - - offset + data.size end file @@ -348,7 +452,7 @@ class MergeRequestDiff < ActiveRecord::Base diff_hash.tap do |hash| diff_text = hash[:diff] - if diff_text.encoding == Encoding::BINARY && !diff_text.ascii_only? + if encode_in_base64?(diff_text) hash[:binary] = true hash[:diff] = [diff_text].pack('m0') end @@ -356,6 +460,47 @@ class MergeRequestDiff < ActiveRecord::Base end end + def use_external_diff? + return false unless has_attribute?(:external_diff) + return false unless Gitlab.config.external_diffs.enabled + + case Gitlab.config.external_diffs.when + when 'always' + true + when 'outdated' + outdated_by_merge? || outdated_by_closure? || old_version? + else + false # Disable external diffs if misconfigured + end + end + + def outdated_by_merge? + return false unless merge_request&.metrics&.merged_at + + merge_request.merged? && merge_request.metrics.merged_at < EXTERNAL_DIFF_CUTOFF.ago + end + + def outdated_by_closure? + return false unless merge_request&.metrics&.latest_closed_at + + 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) + .limit(1) + .pluck(:latest_merge_request_diff_id) + .first + + self.id != latest_id + end + def load_diffs(options) # Ensure all diff files operate on the same external diff file instance if # present. This reduces file open/close overhead. @@ -389,7 +534,8 @@ class MergeRequestDiff < ActiveRecord::Base if diff_collection.any? new_attributes[:state] = :collected - create_merge_request_diff_files(diff_collection) + rows = build_merge_request_diff_files(diff_collection) + create_merge_request_diff_files(rows) end # Set our state to 'overflow' to make the #empty? and #collected? @@ -406,10 +552,10 @@ class MergeRequestDiff < ActiveRecord::Base def save_commits MergeRequestDiffCommit.create_bulk(self.id, compare.commits.reverse) - # merge_request_diff_commits.reload is preferred way to reload associated + # merge_request_diff_commits.reset is preferred way to reload associated # objects but it returns cached result for some reason in this case # we can circumvent that by specifying that we need an uncached reload - commits = self.class.uncached { merge_request_diff_commits.reload } + commits = self.class.uncached { merge_request_diff_commits.reset } self.commits_count = commits.size end diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb index 4ad3690512d..b897bbc8cf5 100644 --- a/app/models/merge_request_diff_commit.rb +++ b/app/models/merge_request_diff_commit.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class MergeRequestDiffCommit < ActiveRecord::Base +class MergeRequestDiffCommit < ApplicationRecord include ShaAttribute belongs_to :merge_request_diff diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb index e8d936e265c..01ee82ae398 100644 --- a/app/models/merge_request_diff_file.rb +++ b/app/models/merge_request_diff_file.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class MergeRequestDiffFile < ActiveRecord::Base +class MergeRequestDiffFile < ApplicationRecord include Gitlab::EncodingHelper include DiffFile @@ -23,6 +23,6 @@ class MergeRequestDiffFile < ActiveRecord::Base super end - binary? ? content.unpack('m0').first : content + binary? ? content.unpack1('m0') : content end end diff --git a/app/models/merge_requests_closing_issues.rb b/app/models/merge_requests_closing_issues.rb index 242b65bedc0..61af50841ee 100644 --- a/app/models/merge_requests_closing_issues.rb +++ b/app/models/merge_requests_closing_issues.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class MergeRequestsClosingIssues < ActiveRecord::Base +class MergeRequestsClosingIssues < ApplicationRecord belongs_to :merge_request belongs_to :issue diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 26cfdc5ef30..37c129e843a 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Milestone < ActiveRecord::Base +class Milestone < ApplicationRecord # Represents a "No Milestone" state used for filtering Issues and Merge # Requests that have no milestone assigned. MilestoneStruct = Struct.new(:title, :name, :id) @@ -37,6 +37,7 @@ class Milestone < ActiveRecord::Base scope :active, -> { with_state(:active) } scope :closed, -> { with_state(:closed) } scope :for_projects, -> { where(group: nil).includes(:project) } + scope :started, -> { active.where('milestones.start_date <= CURRENT_DATE') } scope :for_projects_and_groups, -> (projects, groups) do projects = projects.compact if projects.is_a? Array @@ -57,6 +58,7 @@ class Milestone < ActiveRecord::Base validate :uniqueness_of_title, if: :title_changed? validate :milestone_type_check validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? } + validate :dates_within_4_digits strip_attributes :title @@ -149,7 +151,7 @@ class Milestone < ActiveRecord::Base def self.upcoming_ids(projects, groups) rel = unscoped .for_projects_and_groups(projects, groups) - .active.where('milestones.due_date > NOW()') + .active.where('milestones.due_date > CURRENT_DATE') if Gitlab::Database.postgresql? rel.order(:project_id, :group_id, :due_date).select('DISTINCT ON (project_id, group_id) id') @@ -161,7 +163,7 @@ class Milestone < ActiveRecord::Base ON milestones.project_id <=> earlier_milestones.project_id AND milestones.group_id <=> earlier_milestones.group_id AND milestones.due_date > earlier_milestones.due_date - AND earlier_milestones.due_date > NOW() + AND earlier_milestones.due_date > CURRENT_DATE AND earlier_milestones.state = 'active' HEREDOC @@ -290,22 +292,22 @@ class Milestone < ActiveRecord::Base end title_exists = relation.find_by_title(title) - errors.add(:title, "already being used for another group or project milestone.") if title_exists + errors.add(:title, _("already being used for another group or project milestone.")) if title_exists end # Milestone should be either a project milestone or a group milestone def milestone_type_check if group_id && project_id field = project_id_changed? ? :project_id : :group_id - errors.add(field, "milestone should belong either to a project or a group.") + errors.add(field, _("milestone should belong either to a project or a group.")) end end def milestone_format_reference(format = :iid) - raise ArgumentError, 'Unknown format' unless [:iid, :name].include?(format) + raise ArgumentError, _('Unknown format') unless [:iid, :name].include?(format) if group_milestone? && format == :iid - raise ArgumentError, 'Cannot refer to a group milestone by an internal id!' + raise ArgumentError, _('Cannot refer to a group milestone by an internal id!') end if format == :name && !name.include?('"') @@ -321,7 +323,17 @@ class Milestone < ActiveRecord::Base def start_date_should_be_less_than_due_date if due_date <= start_date - errors.add(:due_date, "must be greater than start date") + errors.add(:due_date, _("must be greater than start date")) + end + end + + def dates_within_4_digits + if start_date && start_date > Date.new(9999, 12, 31) + errors.add(:start_date, _("date must not be after 9999-12-31")) + end + + if due_date && due_date > Date.new(9999, 12, 31) + errors.add(:due_date, _("date must not be after 9999-12-31")) end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index f7592532c5b..3c270c7396a 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -11,6 +11,7 @@ class Namespace < ApplicationRecord include IgnorableColumn include FeatureGate include FromUnion + include Gitlab::Utils::StrongMemoize ignore_column :deleted_at @@ -49,17 +50,20 @@ class Namespace < ApplicationRecord validate :nesting_level_allowed + validates_associated :runners + delegate :name, to: :owner, allow_nil: true, prefix: true + delegate :avatar_url, to: :owner, allow_nil: true after_commit :refresh_access_of_projects_invited_groups, on: :update, if: -> { previous_changes.key?('share_with_group_lock') } before_create :sync_share_with_group_lock_with_parent before_update :sync_share_with_group_lock_with_parent, if: :parent_changed? - after_update :force_share_with_group_lock_on_descendants, if: -> { share_with_group_lock_changed? && share_with_group_lock? } + after_update :force_share_with_group_lock_on_descendants, if: -> { saved_change_to_share_with_group_lock? && share_with_group_lock? } # Legacy Storage specific hooks - after_update :move_dir, if: :path_or_parent_changed? + after_update :move_dir, if: :saved_change_to_path_or_parent? before_destroy(prepend: true) { prepare_for_destroy } after_destroy :rm_dir @@ -72,8 +76,10 @@ class Namespace < ApplicationRecord 'namespaces.*', 'COALESCE(SUM(ps.storage_size), 0) AS storage_size', 'COALESCE(SUM(ps.repository_size), 0) AS repository_size', + 'COALESCE(SUM(ps.wiki_size), 0) AS wiki_size', 'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size', - 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size' + 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size', + 'COALESCE(SUM(ps.packages_size), 0) AS packages_size' ) end @@ -140,7 +146,7 @@ class Namespace < ApplicationRecord def send_update_instructions projects.each do |project| - project.send_move_instructions("#{full_path_was}/#{project.path}") + project.send_move_instructions("#{full_path_before_last_save}/#{project.path}") end end @@ -148,8 +154,12 @@ class Namespace < ApplicationRecord type == 'Group' ? 'group' : 'user' end + def user? + kind == 'user' + end + def find_fork_of(project) - return nil unless project.fork_network + return unless project.fork_network if Gitlab::SafeRequestStore.active? forks_in_namespace = Gitlab::SafeRequestStore.fetch("namespaces:#{id}:forked_projects") do @@ -196,12 +206,12 @@ class Namespace < ApplicationRecord .ancestors(upto: top, hierarchy_order: hierarchy_order) end - def self_and_ancestors + def self_and_ancestors(hierarchy_order: nil) return self.class.where(id: id) unless parent_id Gitlab::ObjectHierarchy .new(self.class.where(id: id)) - .base_and_ancestors + .base_and_ancestors(hierarchy_order: hierarchy_order) end # Returns all the descendants of the current namespace. @@ -221,10 +231,6 @@ class Namespace < ApplicationRecord [owner_id] end - def parent_changed? - parent_id_changed? - end - # Includes projects from this namespace and projects from all subgroups # that belongs to this namespace def all_projects @@ -254,12 +260,12 @@ class Namespace < ApplicationRecord false end - def full_path_was - if parent_id_was.nil? - path_was + def full_path_before_last_save + if parent_id_before_last_save.nil? + path_before_last_save else - previous_parent = Group.find_by(id: parent_id_was) - previous_parent.full_path + '/' + path_was + previous_parent = Group.find_by(id: parent_id_before_last_save) + previous_parent.full_path + '/' + path_before_last_save end end @@ -267,10 +273,34 @@ class Namespace < ApplicationRecord owner.refresh_authorized_projects end + def auto_devops_enabled? + first_auto_devops_config[:status] + end + + def first_auto_devops_config + return { scope: :group, status: auto_devops_enabled } unless auto_devops_enabled.nil? + + strong_memoize(:first_auto_devops_config) do + if has_parent? + parent.first_auto_devops_config + else + { scope: :instance, status: Gitlab::CurrentSettings.auto_devops_enabled? } + end + end + end + private - def path_or_parent_changed? - path_changed? || parent_changed? + def parent_changed? + parent_id_changed? + end + + def saved_change_to_parent? + saved_change_to_parent_id? + end + + def saved_change_to_path_or_parent? + saved_change_to_path? || saved_change_to_parent_id? end def refresh_access_of_projects_invited_groups diff --git a/app/models/note.rb b/app/models/note.rb index 1578ae9c4cc..081d6f91230 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -3,7 +3,7 @@ # A note on the root of an issue, merge request, commit, or snippet. # # A note of this type is never resolvable. -class Note < ActiveRecord::Base +class Note < ApplicationRecord extend ActiveModel::Naming include Participable include Mentionable @@ -313,6 +313,14 @@ class Note < ActiveRecord::Base !system? end + # Since we're using `updated_at` as `last_edited_at`, it could be touched by transforming / resolving a note. + # This makes sure it is only marked as edited when the note body is updated. + def edited? + return false if updated_by.blank? + + super + end + def cross_reference_not_visible_for?(user) cross_reference? && !all_referenced_mentionables_allowed?(user) end diff --git a/app/models/note_diff_file.rb b/app/models/note_diff_file.rb index e369122003e..fcc9e2b3fd8 100644 --- a/app/models/note_diff_file.rb +++ b/app/models/note_diff_file.rb @@ -1,12 +1,16 @@ # frozen_string_literal: true -class NoteDiffFile < ActiveRecord::Base +class NoteDiffFile < ApplicationRecord include DiffFile scope :for_commit_or_unresolved, -> do joins(:diff_note).where("resolved_at IS NULL OR noteable_type = 'Commit'") end + scope :referencing_sha, -> (oids, project_id:) do + joins(:diff_note).where(notes: { project_id: project_id, commit_id: oids }) + end + delegate :original_position, :project, to: :diff_note belongs_to :diff_note, inverse_of: :note_diff_file diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index 481c1d963c6..9b2bbb7eba5 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -47,14 +47,14 @@ class NotificationRecipient def suitable_notification_level? case notification_level - when :disabled, nil - false - when :custom - custom_enabled? || %i[participating mention].include?(@type) - when :watch, :participating - !action_excluded? when :mention @type == :mention + when :participating + @custom_action == :failed_pipeline || %i[participating mention].include?(@type) + when :custom + custom_enabled? || %i[participating mention].include?(@type) + when :watch + !excluded_watcher_action? else false end @@ -100,43 +100,37 @@ class NotificationRecipient end end - def action_excluded? - excluded_watcher_action? || excluded_participating_action? - end - def excluded_watcher_action? - return false unless @custom_action && notification_level == :watch + return false unless @custom_action NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(@custom_action) end - def excluded_participating_action? - return false unless @custom_action && notification_level == :participating - - NotificationSetting::EXCLUDED_PARTICIPATING_EVENTS.include?(@custom_action) - end - private def read_ability - return nil if @skip_read_ability + return if @skip_read_ability return @read_ability if instance_variable_defined?(:@read_ability) @read_ability = - case @target - when Issuable - :"read_#{@target.to_ability_name}" - when Ci::Pipeline + if @target.is_a?(Ci::Pipeline) :read_build # We have build trace in pipeline emails - when ActiveRecord::Base - :"read_#{@target.class.model_name.name.underscore}" - else - nil + elsif default_ability_for_target + :"read_#{default_ability_for_target}" + end + end + + def default_ability_for_target + @default_ability_for_target ||= + if @target.respond_to?(:to_ability_name) + @target.to_ability_name + elsif @target.class.respond_to?(:model_name) + @target.class.model_name.name.underscore end end def default_project - return nil if @target.nil? + return if @target.nil? return @target if @target.is_a?(Project) return @target.project if @target.respond_to?(:project) end @@ -156,23 +150,11 @@ class NotificationRecipient # Returns the notification_setting of the lowest group in hierarchy with non global level def closest_non_global_group_notification_settting return unless @group - return if indexed_group_notification_settings.empty? - notification_setting = nil - - @group.self_and_ancestors_ids.each do |id| - notification_setting = indexed_group_notification_settings[id] - break if notification_setting - end - - notification_setting - end - - def indexed_group_notification_settings - strong_memoize(:indexed_group_notification_settings) do - @group.notification_settings.where(user_id: user.id) - .where.not(level: NotificationSetting.levels[:global]) - .index_by(&:source_id) - end + @group + .notification_settings(hierarchy_order: :asc) + .where(user: user) + .where.not(level: NotificationSetting.levels[:global]) + .first end end diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index e82eaf4e069..8306b11a7b6 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class NotificationSetting < ActiveRecord::Base +class NotificationSetting < ApplicationRecord include IgnorableColumn ignore_column :events @@ -54,14 +54,11 @@ class NotificationSetting < ActiveRecord::Base self.class.email_events(source) end - EXCLUDED_PARTICIPATING_EVENTS = [ - :success_pipeline - ].freeze - EXCLUDED_WATCHER_EVENTS = [ :push_to_merge_request, - :issue_due - ].push(*EXCLUDED_PARTICIPATING_EVENTS).freeze + :issue_due, + :success_pipeline + ].freeze def self.find_or_create_for(source) setting = find_or_initialize_by(source: source) diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 7a33ade826b..524df30289e 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true -class PagesDomain < ActiveRecord::Base +class PagesDomain < ApplicationRecord VERIFICATION_KEY = 'gitlab-pages-verification-code'.freeze VERIFICATION_THRESHOLD = 3.days.freeze belongs_to :project + has_many :acme_orders, class_name: "PagesDomainAcmeOrder" validates :domain, hostname: { allow_numeric_hostname: true } validates :domain, uniqueness: { case_sensitive: false } @@ -26,7 +27,7 @@ class PagesDomain < ActiveRecord::Base after_initialize :set_verification_code after_create :update_daemon - after_update :update_daemon, if: :pages_config_changed? + after_update :update_daemon, if: :saved_change_to_pages_config? after_destroy :update_daemon scope :enabled, -> { where('enabled_until >= ?', Time.now ) } @@ -38,6 +39,8 @@ class PagesDomain < ActiveRecord::Base where(verified_at.eq(nil).or(enabled_until.eq(nil).or(enabled_until.lt(threshold)))) end + scope :for_removal, -> { where("remove_at < ?", Time.now) } + def verified? !!verified_at end @@ -132,6 +135,14 @@ class PagesDomain < ActiveRecord::Base "#{VERIFICATION_KEY}=#{verification_code}" end + def certificate=(certificate) + super(certificate) + + # set nil, if certificate is nil + self.certificate_valid_not_before = x509&.not_before + self.certificate_valid_not_after = x509&.not_after + end + private def set_verification_code @@ -146,21 +157,21 @@ class PagesDomain < ActiveRecord::Base end # rubocop: enable CodeReuse/ServiceClass - def pages_config_changed? - project_id_changed? || - domain_changed? || - certificate_changed? || - key_changed? || + def saved_change_to_pages_config? + saved_change_to_project_id? || + saved_change_to_domain? || + saved_change_to_certificate? || + saved_change_to_key? || became_enabled? || became_disabled? end def became_enabled? - enabled_until.present? && !enabled_until_was.present? + enabled_until.present? && !enabled_until_before_last_save.present? end def became_disabled? - !enabled_until.present? && enabled_until_was.present? + !enabled_until.present? && enabled_until_before_last_save.present? end def validate_matching_key @@ -184,7 +195,7 @@ class PagesDomain < ActiveRecord::Base end def x509 - return unless certificate + return unless certificate.present? @x509 ||= OpenSSL::X509::Certificate.new(certificate) rescue OpenSSL::X509::CertificateError diff --git a/app/models/pages_domain_acme_order.rb b/app/models/pages_domain_acme_order.rb new file mode 100644 index 00000000000..63d7fbc8206 --- /dev/null +++ b/app/models/pages_domain_acme_order.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class PagesDomainAcmeOrder < ApplicationRecord + belongs_to :pages_domain + + scope :expired, -> { where("expires_at < ?", Time.now) } + + validates :pages_domain, presence: true + validates :expires_at, presence: true + validates :url, presence: true + validates :challenge_token, presence: true + validates :challenge_file_content, presence: true + validates :private_key, presence: true + + attr_encrypted :private_key, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm', + encode: true + + def self.find_by_domain_and_token(domain_name, challenge_token) + joins(:pages_domain).find_by(pages_domains: { domain: domain_name }, challenge_token: challenge_token) + end +end diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index ed78a46eaf3..f69f0e2dccb 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -1,12 +1,10 @@ # frozen_string_literal: true -class PersonalAccessToken < ActiveRecord::Base +class PersonalAccessToken < ApplicationRecord include Expirable - include IgnorableColumn include TokenAuthenticatable add_authentication_token_field :token, digest: true - ignore_column :token REDIS_EXPIRY_TIME = 3.minutes @@ -58,7 +56,7 @@ class PersonalAccessToken < ActiveRecord::Base protected def validate_scopes - unless revoked || scopes.all? { |scope| Gitlab::Auth.available_scopes.include?(scope.to_sym) } + unless revoked || scopes.all? { |scope| Gitlab::Auth.all_available_scopes.include?(scope.to_sym) } errors.add :scopes, "can only contain available scopes" end end diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb index 4635fc72dc7..50eed7344bd 100644 --- a/app/models/pool_repository.rb +++ b/app/models/pool_repository.rb @@ -3,11 +3,11 @@ # The PoolRepository model is the database equivalent of an ObjectPool for Gitaly # That is; PoolRepository is the record in the database, ObjectPool is the # repository on disk -class PoolRepository < ActiveRecord::Base +class PoolRepository < ApplicationRecord include Shardable include AfterCommitQueue - has_one :source_project, class_name: 'Project' + belongs_to :source_project, class_name: 'Project' validates :source_project, presence: true has_many :member_projects, class_name: 'Project' @@ -81,10 +81,7 @@ class PoolRepository < ActiveRecord::Base object_pool.link(repository.raw) end - # This RPC can cause data loss, as not all objects are present the local repository - def unlink_repository(repository) - object_pool.unlink_repository(repository.raw) - + def mark_obsolete_if_last(repository) if member_projects.where.not(id: repository.project.id).exists? true else @@ -102,7 +99,8 @@ class PoolRepository < ActiveRecord::Base end def inspect - "#<#{self.class.name} id:#{id} state:#{state} disk_path:#{disk_path} source_project: #{source_project.full_path}>" + source = source_project ? source_project.full_path : 'nil' + "#<#{self.class.name} id:#{id} state:#{state} disk_path:#{disk_path} source_project: #{source}>" end private diff --git a/app/models/postgresql/replication_slot.rb b/app/models/postgresql/replication_slot.rb index e264fe88e47..74ccf23cf69 100644 --- a/app/models/postgresql/replication_slot.rb +++ b/app/models/postgresql/replication_slot.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Postgresql - class ReplicationSlot < ActiveRecord::Base + class ReplicationSlot < ApplicationRecord self.table_name = 'pg_replication_slots' # Returns true if there are any replication slots in use. diff --git a/app/models/programming_language.rb b/app/models/programming_language.rb index 5f0f313b7f9..375fbe9b5a9 100644 --- a/app/models/programming_language.rb +++ b/app/models/programming_language.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ProgrammingLanguage < ActiveRecord::Base +class ProgrammingLanguage < ApplicationRecord validates :name, presence: true validates :color, allow_blank: false, color: true diff --git a/app/models/project.rb b/app/models/project.rb index 83f8d004a46..e64a4b313aa 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -2,7 +2,7 @@ require 'carrierwave/orm/activerecord' -class Project < ActiveRecord::Base +class Project < ApplicationRecord include Gitlab::ConfigHelper include Gitlab::ShellAdapter include Gitlab::VisibilityLevel @@ -38,7 +38,6 @@ class Project < ActiveRecord::Base BoardLimitExceeded = Class.new(StandardError) STATISTICS_ATTRIBUTE = 'repositories_count'.freeze - NUMBER_OF_PERMITTED_BOARDS = 1 UNKNOWN_IMPORT_URL = 'http://unknown.git'.freeze # Hashed Storage versions handle rolling out new storage to project and dependents models: # nil: legacy @@ -85,13 +84,13 @@ class Project < ActiveRecord::Base default_value_for :snippets_enabled, gitlab_config_features.snippets default_value_for :only_allow_merge_if_all_discussions_are_resolved, false - add_authentication_token_field :runners_token, encrypted: true, migrating: true + add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:projects_tokens_optional_encryption, default_enabled: true) ? :optional : :required } before_validation :mark_remote_mirrors_for_removal, if: -> { RemoteMirror.table_exists? } before_save :ensure_runners_token - after_save :update_project_statistics, if: :namespace_id_changed? + after_save :update_project_statistics, if: :saved_change_to_namespace_id? after_save :create_import_state, if: ->(project) { project.import? && project.import_state.nil? } @@ -117,7 +116,7 @@ class Project < ActiveRecord::Base after_initialize :use_hashed_storage after_create :check_repository_absence! after_create :ensure_storage_path_exists - after_save :ensure_storage_path_exists, if: :namespace_id_changed? + after_save :ensure_storage_path_exists, if: :saved_change_to_namespace_id? acts_as_ordered_taggable @@ -137,7 +136,7 @@ class Project < ActiveRecord::Base alias_attribute :parent_id, :namespace_id has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event' - has_many :boards, before_add: :validate_board_limit + has_many :boards # Project services has_one :campfire_service @@ -147,6 +146,7 @@ class Project < ActiveRecord::Base has_one :pipelines_email_service has_one :irker_service has_one :pivotaltracker_service + has_one :hipchat_service has_one :flowdock_service has_one :assembla_service has_one :asana_service @@ -160,6 +160,7 @@ class Project < ActiveRecord::Base has_one :pushover_service has_one :jira_service has_one :redmine_service + has_one :youtrack_service has_one :custom_issue_tracker_service has_one :bugzilla_service has_one :gitlab_issue_tracker_service, inverse_of: :project @@ -187,6 +188,7 @@ class Project < ActiveRecord::Base has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent 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' # Merge Requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project @@ -290,12 +292,14 @@ class Project < ActiveRecord::Base accepts_nested_attributes_for :project_feature, update_only: true accepts_nested_attributes_for :import_data accepts_nested_attributes_for :auto_devops, update_only: true + accepts_nested_attributes_for :ci_cd_settings, update_only: true accepts_nested_attributes_for :remote_mirrors, allow_destroy: true, reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? } accepts_nested_attributes_for :error_tracking_setting, update_only: true + accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true delegate :name, to: :owner, allow_nil: true, prefix: true delegate :members, to: :team, prefix: true @@ -306,13 +310,15 @@ class Project < ActiveRecord::Base delegate :group_clusters_enabled?, to: :group, allow_nil: true delegate :root_ancestor, to: :namespace, allow_nil: true delegate :last_pipeline, to: :commit, allow_nil: true + delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true + delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings # Validations validates :creator, presence: true, on: :create validates :description, length: { maximum: 2000 }, allow_blank: true validates :ci_config_path, format: { without: %r{(\.{2}|\A/)}, - message: 'cannot include leading slash or directory traversal.' }, + message: _('cannot include leading slash or directory traversal.') }, length: { maximum: 255 }, allow_blank: true validates :name, @@ -327,14 +333,14 @@ class Project < ActiveRecord::Base validates :namespace, presence: true validates :name, uniqueness: { scope: :namespace_id } - validates :import_url, public_url: { protocols: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS }, + validates :import_url, public_url: { schemes: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS }, ports: ->(project) { project.persisted? ? VALID_MIRROR_PORTS : VALID_IMPORT_PORTS }, enforce_user: true }, if: [:external_import?, :import_url_changed?] validates :star_count, numericality: { greater_than_or_equal_to: 0 } validate :check_personal_projects_limit, on: :create validate :check_repository_path_availability, on: :update, if: ->(project) { project.renamed? } - validate :visibility_level_allowed_by_group, if: -> { changes.has_key?(:visibility_level) } - validate :visibility_level_allowed_as_fork, if: -> { changes.has_key?(:visibility_level) } + validate :visibility_level_allowed_by_group, if: :should_validate_visibility_level? + validate :visibility_level_allowed_as_fork, if: :should_validate_visibility_level? validate :check_wiki_path_conflict validate :validate_pages_https_only, if: -> { changes.has_key?(:pages_https_only) } validates :repository_storage, @@ -353,7 +359,8 @@ class Project < ActiveRecord::Base # last_activity_at is throttled every minute, but last_repository_updated_at is updated with every push scope :sorted_by_activity, -> { reorder("GREATEST(COALESCE(last_activity_at, '1970-01-01'), COALESCE(last_repository_updated_at, '1970-01-01')) DESC") } - scope :sorted_by_stars, -> { reorder(star_count: :desc) } + scope :sorted_by_stars_desc, -> { reorder(star_count: :desc) } + scope :sorted_by_stars_asc, -> { reorder(star_count: :asc) } scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) } scope :personal, ->(user) { where(namespace_id: user.namespace_id) } @@ -402,6 +409,7 @@ class Project < ActiveRecord::Base scope :with_builds_enabled, -> { with_feature_enabled(:builds) } scope :with_issues_enabled, -> { with_feature_enabled(:issues) } scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) } + scope :with_merge_requests_available_for_user, ->(current_user) { with_feature_available_for_user(:merge_requests, current_user) } scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) } scope :with_remote_mirrors, -> { joins(:remote_mirrors).where(remote_mirrors: { enabled: true }).distinct } @@ -419,13 +427,13 @@ class Project < ActiveRecord::Base enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } chronic_duration_attr :build_timeout_human_readable, :build_timeout, - default: 3600, error_message: 'Maximum job timeout has a value which could not be accepted' + default: 3600, error_message: _('Maximum job timeout has a value which could not be accepted') validates :build_timeout, allow_nil: true, numericality: { greater_than_or_equal_to: 10.minutes, less_than: 1.month, only_integer: true, - message: 'needs to be beetween 10 minutes and 1 month' } + message: _('needs to be beetween 10 minutes and 1 month') } # Used by Projects::CleanupService to hold a map of rewritten object IDs mount_uploader :bfg_object_map, AttachmentUploader @@ -459,10 +467,12 @@ class Project < ActiveRecord::Base # Returns a collection of projects that is either public or visible to the # logged in user. - def self.public_or_visible_to_user(user = nil) + def self.public_or_visible_to_user(user = nil, min_access_level = nil) + min_access_level = nil if user&.admin? + if user where('EXISTS (?) OR projects.visibility_level IN (?)', - user.authorizations_for_projects, + user.authorizations_for_projects(min_access_level: min_access_level), Gitlab::VisibilityLevel.levels_for_user(user)) else public_to_user @@ -472,30 +482,32 @@ class Project < ActiveRecord::Base # project features may be "disabled", "internal", "enabled" or "public". If "internal", # they are only available to team members. This scope returns projects where # the feature is either public, enabled, or internal with permission for the user. + # Note: this scope doesn't enforce that the user has access to the projects, it just checks + # that the user has access to the feature. It's important to use this scope with others + # that checks project authorizations first. # # This method uses an optimised version of `with_feature_access_level` for # logged in users to more efficiently get private projects with the given # feature. def self.with_feature_available_for_user(feature, user) visible = [ProjectFeature::ENABLED, ProjectFeature::PUBLIC] - min_access_level = ProjectFeature.required_minimum_access_level(feature) if user&.admin? with_feature_enabled(feature) elsif user + min_access_level = ProjectFeature.required_minimum_access_level(feature) column = ProjectFeature.quoted_access_level_column(feature) with_project_feature - .where( - "(projects.visibility_level > :private AND (#{column} IS NULL OR #{column} >= (:public_visible) OR (#{column} = :private_visible AND EXISTS(:authorizations))))"\ - " OR (projects.visibility_level = :private AND (#{column} IS NULL OR #{column} >= :private_visible) AND EXISTS(:authorizations))", - { - private: Gitlab::VisibilityLevel::PRIVATE, - public_visible: ProjectFeature::ENABLED, - private_visible: ProjectFeature::PRIVATE, - authorizations: user.authorizations_for_projects(min_access_level: min_access_level) - }) + .where("#{column} IS NULL OR #{column} IN (:public_visible) OR (#{column} = :private_visible AND EXISTS (:authorizations))", + { + public_visible: visible, + private_visible: ProjectFeature::PRIVATE, + authorizations: user.authorizations_for_projects(min_access_level: min_access_level) + }) else + # This has to be added to include features whose value is nil in the db + visible << nil with_feature_access_level(feature, visible) end end @@ -540,7 +552,9 @@ class Project < ActiveRecord::Base when 'latest_activity_asc' reorder(last_activity_at: :asc) when 'stars_desc' - sorted_by_stars + sorted_by_stars_desc + when 'stars_asc' + sorted_by_stars_asc else order_by(method) end @@ -586,6 +600,17 @@ class Project < ActiveRecord::Base def group_ids joins(:namespace).where(namespaces: { type: 'Group' }).select(:namespace_id) end + + # Returns ids of projects with milestones available for given user + # + # Used on queries to find milestones which user can see + # For example: Milestone.where(project_id: ids_with_milestone_available_for(user)) + def ids_with_milestone_available_for(user) + with_issues_enabled = with_issues_available_for_user(user).select(:id) + with_merge_requests_enabled = with_merge_requests_available_for_user(user).select(:id) + + from_union([with_issues_enabled, with_merge_requests_enabled]).select(:id) + end end def all_pipelines @@ -630,12 +655,29 @@ class Project < ActiveRecord::Base end def has_auto_devops_implicitly_enabled? - auto_devops&.enabled.nil? && - (Gitlab::CurrentSettings.auto_devops_enabled? || Feature.enabled?(:force_autodevops_on_by_default, self)) + auto_devops_config = first_auto_devops_config + + auto_devops_config[:scope] != :project && auto_devops_config[:status] end def has_auto_devops_implicitly_disabled? - auto_devops&.enabled.nil? && !(Gitlab::CurrentSettings.auto_devops_enabled? || Feature.enabled?(:force_autodevops_on_by_default, self)) + auto_devops_config = first_auto_devops_config + + auto_devops_config[:scope] != :project && !auto_devops_config[:status] + end + + def first_auto_devops_config + return namespace.first_auto_devops_config if auto_devops&.enabled.nil? + + { scope: :project, status: auto_devops&.enabled || Feature.enabled?(:force_autodevops_on_by_default, self) } + end + + def multiple_mr_assignees_enabled? + Feature.enabled?(:multiple_merge_request_assignees, self) + end + + def daily_statistics_enabled? + Feature.enabled?(:project_daily_statistics, self, default_enabled: true) end def empty_repo? @@ -832,7 +874,7 @@ class Project < ActiveRecord::Base def mark_stuck_remote_mirrors_as_failed! remote_mirrors.stuck.update_all( update_status: :failed, - last_error: 'The remote mirror took to long to complete.', + last_error: _('The remote mirror took to long to complete.'), last_update_at: Time.now ) end @@ -864,19 +906,23 @@ class Project < ActiveRecord::Base self.errors.add(:limit_reached, error % { limit: limit }) end + def should_validate_visibility_level? + new_record? || changes.has_key?(:visibility_level) + end + def visibility_level_allowed_by_group return if visibility_level_allowed_by_group? level_name = Gitlab::VisibilityLevel.level_name(self.visibility_level).downcase group_level_name = Gitlab::VisibilityLevel.level_name(self.group.visibility_level).downcase - self.errors.add(:visibility_level, "#{level_name} is not allowed in a #{group_level_name} group.") + self.errors.add(:visibility_level, _("%{level_name} is not allowed in a %{group_level_name} group.") % { level_name: level_name, group_level_name: group_level_name }) end def visibility_level_allowed_as_fork return if visibility_level_allowed_as_fork? level_name = Gitlab::VisibilityLevel.level_name(self.visibility_level).downcase - self.errors.add(:visibility_level, "#{level_name} is not allowed since the fork source project has lower visibility.") + self.errors.add(:visibility_level, _("%{level_name} is not allowed since the fork source project has lower visibility.") % { level_name: level_name }) end def check_wiki_path_conflict @@ -885,7 +931,7 @@ class Project < ActiveRecord::Base path_to_check = path.ends_with?('.wiki') ? path.chomp('.wiki') : "#{path}.wiki" if Project.where(namespace_id: namespace_id, path: path_to_check).exists? - errors.add(:name, 'has already been taken') + errors.add(:name, _('has already been taken')) end end @@ -905,7 +951,7 @@ class Project < ActiveRecord::Base return unless pages_https_only? unless pages_domains.all?(&:https?) - errors.add(:pages_https_only, "cannot be enabled unless all domains have TLS certificates") + errors.add(:pages_https_only, _("cannot be enabled unless all domains have TLS certificates")) end end @@ -1185,7 +1231,7 @@ class Project < ActiveRecord::Base def valid_repo? repository.exists? rescue - errors.add(:path, 'Invalid repository path') + errors.add(:path, _('Invalid repository path')) false end @@ -1195,11 +1241,9 @@ class Project < ActiveRecord::Base def repo_exists? strong_memoize(:repo_exists) do - begin - repository.exists? - rescue - false - end + repository.exists? + rescue + false end end @@ -1225,7 +1269,7 @@ class Project < ActiveRecord::Base end def fork_source - return nil unless forked? + return unless forked? forked_from_project || fork_network&.root_project end @@ -1278,7 +1322,7 @@ class Project < ActiveRecord::Base # Check if repository with same path already exists on disk we can # skip this for the hashed storage because the path does not change if legacy_storage? && repository_with_same_path_already_exists? - errors.add(:base, 'There is already a repository with that name on disk') + errors.add(:base, _('There is already a repository with that name on disk')) return false end @@ -1300,7 +1344,7 @@ class Project < ActiveRecord::Base repository.after_create true else - errors.add(:base, 'Failed to create repository via gitlab-shell') + errors.add(:base, _('Failed to create repository via gitlab-shell')) false end end @@ -1373,9 +1417,10 @@ class Project < ActiveRecord::Base repository.raw_repository.write_ref('HEAD', "refs/heads/#{branch}") repository.copy_gitattributes(branch) repository.after_change_head + ProjectCacheWorker.perform_async(self.id, [], [:commit_count]) reload_default_branch else - errors.add(:base, "Could not change HEAD: branch '#{branch}' does not exist") + errors.add(:base, _("Could not change HEAD: branch '%{branch}' does not exist") % { branch: branch }) false end end @@ -1413,7 +1458,7 @@ class Project < ActiveRecord::Base # update visibility_level of forks def update_forks_visibility_level - return unless visibility_level < visibility_level_was + return unless visibility_level < visibility_level_before_last_save forks.each do |forked_project| if forked_project.visibility_level > visibility_level @@ -1427,7 +1472,7 @@ class Project < ActiveRecord::Base ProjectWiki.new(self, self.owner).wiki true rescue ProjectWiki::CouldNotCreateWikiError - errors.add(:base, 'Failed create wiki') + errors.add(:base, _('Failed create wiki')) false end @@ -1674,7 +1719,7 @@ class Project < ActiveRecord::Base end def export_path - return nil unless namespace.present? || hashed_storage?(:repository) + return unless namespace.present? || hashed_storage?(:repository) import_export_shared.archive_path end @@ -1838,7 +1883,7 @@ class Project < ActiveRecord::Base # Set repository as writable again def set_repository_writable! with_lock do - update_column(repository_read_only, false) + update_column(:repository_read_only, false) end end @@ -1893,8 +1938,8 @@ class Project < ActiveRecord::Base false end - def full_path_was - File.join(namespace.full_path, previous_changes['path'].first) + def full_path_before_last_save + File.join(namespace.full_path, path_before_last_save) end alias_method :name_with_namespace, :full_name @@ -1916,7 +1961,7 @@ class Project < ActiveRecord::Base # # @param [Symbol] feature that needs to be rolled out for the project (:repository, :attachments) def hashed_storage?(feature) - raise ArgumentError, "Invalid feature" unless HASHED_STORAGE_FEATURES.include?(feature) + raise ArgumentError, _("Invalid feature") unless HASHED_STORAGE_FEATURES.include?(feature) self.storage_version && self.storage_version >= HASHED_STORAGE_FEATURES[feature] end @@ -1925,6 +1970,14 @@ class Project < ActiveRecord::Base persisted? && path_changed? end + def human_merge_method + if merge_method == :ff + 'Fast-forward' + else + merge_method.to_s.humanize + end + end + def merge_method if self.merge_requests_ff_only_enabled :ff @@ -1957,9 +2010,19 @@ class Project < ActiveRecord::Base return unless storage_upgradable? if git_transfer_in_progress? - ProjectMigrateHashedStorageWorker.perform_in(Gitlab::ReferenceCounter::REFERENCE_EXPIRE_TIME, id) + HashedStorage::ProjectMigrateWorker.perform_in(Gitlab::ReferenceCounter::REFERENCE_EXPIRE_TIME, id) else - ProjectMigrateHashedStorageWorker.perform_async(id) + HashedStorage::ProjectMigrateWorker.perform_async(id) + end + end + + def rollback_to_legacy_storage! + return if legacy_storage? + + if git_transfer_in_progress? + HashedStorage::ProjectRollbackWorker.perform_in(Gitlab::ReferenceCounter::REFERENCE_EXPIRE_TIME, id) + else + HashedStorage::ProjectRollbackWorker.perform_async(id) end end @@ -1973,12 +2036,8 @@ class Project < ActiveRecord::Base @storage = nil if storage_version_changed? end - def gl_repository(is_wiki:) - Gitlab::GlRepository.gl_repository(self, is_wiki) - end - - def reference_counter(wiki: false) - Gitlab::ReferenceCounter.new(gl_repository(is_wiki: wiki)) + def reference_counter(type: Gitlab::GlRepository::PROJECT) + Gitlab::ReferenceCounter.new(type.identifier_for_subject(self)) end def badges @@ -2009,6 +2068,11 @@ class Project < ActiveRecord::Base fetch_branch_allows_collaboration(user, branch_name) end + def external_authorization_classification_label + super || ::Gitlab::CurrentSettings.current_application_settings + .external_authorization_service_default_label + end + def licensed_features [] end @@ -2075,7 +2139,7 @@ class Project < ActiveRecord::Base end def leave_pool_repository - pool_repository&.unlink_repository(repository) && update_column(:pool_repository_id, nil) + pool_repository&.mark_obsolete_if_last(repository) && update_column(:pool_repository_id, nil) end def link_pool_repository @@ -2095,13 +2159,11 @@ class Project < ActiveRecord::Base end def create_new_pool_repository - pool = begin - create_pool_repository!(shard: Shard.by_name(repository_storage), source_project: self) - rescue ActiveRecord::RecordNotUnique - pool_repository(true) - end + pool = PoolRepository.safe_find_or_create_by!(shard: Shard.by_name(repository_storage), source_project: self) + update!(pool_repository: pool) pool.schedule unless pool.scheduled? + pool end @@ -2122,14 +2184,14 @@ class Project < ActiveRecord::Base end def wiki_reference_count - reference_counter(wiki: true).value + reference_counter(type: Gitlab::GlRepository::WIKI).value end def check_repository_absence! return if skip_disk_validation if repository_storage.blank? || repository_with_same_path_already_exists? - errors.add(:base, 'There is already a repository with that name on disk') + errors.add(:base, _('There is already a repository with that name on disk')) throw :abort end end @@ -2162,17 +2224,6 @@ class Project < ActiveRecord::Base "projects/#{id}/pushes_since_gc" end - # Similar to the normal callbacks that hook into the life cycle of an - # Active Record object, you can also define callbacks that get triggered - # when you add an object to an association collection. If any of these - # callbacks throw an exception, the object will not be added to the - # collection. Before you add a new board to the boards collection if you - # already have 1, 2, or n it will fail, but it if you have 0 that is lower - # than the number of permitted boards per project it won't fail. - def validate_board_limit(board) - raise BoardLimitExceeded, 'Number of permitted boards exceeded' if boards.size >= NUMBER_OF_PERMITTED_BOARDS - end - def update_project_statistics stats = statistics || build_statistics stats.update(namespace_id: namespace_id) @@ -2186,7 +2237,7 @@ class Project < ActiveRecord::Base errors.delete(error) end - errors.add(:base, "The project is still being deleted. Please try again later.") + errors.add(:base, _("The project is still being deleted. Please try again later.")) end def pending_delete_twin diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index 2c590008db2..f95d3ab54e2 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ProjectAuthorization < ActiveRecord::Base +class ProjectAuthorization < ApplicationRecord include FromUnion belongs_to :user diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb index e353a6443c4..67c12363a3c 100644 --- a/app/models/project_auto_devops.rb +++ b/app/models/project_auto_devops.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true -class ProjectAutoDevops < ActiveRecord::Base +class ProjectAutoDevops < ApplicationRecord + include IgnorableColumn + + ignore_column :domain + belongs_to :project enum deploy_strategy: { @@ -12,31 +16,10 @@ class ProjectAutoDevops < ActiveRecord::Base scope :enabled, -> { where(enabled: true) } scope :disabled, -> { where(enabled: false) } - validates :domain, allow_blank: true, hostname: { allow_numeric_hostname: true } - after_save :create_gitlab_deploy_token, if: :needs_to_create_deploy_token? - def instance_domain - Gitlab::CurrentSettings.auto_devops_domain - end - - def has_domain? - domain.present? || instance_domain.present? - end - - # From 11.8, AUTO_DEVOPS_DOMAIN has been replaced by KUBE_INGRESS_BASE_DOMAIN. - # See Clusters::Cluster#predefined_variables and https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24580 - # for more info. - # - # Suppport AUTO_DEVOPS_DOMAIN is scheduled to be removed on - # https://gitlab.com/gitlab-org/gitlab-ce/issues/52363 def predefined_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| - if has_domain? - variables.append(key: 'AUTO_DEVOPS_DOMAIN', - value: domain.presence || instance_domain) - end - variables.concat(deployment_strategy_default_variables) end end diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index 1dad235cc2b..492d50766ea 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -1,11 +1,23 @@ # frozen_string_literal: true -class ProjectCiCdSetting < ActiveRecord::Base +class ProjectCiCdSetting < ApplicationRecord belongs_to :project, inverse_of: :ci_cd_settings # The version of the schema that first introduced this model/table. MINIMUM_SCHEMA_VERSION = 20180403035759 + DEFAULT_GIT_DEPTH = 50 + + before_create :set_default_git_depth + + validates :default_git_depth, + numericality: { + only_integer: true, + greater_than_or_equal_to: 0, + less_than_or_equal_to: 1000 + }, + allow_nil: true + def self.available? @available ||= ActiveRecord::Migrator.current_version >= MINIMUM_SCHEMA_VERSION @@ -15,4 +27,10 @@ class ProjectCiCdSetting < ActiveRecord::Base @available = nil super end + + private + + def set_default_git_depth + self.default_git_depth ||= DEFAULT_GIT_DEPTH + end end diff --git a/app/models/project_custom_attribute.rb b/app/models/project_custom_attribute.rb index 4e767cb3b26..b0da586988a 100644 --- a/app/models/project_custom_attribute.rb +++ b/app/models/project_custom_attribute.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ProjectCustomAttribute < ActiveRecord::Base +class ProjectCustomAttribute < ApplicationRecord belongs_to :project validates :project, :key, :value, presence: true diff --git a/app/models/project_daily_statistic.rb b/app/models/project_daily_statistic.rb new file mode 100644 index 00000000000..5ee11ab186e --- /dev/null +++ b/app/models/project_daily_statistic.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class ProjectDailyStatistic < ApplicationRecord + belongs_to :project + + scope :of_project, -> (project) { where(project: project) } + scope :of_last_30_days, -> { where('date >= ?', 29.days.ago.utc.to_date) } + scope :sorted_by_date_desc, -> { order(project_id: :desc, date: :desc) } + scope :sum_fetch_count, -> { sum(:fetch_count) } +end diff --git a/app/models/project_deploy_token.rb b/app/models/project_deploy_token.rb index 719c492a1ff..a55667496fb 100644 --- a/app/models/project_deploy_token.rb +++ b/app/models/project_deploy_token.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ProjectDeployToken < ActiveRecord::Base +class ProjectDeployToken < ApplicationRecord belongs_to :project belongs_to :deploy_token, inverse_of: :project_deploy_tokens diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index f700090a493..6bcb051bff6 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ProjectFeature < ActiveRecord::Base +class ProjectFeature < ApplicationRecord # == Project features permissions # # Grants access level to project tools @@ -72,11 +72,13 @@ class ProjectFeature < ActiveRecord::Base default_value_for :wiki_access_level, value: ENABLED, allows_nil: false default_value_for :repository_access_level, value: ENABLED, allows_nil: false + scope :for_project_id, -> (project) { where(project: project) } + def feature_available?(feature, user) # This feature might not be behind a feature flag at all, so default to true return false unless ::Feature.enabled?(feature, user, default_enabled: true) - get_permission(user, access_level(feature)) + get_permission(user, feature) end def access_level(feature) @@ -134,12 +136,12 @@ class ProjectFeature < ActiveRecord::Base (FEATURES - %i(pages)).each {|f| validator.call("#{f}_access_level")} end - def get_permission(user, level) - case level + def get_permission(user, feature) + case access_level(feature) when DISABLED false when PRIVATE - user && (project.team.member?(user) || user.full_private_access?) + team_access?(user, feature) when ENABLED true when PUBLIC @@ -148,4 +150,11 @@ class ProjectFeature < ActiveRecord::Base true end end + + def team_access?(user, feature) + return unless user + return true if user.full_private_access? + + project.team.member?(user, ProjectFeature.required_minimum_access_level(feature)) + end end diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb index bc3759142ae..feaf172d48d 100644 --- a/app/models/project_group_link.rb +++ b/app/models/project_group_link.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ProjectGroupLink < ActiveRecord::Base +class ProjectGroupLink < ApplicationRecord include Expirable GUEST = 10 @@ -14,7 +14,7 @@ class ProjectGroupLink < ActiveRecord::Base validates :project_id, presence: true validates :group, presence: true - validates :group_id, uniqueness: { scope: [:project_id], message: "already shared with this group" } + validates :group_id, uniqueness: { scope: [:project_id], message: _("already shared with this group") } validates :group_access, presence: true validates :group_access, inclusion: { in: Gitlab::Access.values }, presence: true validate :different_group @@ -44,7 +44,7 @@ class ProjectGroupLink < ActiveRecord::Base group_ids = project_group.ancestors.map(&:id).push(project_group.id) if group_ids.include?(self.group.id) - errors.add(:base, "Project cannot be shared with the group it is in or one of its ancestors.") + errors.add(:base, _("Project cannot be shared with the group it is in or one of its ancestors.")) end end diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb index aa0c121fe99..580e8dfd833 100644 --- a/app/models/project_import_data.rb +++ b/app/models/project_import_data.rb @@ -2,7 +2,7 @@ require 'carrierwave/orm/activerecord' -class ProjectImportData < ActiveRecord::Base +class ProjectImportData < ApplicationRecord belongs_to :project, inverse_of: :import_data attr_encrypted :credentials, key: Settings.attr_encrypted_db_key_base, diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb index 488f0cb5971..1605345efd5 100644 --- a/app/models/project_import_state.rb +++ b/app/models/project_import_state.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ProjectImportState < ActiveRecord::Base +class ProjectImportState < ApplicationRecord include AfterCommitQueue self.table_name = "project_mirror_data" diff --git a/app/models/project_metrics_setting.rb b/app/models/project_metrics_setting.rb new file mode 100644 index 00000000000..a2a7dc571a4 --- /dev/null +++ b/app/models/project_metrics_setting.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ProjectMetricsSetting < ApplicationRecord + belongs_to :project + + validates :external_dashboard_url, + length: { maximum: 255 }, + addressable_url: { enforce_sanitization: true, ascii_only: true } +end diff --git a/app/models/project_repository.rb b/app/models/project_repository.rb index 38913f3f2f5..092efabd73f 100644 --- a/app/models/project_repository.rb +++ b/app/models/project_repository.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ProjectRepository < ActiveRecord::Base +class ProjectRepository < ApplicationRecord include Shardable belongs_to :project, inverse_of: :project_repository diff --git a/app/models/project_services/asana_service.rb b/app/models/project_services/asana_service.rb index cc5f1207653..3e28dc23782 100644 --- a/app/models/project_services/asana_service.rb +++ b/app/models/project_services/asana_service.rb @@ -11,7 +11,7 @@ class AsanaService < Service end def description - 'Asana - Teamwork without email' + s_('AsanaService|Asana - Teamwork without email') end def help @@ -36,13 +36,13 @@ http://app.asana.com/-/account_api' { type: 'text', name: 'api_key', - placeholder: 'User Personal Access Token. User must have access to task, all comments will be attributed to this user.', + placeholder: s_('AsanaService|User Personal Access Token. User must have access to task, all comments will be attributed to this user.'), required: true }, { type: 'text', name: 'restrict_to_branch', - placeholder: 'Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches.' + placeholder: s_('AsanaService|Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches.') } ] end @@ -73,7 +73,7 @@ http://app.asana.com/-/account_api' project_name = project.full_name data[:commits].each do |commit| - push_msg = "#{user} pushed to branch #{branch} of #{project_name} ( #{commit[:url]} ):" + push_msg = s_("AsanaService|%{user} pushed to branch %{branch} of %{project_name} ( %{commit_url} ):") % { user: user, branch: branch, project_name: project_name, commit_url: commit[:url] } check_commit(commit[:message], push_msg) end end diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb index 71f5607dbdb..dfeb21680a9 100644 --- a/app/models/project_services/bamboo_service.rb +++ b/app/models/project_services/bamboo_service.rb @@ -31,15 +31,15 @@ class BambooService < CiService end def title - 'Atlassian Bamboo CI' + s_('BambooService|Atlassian Bamboo CI') end def description - 'A continuous integration and build server' + s_('BambooService|A continuous integration and build server') end def help - 'You must set up automatic revision labeling and a repository trigger in Bamboo.' + s_('BambooService|You must set up automatic revision labeling and a repository trigger in Bamboo.') end def self.to_param @@ -49,11 +49,11 @@ class BambooService < CiService def fields [ { type: 'text', name: 'bamboo_url', - placeholder: 'Bamboo root URL like https://bamboo.example.com', required: true }, + placeholder: s_('BambooService|Bamboo root URL like https://bamboo.example.com'), required: true }, { type: 'text', name: 'build_key', - placeholder: 'Bamboo build plan key like KEY', required: true }, + placeholder: s_('BambooService|Bamboo build plan key like KEY'), required: true }, { type: 'text', name: 'username', - placeholder: 'A user with API access, if applicable' }, + placeholder: s_('BambooService|A user with API access, if applicable') }, { type: 'password', name: 'password' } ] end diff --git a/app/models/project_services/campfire_service.rb b/app/models/project_services/campfire_service.rb index 1d7877a1fb5..ad26e42a21b 100644 --- a/app/models/project_services/campfire_service.rb +++ b/app/models/project_services/campfire_service.rb @@ -57,7 +57,7 @@ class CampfireService < Service # https://github.com/basecamp/campfire-api/blob/master/sections/messages.md#create-message def speak(room_name, message, auth) room = rooms(auth).find { |r| r["name"] == room_name } - return nil unless room + return unless room path = "/room/#{room["id"]}/speak.json" body = { diff --git a/app/models/project_services/chat_message/deployment_message.rb b/app/models/project_services/chat_message/deployment_message.rb new file mode 100644 index 00000000000..dae3a56116e --- /dev/null +++ b/app/models/project_services/chat_message/deployment_message.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module ChatMessage + class DeploymentMessage < BaseMessage + attr_reader :commit_title + attr_reader :commit_url + attr_reader :deployable_id + attr_reader :deployable_url + attr_reader :environment + attr_reader :short_sha + attr_reader :status + attr_reader :user_url + + def initialize(data) + super + + @commit_title = data[:commit_title] + @commit_url = data[:commit_url] + @deployable_id = data[:deployable_id] + @deployable_url = data[:deployable_url] + @environment = data[:environment] + @short_sha = data[:short_sha] + @status = data[:status] + @user_url = data[:user_url] + end + + def attachments + [{ + text: "#{project_link} with job #{deployment_link} by #{user_link}\n#{commit_link}: #{commit_title}", + color: color + }] + end + + def activity + {} + end + + private + + def message + "Deploy to #{environment} #{humanized_status}" + end + + def color + case status + when 'success' + 'good' + when 'canceled' + 'warning' + when 'failed' + 'danger' + else + '#334455' + end + end + + def project_link + link(project_name, project_url) + end + + def deployment_link + link("##{deployable_id}", deployable_url) + end + + def user_link + link(user_combined_name, user_url) + end + + def commit_link + link(short_sha, commit_url) + end + + def humanized_status + status == 'success' ? 'succeeded' : status + end + end +end diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index c10ee07ccf4..7c9ecc6b821 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -33,7 +33,7 @@ class ChatNotificationService < Service def self.supported_events %w[push issue confidential_issue merge_request note confidential_note tag_push - pipeline wiki_page] + pipeline wiki_page deployment] end def fields @@ -122,6 +122,8 @@ class ChatNotificationService < Service ChatMessage::PipelineMessage.new(data) if should_pipeline_be_notified?(data) when "wiki_page" ChatMessage::WikiPageMessage.new(data) + when "deployment" + ChatMessage::DeploymentMessage.new(data) end end diff --git a/app/models/project_services/discord_service.rb b/app/models/project_services/discord_service.rb index 21afd14dbff..4385834ed0a 100644 --- a/app/models/project_services/discord_service.rb +++ b/app/models/project_services/discord_service.rb @@ -4,11 +4,11 @@ require "discordrb/webhooks" class DiscordService < ChatNotificationService def title - "Discord Notifications" + s_("DiscordService|Discord Notifications") end def description - "Receive event notifications in Discord" + s_("DiscordService|Receive event notifications in Discord") end def self.to_param @@ -33,6 +33,11 @@ class DiscordService < ChatNotificationService # No-op. end + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + def default_fields [ { type: "text", name: "webhook", placeholder: "e.g. https://discordapp.com/api/webhooks/…" }, diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb index fb73d430fb1..45de64a9990 100644 --- a/app/models/project_services/emails_on_push_service.rb +++ b/app/models/project_services/emails_on_push_service.rb @@ -7,11 +7,11 @@ class EmailsOnPushService < Service validates :recipients, presence: true, if: :valid_recipients? def title - 'Emails on push' + s_('EmailsOnPushService|Emails on push') end def description - 'Email the commits and diff of each push to a list of recipients.' + s_('EmailsOnPushService|Email the commits and diff of each push to a list of recipients.') end def self.to_param @@ -45,11 +45,11 @@ class EmailsOnPushService < Service def fields domains = Notify.allowed_email_domains.map { |domain| "user@#{domain}" }.join(", ") [ - { type: 'checkbox', name: 'send_from_committer_email', title: "Send from committer", - help: "Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. #{domains})." }, - { type: 'checkbox', name: 'disable_diffs', title: "Disable code diffs", - help: "Don't include possibly sensitive code diffs in notification body." }, - { type: 'textarea', name: 'recipients', placeholder: 'Emails separated by whitespace' } + { type: 'checkbox', name: 'send_from_committer_email', title: s_("EmailsOnPushService|Send from committer"), + help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. %{domains}).") % { domains: domains } }, + { type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"), + help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") }, + { type: 'textarea', name: 'recipients', placeholder: s_('EmailsOnPushService|Emails separated by whitespace') } ] end end diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb index d2835c6ac82..593ce69b0fd 100644 --- a/app/models/project_services/external_wiki_service.rb +++ b/app/models/project_services/external_wiki_service.rb @@ -6,11 +6,11 @@ class ExternalWikiService < Service validates :external_wiki_url, presence: true, public_url: true, if: :activated? def title - 'External Wiki' + s_('ExternalWikiService|External Wiki') end def description - 'Replaces the link to the internal wiki with a link to an external wiki.' + s_('ExternalWikiService|Replaces the link to the internal wiki with a link to an external wiki.') end def self.to_param @@ -19,7 +19,7 @@ class ExternalWikiService < Service def fields [ - { type: 'text', name: 'external_wiki_url', placeholder: 'The URL of the external Wiki', required: true } + { type: 'text', name: 'external_wiki_url', placeholder: s_('ExternalWikiService|The URL of the external Wiki'), required: true } ] end diff --git a/app/models/project_services/flowdock_service.rb b/app/models/project_services/flowdock_service.rb index 76624263aab..094488cb431 100644 --- a/app/models/project_services/flowdock_service.rb +++ b/app/models/project_services/flowdock_service.rb @@ -9,7 +9,7 @@ class FlowdockService < Service end def description - 'Flowdock is a collaboration web app for technical teams.' + s_('FlowdockService|Flowdock is a collaboration web app for technical teams.') end def self.to_param @@ -18,7 +18,7 @@ class FlowdockService < Service def fields [ - { type: 'text', name: 'token', placeholder: 'Flowdock Git source token', required: true } + { type: 'text', name: 'token', placeholder: s_('FlowdockService|Flowdock Git source token'), required: true } ] end diff --git a/app/models/project_services/hangouts_chat_service.rb b/app/models/project_services/hangouts_chat_service.rb index 272cd0f4e47..699cf1659d1 100644 --- a/app/models/project_services/hangouts_chat_service.rb +++ b/app/models/project_services/hangouts_chat_service.rb @@ -35,6 +35,11 @@ class HangoutsChatService < ChatNotificationService 'https://chat.googleapis.com/v1/spaces…' end + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + def default_fields [ { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb new file mode 100644 index 00000000000..a69b7b4c4b6 --- /dev/null +++ b/app/models/project_services/hipchat_service.rb @@ -0,0 +1,311 @@ +# frozen_string_literal: true + +class HipchatService < Service + include ActionView::Helpers::SanitizeHelper + + MAX_COMMITS = 3 + HIPCHAT_ALLOWED_TAGS = %w[ + a b i strong em br img pre code + table th tr td caption colgroup col thead tbody tfoot + ul ol li dl dt dd + ].freeze + + prop_accessor :token, :room, :server, :color, :api_version + boolean_accessor :notify_only_broken_pipelines, :notify + validates :token, presence: true, if: :activated? + + def initialize_properties + if properties.nil? + self.properties = {} + self.notify_only_broken_pipelines = true + end + end + + def title + 'HipChat' + end + + def description + 'Private group chat and IM' + end + + def self.to_param + 'hipchat' + end + + def fields + [ + { type: 'text', name: 'token', placeholder: 'Room token', required: true }, + { type: 'text', name: 'room', placeholder: 'Room name or ID' }, + { type: 'checkbox', name: 'notify' }, + { type: 'select', name: 'color', choices: %w(yellow red green purple gray random) }, + { type: 'text', name: 'api_version', + placeholder: 'Leave blank for default (v2)' }, + { type: 'text', name: 'server', + placeholder: 'Leave blank for default. https://hipchat.example.com' }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' } + ] + end + + def self.supported_events + %w(push issue confidential_issue merge_request note confidential_note tag_push pipeline) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + message = create_message(data) + return unless message.present? + + gate[room].send('GitLab', message, message_options(data)) # rubocop:disable GitlabSecurity/PublicSend + end + + def test(data) + begin + result = execute(data) + rescue StandardError => error + return { success: false, result: error } + end + + { success: true, result: result } + end + + private + + def gate + options = { api_version: api_version.present? ? api_version : 'v2' } + options[:server_url] = server unless server.blank? + @gate ||= HipChat::Client.new(token, options) + end + + def message_options(data = nil) + { notify: notify.present? && Gitlab::Utils.to_boolean(notify), color: message_color(data) } + end + + def create_message(data) + object_kind = data[:object_kind] + + case object_kind + when "push", "tag_push" + create_push_message(data) + when "issue" + create_issue_message(data) unless update?(data) + when "merge_request" + create_merge_request_message(data) unless update?(data) + when "note" + create_note_message(data) + when "pipeline" + create_pipeline_message(data) if should_pipeline_be_notified?(data) + end + end + + def render_line(text) + markdown(text.lines.first.chomp, pipeline: :single_line) if text + end + + def create_push_message(push) + ref_type = Gitlab::Git.tag_ref?(push[:ref]) ? 'tag' : 'branch' + ref = Gitlab::Git.ref_name(push[:ref]) + + before = push[:before] + after = push[:after] + + message = [] + message << "#{push[:user_name]} " + + if Gitlab::Git.blank_ref?(before) + message << "pushed new #{ref_type} <a href=\""\ + "#{project_url}/commits/#{CGI.escape(ref)}\">#{ref}</a>"\ + " to #{project_link}\n" + elsif Gitlab::Git.blank_ref?(after) + message << "removed #{ref_type} <b>#{ref}</b> from <a href=\"#{project.web_url}\">#{project_name}</a> \n" + else + message << "pushed to #{ref_type} <a href=\""\ + "#{project.web_url}/commits/#{CGI.escape(ref)}\">#{ref}</a> " + message << "of <a href=\"#{project.web_url}\">#{project.full_name.gsub!(/\s/, '')}</a> " + message << "(<a href=\"#{project.web_url}/compare/#{before}...#{after}\">Compare changes</a>)" + + push[:commits].take(MAX_COMMITS).each do |commit| + message << "<br /> - #{render_line(commit[:message])} (<a href=\"#{commit[:url]}\">#{commit[:id][0..5]}</a>)" + end + + if push[:commits].count > MAX_COMMITS + message << "<br />... #{push[:commits].count - MAX_COMMITS} more commits" + end + end + + message.join + end + + def markdown(text, options = {}) + return "" unless text + + context = { + project: project, + pipeline: :email + } + + Banzai.render(text, context) + + context.merge!(options) + + html = Banzai.render_and_post_process(text, context) + sanitized_html = sanitize(html, tags: HIPCHAT_ALLOWED_TAGS, attributes: %w[href title alt]) + + sanitized_html.truncate(200, separator: ' ', omission: '...') + end + + def create_issue_message(data) + user_name = data[:user][:name] + + obj_attr = data[:object_attributes] + obj_attr = HashWithIndifferentAccess.new(obj_attr) + title = render_line(obj_attr[:title]) + state = obj_attr[:state] + issue_iid = obj_attr[:iid] + issue_url = obj_attr[:url] + description = obj_attr[:description] + + issue_link = "<a href=\"#{issue_url}\">issue ##{issue_iid}</a>" + + message = ["#{user_name} #{state} #{issue_link} in #{project_link}: <b>#{title}</b>"] + message << "<pre>#{markdown(description)}</pre>" + + message.join + end + + def create_merge_request_message(data) + user_name = data[:user][:name] + + obj_attr = data[:object_attributes] + obj_attr = HashWithIndifferentAccess.new(obj_attr) + merge_request_id = obj_attr[:iid] + state = obj_attr[:state] + description = obj_attr[:description] + title = render_line(obj_attr[:title]) + + merge_request_url = "#{project_url}/merge_requests/#{merge_request_id}" + merge_request_link = "<a href=\"#{merge_request_url}\">merge request !#{merge_request_id}</a>" + message = ["#{user_name} #{state} #{merge_request_link} in " \ + "#{project_link}: <b>#{title}</b>"] + + message << "<pre>#{markdown(description)}</pre>" + message.join + end + + def format_title(title) + "<b>#{render_line(title)}</b>" + end + + def create_note_message(data) + data = HashWithIndifferentAccess.new(data) + user_name = data[:user][:name] + + obj_attr = HashWithIndifferentAccess.new(data[:object_attributes]) + note = obj_attr[:note] + note_url = obj_attr[:url] + noteable_type = obj_attr[:noteable_type] + commit_id = nil + + case noteable_type + when "Commit" + commit_attr = HashWithIndifferentAccess.new(data[:commit]) + commit_id = commit_attr[:id] + subject_desc = commit_id + subject_desc = Commit.truncate_sha(subject_desc) + subject_type = "commit" + title = format_title(commit_attr[:message]) + when "Issue" + subj_attr = HashWithIndifferentAccess.new(data[:issue]) + subject_id = subj_attr[:iid] + subject_desc = "##{subject_id}" + subject_type = "issue" + title = format_title(subj_attr[:title]) + when "MergeRequest" + subj_attr = HashWithIndifferentAccess.new(data[:merge_request]) + subject_id = subj_attr[:iid] + subject_desc = "!#{subject_id}" + subject_type = "merge request" + title = format_title(subj_attr[:title]) + when "Snippet" + subj_attr = HashWithIndifferentAccess.new(data[:snippet]) + subject_id = subj_attr[:id] + subject_desc = "##{subject_id}" + subject_type = "snippet" + title = format_title(subj_attr[:title]) + end + + subject_html = "<a href=\"#{note_url}\">#{subject_type} #{subject_desc}</a>" + message = ["#{user_name} commented on #{subject_html} in #{project_link}: "] + message << title + + message << "<pre>#{markdown(note, ref: commit_id)}</pre>" + message.join + end + + def create_pipeline_message(data) + pipeline_attributes = data[:object_attributes] + pipeline_id = pipeline_attributes[:id] + ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' + ref = pipeline_attributes[:ref] + user_name = (data[:user] && data[:user][:name]) || 'API' + status = pipeline_attributes[:status] + duration = pipeline_attributes[:duration] + + branch_link = "<a href=\"#{project_url}/commits/#{CGI.escape(ref)}\">#{ref}</a>" + pipeline_url = "<a href=\"#{project_url}/pipelines/#{pipeline_id}\">##{pipeline_id}</a>" + + "#{project_link}: Pipeline #{pipeline_url} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status(status)} in #{duration} second(s)" + end + + def message_color(data) + pipeline_status_color(data) || color || 'yellow' + end + + def pipeline_status_color(data) + return unless data && data[:object_kind] == 'pipeline' + + case data[:object_attributes][:status] + when 'success' + 'green' + else + 'red' + end + end + + def project_name + project.full_name.gsub(/\s/, '') + end + + def project_url + project.web_url + end + + def project_link + "<a href=\"#{project_url}\">#{project_name}</a>" + end + + def update?(data) + data[:object_attributes][:action] == 'update' + end + + def humanized_status(status) + case status + when 'success' + 'passed' + else + status + end + end + + def should_pipeline_be_notified?(data) + case data[:object_attributes][:status] + when 'success' + !notify_only_broken_pipelines? + when 'failed' + true + else + false + end + end +end diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb index 83fd9a34438..fb76bc89c98 100644 --- a/app/models/project_services/irker_service.rb +++ b/app/models/project_services/irker_service.rb @@ -112,7 +112,7 @@ class IrkerService < Service end def consider_uri(uri) - return nil if uri.scheme.nil? + return if uri.scheme.nil? # Authorize both irc://domain.com/#chan and irc://domain.com/chan if uri.is_a?(URI) && uri.scheme[/^ircs?\z/] && !uri.path.nil? diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 9066a0b7f1d..7b4832b84a8 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -11,7 +11,7 @@ class JiraService < IssueTrackerService validates :password, presence: true, if: :activated? validates :jira_issue_transition_id, - format: { with: Gitlab::Regex.jira_transition_id_regex, message: "transition ids can have only numbers which can be split with , or ;" }, + format: { with: Gitlab::Regex.jira_transition_id_regex, message: s_("JiraService|transition ids can have only numbers which can be split with , or ;") }, allow_blank: true # JIRA cloud version is deprecating authentication via username and password. @@ -86,7 +86,7 @@ class JiraService < IssueTrackerService if self.properties && self.properties['description'].present? self.properties['description'] else - 'Jira issue tracker' + s_('JiraService|Jira issue tracker') end end @@ -96,11 +96,11 @@ class JiraService < IssueTrackerService def fields [ - { type: 'text', name: 'url', title: 'Web URL', placeholder: 'https://jira.example.com', required: true }, - { type: 'text', name: 'api_url', title: 'JIRA API URL', placeholder: 'If different from Web URL' }, - { type: 'text', name: 'username', title: 'Username or Email', placeholder: 'Use a username for server version and an email for cloud version', required: true }, - { type: 'password', name: 'password', title: 'Password or API token', placeholder: 'Use a password for server version and an API token for cloud version', required: true }, - { type: 'text', name: 'jira_issue_transition_id', title: 'Transition ID(s)', placeholder: 'Use , or ; to separate multiple transition IDs' } + { type: 'text', name: 'url', title: s_('JiraService|Web URL'), placeholder: 'https://jira.example.com', required: true }, + { type: 'text', name: 'api_url', title: s_('JiraService|JIRA API URL'), placeholder: s_('JiraService|If different from Web URL') }, + { type: 'text', name: 'username', title: s_('JiraService|Username or Email'), placeholder: s_('JiraService|Use a username for server version and an email for cloud version'), required: true }, + { type: 'password', name: 'password', title: s_('JiraService|Password or API token'), placeholder: s_('JiraService|Use a password for server version and an API token for cloud version'), required: true }, + { type: 'text', name: 'jira_issue_transition_id', title: s_('JiraService|Transition ID(s)'), placeholder: s_('JiraService|Use , or ; to separate multiple transition IDs') } ] end @@ -139,7 +139,7 @@ class JiraService < IssueTrackerService def create_cross_reference_note(mentioned, noteable, author) unless can_cross_reference?(noteable) - return "Events for #{noteable.model_name.plural.humanize(capitalize: false)} are disabled." + return s_("JiraService|Events for %{noteable_model_name} are disabled.") % { noteable_model_name: noteable.model_name.plural.humanize(capitalize: false) } end jira_issue = jira_request { client.Issue.find(mentioned.id) } @@ -205,17 +205,15 @@ class JiraService < IssueTrackerService # if any transition fails it will log the error message and stop the transition sequence def transition_issue(issue) jira_issue_transition_id.scan(Gitlab::Regex.jira_transition_id_regex).each do |transition_id| - begin - issue.transitions.build.save!(transition: { id: transition_id }) - rescue => error - log_error("Issue transition failed", error: error.message, client_url: client_url) - return false - end + issue.transitions.build.save!(transition: { id: transition_id }) + rescue => error + log_error("Issue transition failed", error: error.message, client_url: client_url) + return false end end def add_issue_solved_comment(issue, commit_id, commit_url) - link_title = "GitLab: Solved by commit #{commit_id}." + link_title = "Solved by commit #{commit_id}." comment = "Issue solved with [#{commit_id}|#{commit_url}]." link_props = build_remote_link_props(url: commit_url, title: link_title, resolved: true) send_message(issue, comment, link_props) @@ -230,7 +228,7 @@ class JiraService < IssueTrackerService project_name = data[:project][:name] message = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title.chomp}'" - link_title = "GitLab: Mentioned on #{entity_name} - #{entity_title}" + link_title = "#{entity_name.capitalize} - #{entity_title}" link_props = build_remote_link_props(url: entity_url, title: link_title) unless comment_exists?(issue, message) @@ -267,6 +265,7 @@ class JiraService < IssueTrackerService def find_remote_link(issue, url) links = jira_request { issue.remotelink.all } + return unless links links.find { |link| link.object["url"] == url } end @@ -278,6 +277,7 @@ class JiraService < IssueTrackerService { GlobalID: 'GitLab', + relationship: 'mentioned on', object: { url: url, title: title, @@ -339,9 +339,9 @@ class JiraService < IssueTrackerService def self.event_description(event) case event when "merge_request", "merge_request_events" - "JIRA comments will be created when an issue gets referenced in a merge request." + s_("JiraService|JIRA comments will be created when an issue gets referenced in a merge request.") when "commit", "commit_events" - "JIRA comments will be created when an issue gets referenced in a commit." + s_("JiraService|JIRA comments will be created when an issue gets referenced in a commit.") end end end diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index f69edd60003..aa6b4aa1d5e 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -86,7 +86,7 @@ class KubernetesService < DeploymentService ] end - def actual_namespace + def kubernetes_namespace_for(project) if namespace.present? namespace else @@ -113,8 +113,8 @@ class KubernetesService < DeploymentService Gitlab::Ci::Variables::Collection.new.tap do |variables| variables .append(key: 'KUBE_URL', value: api_url) - .append(key: 'KUBE_TOKEN', value: token, public: false) - .append(key: 'KUBE_NAMESPACE', value: actual_namespace) + .append(key: 'KUBE_TOKEN', value: token, public: false, masked: true) + .append(key: 'KUBE_NAMESPACE', value: kubernetes_namespace_for(project)) .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true) if ca_pem.present? @@ -131,8 +131,10 @@ class KubernetesService < DeploymentService # short time later def terminals(environment) with_reactive_cache do |data| - pods = filter_by_label(data[:pods], app: environment.slug) - terminals = pods.flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) } + project = environment.project + + pods = filter_by_project_environment(data[:pods], project.full_path_slug, environment.slug) + terminals = pods.flat_map { |pod| terminals_for_pod(api_url, kubernetes_namespace_for(project), pod) }.compact terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) } end end @@ -169,7 +171,7 @@ class KubernetesService < DeploymentService def kubeconfig to_kubeconfig( url: api_url, - namespace: actual_namespace, + namespace: kubernetes_namespace_for(project), token: token, ca_pem: ca_pem) end @@ -186,7 +188,7 @@ class KubernetesService < DeploymentService end def build_kube_client! - raise "Incomplete settings" unless api_url && actual_namespace && token + raise "Incomplete settings" unless api_url && kubernetes_namespace_for(project) && token Gitlab::Kubernetes::KubeClient.new( api_url, @@ -200,7 +202,7 @@ class KubernetesService < DeploymentService def read_pods kubeclient = build_kube_client! - kubeclient.get_pods(namespace: actual_namespace).as_json + kubeclient.get_pods(namespace: kubernetes_namespace_for(project)).as_json rescue Kubeclient::ResourceNotFoundError [] end diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb index c34078f13c1..c22a6dc26f6 100644 --- a/app/models/project_services/microsoft_teams_service.rb +++ b/app/models/project_services/microsoft_teams_service.rb @@ -33,6 +33,11 @@ class MicrosoftTeamsService < ChatNotificationService def default_channel_placeholder end + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + def default_fields [ { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, diff --git a/app/models/project_services/mock_ci_service.rb b/app/models/project_services/mock_ci_service.rb index d8bba58dcbf..c5e5f4f6400 100644 --- a/app/models/project_services/mock_ci_service.rb +++ b/app/models/project_services/mock_ci_service.rb @@ -2,7 +2,7 @@ # For an example companion mocking service, see https://gitlab.com/gitlab-org/gitlab-mock-ci-service class MockCiService < CiService - ALLOWED_STATES = %w[failed canceled running pending success success_with_warnings skipped not_found].freeze + ALLOWED_STATES = %w[failed canceled running pending success success-with-warnings skipped not_found].freeze prop_accessor :mock_service_url validates :mock_service_url, presence: true, public_url: true, if: :activated? diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb index d60a6a7efa3..ae5d5038099 100644 --- a/app/models/project_services/pipelines_email_service.rb +++ b/app/models/project_services/pipelines_email_service.rb @@ -2,19 +2,19 @@ class PipelinesEmailService < Service prop_accessor :recipients - boolean_accessor :notify_only_broken_pipelines + boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch validates :recipients, presence: true, if: :valid_recipients? def initialize_properties - self.properties ||= { notify_only_broken_pipelines: true } + self.properties ||= { notify_only_broken_pipelines: true, notify_only_default_branch: false } end def title - 'Pipelines emails' + _('Pipelines emails') end def description - 'Email the pipelines status to a list of recipients.' + _('Email the pipelines status to a list of recipients.') end def self.to_param @@ -51,10 +51,12 @@ class PipelinesEmailService < Service [ { type: 'textarea', name: 'recipients', - placeholder: 'Emails separated by comma', + placeholder: _('Emails separated by comma'), required: true }, { type: 'checkbox', - name: 'notify_only_broken_pipelines' } + name: 'notify_only_broken_pipelines' }, + { type: 'checkbox', + name: 'notify_only_default_branch' } ] end @@ -67,6 +69,16 @@ class PipelinesEmailService < Service end def should_pipeline_be_notified?(data) + notify_for_pipeline_branch?(data) && notify_for_pipeline?(data) + end + + def notify_for_pipeline_branch?(data) + return true unless notify_only_default_branch? + + data[:object_attributes][:ref] == data[:project][:default_branch] + end + + def notify_for_pipeline?(data) case data[:object_attributes][:status] when 'success' !notify_only_broken_pipelines? diff --git a/app/models/project_services/pivotaltracker_service.rb b/app/models/project_services/pivotaltracker_service.rb index 617e502b639..c15993bdc06 100644 --- a/app/models/project_services/pivotaltracker_service.rb +++ b/app/models/project_services/pivotaltracker_service.rb @@ -11,7 +11,7 @@ class PivotaltrackerService < Service end def description - 'Project Management Software (Source Commits Endpoint)' + s_('PivotalTrackerService|Project Management Software (Source Commits Endpoint)') end def self.to_param @@ -23,14 +23,14 @@ class PivotaltrackerService < Service { type: 'text', name: 'token', - placeholder: 'Pivotal Tracker API token.', + placeholder: s_('PivotalTrackerService|Pivotal Tracker API token.'), required: true }, { type: 'text', name: 'restrict_to_branch', - placeholder: 'Comma-separated list of branches which will be ' \ - 'automatically inspected. Leave blank to include all branches.' + placeholder: s_('PivotalTrackerService|Comma-separated list of branches which will be ' \ + 'automatically inspected. Leave blank to include all branches.') } ] end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 60cb2d380d5..c68a9d923c8 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -71,7 +71,7 @@ class PrometheusService < MonitoringService end def prometheus_client - RestClient::Resource.new(api_url, max_redirects: 0) if api_url && manual_configuration? && active? + RestClient::Resource.new(api_url, max_redirects: 0) if should_return_client? end def prometheus_available? @@ -83,6 +83,10 @@ class PrometheusService < MonitoringService private + def should_return_client? + api_url && manual_configuration? && active? && valid? + end + def synchronize_service_state self.active = prometheus_available? || manual_configuration? diff --git a/app/models/project_services/pushover_service.rb b/app/models/project_services/pushover_service.rb index 4e48c348b45..0d35bab7f80 100644 --- a/app/models/project_services/pushover_service.rb +++ b/app/models/project_services/pushover_service.rb @@ -11,7 +11,7 @@ class PushoverService < Service end def description - 'Pushover makes it easy to get real-time notifications on your Android device, iPhone, iPad, and Desktop.' + s_('PushoverService|Pushover makes it easy to get real-time notifications on your Android device, iPhone, iPad, and Desktop.') end def self.to_param @@ -20,15 +20,15 @@ class PushoverService < Service def fields [ - { type: 'text', name: 'api_key', placeholder: 'Your application key', required: true }, - { type: 'text', name: 'user_key', placeholder: 'Your user key', required: true }, - { type: 'text', name: 'device', placeholder: 'Leave blank for all active devices' }, + { type: 'text', name: 'api_key', placeholder: s_('PushoverService|Your application key'), required: true }, + { type: 'text', name: 'user_key', placeholder: s_('PushoverService|Your user key'), required: true }, + { type: 'text', name: 'device', placeholder: s_('PushoverService|Leave blank for all active devices') }, { type: 'select', name: 'priority', required: true, choices: [ - ['Lowest Priority', -2], - ['Low Priority', -1], - ['Normal Priority', 0], - ['High Priority', 1] + [s_('PushoverService|Lowest Priority'), -2], + [s_('PushoverService|Low Priority'), -1], + [s_('PushoverService|Normal Priority'), 0], + [s_('PushoverService|High Priority'), 1] ], default_choice: 0 }, { type: 'select', name: 'sound', choices: @@ -73,15 +73,15 @@ class PushoverService < Service message = if Gitlab::Git.blank_ref?(before) - "#{data[:user_name]} pushed new branch \"#{ref}\"." + s_("PushoverService|%{user_name} pushed new branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref } elsif Gitlab::Git.blank_ref?(after) - "#{data[:user_name]} deleted branch \"#{ref}\"." + s_("PushoverService|%{user_name} deleted branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref } else - "#{data[:user_name]} push to branch \"#{ref}\"." + s_("PushoverService|%{user_name} push to branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref } end if data[:total_commits_count] > 0 - message = [message, "Total commits count: #{data[:total_commits_count]}"].join("\n") + message = [message, s_("PushoverService|Total commits count: %{total_commits_count}") % { total_commits_count: data[:total_commits_count] }].join("\n") end pushover_data = { @@ -92,7 +92,7 @@ class PushoverService < Service title: "#{project.full_name}", message: message, url: data[:project][:web_url], - url_title: "See project #{project.full_name}" + url_title: s_("PushoverService|See project %{project_full_name}") % { project_full_name: project.full_name } } # Sound parameter MUST NOT be sent to API if not selected diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb new file mode 100644 index 00000000000..175c2ebf197 --- /dev/null +++ b/app/models/project_services/youtrack_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class YoutrackService < IssueTrackerService + validates :project_url, :issues_url, presence: true, public_url: true, if: :activated? + + prop_accessor :description, :project_url, :issues_url + + # {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030 + def self.reference_pattern(only_long: false) + if only_long + /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+)/ + else + /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+)|(#{Issue.reference_prefix}(?<issue>\d+))/ + end + end + + def title + 'YouTrack' + end + + def description + if self.properties && self.properties['description'].present? + self.properties['description'] + else + 'YouTrack issue tracker' + end + end + + def self.to_param + 'youtrack' + end + + def fields + [ + { type: 'text', name: 'description', placeholder: description }, + { type: 'text', name: 'project_url', placeholder: 'Project url', required: true }, + { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true } + ] + end +end diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 781a197d56f..11e3737298c 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -1,13 +1,22 @@ # frozen_string_literal: true -class ProjectStatistics < ActiveRecord::Base +class ProjectStatistics < ApplicationRecord belongs_to :project belongs_to :namespace + default_value_for :wiki_size, 0 + + # older migrations fail due to non-existent attribute without this + def wiki_size + has_attribute?(:wiki_size) ? super : 0 + end + before_save :update_storage_size - COLUMNS_TO_REFRESH = [:repository_size, :lfs_objects_size, :commit_count].freeze - INCREMENTABLE_COLUMNS = { build_artifacts_size: %i[storage_size] }.freeze + COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count].freeze + INCREMENTABLE_COLUMNS = { build_artifacts_size: %i[storage_size], packages_size: %i[storage_size] }.freeze + + scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) } def total_repository_size repository_size + lfs_objects_size @@ -27,17 +36,25 @@ class ProjectStatistics < ActiveRecord::Base self.commit_count = project.repository.commit_count end - # Repository#size needs to be converted from MB to Byte. def update_repository_size self.repository_size = project.repository.size * 1.megabyte end + def update_wiki_size + self.wiki_size = project.wiki.repository.size * 1.megabyte + end + def update_lfs_objects_size self.lfs_objects_size = project.lfs_objects.sum(:size) end + # older migrations fail due to non-existent attribute without this + def packages_size + has_attribute?(:packages_size) ? super : 0 + end + def update_storage_size - self.storage_size = repository_size + lfs_objects_size + build_artifacts_size + self.storage_size = repository_size + wiki_size + lfs_objects_size + build_artifacts_size + packages_size end # Since this incremental update method does not call update_storage_size above, diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index c43bd45a62f..c91add6439f 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -13,6 +13,11 @@ class ProjectWiki CouldNotCreateWikiError = Class.new(StandardError) SIDEBAR = '_sidebar' + TITLE_ORDER = 'title' + CREATED_AT_ORDER = 'created_at' + DIRECTION_DESC = 'desc' + DIRECTION_ASC = 'asc' + # Returns a string describing what went wrong after # an operation fails. attr_reader :error_message @@ -59,7 +64,7 @@ class ProjectWiki # Returns the Gitlab::Git::Wiki object. def wiki @wiki ||= begin - gl_repository = Gitlab::GlRepository.gl_repository(project, true) + gl_repository = Gitlab::GlRepository::WIKI.identifier_for_subject(project) raw_repository = Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', gl_repository, full_path) create_repo!(raw_repository) unless raw_repository.exists? @@ -77,13 +82,28 @@ class ProjectWiki end def empty? - pages(limit: 1).empty? + list_pages(limit: 1).empty? end + # Lists wiki pages of the repository. + # + # limit - max number of pages returned by the method. + # sort - criterion by which the pages are sorted. + # direction - order of the sorted pages. + # load_content - option, which specifies whether the content inside the page + # will be loaded. + # # Returns an Array of GitLab WikiPage instances or an # empty Array if this Wiki has no pages. - def pages(limit: 0) - wiki.pages(limit: limit).map { |page| WikiPage.new(self, page, true) } + def list_pages(limit: 0, sort: nil, direction: DIRECTION_ASC, load_content: false) + wiki.list_pages( + limit: limit, + sort: sort, + direction_desc: direction == DIRECTION_DESC, + load_content: load_content + ).map do |page| + WikiPage.new(self, page, true) + end end # Finds a page within the repository based on a tile @@ -151,7 +171,7 @@ class ProjectWiki end def repository - @repository ||= Repository.new(full_path, @project, disk_path: disk_path, is_wiki: true) + @repository ||= Repository.new(full_path, @project, disk_path: disk_path, repo_type: Gitlab::GlRepository::WIKI) end def default_branch @@ -183,7 +203,7 @@ class ProjectWiki end def commit_details(action, message = nil, title = nil) - commit_message = message || default_message(action, title) + commit_message = message.presence || default_message(action, title) git_user = Gitlab::Git::User.from_gitlab(@user) Gitlab::Git::Wiki::CommitDetails.new(@user.id, diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb index 5594594a48d..62090444f79 100644 --- a/app/models/prometheus_metric.rb +++ b/app/models/prometheus_metric.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PrometheusMetric < ActiveRecord::Base +class PrometheusMetric < ApplicationRecord belongs_to :project, validate: true, inverse_of: :prometheus_metrics enum group: { diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index d075440b147..ee0c94c20af 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ProtectedBranch < ActiveRecord::Base +class ProtectedBranch < ApplicationRecord include ProtectedRef protected_ref_access_levels :merge, :push @@ -18,13 +18,23 @@ class ProtectedBranch < ActiveRecord::Base def self.protected?(project, ref_name) return true if project.empty_repo? && default_branch_protected? - refs = project.protected_branches.select(:name) + self.matching(ref_name, protected_refs: protected_refs(project)).present? + end - self.matching(ref_name, protected_refs: refs).present? + def self.any_protected?(project, ref_names) + protected_refs(project).any? do |protected_ref| + ref_names.any? do |ref_name| + protected_ref.matches?(ref_name) + end + end end def self.default_branch_protected? Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_FULL || Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE end + + def self.protected_refs(project) + project.protected_branches.select(:name) + end end diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb index b0d5c64e931..de240e40316 100644 --- a/app/models/protected_branch/merge_access_level.rb +++ b/app/models/protected_branch/merge_access_level.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base +class ProtectedBranch::MergeAccessLevel < ApplicationRecord include ProtectedBranchAccess end diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb index b2a88229853..bde1d29ad7f 100644 --- a/app/models/protected_branch/push_access_level.rb +++ b/app/models/protected_branch/push_access_level.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -class ProtectedBranch::PushAccessLevel < ActiveRecord::Base +class ProtectedBranch::PushAccessLevel < ApplicationRecord include ProtectedBranchAccess end diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb index d28ebabfe49..6b507429e57 100644 --- a/app/models/protected_tag.rb +++ b/app/models/protected_tag.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ProtectedTag < ActiveRecord::Base +class ProtectedTag < ApplicationRecord include ProtectedRef validates :name, uniqueness: { scope: :project_id } diff --git a/app/models/protected_tag/create_access_level.rb b/app/models/protected_tag/create_access_level.rb index b06e55fb5dd..9fcfa7646a2 100644 --- a/app/models/protected_tag/create_access_level.rb +++ b/app/models/protected_tag/create_access_level.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ProtectedTag::CreateAccessLevel < ActiveRecord::Base +class ProtectedTag::CreateAccessLevel < ApplicationRecord include ProtectedTagAccess def check_access(user) diff --git a/app/models/push_event.rb b/app/models/push_event.rb index 9c0267c3140..4698df39730 100644 --- a/app/models/push_event.rb +++ b/app/models/push_event.rb @@ -69,7 +69,7 @@ class PushEvent < Event PUSHED end - def push? + def push_action? true end diff --git a/app/models/push_event_payload.rb b/app/models/push_event_payload.rb index c7769edf055..537859ec7b7 100644 --- a/app/models/push_event_payload.rb +++ b/app/models/push_event_payload.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PushEventPayload < ActiveRecord::Base +class PushEventPayload < ApplicationRecord include ShaAttribute belongs_to :event, inverse_of: :push_event_payload diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb index c6bd4bb6dfa..2e4769364c6 100644 --- a/app/models/redirect_route.rb +++ b/app/models/redirect_route.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class RedirectRoute < ActiveRecord::Base +class RedirectRoute < ApplicationRecord belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations validates :source, presence: true diff --git a/app/models/release.rb b/app/models/release.rb index 0dae5c90394..7bbeb3c9976 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Release < ActiveRecord::Base +class Release < ApplicationRecord include CacheMarkdownField include Gitlab::Utils::StrongMemoize @@ -15,6 +15,7 @@ class Release < ActiveRecord::Base accepts_nested_attributes_for :links, allow_destroy: true validates :description, :project, :tag, presence: true + validates :name, presence: true, on: :create scope :sorted, -> { order(created_at: :desc) } @@ -30,8 +31,11 @@ class Release < ActiveRecord::Base actual_tag.nil? end - def assets_count - links.count + sources.count + def assets_count(except: []) + links_count = links.count + sources_count = except.include?(:sources) ? 0 : sources.count + + links_count + sources_count end def sources diff --git a/app/models/releases/link.rb b/app/models/releases/link.rb index 6c507c47752..58c2b98e524 100644 --- a/app/models/releases/link.rb +++ b/app/models/releases/link.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true module Releases - class Link < ActiveRecord::Base + class Link < ApplicationRecord self.table_name = 'release_links' belongs_to :release - validates :url, presence: true, url: { protocols: %w(http https ftp) }, uniqueness: { scope: :release } + validates :url, presence: true, addressable_url: { schemes: %w(http https ftp) }, uniqueness: { scope: :release } validates :name, presence: true, uniqueness: { scope: :release } scope :sorted, -> { order(created_at: :desc) } diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index 5eba7ddd75c..af705b29f7a 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class RemoteMirror < ActiveRecord::Base +class RemoteMirror < ApplicationRecord include AfterCommitQueue include MirrorAuthentication @@ -17,13 +17,13 @@ class RemoteMirror < ActiveRecord::Base belongs_to :project, inverse_of: :remote_mirrors - validates :url, presence: true, public_url: { protocols: %w(ssh git http https), allow_blank: true, enforce_user: true } + validates :url, presence: true, public_url: { schemes: %w(ssh git http https), allow_blank: true, enforce_user: true } before_save :set_new_remote_name, if: :mirror_url_changed? after_save :set_override_remote_mirror_available, unless: -> { Gitlab::CurrentSettings.current_application_settings.mirror_available } - after_save :refresh_remote, if: :mirror_url_changed? - after_update :reset_fields, if: :mirror_url_changed? + after_save :refresh_remote, if: :saved_change_to_mirror_url? + after_update :reset_fields, if: :saved_change_to_mirror_url? after_commit :remove_remote, on: :destroy @@ -133,6 +133,10 @@ class RemoteMirror < ActiveRecord::Base end alias_method :enabled?, :enabled + def disabled? + !enabled? + end + def updated_since?(timestamp) last_update_started_at && last_update_started_at > timestamp && !update_failed? end @@ -248,7 +252,7 @@ class RemoteMirror < ActiveRecord::Base # Before adding a new remote we have to delete the data from # the previous remote name - prev_remote_name = remote_name_was || fallback_remote_name + prev_remote_name = remote_name_before_last_save || fallback_remote_name run_after_commit do project.repository.async_remove_remote(prev_remote_name) end @@ -265,4 +269,8 @@ class RemoteMirror < ActiveRecord::Base def mirror_url_changed? url_changed? || credentials_changed? end + + def saved_change_to_mirror_url? + saved_change_to_url? || saved_change_to_credentials? + end end diff --git a/app/models/repository.rb b/app/models/repository.rb index ed55a6e572b..e05d3dd58ac 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -19,7 +19,7 @@ class Repository include Gitlab::RepositoryCacheAdapter - attr_accessor :full_path, :disk_path, :project, :is_wiki + attr_accessor :full_path, :disk_path, :project, :repo_type delegate :ref_name_for_sha, to: :raw_repository delegate :bundle_to_disk, to: :raw_repository @@ -39,7 +39,8 @@ class Repository changelog license_blob license_key gitignore gitlab_ci_yml branch_names tag_names branch_count tag_count avatar exists? root_ref has_visible_content? - issue_template_names merge_request_template_names xcode_project?).freeze + issue_template_names merge_request_template_names + metrics_dashboard_paths xcode_project?).freeze # Methods that use cache_method but only memoize the value MEMOIZED_CACHED_METHODS = %i(license).freeze @@ -57,15 +58,16 @@ class Repository avatar: :avatar, issue_template: :issue_template_names, merge_request_template: :merge_request_template_names, + metrics_dashboard: :metrics_dashboard_paths, xcode_config: :xcode_project? }.freeze - def initialize(full_path, project, disk_path: nil, is_wiki: false) + def initialize(full_path, project, disk_path: nil, repo_type: Gitlab::GlRepository::PROJECT) @full_path = full_path @disk_path = disk_path || full_path @project = project @commit_cache = {} - @is_wiki = is_wiki + @repo_type = repo_type end def ==(other) @@ -79,7 +81,7 @@ class Repository end def raw_repository - return nil unless full_path + return unless full_path @raw_repository ||= initialize_raw_repository end @@ -103,7 +105,7 @@ class Repository end def commit(ref = nil) - return nil unless exists? + return unless exists? return ref if ref.is_a?(::Commit) find_commit(ref || root_ref) @@ -265,16 +267,14 @@ class Repository # to avoid unnecessary syncing. def keep_around(*shas) shas.each do |sha| - begin - next unless sha.present? && commit_by(oid: sha) + next unless sha.present? && commit_by(oid: sha) - next if kept_around?(sha) + next if kept_around?(sha) - # This will still fail if the file is corrupted (e.g. 0 bytes) - raw_repository.write_ref(keep_around_ref_name(sha), sha) - rescue Gitlab::Git::CommandError => ex - Rails.logger.error "Unable to create keep-around reference for repository #{disk_path}: #{ex}" - end + # This will still fail if the file is corrupted (e.g. 0 bytes) + raw_repository.write_ref(keep_around_ref_name(sha), sha) + rescue Gitlab::Git::CommandError => ex + Rails.logger.error "Unable to create keep-around reference for repository #{disk_path}: #{ex}" end end @@ -283,14 +283,19 @@ class Repository end def diverging_commit_counts(branch) + return diverging_commit_counts_without_max(branch) if Feature.enabled?('gitaly_count_diverging_commits_no_max') + + ## TODO: deprecate the below code after 12.0 @root_ref_hash ||= raw_repository.commit(root_ref).id cache.fetch(:"diverging_commit_counts_#{branch.name}") do # Rugged seems to throw a `ReferenceError` when given branch_names rather # than SHA-1 hashes + branch_sha = branch.dereferenced_target.sha + number_commits_behind, number_commits_ahead = raw_repository.diverging_commit_count( @root_ref_hash, - branch.dereferenced_target.sha, + branch_sha, max_count: MAX_DIVERGING_COUNT) if number_commits_behind + number_commits_ahead >= MAX_DIVERGING_COUNT @@ -301,13 +306,30 @@ class Repository end end - def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:) + def diverging_commit_counts_without_max(branch) + @root_ref_hash ||= raw_repository.commit(root_ref).id + cache.fetch(:"diverging_commit_counts_without_max_#{branch.name}") do + # Rugged seems to throw a `ReferenceError` when given branch_names rather + # than SHA-1 hashes + branch_sha = branch.dereferenced_target.sha + + number_commits_behind, number_commits_ahead = + raw_repository.diverging_commit_count( + @root_ref_hash, + branch_sha) + + { behind: number_commits_behind, ahead: number_commits_ahead } + end + end + + def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:, path: nil) raw_repository.archive_metadata( ref, storage_path, project.path, format, - append_sha: append_sha + append_sha: append_sha, + path: path ) end @@ -464,7 +486,7 @@ class Repository def after_import expire_content_cache - DetectRepositoryLanguagesWorker.perform_async(project.id, project.owner.id) + DetectRepositoryLanguagesWorker.perform_async(project.id) end # Runs code after a new commit has been pushed. @@ -534,10 +556,9 @@ class Repository end def root_ref - # When the repo does not exist, or there is no root ref, we raise this error so no data is cached. - raw_repository&.root_ref or raise Gitlab::Git::Repository::NoRepository # rubocop:disable Style/AndOr + raw_repository&.root_ref end - cache_method :root_ref + cache_method_asymmetrically :root_ref # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/314 def exists? @@ -604,6 +625,11 @@ class Repository end cache_method :merge_request_template_names, fallback: [] + def metrics_dashboard_paths + Gitlab::Metrics::Dashboard::Finder.find_all_paths_from_source(project) + end + cache_method :metrics_dashboard_paths + def readme head_tree&.readme end @@ -854,6 +880,12 @@ class Repository end end + def merge_to_ref(user, source_sha, merge_request, target_ref, message) + branch = merge_request.target_branch + + raw.merge_to_ref(user, source_sha, branch, target_ref, message) + end + def ff_merge(user, source, target_branch, merge_request: nil) their_commit_id = commit(source)&.id raise 'Invalid merge source' if their_commit_id.nil? @@ -1026,11 +1058,41 @@ class Repository raw_repository.fetch_ref(source_repository.raw_repository, source_ref: source_ref, target_ref: target_ref) end + # DEPRECATED: https://gitlab.com/gitlab-org/gitaly/issues/1628 + def rebase_deprecated(user, merge_request) + rebase_sha = raw.rebase_deprecated( + user, + merge_request.id, + branch: merge_request.source_branch, + branch_sha: merge_request.source_branch_sha, + remote_repository: merge_request.target_project.repository.raw, + remote_branch: merge_request.target_branch + ) + + # To support the full deprecated behaviour, set the + # `rebase_commit_sha` for the merge_request here and return the value + merge_request.update(rebase_commit_sha: rebase_sha, merge_error: nil) + + rebase_sha + end + def rebase(user, merge_request) - raw.rebase(user, merge_request.id, branch: merge_request.source_branch, - branch_sha: merge_request.source_branch_sha, - remote_repository: merge_request.target_project.repository.raw, - remote_branch: merge_request.target_branch) + if Feature.disabled?(:two_step_rebase, default_enabled: true) + return rebase_deprecated(user, merge_request) + end + + MergeRequest.transaction do + raw.rebase( + user, + merge_request.id, + branch: merge_request.source_branch, + branch_sha: merge_request.source_branch_sha, + remote_repository: merge_request.target_project.repository.raw, + remote_branch: merge_request.target_branch + ) do |commit_id| + merge_request.update!(rebase_commit_sha: commit_id, merge_error: nil) + end + end end def squash(user, merge_request, message) @@ -1061,6 +1123,19 @@ class Repository blob.data end + def create_if_not_exists + return if exists? + + raw.create_repository + after_create + end + + def blobs_metadata(paths, ref = 'HEAD') + references = Array.wrap(paths).map { |path| [ref, path] } + + Gitlab::Git::Blob.batch_metadata(raw, references).map { |raw_blob| Blob.decorate(raw_blob) } + end + private # TODO Generice finder, later split this on finders by Ref or Oid @@ -1109,7 +1184,7 @@ class Repository def initialize_raw_repository Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', - Gitlab::GlRepository.gl_repository(project, is_wiki), + repo_type.identifier_for_subject(project), project.full_path) end end diff --git a/app/models/repository_language.rb b/app/models/repository_language.rb index b18142a2ac4..e6867f905e2 100644 --- a/app/models/repository_language.rb +++ b/app/models/repository_language.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class RepositoryLanguage < ActiveRecord::Base +class RepositoryLanguage < ApplicationRecord belongs_to :project belongs_to :programming_language diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb index 3fd96b9dc18..f2c7cb6a65d 100644 --- a/app/models/resource_label_event.rb +++ b/app/models/resource_label_event.rb @@ -2,7 +2,7 @@ # This model is not used yet, it will be used for: # https://gitlab.com/gitlab-org/gitlab-ce/issues/48483 -class ResourceLabelEvent < ActiveRecord::Base +class ResourceLabelEvent < ApplicationRecord include Importable include Gitlab::Utils::StrongMemoize include CacheMarkdownField diff --git a/app/models/route.rb b/app/models/route.rb index 4b23dfa5778..91ea2966013 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Route < ActiveRecord::Base +class Route < ApplicationRecord include CaseSensitivity belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations @@ -14,26 +14,26 @@ class Route < ActiveRecord::Base before_validation :delete_conflicting_orphaned_routes after_create :delete_conflicting_redirects - after_update :delete_conflicting_redirects, if: :path_changed? + after_update :delete_conflicting_redirects, if: :saved_change_to_path? after_update :create_redirect_for_old_path after_update :rename_descendants scope :inside_path, -> (path) { where('routes.path LIKE ?', "#{sanitize_sql_like(path)}/%") } def rename_descendants - return unless path_changed? || name_changed? + return unless saved_change_to_path? || saved_change_to_name? - descendant_routes = self.class.inside_path(path_was) + descendant_routes = self.class.inside_path(path_before_last_save) descendant_routes.each do |route| attributes = {} - if path_changed? && route.path.present? - attributes[:path] = route.path.sub(path_was, path) + if saved_change_to_path? && route.path.present? + attributes[:path] = route.path.sub(path_before_last_save, path) end - if name_changed? && name_was.present? && route.name.present? - attributes[:name] = route.name.sub(name_was, name) + if saved_change_to_name? && name_before_last_save.present? && route.name.present? + attributes[:name] = route.name.sub(name_before_last_save, name) end if attributes.present? @@ -65,7 +65,7 @@ class Route < ActiveRecord::Base private def create_redirect_for_old_path - create_redirect(path_was) if path_changed? + create_redirect(path_before_last_save) if saved_change_to_path? end def delete_conflicting_orphaned_routes diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index 6caab24143b..0427d5b9ca7 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class SentNotification < ActiveRecord::Base +class SentNotification < ApplicationRecord serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize belongs_to :project diff --git a/app/models/serverless/function.rb b/app/models/serverless/function.rb new file mode 100644 index 00000000000..5d4f8e0c9e2 --- /dev/null +++ b/app/models/serverless/function.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Serverless + class Function + attr_accessor :name, :namespace + + def initialize(project, name, namespace) + @project = project + @name = name + @namespace = namespace + end + + def id + @project.id.to_s + "/" + @name + "/" + @namespace + end + + def self.find_by_id(id) + array = id.split("/") + project = Project.find_by_id(array[0]) + name = array[1] + namespace = array[2] + + self.new(project, name, namespace) + end + end +end diff --git a/app/models/service.rb b/app/models/service.rb index 3461e0bfe70..9896aa12e90 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -2,7 +2,7 @@ # To add new service you should build a class inherited from Service # and implement a set of methods -class Service < ActiveRecord::Base +class Service < ApplicationRecord include Sortable include Importable include ProjectServicesLoggable @@ -50,6 +50,7 @@ class Service < ActiveRecord::Base scope :job_hooks, -> { where(job_events: true, active: true) } scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) } scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) } + scope :deployment_hooks, -> { where(deployment_events: true, active: true) } scope :external_issue_trackers, -> { issue_trackers.active.without_defaults } scope :deployment, -> { where(category: 'deployment') } @@ -255,6 +256,7 @@ class Service < ActiveRecord::Base external_wiki flowdock hangouts_chat + hipchat irker jira kubernetes @@ -266,6 +268,7 @@ class Service < ActiveRecord::Base prometheus pushover redmine + youtrack slack_slash_commands slack teamcity @@ -333,6 +336,8 @@ class Service < ActiveRecord::Base "Event will be triggered when a wiki page is created/updated" when "commit", "commit_events" "Event will be triggered when a commit is created/updated" + when "deployment" + "Event will be triggered when a deployment finishes" end end diff --git a/app/models/shard.rb b/app/models/shard.rb index e39d4232486..335a279c6aa 100644 --- a/app/models/shard.rb +++ b/app/models/shard.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Shard < ActiveRecord::Base +class Shard < ApplicationRecord # Store shard names from the configuration file in the database. This is not a # list of active shards - we just want to assign an immutable, unique ID to # every shard name for easy indexing / referencing. diff --git a/app/models/snippet.rb b/app/models/snippet.rb index f23ddd64fe3..f4fdac2558c 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Snippet < ActiveRecord::Base +class Snippet < ApplicationRecord include Gitlab::VisibilityLevel include Redactable include CacheMarkdownField diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb index ef3f974b959..5b9ece8373f 100644 --- a/app/models/spam_log.rb +++ b/app/models/spam_log.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class SpamLog < ActiveRecord::Base +class SpamLog < ApplicationRecord belongs_to :user validates :user, presence: true diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb index fd23cc9ac87..b6fb39ee81f 100644 --- a/app/models/ssh_host_key.rb +++ b/app/models/ssh_host_key.rb @@ -27,7 +27,7 @@ class SshHostKey def self.find_by(opts = {}) opts = HashWithIndifferentAccess.new(opts) - return nil unless opts.key?(:id) + return unless opts.key?(:id) project_id, url = opts[:id].split(':', 2) project = Project.find_by(id: project_id) diff --git a/app/models/storage/legacy_project.rb b/app/models/storage/legacy_project.rb index 76ac5c13c18..b483c677be9 100644 --- a/app/models/storage/legacy_project.rb +++ b/app/models/storage/legacy_project.rb @@ -30,7 +30,7 @@ module Storage end def rename_repo(old_full_path: nil, new_full_path: nil) - old_full_path ||= project.full_path_was + old_full_path ||= project.full_path_before_last_save new_full_path ||= project.build_full_path if gitlab_shell.mv_repository(repository_storage, old_full_path, new_full_path) diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 0f6ee0ddf7e..24a2b8b5167 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Subscription < ActiveRecord::Base +class Subscription < ApplicationRecord belongs_to :user belongs_to :project belongs_to :subscribable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb index 7eee4fbbe5f..22e2f11230d 100644 --- a/app/models/suggestion.rb +++ b/app/models/suggestion.rb @@ -1,11 +1,19 @@ # frozen_string_literal: true class Suggestion < ApplicationRecord + include Suggestible + belongs_to :note, inverse_of: :suggestions validates :note, presence: true validates :commit_id, presence: true, if: :applied? - delegate :original_position, :position, :noteable, to: :note + delegate :position, :noteable, to: :note + + scope :active, -> { where(outdated: false) } + + def diff_file + note.latest_diff_file + end def project noteable.source_project @@ -19,42 +27,37 @@ class Suggestion < ApplicationRecord position.file_path end - def diff_file - repository = project.repository - position.diff_file(repository) - end - - # For now, suggestions only serve as a way to send patches that - # will change a single line (being able to apply multiple in the same place), - # which explains `from_line` and `to_line` being the same line. - # We'll iterate on that in https://gitlab.com/gitlab-org/gitlab-ce/issues/53310 - # when allowing multi-line suggestions. - def from_line - position.new_line - end - alias_method :to_line, :from_line - - def from_original_line - original_position.new_line - end - alias_method :to_original_line, :from_original_line - # `from_line_index` and `to_line_index` represents diff/blob line numbers in # index-like way (N-1). def from_line_index from_line - 1 end - alias_method :to_line_index, :from_line_index - def appliable? - return false unless note.supports_suggestion? + def to_line_index + to_line - 1 + end + def appliable?(cached: true) !applied? && noteable.opened? && + !outdated?(cached: cached) && + note.supports_suggestion? && different_content? && note.active? end + # Overwrites outdated column + def outdated?(cached: true) + return super() if cached + return true unless diff_file + + from_content != fetch_from_content + end + + def target_line + position.new_line + end + private def different_content? diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index d555ebe5322..55da37c9545 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class SystemNoteMetadata < ActiveRecord::Base +class SystemNoteMetadata < ApplicationRecord # These notes's action text might contain a reference that is external. # We should always force a deep validation upon references that are found # in this note type. diff --git a/app/models/term_agreement.rb b/app/models/term_agreement.rb index 9b3c8ac68bd..a4a9dc10282 100644 --- a/app/models/term_agreement.rb +++ b/app/models/term_agreement.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class TermAgreement < ActiveRecord::Base +class TermAgreement < ApplicationRecord belongs_to :term, class_name: 'ApplicationSetting::Term' belongs_to :user diff --git a/app/models/timelog.rb b/app/models/timelog.rb index e04c644a53a..048134fbf04 100644 --- a/app/models/timelog.rb +++ b/app/models/timelog.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Timelog < ActiveRecord::Base +class Timelog < ApplicationRecord validates :time_spent, :user, presence: true validate :issuable_id_is_present diff --git a/app/models/todo.rb b/app/models/todo.rb index d9b86d941b6..f1fc5e599eb 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Todo < ActiveRecord::Base +class Todo < ApplicationRecord include Sortable include FromUnion @@ -31,8 +31,16 @@ class Todo < ActiveRecord::Base belongs_to :note belongs_to :project belongs_to :group - belongs_to :target, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations + belongs_to :target, -> { + if self.klass.respond_to?(:with_api_entity_associations) + self.with_api_entity_associations + else + self + end + }, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations + belongs_to :user + belongs_to :issue, -> { where("target_type = 'Issue'") }, foreign_key: :target_id delegate :name, :email, to: :author, prefix: true, allow_nil: true @@ -52,6 +60,8 @@ class Todo < ActiveRecord::Base scope :for_type, -> (type) { where(target_type: type) } scope :for_target, -> (id) { where(target_id: id) } scope :for_commit, -> (id) { where(commit_id: id) } + scope :with_api_entity_associations, -> { preload(:target, :author, :note, group: :route, project: [:route, { namespace: :route }]) } + scope :joins_issue_and_assignees, -> { left_joins(issue: :assignees) } state_machine :state, initial: :pending do event :done do diff --git a/app/models/trending_project.rb b/app/models/trending_project.rb index 7b22e8cb760..810dee672b2 100644 --- a/app/models/trending_project.rb +++ b/app/models/trending_project.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class TrendingProject < ActiveRecord::Base +class TrendingProject < ApplicationRecord belongs_to :project # The number of months to include in the trending calculation. diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb index 37598173fd1..81415eb383b 100644 --- a/app/models/u2f_registration.rb +++ b/app/models/u2f_registration.rb @@ -2,7 +2,7 @@ # Registration information for U2F (universal 2nd factor) devices, like Yubikeys -class U2fRegistration < ActiveRecord::Base +class U2fRegistration < ApplicationRecord belongs_to :user def self.register(user, app_id, params, challenges) @@ -19,7 +19,7 @@ class U2fRegistration < ActiveRecord::Base user: user, name: params[:name]) rescue JSON::ParserError, NoMethodError, ArgumentError - registration.errors.add(:base, 'Your U2F device did not send a valid JSON response.') + registration.errors.add(:base, _('Your U2F device did not send a valid JSON response.')) rescue U2F::Error => e registration.errors.add(:base, e.message) end diff --git a/app/models/upload.rb b/app/models/upload.rb index 20860f14b83..ca74f16b3b8 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Upload < ActiveRecord::Base +class Upload < ApplicationRecord # Upper limit for foreground checksum processing CHECKSUM_THRESHOLD = 100.megabytes @@ -45,7 +45,7 @@ class Upload < ActiveRecord::Base end def absolute_path - raise ObjectStorage::RemoteStoreError, "Remote object has no absolute path." unless local? + raise ObjectStorage::RemoteStoreError, _("Remote object has no absolute path.") unless local? return path unless relative_path? uploader_class.absolute_path(self) @@ -71,10 +71,10 @@ class Upload < ActiveRecord::Base # Help sysadmins find missing upload files if persisted? && !exist if Gitlab::Sentry.enabled? - Raven.capture_message("Upload file does not exist", extra: self.attributes) + Raven.capture_message(_("Upload file does not exist"), extra: self.attributes) end - Gitlab::Metrics.counter(:upload_file_does_not_exist_total, 'The number of times an upload record could not find its file').increment + Gitlab::Metrics.counter(:upload_file_does_not_exist_total, _('The number of times an upload record could not find its file')).increment end exist diff --git a/app/models/user.rb b/app/models/user.rb index ee51c35d576..2eb5c63a4cc 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -105,6 +105,7 @@ class User < ApplicationRecord has_many :groups, through: :group_members has_many :owned_groups, -> { where(members: { access_level: Gitlab::Access::OWNER }) }, through: :group_members, source: :group has_many :maintainers_groups, -> { where(members: { access_level: Gitlab::Access::MAINTAINER }) }, through: :group_members, source: :group + has_many :developer_groups, -> { where(members: { access_level: ::Gitlab::Access::DEVELOPER }) }, through: :group_members, source: :group has_many :owned_or_maintainers_groups, -> { where(members: { access_level: [Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) }, through: :group_members, @@ -159,12 +160,12 @@ class User < ApplicationRecord # Validations # # Note: devise :validatable above adds validations for :email and :password - validates :name, presence: true + validates :name, presence: true, length: { maximum: 128 } validates :email, confirmation: true validates :notification_email, presence: true - validates :notification_email, email: true, if: ->(user) { user.notification_email != user.email } - validates :public_email, presence: true, uniqueness: true, email: true, allow_blank: true - validates :commit_email, email: true, allow_nil: true, if: ->(user) { user.commit_email != user.email } + validates :notification_email, devise_email: true, if: ->(user) { user.notification_email != user.email } + validates :public_email, presence: true, uniqueness: true, devise_email: true, allow_blank: true + validates :commit_email, devise_email: true, allow_nil: true, if: ->(user) { user.commit_email != user.email } validates :bio, length: { maximum: 255 }, allow_blank: true validates :projects_limit, presence: true, @@ -193,7 +194,7 @@ class User < ApplicationRecord before_validation :ensure_namespace_correct before_save :ensure_namespace_correct # in case validation is skipped after_validation :set_username_errors - after_update :username_changed_hook, if: :username_changed? + after_update :username_changed_hook, if: :saved_change_to_username? after_destroy :post_destroy_hook after_destroy :remove_key_cache after_commit(on: :update) do @@ -229,6 +230,9 @@ class User < ApplicationRecord delegate :notes_filter_for, to: :user_preference delegate :set_notes_filter, to: :user_preference delegate :first_day_of_week, :first_day_of_week=, to: :user_preference + delegate :timezone, :timezone=, to: :user_preference + delegate :time_display_relative, :time_display_relative=, to: :user_preference + delegate :time_format_in_24h, :time_format_in_24h=, to: :user_preference accepts_nested_attributes_for :user_preference, update_only: true @@ -276,6 +280,7 @@ class User < ApplicationRecord scope :by_username, -> (usernames) { iwhere(username: Array(usernames).map(&:to_s)) } scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) } scope :with_emails, -> { preload(:emails) } + scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) } # Limits the users to those that have TODOs, optionally in the given state. # @@ -432,7 +437,7 @@ class User < ApplicationRecord fuzzy_arel_match(:name, query, lower_exact_match: true) .or(fuzzy_arel_match(:username, query, lower_exact_match: true)) .or(arel_table[:email].eq(query)) - ).reorder(order % { query: ActiveRecord::Base.connection.quote(query) }, :name) + ).reorder(order % { query: ApplicationRecord.connection.quote(query) }, :name) end # Limits the result set to users _not_ in the given query/list of IDs. @@ -470,7 +475,7 @@ class User < ApplicationRecord end def by_login(login) - return nil unless login + return unless login if login.include?('@'.freeze) unscoped.iwhere(email: login).take @@ -515,7 +520,7 @@ class User < ApplicationRecord def ghost email = 'ghost%s@example.com' unique_internal(where(ghost: true), 'ghost', email) do |u| - u.bio = 'This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.' + u.bio = _('This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.') u.name = 'Ghost User' end end @@ -535,20 +540,16 @@ class User < ApplicationRecord username end - def self.internal_attributes - [:ghost] - end - def internal? - self.class.internal_attributes.any? { |a| self[a] } + ghost? end def self.internal - where(Hash[internal_attributes.zip([true] * internal_attributes.size)]) + where(ghost: true) end def self.non_internal - where(internal_attributes.map { |attr| "#{attr} IS NOT TRUE" }.join(" AND ")) + where('ghost IS NOT TRUE') end # @@ -624,32 +625,32 @@ class User < ApplicationRecord def namespace_move_dir_allowed if namespace&.any_project_has_container_registry_tags? - errors.add(:username, 'cannot be changed if a personal project has container registry tags.') + errors.add(:username, _('cannot be changed if a personal project has container registry tags.')) end end def unique_email if !emails.exists?(email: email) && Email.exists?(email: email) - errors.add(:email, 'has already been taken') + errors.add(:email, _('has already been taken')) end end def owns_notification_email return if temp_oauth_email? - errors.add(:notification_email, "is not an email you own") unless all_emails.include?(notification_email) + errors.add(:notification_email, _("is not an email you own")) unless all_emails.include?(notification_email) end def owns_public_email return if public_email.blank? - errors.add(:public_email, "is not an email you own") unless all_emails.include?(public_email) + errors.add(:public_email, _("is not an email you own")) unless all_emails.include?(public_email) end def owns_commit_email return if read_attribute(:commit_email).blank? - errors.add(:commit_email, "is not an email you own") unless verified_emails.include?(commit_email) + errors.add(:commit_email, _("is not an email you own")) unless verified_emails.include?(commit_email) end # Define commit_email-related attribute methods explicitly instead of relying @@ -759,11 +760,15 @@ class User < ApplicationRecord # Typically used in conjunction with projects table to get projects # a user has been given access to. + # The param `related_project_column` is the column to compare to the + # project_authorizations. By default is projects.id # # Example use: # `Project.where('EXISTS(?)', user.authorizations_for_projects)` - def authorizations_for_projects(min_access_level: nil) - authorizations = project_authorizations.select(1).where('project_authorizations.project_id = projects.id') + def authorizations_for_projects(min_access_level: nil, related_project_column: 'projects.id') + authorizations = project_authorizations + .select(1) + .where("project_authorizations.project_id = #{related_project_column}") return authorizations unless min_access_level.present? @@ -882,7 +887,12 @@ class User < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass def several_namespaces? - owned_groups.any? || maintainers_groups.any? + union_sql = ::Gitlab::SQL::Union.new( + [owned_groups, + maintainers_groups, + groups_with_developer_maintainer_project_access]).to_sql + + ::Group.from("(#{union_sql}) #{::Group.table_name}").any? end def namespace_id @@ -917,6 +927,10 @@ class User < ApplicationRecord DeployKey.unscoped.in_projects(authorized_projects.pluck(:id)).distinct(:id) end + def highest_role + members.maximum(:access_level) || Gitlab::Access::NO_ACCESS + end + def accessible_deploy_keys @accessible_deploy_keys ||= begin key_ids = project_deploy_keys.pluck(:id) @@ -1164,12 +1178,24 @@ class User < ApplicationRecord @manageable_namespaces ||= [namespace] + manageable_groups end - def manageable_groups - Gitlab::ObjectHierarchy.new(owned_or_maintainers_groups).base_and_descendants + def manageable_groups(include_groups_with_developer_maintainer_access: false) + owned_and_maintainer_group_hierarchy = Gitlab::ObjectHierarchy.new(owned_or_maintainers_groups).base_and_descendants + + if include_groups_with_developer_maintainer_access + union_sql = ::Gitlab::SQL::Union.new( + [owned_and_maintainer_group_hierarchy, + groups_with_developer_maintainer_project_access]).to_sql + + ::Group.from("(#{union_sql}) #{::Group.table_name}") + else + owned_and_maintainer_group_hierarchy + end end - def manageable_groups_with_routes - manageable_groups.eager_load(:route).order('routes.path') + def manageable_groups_with_routes(include_groups_with_developer_maintainer_access: false) + manageable_groups(include_groups_with_developer_maintainer_access: include_groups_with_developer_maintainer_access) + .eager_load(:route) + .order('routes.path') end def namespaces @@ -1471,15 +1497,6 @@ class User < ApplicationRecord devise_mailer.__send__(notification, self, *args).deliver_later # rubocop:disable GitlabSecurity/PublicSend end - # This works around a bug in Devise 4.2.0 that erroneously causes a user to - # be considered active in MySQL specs due to a sub-second comparison - # issue. For more details, see: https://gitlab.com/gitlab-org/gitlab-ee/issues/2362#note_29004709 - def confirmation_period_valid? - return false if self.class.allow_unconfirmed_access_for == 0.days - - super - end - def ensure_user_rights_and_limits if external? self.can_create_group = false @@ -1568,4 +1585,16 @@ class User < ApplicationRecord ensure Gitlab::ExclusiveLease.cancel(lease_key, uuid) end + + def groups_with_developer_maintainer_project_access + project_creation_levels = [::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS] + + if ::Gitlab::CurrentSettings.default_project_creation == ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS + project_creation_levels << nil + end + + developer_groups_hierarchy = ::Gitlab::ObjectHierarchy.new(developer_groups).base_and_descendants + ::Group.where(id: developer_groups_hierarchy.select(:id), + project_creation_level: project_creation_levels) + end end diff --git a/app/models/user_agent_detail.rb b/app/models/user_agent_detail.rb index e2b2e7f1df9..fea1fce3c8d 100644 --- a/app/models/user_agent_detail.rb +++ b/app/models/user_agent_detail.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class UserAgentDetail < ActiveRecord::Base +class UserAgentDetail < ApplicationRecord belongs_to :subject, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations validates :user_agent, :ip_address, :subject_id, :subject_type, presence: true diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb index 76e7bc06b4e..027ee44c6a9 100644 --- a/app/models/user_callout.rb +++ b/app/models/user_callout.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class UserCallout < ActiveRecord::Base +class UserCallout < ApplicationRecord belongs_to :user # We use `UserCalloutEnums.feature_names` here so that EE can more easily diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb index e0ffe8ebbfd..727975c3f6e 100644 --- a/app/models/user_custom_attribute.rb +++ b/app/models/user_custom_attribute.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class UserCustomAttribute < ActiveRecord::Base +class UserCustomAttribute < ApplicationRecord belongs_to :user validates :user_id, :key, :value, presence: true diff --git a/app/models/user_interacted_project.rb b/app/models/user_interacted_project.rb index ae6778e49be..f6f72f4b77a 100644 --- a/app/models/user_interacted_project.rb +++ b/app/models/user_interacted_project.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class UserInteractedProject < ActiveRecord::Base +class UserInteractedProject < ApplicationRecord belongs_to :user belongs_to :project @@ -26,16 +26,14 @@ class UserInteractedProject < ActiveRecord::Base cached_exists?(attributes) do transaction(requires_new: true) do - begin - where(attributes).select(1).first || create!(attributes) - true # not caching the whole record here for now - rescue ActiveRecord::RecordNotUnique - # Note, above queries are not atomic and prone - # to race conditions (similar like #find_or_create!). - # In the case where we hit this, the record we want - # already exists - shortcut and return. - true - end + where(attributes).select(1).first || create!(attributes) + true # not caching the whole record here for now + rescue ActiveRecord::RecordNotUnique + # Note, above queries are not atomic and prone + # to race conditions (similar like #find_or_create!). + # In the case where we hit this, the record we want + # already exists - shortcut and return. + true end end end diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index 32d0407800f..f1326f4c8cb 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class UserPreference < ActiveRecord::Base +class UserPreference < ApplicationRecord # We could use enums, but Rails 4 doesn't support multiple # enum options with same name for multiple fields, also it creates # extra methods that aren't really needed here. @@ -10,6 +10,10 @@ class UserPreference < ActiveRecord::Base validates :issue_notes_filter, :merge_request_notes_filter, inclusion: { in: NOTES_FILTERS.values }, presence: true + default_value_for :timezone, value: Time.zone.tzinfo.name, allows_nil: false + default_value_for :time_display_relative, value: true, allows_nil: false + default_value_for :time_format_in_24h, value: false, allows_nil: false + class << self def notes_filters { diff --git a/app/models/user_status.rb b/app/models/user_status.rb index 2bbb0c59ac1..6ced4f56823 100644 --- a/app/models/user_status.rb +++ b/app/models/user_status.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class UserStatus < ActiveRecord::Base +class UserStatus < ApplicationRecord include CacheMarkdownField self.primary_key = :user_id diff --git a/app/models/user_synced_attributes_metadata.rb b/app/models/user_synced_attributes_metadata.rb index 7115262942d..5aacf11b1cb 100644 --- a/app/models/user_synced_attributes_metadata.rb +++ b/app/models/user_synced_attributes_metadata.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class UserSyncedAttributesMetadata < ActiveRecord::Base +class UserSyncedAttributesMetadata < ApplicationRecord belongs_to :user validates :user, presence: true diff --git a/app/models/users_star_project.rb b/app/models/users_star_project.rb index bdaf58ae1c1..9be6bd2e6f3 100644 --- a/app/models/users_star_project.rb +++ b/app/models/users_star_project.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class UsersStarProject < ActiveRecord::Base +class UsersStarProject < ApplicationRecord belongs_to :project, counter_cache: :star_count, touch: true belongs_to :user diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index b1d6d461928..cd4c7895587 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -28,16 +28,17 @@ class WikiPage def self.group_by_directory(pages) return [] if pages.blank? - pages.sort_by { |page| [page.directory, page.slug] } - .group_by(&:directory) - .map do |dir, pages| - if dir.present? - WikiDirectory.new(dir, pages) - else - pages - end + pages.each_with_object([]) do |page, grouped_pages| + next grouped_pages << page unless page.directory.present? + + directory = grouped_pages.find do |obj| + obj.is_a?(WikiDirectory) && obj.slug == page.directory end - .flatten + + next directory.pages << page if directory + + grouped_pages << WikiDirectory.new(page.directory, [page]) + end end def self.unhyphenize(name) @@ -132,7 +133,7 @@ class WikiPage # The GitLab Commit instance for this page. def version - return nil unless persisted? + return unless persisted? @version ||= @page.version end diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 72de04203a6..5dd2279ef99 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -22,6 +22,13 @@ class BasePolicy < DeclarativePolicy::Base Gitlab::CurrentSettings.current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC) end - # This is prevented in some cases in `gitlab-ee` + condition(:external_authorization_enabled, scope: :global, score: 0) do + ::Gitlab::ExternalAuthorization.perform_check? + end + + rule { external_authorization_enabled & ~full_private_access }.policy do + prevent :read_cross_project + end + rule { default }.enable :read_cross_project end diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb index 2c90b8a73cd..662c29a0973 100644 --- a/app/policies/ci/pipeline_policy.rb +++ b/app/policies/ci/pipeline_policy.rb @@ -14,6 +14,10 @@ module Ci @subject.external? end + condition(:triggerer_of_pipeline) do + @subject.triggered_by?(@user) + end + # Disallow users without permissions from accessing internal pipelines rule { ~can?(:read_build) & ~external_pipeline }.policy do prevent :read_pipeline @@ -29,6 +33,14 @@ module Ci enable :destroy_pipeline end + rule { can?(:admin_pipeline) }.policy do + enable :read_pipeline_variable + end + + rule { can?(:update_pipeline) & triggerer_of_pipeline }.policy do + enable :read_pipeline_variable + end + def ref_protected?(user, project, tag, ref) access = ::Gitlab::UserAccess.new(user, project: project) diff --git a/app/policies/clusters/cluster_policy.rb b/app/policies/clusters/cluster_policy.rb index d6d590687e2..316bd39f7a3 100644 --- a/app/policies/clusters/cluster_policy.rb +++ b/app/policies/clusters/cluster_policy.rb @@ -6,5 +6,6 @@ module Clusters delegate { cluster.group } delegate { cluster.project } + delegate { cluster.instance } end end diff --git a/app/policies/clusters/instance_policy.rb b/app/policies/clusters/instance_policy.rb new file mode 100644 index 00000000000..e1045c85e6d --- /dev/null +++ b/app/policies/clusters/instance_policy.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Clusters + class InstancePolicy < BasePolicy + include ClusterableActions + + condition(:has_clusters, scope: :subject) { clusterable_has_clusters? } + condition(:can_have_multiple_clusters) { multiple_clusters_available? } + condition(:instance_clusters_enabled) { Instance.enabled? } + + rule { admin & instance_clusters_enabled }.policy do + enable :read_cluster + enable :add_cluster + enable :create_cluster + enable :update_cluster + enable :admin_cluster + end + + rule { ~can_have_multiple_clusters & has_clusters }.prevent :add_cluster + end +end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 16c58730878..e85397422e6 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -44,7 +44,6 @@ class GlobalPolicy < BasePolicy prevent :access_api prevent :access_git prevent :receive_notifications - prevent :use_quick_actions end rule { required_terms_not_accepted }.policy do @@ -68,6 +67,10 @@ class GlobalPolicy < BasePolicy enable :read_users_list end + rule { ~anonymous }.policy do + enable :read_instance_metadata + end + rule { admin }.policy do enable :read_custom_attribute enable :update_custom_attribute diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index c25766a5af8..ea86858181d 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -26,7 +26,7 @@ class GroupPolicy < BasePolicy condition(:can_change_parent_share_with_group_lock) { can?(:change_share_with_group_lock, @subject.parent) } condition(:has_projects) do - GroupProjectsFinder.new(group: @subject, current_user: @user, options: { include_subgroups: true }).execute.any? + GroupProjectsFinder.new(group: @subject, current_user: @user, options: { include_subgroups: true, only_owned: true }).execute.any? end condition(:has_clusters, scope: :subject) { clusterable_has_clusters? } @@ -35,6 +35,14 @@ class GroupPolicy < BasePolicy with_options scope: :subject, score: 0 condition(:request_access_enabled) { @subject.request_access_enabled } + condition(:create_projects_disabled) do + @subject.project_creation_level == ::Gitlab::Access::NO_ONE_PROJECT_ACCESS + end + + condition(:developer_maintainer_access) do + @subject.project_creation_level == ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS + end + rule { public_group }.policy do enable :read_group enable :read_list @@ -53,8 +61,9 @@ class GroupPolicy < BasePolicy rule { admin }.enable :read_group rule { has_projects }.policy do - enable :read_group + enable :read_list enable :read_label + enable :read_group end rule { has_access }.enable :read_namespace @@ -114,9 +123,16 @@ class GroupPolicy < BasePolicy rule { ~can_have_multiple_clusters & has_clusters }.prevent :add_cluster + rule { developer & developer_maintainer_access }.enable :create_projects + rule { create_projects_disabled }.prevent :create_projects + def access_level return GroupMember::NO_ACCESS if @user.nil? - @access_level ||= @subject.max_member_access_for_user(@user) + @access_level ||= lookup_access_level! + end + + def lookup_access_level! + @subject.max_member_access_for_user(@user) end end diff --git a/app/policies/identity_provider_policy.rb b/app/policies/identity_provider_policy.rb new file mode 100644 index 00000000000..d34cdd5bdd4 --- /dev/null +++ b/app/policies/identity_provider_policy.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class IdentityProviderPolicy < BasePolicy + desc "Provider is SAML or CAS3" + condition(:protected_provider, scope: :subject, score: 0) { %w(saml cas3).include?(@subject.to_s) } + + rule { anonymous }.prevent_all + + rule { default }.policy do + enable :unlink + enable :link + end + + rule { protected_provider }.prevent(:unlink) +end diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb index ecb2797d1d9..537319addc2 100644 --- a/app/policies/issuable_policy.rb +++ b/app/policies/issuable_policy.rb @@ -17,6 +17,7 @@ class IssuablePolicy < BasePolicy enable :reopen_issue enable :read_merge_request enable :update_merge_request + enable :reopen_merge_request end rule { locked & ~is_project_member }.policy do diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb index a2950951d03..a3692857ff4 100644 --- a/app/policies/merge_request_policy.rb +++ b/app/policies/merge_request_policy.rb @@ -1,4 +1,7 @@ # frozen_string_literal: true class MergeRequestPolicy < IssuablePolicy + rule { locked }.policy do + prevent :reopen_merge_request + end end diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb index 2b5cca76c20..40dd49b4afd 100644 --- a/app/policies/personal_snippet_policy.rb +++ b/app/policies/personal_snippet_policy.rb @@ -7,7 +7,7 @@ class PersonalSnippetPolicy < BasePolicy rule { public_snippet }.policy do enable :read_personal_snippet - enable :comment_personal_snippet + enable :create_note end rule { is_author }.policy do @@ -15,7 +15,7 @@ class PersonalSnippetPolicy < BasePolicy enable :update_personal_snippet enable :destroy_personal_snippet enable :admin_personal_snippet - enable :comment_personal_snippet + enable :create_note end rule { ~anonymous }.enable :create_personal_snippet @@ -23,15 +23,12 @@ class PersonalSnippetPolicy < BasePolicy rule { internal_snippet & ~external_user }.policy do enable :read_personal_snippet - enable :comment_personal_snippet + enable :create_note end - rule { anonymous }.prevent :comment_personal_snippet + rule { anonymous }.prevent :create_note - rule { can?(:comment_personal_snippet) }.policy do - enable :create_note - enable :award_emoji - end + rule { can?(:create_note) }.enable :award_emoji rule { full_private_access }.enable :read_personal_snippet end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 95dd8b2795e..3218c04b219 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -89,6 +89,15 @@ class ProjectPolicy < BasePolicy ::Gitlab::CurrentSettings.current_application_settings.mirror_available end + with_scope :subject + condition(:classification_label_authorized, score: 32) do + ::Gitlab::ExternalAuthorization.access_allowed?( + @user, + @subject.external_authorization_classification_label, + @subject.full_path + ) + end + # We aren't checking `:read_issue` or `:read_merge_request` in this case # because it could be possible for a user to see an issuable-iid # (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be @@ -187,6 +196,7 @@ class ProjectPolicy < BasePolicy rule { can?(:reporter_access) }.policy do enable :download_code + enable :read_statistics enable :download_wiki_code enable :fork_project enable :create_project_snippet @@ -203,6 +213,7 @@ class ProjectPolicy < BasePolicy enable :read_deployment enable :read_merge_request enable :read_sentry_issue + enable :read_prometheus end # We define `:public_user_access` separately because there are cases in gitlab-ee @@ -231,6 +242,7 @@ class ProjectPolicy < BasePolicy enable :admin_merge_request enable :admin_milestone enable :update_merge_request + enable :reopen_merge_request enable :create_commit_status enable :update_commit_status enable :create_build @@ -278,6 +290,8 @@ class ProjectPolicy < BasePolicy enable :admin_cluster enable :create_environment_terminal enable :destroy_release + enable :destroy_artifacts + enable :daily_statistics end rule { (mirror_available & can?(:admin_project)) | admin }.enable :admin_remote_mirror @@ -299,6 +313,8 @@ class ProjectPolicy < BasePolicy rule { issues_disabled }.policy do prevent(*create_read_update_admin_destroy(:issue)) + prevent(*create_read_update_admin_destroy(:board)) + prevent(*create_read_update_admin_destroy(:list)) end rule { merge_requests_disabled | repository_disabled }.policy do @@ -410,6 +426,25 @@ class ProjectPolicy < BasePolicy rule { ~can_have_multiple_clusters & has_clusters }.prevent :add_cluster + rule { ~can?(:read_cross_project) & ~classification_label_authorized }.policy do + # Preventing access here still allows the projects to be listed. Listing + # projects doesn't check the `:read_project` ability. But instead counts + # on the `project_authorizations` table. + # + # All other actions should explicitly check read project, which would + # trigger the `classification_label_authorized` condition. + # + # `:read_project_for_iids` is not prevented by this condition, as it is + # used for cross-project reference checks. + prevent :guest_access + prevent :public_access + prevent :public_user_access + prevent :reporter_access + prevent :developer_access + prevent :maintainer_access + prevent :owner_access + end + private def team_member? @@ -453,6 +488,10 @@ class ProjectPolicy < BasePolicy def team_access_level return -1 if @user.nil? + lookup_access_level! + end + + def lookup_access_level! # NOTE: max_member_access has its own cache project.team.max_member_access(@user.id) end @@ -462,7 +501,7 @@ class ProjectPolicy < BasePolicy when ProjectFeature::DISABLED false when ProjectFeature::PRIVATE - guest? || admin? + admin? || team_access_level >= ProjectFeature.required_minimum_access_level(feature) else true end diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index 6323c1b3389..c5675ef3ea3 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -13,4 +13,8 @@ class BlobPresenter < Gitlab::View::Presenter::Simple plain: plain ) end + + def web_url + Gitlab::Routing.url_helpers.project_blob_url(blob.repository.project, File.join(blob.commit_id, blob.path)) + end end diff --git a/app/presenters/blobs/unfold_presenter.rb b/app/presenters/blobs/unfold_presenter.rb new file mode 100644 index 00000000000..7b13db3bb74 --- /dev/null +++ b/app/presenters/blobs/unfold_presenter.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'gt_one_coercion' + +module Blobs + class UnfoldPresenter < BlobPresenter + include Virtus.model + include Gitlab::Utils::StrongMemoize + + attribute :full, Boolean, default: false + attribute :since, GtOneCoercion + attribute :to, GtOneCoercion + attribute :bottom, Boolean + attribute :unfold, Boolean, default: true + attribute :offset, Integer + attribute :indent, Integer, default: 0 + + def initialize(blob, params) + @subject = blob + @all_lines = highlight.lines + super(params) + + if full? + self.attributes = { since: 1, to: @all_lines.size, bottom: false, unfold: false, offset: 0, indent: 0 } + end + end + + # Converts a String array to Gitlab::Diff::Line array, with match line added + def diff_lines + diff_lines = lines.map do |line| + Gitlab::Diff::Line.new(line, nil, nil, nil, nil, rich_text: line) + end + + add_match_line(diff_lines) + + diff_lines + end + + def lines + strong_memoize(:lines) do + lines = @all_lines + lines = lines[since - 1..to - 1] unless full? + lines.map(&:html_safe) + end + end + + def match_line_text + return '' if bottom? + + lines_length = lines.length - 1 + line = [since, lines_length].join(',') + "@@ -#{line}+#{line} @@" + end + + private + + def add_match_line(diff_lines) + return unless unfold? + + if bottom? && to < @all_lines.size + old_pos = to - offset + new_pos = to + elsif since != 1 + old_pos = new_pos = since + end + + # Match line is not needed when it reaches the top limit or bottom limit of the file. + return unless new_pos + + match_line = Gitlab::Diff::Line.new(match_line_text, 'match', nil, old_pos, new_pos) + + bottom? ? diff_lines.push(match_line) : diff_lines.unshift(match_line) + end + end +end diff --git a/app/presenters/ci/bridge_presenter.rb b/app/presenters/ci/bridge_presenter.rb new file mode 100644 index 00000000000..ee11cffe355 --- /dev/null +++ b/app/presenters/ci/bridge_presenter.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Ci + class BridgePresenter < CommitStatusPresenter + def detailed_status + @detailed_status ||= subject.detailed_status(user) + end + end +end diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index d60281c8a0b..471b6d3b726 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -25,17 +25,19 @@ module Ci end def git_depth - strong_memoize(:git_depth) do - git_depth = variables&.find { |variable| variable[:key] == 'GIT_DEPTH' }&.dig(:value) - git_depth.to_i - end + if git_depth_variable + git_depth_variable[:value] + else + project.default_git_depth + end.to_i end def refspecs specs = [] + specs << refspec_for_merge_request_ref if merge_request_ref? if git_depth > 0 - specs << refspec_for_branch(ref) if branch? || merge_request? + specs << refspec_for_branch(ref) if branch? || legacy_detached_merge_request_pipeline? specs << refspec_for_tag(ref) if tag? else specs << refspec_for_branch @@ -83,5 +85,15 @@ module Ci def refspec_for_tag(ref = '*') "+#{Gitlab::Git::TAG_REF_PREFIX}#{ref}:#{RUNNER_REMOTE_TAG_PREFIX}#{ref}" end + + def refspec_for_merge_request_ref + "+#{ref}:#{ref}" + end + + def git_depth_variable + strong_memoize(:git_depth_variable) do + variables&.find { |variable| variable[:key] == 'GIT_DEPTH' } + end + end end end diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index 57daf04efc6..358473d0a74 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -3,6 +3,7 @@ module Ci class PipelinePresenter < Gitlab::View::Presenter::Delegated include Gitlab::Utils::StrongMemoize + include ActionView::Helpers::UrlHelper # We use a class method here instead of a constant, allowing EE to redefine # the returned `Hash` more easily. @@ -32,5 +33,49 @@ module Ci "Pipeline is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}" end end + + 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 } + 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 } + 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 + end + end + + def link_to_pipeline_ref + link_to(pipeline.ref, + project_commits_path(pipeline.project, pipeline.ref), + class: "ref-name") + end + + def link_to_merge_request + return unless merge_request_presenter + + link_to(merge_request_presenter.to_reference, + project_merge_request_path(merge_request_presenter.project, merge_request_presenter), + class: 'mr-iid') + end + + def link_to_merge_request_source_branch + merge_request_presenter&.source_branch_link + end + + def link_to_merge_request_target_branch + merge_request_presenter&.target_branch_link + end + + private + + def merge_request_presenter + return unless pipeline.triggered_by_merge_request? + + @merge_request_presenter ||= pipeline.merge_request.present(current_user: current_user) + end end end diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb index d94d9118eee..34bdf156623 100644 --- a/app/presenters/clusterable_presenter.rb +++ b/app/presenters/clusterable_presenter.rb @@ -44,6 +44,10 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated raise NotImplementedError end + def update_applications_cluster_path(cluster, application) + raise NotImplementedError + end + def cluster_path(cluster, params = {}) raise NotImplementedError end diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb index 7a5b68f9a4b..1634d2479a0 100644 --- a/app/presenters/clusters/cluster_presenter.rb +++ b/app/presenters/clusters/cluster_presenter.rb @@ -22,10 +22,6 @@ module Clusters "https://console.cloud.google.com/kubernetes/clusters/details/#{provider.zone}/#{name}" if gcp? end - def can_toggle_cluster? - can?(current_user, :update_cluster, cluster) && created? - end - def can_read_cluster? can?(current_user, :read_cluster, cluster) end @@ -35,6 +31,8 @@ module Clusters s_("ClusterIntegration|Project cluster") elsif cluster.group_type? s_("ClusterIntegration|Group cluster") + elsif cluster.instance_type? + s_("ClusterIntegration|Instance cluster") end end @@ -43,11 +41,17 @@ module Clusters project_cluster_path(project, cluster) elsif cluster.group_type? group_cluster_path(group, cluster) + elsif cluster.instance_type? + admin_cluster_path(cluster) else raise NotImplementedError end end + def read_only_kubernetes_platform_fields? + !cluster.provided_by_user? + end + private def clusterable diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb index 0cd77da6303..28a25c8b7a3 100644 --- a/app/presenters/commit_status_presenter.rb +++ b/app/presenters/commit_status_presenter.rb @@ -11,7 +11,8 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated runner_unsupported: 'Your runner is outdated, please upgrade your runner', stale_schedule: 'Delayed job could not be executed by some reason, please try again', job_execution_timeout: 'The script exceeded the maximum execution time set for the job', - archived_failure: 'The job is archived and cannot be run' + archived_failure: 'The job is archived and cannot be run', + unmet_prerequisites: 'The job failed to complete prerequisite tasks' }.freeze private_constant :CALLOUT_FAILURE_MESSAGES diff --git a/app/presenters/group_clusterable_presenter.rb b/app/presenters/group_clusterable_presenter.rb index ef6bbc0d109..f5b0bb64487 100644 --- a/app/presenters/group_clusterable_presenter.rb +++ b/app/presenters/group_clusterable_presenter.rb @@ -14,6 +14,11 @@ class GroupClusterablePresenter < ClusterablePresenter install_applications_group_cluster_path(clusterable, cluster, application) end + override :update_applications_cluster_path + def update_applications_cluster_path(cluster, application) + update_applications_group_cluster_path(clusterable, cluster, application) + end + override :cluster_path def cluster_path(cluster, params = {}) group_cluster_path(clusterable, cluster, params) diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb new file mode 100644 index 00000000000..f8bbe5216f1 --- /dev/null +++ b/app/presenters/instance_clusterable_presenter.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +class InstanceClusterablePresenter < ClusterablePresenter + extend ::Gitlab::Utils::Override + include ActionView::Helpers::UrlHelper + + def self.fabricate(clusterable, **attributes) + attributes_with_presenter_class = attributes.merge(presenter_class: InstanceClusterablePresenter) + + Gitlab::View::Presenter::Factory + .new(clusterable, attributes_with_presenter_class) + .fabricate! + end + + override :index_path + def index_path + admin_clusters_path + end + + override :new_path + def new_path + new_admin_cluster_path + end + + override :cluster_status_cluster_path + def cluster_status_cluster_path(cluster, params = {}) + cluster_status_admin_cluster_path(cluster, params) + end + + override :install_applications_cluster_path + def install_applications_cluster_path(cluster, application) + install_applications_admin_cluster_path(cluster, application) + end + + override :update_applications_cluster_path + def update_applications_cluster_path(cluster, application) + update_applications_admin_cluster_path(cluster, application) + end + + override :cluster_path + def cluster_path(cluster, params = {}) + admin_cluster_path(cluster, params) + end + + override :create_user_clusters_path + def create_user_clusters_path + create_user_admin_clusters_path + end + + override :create_gcp_clusters_path + def create_gcp_clusters_path + create_gcp_admin_clusters_path + end + + override :empty_state_help_text + def empty_state_help_text + s_('ClusterIntegration|Adding an integration will share the cluster across all projects.') + end + + override :sidebar_text + def sidebar_text + s_('ClusterIntegration|Adding a Kubernetes cluster will automatically share the cluster across all projects. Use review apps, deploy your applications, and easily run your pipelines for all projects using the same cluster.') + end + + override :learn_more_link + def learn_more_link + link_to(s_('ClusterIntegration|Learn more about instance Kubernetes clusters'), help_page_path('user/instance/clusters/index'), target: '_blank', rel: 'noopener noreferrer') + end +end diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb index c12a202efbc..c9dc0dbf443 100644 --- a/app/presenters/issue_presenter.rb +++ b/app/presenters/issue_presenter.rb @@ -4,6 +4,16 @@ class IssuePresenter < Gitlab::View::Presenter::Delegated presents :issue def web_url - Gitlab::UrlBuilder.build(issue) + url_builder.url + end + + def issue_path + url_builder.issue_path(issue) + end + + private + + def url_builder + @url_builder ||= Gitlab::UrlBuilder.new(issue) end end diff --git a/app/presenters/label_presenter.rb b/app/presenters/label_presenter.rb new file mode 100644 index 00000000000..1077bf543d9 --- /dev/null +++ b/app/presenters/label_presenter.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class LabelPresenter < Gitlab::View::Presenter::Delegated + presents :label + + def edit_path + case label + when GroupLabel then edit_group_label_path(label.group, label) + when ProjectLabel then edit_project_label_path(label.project, label) + end + end + + def destroy_path + case label + when GroupLabel then group_label_path(label.group, label) + when ProjectLabel then project_label_path(label.project, label) + end + end + + def filter_path(type: :issue) + case context_subject + when Group + send("#{type.to_s.pluralize}_group_path", # rubocop:disable GitlabSecurity/PublicSend + context_subject, + label_name: [label.name]) + when Project + send("namespace_project_#{type.to_s.pluralize}_path", # rubocop:disable GitlabSecurity/PublicSend + context_subject.namespace, + context_subject, + label_name: [label.name]) + end + end + + def can_subscribe_to_label_in_different_levels? + issuable_subject.is_a?(Project) && label.is_a?(GroupLabel) + end + + def project_label? + label.is_a?(ProjectLabel) + end + + def subject_name + label.subject.name + end + + private + + def context_subject + issuable_subject || label.try(:subject) + end +end diff --git a/app/presenters/member_presenter.rb b/app/presenters/member_presenter.rb index 9e9b6973b8e..2561c3f0244 100644 --- a/app/presenters/member_presenter.rb +++ b/app/presenters/member_presenter.rb @@ -32,6 +32,11 @@ class MemberPresenter < Gitlab::View::Presenter::Delegated request? && can_update? end + # This functionality is only available in EE. + def can_override? + false + end + private def admin_member_permission diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index c59e73f824c..9c44ed711a6 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -13,7 +13,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated def ci_status if pipeline status = pipeline.status - status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings? + status = "success-with-warnings" if pipeline.success? && pipeline.has_warnings? status || "preparing" else @@ -22,9 +22,9 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end end - def cancel_merge_when_pipeline_succeeds_path - if can_cancel_merge_when_pipeline_succeeds?(current_user) - cancel_merge_when_pipeline_succeeds_project_merge_request_path(project, merge_request) + def cancel_auto_merge_path + if can_cancel_auto_merge?(current_user) + cancel_auto_merge_project_merge_request_path(project, merge_request) end end @@ -50,7 +50,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated if user_can_fork_project? && cached_can_be_reverted? continue_params = { to: merge_request_path(merge_request), - notice: "#{edit_in_new_fork_notice} Try to cherry-pick this commit again.", + notice: _('%{edit_in_new_fork_notice} Try to cherry-pick this commit again.') % { edit_in_new_fork_notice: edit_in_new_fork_notice }, notice_now: edit_in_new_fork_notice_now } @@ -64,7 +64,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated if user_can_fork_project? && can_be_cherry_picked? continue_params = { to: merge_request_path(merge_request), - notice: "#{edit_in_new_fork_notice} Try to revert this commit again.", + notice: _('%{edit_in_new_fork_notice} Try to revert this commit again.') % { edit_in_new_fork_notice: edit_in_new_fork_notice }, notice_now: edit_in_new_fork_notice_now } @@ -98,6 +98,18 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end end + def target_branch_path + if target_branch_exists? + project_branch_path(project, target_branch) + end + end + + def source_branch_commits_path + if source_branch_exists? + project_commits_path(source_project, source_branch) + end + end + def source_branch_path if source_branch_exists? project_branch_path(source_project, source_branch) @@ -144,8 +156,11 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated ).assignable_issues path = assign_related_issues_project_merge_request_path(project, merge_request) if issues.present? - pluralize_this_issue = issues.count > 1 ? "these issues" : "this issue" - link_to "Assign yourself to #{pluralize_this_issue}", path, method: :post + if issues.count > 1 + link_to _('Assign yourself to these issues'), path, method: :post + else + link_to _('Assign yourself to this issue'), path, method: :post + end end # rubocop: enable CodeReuse/ServiceClass end @@ -197,6 +212,26 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated help_page_path('user/project/merge_requests/resolve_conflicts.md') end + def merge_request_pipelines_docs_path + help_page_path('ci/merge_request_pipelines/index.md') + end + + def source_branch_link + if source_branch_exists? + link_to(source_branch, source_branch_commits_path, class: 'ref-name') + else + content_tag(:span, source_branch, class: 'ref-name') + end + end + + def target_branch_link + if target_branch_exists? + link_to(target_branch, target_branch_commits_path, class: 'ref-name') + else + content_tag(:span, target_branch, class: 'ref-name') + end + end + private def cached_can_be_reverted? diff --git a/app/presenters/project_clusterable_presenter.rb b/app/presenters/project_clusterable_presenter.rb index 63e69b91b11..8661ee02b68 100644 --- a/app/presenters/project_clusterable_presenter.rb +++ b/app/presenters/project_clusterable_presenter.rb @@ -14,6 +14,11 @@ class ProjectClusterablePresenter < ClusterablePresenter install_applications_project_cluster_path(clusterable, cluster, application) end + override :update_applications_cluster_path + def update_applications_cluster_path(cluster, application) + update_applications_project_cluster_path(clusterable, cluster, application) + end + override :cluster_path def cluster_path(cluster, params = {}) project_cluster_path(clusterable, cluster, params) diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 4cac90c2567..9afbaf035c7 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -37,16 +37,12 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated autodevops_anchor_data(show_auto_devops_callout: show_auto_devops_callout), kubernetes_cluster_anchor_data, gitlab_ci_anchor_data - ].compact.reject(&:is_link) + ].compact.reject(&:is_link).sort_by.with_index { |item, idx| [item.class_modifier ? 0 : 1, idx] } end def empty_repo_statistics_anchors [ - license_anchor_data, - commits_anchor_data, - branches_anchor_data, - tags_anchor_data, - files_anchor_data + license_anchor_data ].compact.select { |item| item.is_link } end @@ -55,9 +51,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated new_file_anchor_data, readme_anchor_data, changelog_anchor_data, - contribution_guide_anchor_data, - autodevops_anchor_data, - kubernetes_cluster_anchor_data + contribution_guide_anchor_data ].compact.reject { |item| item.is_link } end @@ -265,7 +259,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated if current_user && can?(current_user, :admin_pipeline, project) && repository.gitlab_ci_yml.blank? && !show_auto_devops_callout if auto_devops_enabled? AnchorData.new(false, - statistic_icon('doc-text') + _('Auto DevOps enabled'), + statistic_icon('settings') + _('Auto DevOps enabled'), project_settings_ci_cd_path(project, anchor: 'autodevops-settings'), 'default') else @@ -315,6 +309,10 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated project.tag_list.take(MAX_TOPICS_TO_SHOW) # rubocop: disable CodeReuse/ActiveRecord end + def topics_not_shown + project.tag_list - topics_to_show + end + def count_of_extra_topics_not_shown if project.tag_list.count > MAX_TOPICS_TO_SHOW project.tag_list.count - MAX_TOPICS_TO_SHOW diff --git a/app/presenters/tree_entry_presenter.rb b/app/presenters/tree_entry_presenter.rb new file mode 100644 index 00000000000..7bb10cd1455 --- /dev/null +++ b/app/presenters/tree_entry_presenter.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class TreeEntryPresenter < Gitlab::View::Presenter::Delegated + presents :tree + + def web_url + Gitlab::Routing.url_helpers.project_tree_url(tree.repository.project, File.join(tree.commit_id, tree.path)) + end +end diff --git a/app/serializers/acts_as_taggable_on/tag_entity.rb b/app/serializers/acts_as_taggable_on/tag_entity.rb new file mode 100644 index 00000000000..d4e4b69f8fa --- /dev/null +++ b/app/serializers/acts_as_taggable_on/tag_entity.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class ActsAsTaggableOn::TagEntity < Grape::Entity + expose :id + expose :name +end diff --git a/app/serializers/acts_as_taggable_on/tag_serializer.rb b/app/serializers/acts_as_taggable_on/tag_serializer.rb new file mode 100644 index 00000000000..87f53606aa1 --- /dev/null +++ b/app/serializers/acts_as_taggable_on/tag_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ActsAsTaggableOn::TagSerializer < BaseSerializer + entity ActsAsTaggableOn::TagEntity +end diff --git a/app/serializers/analytics_stage_entity.rb b/app/serializers/analytics_stage_entity.rb index ae7c20c3bba..8bc6da5aeeb 100644 --- a/app/serializers/analytics_stage_entity.rb +++ b/app/serializers/analytics_stage_entity.rb @@ -9,7 +9,8 @@ class AnalyticsStageEntity < Grape::Entity expose :description expose :median, as: :value do |stage| - # median returns a BatchLoader instance which we first have to unwrap by using to_i - !stage.median.to_i.zero? ? distance_of_time_in_words(stage.median) : nil + # median returns a BatchLoader instance which we first have to unwrap by using to_f + # we use to_f to make sure results below 1 are presented to the end-user + stage.median.to_f.nonzero? ? distance_of_time_in_words(stage.median) : nil end end diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index 9ddce0d2c80..67e44ee9d10 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -8,16 +8,18 @@ class BuildDetailsEntity < JobEntity expose :stuck?, as: :stuck expose :user, using: UserEntity expose :runner, using: RunnerEntity + expose :metadata, using: BuildMetadataEntity expose :pipeline, using: PipelineEntity expose :deployment_status, if: -> (*) { build.starts_environment? } do expose :deployment_status, as: :status - - expose :persisted_environment, as: :environment, with: EnvironmentEntity + expose :persisted_environment, as: :environment do |build, options| + options.merge(deployment_details: false).yield_self do |opts| + EnvironmentEntity.represent(build.persisted_environment, opts) + end + end end - expose :metadata, using: BuildMetadataEntity - expose :artifact, if: -> (*) { can?(current_user, :read_build, build) } do expose :download_path, if: -> (*) { build.artifacts? } do |build| download_project_job_artifacts_path(project, build) @@ -40,11 +42,18 @@ class BuildDetailsEntity < JobEntity end end + expose :report_artifacts, + as: :reports, + using: JobArtifactReportEntity, + if: -> (*) { can?(current_user, :read_build, build) } + expose :erased_by, if: -> (*) { build.erased? }, using: UserEntity expose :erase_path, if: -> (*) { build.erasable? && can?(current_user, :erase_build, build) } do |build| erase_project_job_path(project, build) end + expose :failure_reason, if: -> (*) { build.failed? } + expose :terminal_path, if: -> (*) { can_create_build_terminal? } do |build| terminal_project_job_path(project, build) end diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb index 02df1480828..2a916b13f52 100644 --- a/app/serializers/cluster_application_entity.rb +++ b/app/serializers/cluster_application_entity.rb @@ -6,7 +6,9 @@ class ClusterApplicationEntity < Grape::Entity expose :status_reason expose :version expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) } + expose :external_hostname, if: -> (e, _) { e.respond_to?(:external_hostname) } expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) } expose :email, if: -> (e, _) { e.respond_to?(:email) } expose :update_available?, as: :update_available, if: -> (e, _) { e.respond_to?(:update_available?) } + expose :can_uninstall?, as: :can_uninstall end diff --git a/app/serializers/concerns/user_status_tooltip.rb b/app/serializers/concerns/user_status_tooltip.rb index aa6e67e3351..a81e377691e 100644 --- a/app/serializers/concerns/user_status_tooltip.rb +++ b/app/serializers/concerns/user_status_tooltip.rb @@ -3,7 +3,7 @@ module UserStatusTooltip extend ActiveSupport::Concern include ActionView::Helpers::TagHelper - include ActionView::Context + include ::Gitlab::ActionViewOutput::Context include EmojiHelper include UsersHelper @@ -11,7 +11,7 @@ module UserStatusTooltip expose :user_status_if_loaded, as: :status_tooltip_html def user_status_if_loaded - return nil unless object.association(:status).loaded? + return unless object.association(:status).loaded? user_status(object) end diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index 34ae06278c8..943c707218d 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -20,16 +20,39 @@ class DeploymentEntity < Grape::Entity expose :created_at expose :tag expose :last? - expose :user, using: UserEntity - expose :commit, using: CommitEntity - expose :deployable, using: JobEntity - expose :manual_actions, using: JobEntity, if: -> (*) { can_create_deployment? } - expose :scheduled_actions, using: JobEntity, if: -> (*) { can_create_deployment? } + + expose :deployable do |deployment, opts| + deployment.deployable.yield_self do |deployable| + if include_details? + JobEntity.represent(deployable, opts) + elsif can_read_deployables? + { name: deployable.name, + build_path: project_job_path(deployable.project, deployable) } + end + end + end + + expose :commit, using: CommitEntity, if: -> (*) { include_details? } + expose :manual_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? } + expose :scheduled_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? } private + def include_details? + options.fetch(:deployment_details, true) + end + def can_create_deployment? can?(request.current_user, :create_deployment, request.project) end + + def can_read_deployables? + ## + # We intentionally do not check `:read_build, deployment.deployable` + # because it triggers a policy evaluation that involves multiple + # Gitaly calls that might not be cached. + # + can?(request.current_user, :read_build, request.project) + end end diff --git a/app/serializers/detailed_status_entity.rb b/app/serializers/detailed_status_entity.rb index da994d78286..4f23ef0ed82 100644 --- a/app/serializers/detailed_status_entity.rb +++ b/app/serializers/detailed_status_entity.rb @@ -9,16 +9,14 @@ class DetailedStatusEntity < Grape::Entity expose :details_path expose :illustration do |status| - begin - illustration = { - image: ActionController::Base.helpers.image_path(status.illustration[:image]) - } - illustration = status.illustration.merge(illustration) + illustration = { + image: ActionController::Base.helpers.image_path(status.illustration[:image]) + } + illustration = status.illustration.merge(illustration) - illustration - rescue NotImplementedError - # ignored - end + illustration + rescue NotImplementedError + # ignored end expose :favicon do |status| diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb index ede9e04b722..d8630165e49 100644 --- a/app/serializers/diff_file_base_entity.rb +++ b/app/serializers/diff_file_base_entity.rb @@ -27,9 +27,13 @@ class DiffFileBaseEntity < Grape::Entity next unless merge_request.source_project - project_edit_blob_path(merge_request.source_project, - tree_join(merge_request.source_branch, diff_file.new_path), - options) + if Feature.enabled?(:web_ide_default) + ide_edit_path(merge_request.source_project, merge_request.source_branch, diff_file.new_path) + else + project_edit_blob_path(merge_request.source_project, + tree_join(merge_request.source_branch, diff_file.new_path), + options) + end end expose :old_path_html do |diff_file| diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb index 01ee7af37ed..2a5121a2266 100644 --- a/app/serializers/diff_file_entity.rb +++ b/app/serializers/diff_file_entity.rb @@ -7,7 +7,7 @@ class DiffFileEntity < DiffFileBaseEntity expose :added_lines expose :removed_lines - expose :load_collapsed_diff_url, if: -> (diff_file, options) { diff_file.viewer.collapsed? && options[:merge_request] } do |diff_file| + expose :load_collapsed_diff_url, if: -> (diff_file, options) { options[:merge_request] } do |diff_file| merge_request = options[:merge_request] project = merge_request.target_project @@ -57,6 +57,10 @@ class DiffFileEntity < DiffFileBaseEntity diff_file.diff_lines_for_serializer end + expose :is_fully_expanded do |diff_file| + diff_file.fully_expanded? + end + # Used for parallel diffs expose :parallel_diff_lines, using: DiffLineParallelEntity, if: -> (diff_file, _) { diff_file.text? } end diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index 4a7d13915dd..8258135da4e 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -8,10 +8,11 @@ class EnvironmentEntity < Grape::Entity expose :state expose :external_url expose :environment_type + expose :name_without_type expose :last_deployment, using: DeploymentEntity expose :stop_action_available?, as: :has_stop_action - expose :metrics_path, if: -> (environment, _) { environment.has_metrics? } do |environment| + expose :metrics_path, if: -> (*) { environment.has_metrics? } do |environment| metrics_project_environment_path(environment.project, environment) end diff --git a/app/serializers/group_variable_entity.rb b/app/serializers/group_variable_entity.rb index 0edab4a3092..19c5fa26f34 100644 --- a/app/serializers/group_variable_entity.rb +++ b/app/serializers/group_variable_entity.rb @@ -6,4 +6,5 @@ class GroupVariableEntity < Grape::Entity expose :value expose :protected?, as: :protected + expose :masked?, as: :masked end diff --git a/app/serializers/issuable_sidebar_extras_entity.rb b/app/serializers/issuable_sidebar_extras_entity.rb index d60253564e1..fb35b7522c5 100644 --- a/app/serializers/issuable_sidebar_extras_entity.rb +++ b/app/serializers/issuable_sidebar_extras_entity.rb @@ -11,4 +11,6 @@ class IssuableSidebarExtrasEntity < Grape::Entity expose :subscribed do |issuable| issuable.subscribed?(request.current_user, issuable.project) end + + expose :assignees, using: API::Entities::UserBasic end diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb index f7719447b92..2e1d7fb3f87 100644 --- a/app/serializers/issue_board_entity.rb +++ b/app/serializers/issue_board_entity.rb @@ -2,6 +2,7 @@ class IssueBoardEntity < Grape::Entity include RequestAwareEntity + include TimeTrackableEntity expose :id expose :iid @@ -11,6 +12,7 @@ class IssueBoardEntity < Grape::Entity expose :due_date expose :project_id expose :relative_position + expose :time_estimate expose :project do |issue| API::Entities::Project.represent issue.project, only: [:id, :path] diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index c3f7d4651fb..36e601f45c5 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -42,6 +42,14 @@ class IssueEntity < IssuableEntity end expose :preview_note_path do |issue| - preview_markdown_path(issue.project, quick_actions_target_type: 'Issue', quick_actions_target_id: issue.iid) + preview_markdown_path(issue.project, target_type: 'Issue', target_id: issue.iid) + end + + expose :confidential_issues_docs_path, if: -> (issue) { issue.confidential? } do |issue| + help_page_path('user/project/issues/confidential_issues.md') + end + + expose :locked_discussion_docs_path, if: -> (issue) { issue.discussion_locked? } do |issue| + help_page_path('user/discussions/index.md', anchor: 'lock-discussions') end end diff --git a/app/serializers/issue_sidebar_extras_entity.rb b/app/serializers/issue_sidebar_extras_entity.rb index 7b6e860140b..dee891a50b7 100644 --- a/app/serializers/issue_sidebar_extras_entity.rb +++ b/app/serializers/issue_sidebar_extras_entity.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true class IssueSidebarExtrasEntity < IssuableSidebarExtrasEntity - expose :assignees, using: API::Entities::UserBasic end diff --git a/app/serializers/job_artifact_report_entity.rb b/app/serializers/job_artifact_report_entity.rb new file mode 100644 index 00000000000..4280351a6b0 --- /dev/null +++ b/app/serializers/job_artifact_report_entity.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class JobArtifactReportEntity < Grape::Entity + include RequestAwareEntity + + expose :file_type + expose :file_format + expose :size + + expose :download_path do |artifact| + download_project_job_artifacts_path(artifact.job.project, artifact.job, file_type: artifact.file_format) + end +end diff --git a/app/serializers/merge_request_assignee_entity.rb b/app/serializers/merge_request_assignee_entity.rb new file mode 100644 index 00000000000..6849c62e759 --- /dev/null +++ b/app/serializers/merge_request_assignee_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class MergeRequestAssigneeEntity < ::API::Entities::UserBasic + expose :can_merge do |assignee, options| + options[:merge_request]&.can_be_merged_by?(assignee) + end +end diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb index 178e72f4f0a..973e971b4c0 100644 --- a/app/serializers/merge_request_basic_entity.rb +++ b/app/serializers/merge_request_basic_entity.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class MergeRequestBasicEntity < Grape::Entity - expose :assignee_id expose :merge_status expose :merge_error expose :state @@ -9,7 +8,7 @@ class MergeRequestBasicEntity < Grape::Entity expose :rebase_in_progress?, as: :rebase_in_progress expose :milestone, using: API::Entities::Milestone expose :labels, using: LabelEntity - expose :assignee, using: API::Entities::UserBasic + expose :assignees, using: API::Entities::UserBasic expose :task_status, :task_status_short expose :lock_version, :lock_version end diff --git a/app/serializers/merge_request_for_pipeline_entity.rb b/app/serializers/merge_request_for_pipeline_entity.rb new file mode 100644 index 00000000000..17a5c4ebbf9 --- /dev/null +++ b/app/serializers/merge_request_for_pipeline_entity.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class MergeRequestForPipelineEntity < Grape::Entity + include RequestAwareEntity + + expose :iid + + expose :path do |merge_request| + project_merge_request_path(merge_request.project, merge_request) + end + + expose :title + expose :source_branch + expose :source_branch_commits_path, as: :source_branch_path + expose :target_branch + expose :target_branch_commits_path, as: :target_branch_path +end diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index 4cf84336aa4..6f589351670 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -8,9 +8,9 @@ class MergeRequestSerializer < BaseSerializer entity = case opts[:serializer] when 'sidebar' - MergeRequestSidebarBasicEntity + IssuableSidebarBasicEntity when 'sidebar_extras' - IssuableSidebarExtrasEntity + MergeRequestSidebarExtrasEntity when 'basic' MergeRequestBasicEntity else diff --git a/app/serializers/merge_request_sidebar_basic_entity.rb b/app/serializers/merge_request_sidebar_basic_entity.rb deleted file mode 100644 index 0ae7298a7c1..00000000000 --- a/app/serializers/merge_request_sidebar_basic_entity.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class MergeRequestSidebarBasicEntity < IssuableSidebarBasicEntity - expose :assignee, if: lambda { |issuable| issuable.assignee } do - expose :assignee, merge: true, using: API::Entities::UserBasic - - expose :can_merge do |issuable| - issuable.can_be_merged_by?(issuable.assignee) - end - end -end diff --git a/app/serializers/merge_request_sidebar_extras_entity.rb b/app/serializers/merge_request_sidebar_extras_entity.rb new file mode 100644 index 00000000000..7276509c363 --- /dev/null +++ b/app/serializers/merge_request_sidebar_extras_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class MergeRequestSidebarExtrasEntity < IssuableSidebarExtrasEntity + expose :assignees do |merge_request| + MergeRequestAssigneeEntity.represent(merge_request.assignees, merge_request: merge_request) + end +end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 2142ceb6122..a428930dbbf 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -9,7 +9,11 @@ class MergeRequestWidgetEntity < IssuableEntity expose :merge_params expose :merge_status expose :merge_user_id - expose :merge_when_pipeline_succeeds + expose :auto_merge_enabled + expose :auto_merge_strategy + expose :available_auto_merge_strategies do |merge_request| + AutoMergeService.new(merge_request.project, current_user).available_strategies(merge_request) # rubocop: disable CodeReuse/ServiceClass + end expose :source_branch expose :source_branch_protected do |merge_request| merge_request.source_project.present? && ProtectedBranch.protected?(merge_request.source_project, merge_request.source_branch) @@ -20,6 +24,7 @@ class MergeRequestWidgetEntity < IssuableEntity end expose :squash expose :target_branch + expose :target_branch_sha expose :target_project_id expose :target_project_full_path do |merge_request| merge_request.project&.full_path @@ -181,8 +186,8 @@ class MergeRequestWidgetEntity < IssuableEntity presenter(merge_request).remove_wip_path end - expose :cancel_merge_when_pipeline_succeeds_path do |merge_request| - presenter(merge_request).cancel_merge_when_pipeline_succeeds_path + expose :cancel_auto_merge_path do |merge_request| + presenter(merge_request).cancel_auto_merge_path end expose :create_issue_to_resolve_discussions_path do |merge_request| @@ -234,7 +239,7 @@ class MergeRequestWidgetEntity < IssuableEntity end expose :preview_note_path do |merge_request| - preview_markdown_path(merge_request.project, quick_actions_target_type: 'MergeRequest', quick_actions_target_id: merge_request.iid) + preview_markdown_path(merge_request.project, target_type: 'MergeRequest', target_id: merge_request.iid) end expose :merge_commit_path do |merge_request| @@ -255,6 +260,10 @@ class MergeRequestWidgetEntity < IssuableEntity presenter(merge_request).conflicts_docs_path end + expose :merge_request_pipelines_docs_path do |merge_request| + presenter(merge_request).merge_request_pipelines_docs_path + end + private delegate :current_user, to: :request diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb index d78ad4af4dc..dfef4364965 100644 --- a/app/serializers/pipeline_details_entity.rb +++ b/app/serializers/pipeline_details_entity.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true class PipelineDetailsEntity < PipelineEntity + expose :flags do + expose :latest?, as: :latest + end + expose :details do - expose :ordered_stages, as: :stages, using: StageEntity expose :artifacts, using: BuildArtifactEntity expose :manual_actions, using: BuildActionEntity expose :scheduled_actions, using: BuildActionEntity diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index 29b1a6c244b..ec2698ecbe3 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -4,6 +4,7 @@ class PipelineEntity < Grape::Entity include RequestAwareEntity expose :id + expose :iid expose :user, using: UserEntity expose :active?, as: :active @@ -20,22 +21,28 @@ class PipelineEntity < Grape::Entity end expose :flags do - expose :latest?, as: :latest expose :stuck?, as: :stuck expose :auto_devops_source?, as: :auto_devops - expose :merge_request?, as: :merge_request + expose :merge_request_event?, as: :merge_request expose :has_yaml_errors?, as: :yaml_errors expose :can_retry?, as: :retryable expose :can_cancel?, as: :cancelable expose :failure_reason?, as: :failure_reason + expose :detached_merge_request_pipeline?, as: :detached_merge_request_pipeline + expose :merge_request_pipeline?, as: :merge_request_pipeline end expose :details do expose :detailed_status, as: :status, with: DetailedStatusEntity + expose :ordered_stages, as: :stages, using: StageEntity expose :duration expose :finished_at end + expose :merge_request, if: -> (*) { has_presentable_merge_request? }, with: MergeRequestForPipelineEntity do |pipeline| + pipeline.merge_request.present(current_user: request.current_user) + end + expose :ref do expose :name do |pipeline| pipeline.ref @@ -49,10 +56,12 @@ class PipelineEntity < Grape::Entity expose :tag?, as: :tag expose :branch?, as: :branch - expose :merge_request?, as: :merge_request + expose :merge_request_event?, as: :merge_request end expose :commit, using: CommitEntity + expose :source_sha, if: -> (pipeline, _) { pipeline.merge_request_pipeline? } + expose :target_sha, if: -> (pipeline, _) { pipeline.merge_request_pipeline? } expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? } expose :failure_reason, if: -> (pipeline, _) { pipeline.failure_reason? } do |pipeline| @@ -81,6 +90,11 @@ class PipelineEntity < Grape::Entity pipeline.cancelable? end + def has_presentable_merge_request? + pipeline.triggered_by_merge_request? && + can?(request.current_user, :read_merge_request, pipeline.merge_request) + end + def detailed_status pipeline.detailed_status(request.current_user) end diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index 7451433a841..95d73c6422d 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -7,22 +7,7 @@ class PipelineSerializer < BaseSerializer # rubocop: disable CodeReuse/ActiveRecord def represent(resource, opts = {}) if resource.is_a?(ActiveRecord::Relation) - resource = resource.preload([ - :stages, - :retryable_builds, - :cancelable_statuses, - :trigger_requests, - :manual_actions, - :scheduled_actions, - :artifacts, - { - pending_builds: :project, - project: [:route, { namespace: :route }], - artifacts: { - project: [:route, { namespace: :route }] - } - } - ]) + resource = resource.preload(preloaded_relations) end if paginated? @@ -50,4 +35,26 @@ class PipelineSerializer < BaseSerializer data = represent(resource, { only: [{ details: [:stages] }], preload: true }) data.dig(:details, :stages) || [] end + + private + + def preloaded_relations + [ + :stages, + :retryable_builds, + :cancelable_statuses, + :trigger_requests, + :manual_actions, + :scheduled_actions, + :artifacts, + :merge_request, + { + pending_builds: :project, + project: [:route, { namespace: :route }], + artifacts: { + project: [:route, { namespace: :route }] + } + } + ] + end end diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb index c98dc1a1c4a..a46f8af1466 100644 --- a/app/serializers/projects/serverless/service_entity.rb +++ b/app/serializers/projects/serverless/service_entity.rb @@ -32,6 +32,13 @@ module Projects service.dig('podcount') end + expose :metrics_url do |service| + project_serverless_metrics_path( + request.project, + service.dig('environment_scope'), + service.dig('metadata', 'name')) + ".json" + end + expose :created_at do |service| service.dig('metadata', 'creationTimestamp') end diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb index 4d0d4da10be..2dd62e19e29 100644 --- a/app/serializers/suggestion_entity.rb +++ b/app/serializers/suggestion_entity.rb @@ -3,6 +3,8 @@ class SuggestionEntity < API::Entities::Suggestion include RequestAwareEntity + unexpose :from_line, :to_line, :from_content, :to_content + expose :diff_lines, using: DiffLineEntity expose :current_user do expose :can_apply do |suggestion| Ability.allowed?(current_user, :apply_suggestion, suggestion) diff --git a/app/serializers/suggestion_serializer.rb b/app/serializers/suggestion_serializer.rb new file mode 100644 index 00000000000..010344f9fcd --- /dev/null +++ b/app/serializers/suggestion_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class SuggestionSerializer < BaseSerializer + entity SuggestionEntity + + def represent_diff(resource) + represent(resource, { only: [:diff_lines] }) + end +end diff --git a/app/serializers/test_case_entity.rb b/app/serializers/test_case_entity.rb index ec60055ba5b..5c915c1302c 100644 --- a/app/serializers/test_case_entity.rb +++ b/app/serializers/test_case_entity.rb @@ -3,6 +3,7 @@ class TestCaseEntity < Grape::Entity expose :status expose :name + expose :classname expose :execution_time expose :system_output expose :stack_trace diff --git a/app/serializers/variable_entity.rb b/app/serializers/variable_entity.rb index 85cf367fe51..4d48e13cfca 100644 --- a/app/serializers/variable_entity.rb +++ b/app/serializers/variable_entity.rb @@ -6,4 +6,5 @@ class VariableEntity < Grape::Entity expose :value expose :protected?, as: :protected + expose :masked?, as: :masked end diff --git a/app/services/after_branch_delete_service.rb b/app/services/after_branch_delete_service.rb deleted file mode 100644 index e7eb74d3e7d..00000000000 --- a/app/services/after_branch_delete_service.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -## -# Branch can be deleted either by DeleteBranchService -# or by GitPushService. -# -class AfterBranchDeleteService < BaseService - attr_reader :branch_name - - def execute(branch_name) - @branch_name = branch_name - - stop_environments - end - - private - - def stop_environments - Ci::StopEnvironmentsService - .new(project, current_user) - .execute(branch_name) - end -end diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb index 2e4643ed668..7eeaf8aade1 100644 --- a/app/services/application_settings/update_service.rb +++ b/app/services/application_settings/update_service.rb @@ -2,9 +2,17 @@ module ApplicationSettings class UpdateService < ApplicationSettings::BaseService + include ValidatesClassificationLabel + attr_reader :params, :application_setting def execute + validate_classification_label(application_setting, :external_authorization_service_default_label) + + if application_setting.errors.any? + return false + end + update_terms(@params.delete(:terms)) if params.key?(:performance_bar_allowed_group_path) @@ -38,7 +46,7 @@ module ApplicationSettings def performance_bar_allowed_group_id performance_bar_enabled = !params.key?(:performance_bar_enabled) || params.delete(:performance_bar_enabled) group_full_path = params.delete(:performance_bar_allowed_group_path) - return nil unless Gitlab::Utils.to_boolean(performance_bar_enabled) + return unless Gitlab::Utils.to_boolean(performance_bar_enabled) Group.find_by_full_path(group_full_path)&.id if group_full_path.present? end diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index e95ba09c006..707caee482c 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -116,7 +116,7 @@ module Auth build_can_pull?(requested_project) || user_can_pull?(requested_project) || deploy_token_can_pull?(requested_project) when 'push' build_can_push?(requested_project) || user_can_push?(requested_project) - when '*' + when '*', 'delete' user_can_admin?(requested_project) else false diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb new file mode 100644 index 00000000000..058105db3a4 --- /dev/null +++ b/app/services/auto_merge/base_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module AutoMerge + class BaseService < ::BaseService + include Gitlab::Utils::StrongMemoize + + def execute(merge_request) + merge_request.merge_params.merge!(params) + merge_request.auto_merge_enabled = true + merge_request.merge_user = current_user + merge_request.auto_merge_strategy = strategy + + return :failed unless merge_request.save + + yield if block_given? + + strategy.to_sym + end + + def cancel(merge_request) + if cancel_auto_merge(merge_request) + yield if block_given? + + success + else + error("Can't cancel the automatic merge", 406) + end + end + + private + + def strategy + strong_memoize(:strategy) do + self.class.name.demodulize.remove('Service').underscore + end + end + + def cancel_auto_merge(merge_request) + merge_request.auto_merge_enabled = false + merge_request.merge_user = nil + + merge_request.merge_params&.except!( + 'should_remove_source_branch', + 'commit_message', + 'squash_commit_message', + 'auto_merge_strategy' + ) + + merge_request.save + end + end +end diff --git a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb new file mode 100644 index 00000000000..c41073a73e9 --- /dev/null +++ b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module AutoMerge + class MergeWhenPipelineSucceedsService < AutoMerge::BaseService + def execute(merge_request) + super do + if merge_request.saved_change_to_auto_merge_enabled? + SystemNoteService.merge_when_pipeline_succeeds(merge_request, project, current_user, merge_request.diff_head_commit) + end + end + end + + def process(merge_request) + return unless merge_request.actual_head_pipeline&.success? + return unless merge_request.mergeable? + + merge_request.merge_async(merge_request.merge_user_id, merge_request.merge_params) + end + + def cancel(merge_request) + super do + SystemNoteService.cancel_merge_when_pipeline_succeeds(merge_request, @project, @current_user) + end + end + + def available_for?(merge_request) + merge_request.actual_head_pipeline&.active? + end + end +end diff --git a/app/services/auto_merge_service.rb b/app/services/auto_merge_service.rb new file mode 100644 index 00000000000..a3a780ff388 --- /dev/null +++ b/app/services/auto_merge_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class AutoMergeService < BaseService + STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS = 'merge_when_pipeline_succeeds'.freeze + STRATEGIES = [STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS].freeze + + class << self + def all_strategies + STRATEGIES + end + + def get_service_class(strategy) + return unless all_strategies.include?(strategy) + + "::AutoMerge::#{strategy.camelize}Service".constantize + end + end + + def execute(merge_request, strategy) + service = get_service_instance(strategy) + + return :failed unless service&.available_for?(merge_request) + + service.execute(merge_request) + end + + def process(merge_request) + return unless merge_request.auto_merge_enabled? + + get_service_instance(merge_request.auto_merge_strategy).process(merge_request) + end + + def cancel(merge_request) + return error("Can't cancel the automatic merge", 406) unless merge_request.auto_merge_enabled? + + get_service_instance(merge_request.auto_merge_strategy).cancel(merge_request) + end + + def available_strategies(merge_request) + self.class.all_strategies.select do |strategy| + get_service_instance(strategy).available_for?(merge_request) + end + end + + private + + def get_service_instance(strategy) + self.class.get_service_class(strategy)&.new(project, current_user, params) + end +end diff --git a/app/services/boards/visits/latest_service.rb b/app/services/boards/visits/latest_service.rb index 9e4c77a6317..d13e25b4f12 100644 --- a/app/services/boards/visits/latest_service.rb +++ b/app/services/boards/visits/latest_service.rb @@ -4,13 +4,15 @@ module Boards module Visits class LatestService < Boards::BaseService def execute - return nil unless current_user + return unless current_user - if parent.is_a?(Group) - BoardGroupRecentVisit.latest(current_user, parent) - else - BoardProjectRecentVisit.latest(current_user, parent) - end + recent_visit_model.latest(current_user, parent, count: params[:count]) + end + + private + + def recent_visit_model + parent.is_a?(Group) ? BoardGroupRecentVisit : BoardProjectRecentVisit end end end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 35a0efcd0a1..c17712355af 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -25,7 +25,9 @@ module Ci origin_ref: params[:ref], checkout_sha: params[:checkout_sha], after_sha: params[:after], - before_sha: params[:before], + before_sha: params[:before], # The base SHA of the source branch (i.e merge_request.diff_base_sha). + source_sha: params[:source_sha], # The HEAD SHA of the source branch (i.e merge_request.diff_head_sha). + target_sha: params[:target_sha], # The HEAD SHA of the target branch. trigger_request: trigger_request, schedule: schedule, merge_request: merge_request, @@ -35,7 +37,7 @@ module Ci variables_attributes: params[:variables_attributes], project: project, current_user: current_user, - push_options: params[:push_options], + push_options: params[:push_options] || {}, chat_data: params[:chat_data], **extra_options(options)) @@ -53,6 +55,10 @@ module Ci end end + # If pipeline is not persisted, try to recover IID + pipeline.reset_project_iid unless pipeline.persisted? || + Feature.disabled?(:ci_pipeline_rewind_iid, project, default_enabled: true) + pipeline end @@ -98,17 +104,11 @@ module Ci end def schedule_head_pipeline_update - related_merge_requests.each do |merge_request| + pipeline.all_merge_requests.opened.each do |merge_request| UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id) end end - # rubocop: disable CodeReuse/ActiveRecord - def related_merge_requests - pipeline.project.source_of_merge_requests.opened.where(source_branch: pipeline.ref) - end - # rubocop: enable CodeReuse/ActiveRecord - def extra_options(options = {}) # In Ruby 2.4, even when options is empty, f(**options) doesn't work when f # doesn't have any parameters. We reproduce the Ruby 2.5 behavior by diff --git a/app/services/ci/destroy_pipeline_service.rb b/app/services/ci/destroy_pipeline_service.rb index 5c4a34043c1..9aea20c45f7 100644 --- a/app/services/ci/destroy_pipeline_service.rb +++ b/app/services/ci/destroy_pipeline_service.rb @@ -5,6 +5,8 @@ module Ci def execute(pipeline) raise Gitlab::Access::AccessDeniedError unless can?(current_user, :destroy_pipeline, pipeline) + Ci::ExpirePipelineCacheService.new.execute(pipeline, delete: true) + pipeline.destroy! end end diff --git a/app/services/ci/expire_pipeline_cache_service.rb b/app/services/ci/expire_pipeline_cache_service.rb new file mode 100644 index 00000000000..d8d38128af6 --- /dev/null +++ b/app/services/ci/expire_pipeline_cache_service.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Ci + class ExpirePipelineCacheService + def execute(pipeline, delete: false) + store = Gitlab::EtagCaching::Store.new + + update_etag_cache(pipeline, store) + + if delete + Gitlab::Cache::Ci::ProjectPipelineStatus.new(pipeline.project).delete_from_cache + else + Gitlab::Cache::Ci::ProjectPipelineStatus.update_for_pipeline(pipeline) + end + end + + private + + def project_pipelines_path(project) + Gitlab::Routing.url_helpers.project_pipelines_path(project, format: :json) + end + + def project_pipeline_path(project, pipeline) + Gitlab::Routing.url_helpers.project_pipeline_path(project, pipeline, format: :json) + end + + def commit_pipelines_path(project, commit) + Gitlab::Routing.url_helpers.pipelines_project_commit_path(project, commit.id, format: :json) + end + + def new_merge_request_pipelines_path(project) + Gitlab::Routing.url_helpers.project_new_merge_request_path(project, format: :json) + end + + def each_pipelines_merge_request_path(pipeline) + pipeline.all_merge_requests.each do |merge_request| + path = Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(merge_request.target_project, merge_request, format: :json) + + yield(path) + end + end + + # Updates ETag caches of a pipeline. + # + # This logic resides in a separate method so that EE can more easily extend + # it. + # + # @param [Ci::Pipeline] pipeline + # @param [Gitlab::EtagCaching::Store] store + def update_etag_cache(pipeline, store) + project = pipeline.project + + store.touch(project_pipelines_path(project)) + store.touch(project_pipeline_path(project, pipeline)) + store.touch(commit_pipelines_path(project, pipeline.commit)) unless pipeline.commit.nil? + store.touch(new_merge_request_pipelines_path(project)) + each_pipelines_merge_request_path(pipeline) do |path| + store.touch(path) + end + end + end +end diff --git a/app/services/ci/pipeline_schedule_service.rb b/app/services/ci/pipeline_schedule_service.rb new file mode 100644 index 00000000000..387d0351490 --- /dev/null +++ b/app/services/ci/pipeline_schedule_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ci + class PipelineScheduleService < BaseService + def execute(schedule) + # Ensure `next_run_at` is set properly before creating a pipeline. + # Otherwise, multiple pipelines could be created in a short interval. + schedule.schedule_next_run! + + RunPipelineScheduleWorker.perform_async(schedule.id, schedule.owner.id) + end + end +end diff --git a/app/services/ci/play_manual_stage_service.rb b/app/services/ci/play_manual_stage_service.rb new file mode 100644 index 00000000000..2497fc52e6b --- /dev/null +++ b/app/services/ci/play_manual_stage_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Ci + class PlayManualStageService < BaseService + def initialize(project, current_user, params) + super + + @pipeline = params[:pipeline] + end + + def execute(stage) + stage.builds.manual.each do |build| + next unless build.playable? + + build.play(current_user) + rescue Gitlab::Access::AccessDeniedError + logger.error(message: 'Unable to play manual action', build_id: build.id) + end + end + + private + + attr_reader :pipeline, :current_user + + def logger + Gitlab::AppLogger + end + end +end diff --git a/app/services/ci/prepare_build_service.rb b/app/services/ci/prepare_build_service.rb new file mode 100644 index 00000000000..3722faeb020 --- /dev/null +++ b/app/services/ci/prepare_build_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Ci + class PrepareBuildService + attr_reader :build + + def initialize(build) + @build = build + end + + def execute + prerequisites.each(&:complete!) + + build.enqueue! + rescue => e + Gitlab::Sentry.track_acceptable_exception(e, extra: { build_id: build.id }) + + build.drop(:unmet_prerequisites) + end + + private + + def prerequisites + build.prerequisites + end + end +end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 6707a1363d0..dedab98b56d 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -6,7 +6,7 @@ module Ci class RegisterJobService attr_reader :runner - JOB_QUEUE_DURATION_SECONDS_BUCKETS = [1, 3, 10, 30].freeze + JOB_QUEUE_DURATION_SECONDS_BUCKETS = [1, 3, 10, 30, 60, 300].freeze JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET = 5.freeze Result = Struct.new(:build, :valid?) @@ -36,6 +36,11 @@ module Ci builds = builds.with_any_tags end + # pick builds that older than specified age + if params.key?(:job_age) + builds = builds.queued_before(params[:job_age].seconds.ago) + end + builds.each do |build| next unless runner.can_pick?(build) diff --git a/app/services/ci/stop_environments_service.rb b/app/services/ci/stop_environments_service.rb index 973ae5ce5aa..d9a800791f2 100644 --- a/app/services/ci/stop_environments_service.rb +++ b/app/services/ci/stop_environments_service.rb @@ -9,12 +9,11 @@ module Ci return unless @ref.present? - environments.each do |environment| - next unless environment.stop_action_available? - next unless can?(current_user, :stop_environment, environment) + environments.each { |environment| stop(environment) } + end - environment.stop_with_action!(current_user) - end + def execute_for_merge_request(merge_request) + merge_request.environments.each { |environment| stop(environment) } end private @@ -24,5 +23,12 @@ module Ci .new(project, current_user, ref: @ref, recently_updated: true) .execute end + + def stop(environment) + return unless environment.stop_action_available? + return unless can?(current_user, :stop_environment, environment) + + environment.stop_with_action!(current_user) + end end end diff --git a/app/services/clusters/applications/base_helm_service.rb b/app/services/clusters/applications/base_helm_service.rb index 8a71730d5ec..3e7f55f0c63 100644 --- a/app/services/clusters/applications/base_helm_service.rb +++ b/app/services/clusters/applications/base_helm_service.rb @@ -13,19 +13,37 @@ module Clusters def log_error(error) meta = { - exception: error.class.name, error_code: error.respond_to?(:error_code) ? error.error_code : nil, service: self.class.name, app_id: app.id, + app_name: app.name, project_ids: app.cluster.project_ids, - group_ids: app.cluster.group_ids, - message: error.message + group_ids: app.cluster.group_ids } - logger.error(meta) + logger_meta = meta.merge( + exception: error.class.name, + message: error.message, + backtrace: Gitlab::Profiler.clean_backtrace(error.backtrace) + ) + + logger.error(logger_meta) Gitlab::Sentry.track_acceptable_exception(error, extra: meta) end + def log_event(event) + meta = { + service: self.class.name, + app_id: app.id, + app_name: app.name, + project_ids: app.cluster.project_ids, + group_ids: app.cluster.group_ids, + event: event + } + + logger.info(meta) + end + def logger @logger ||= Gitlab::Kubernetes::Logger.build end @@ -46,6 +64,10 @@ module Clusters @install_command ||= app.install_command end + def update_command + @update_command ||= app.update_command + end + def upgrade_command(new_values = "") app.upgrade_command(new_values) end diff --git a/app/services/clusters/applications/base_service.rb b/app/services/clusters/applications/base_service.rb new file mode 100644 index 00000000000..a9feb60be6e --- /dev/null +++ b/app/services/clusters/applications/base_service.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class BaseService + InvalidApplicationError = Class.new(StandardError) + + attr_reader :cluster, :current_user, :params + + def initialize(cluster, user, params = {}) + @cluster = cluster + @current_user = user + @params = params.dup + end + + def execute(request) + instantiate_application.tap do |application| + if application.has_attribute?(:hostname) + application.hostname = params[:hostname] + end + + if application.has_attribute?(:email) + application.email = params[:email] + end + + if application.respond_to?(:oauth_application) + application.oauth_application = create_oauth_application(application, request) + end + + worker = worker_class(application) + + application.make_scheduled! + + worker.perform_async(application.name, application.id) + end + end + + protected + + def worker_class(application) + raise NotImplementedError + end + + def builder + raise NotImplementedError + end + + def project_builders + raise NotImplementedError + end + + def instantiate_application + raise_invalid_application_error if invalid_application? + + builder || raise(InvalidApplicationError, "invalid application: #{application_name}") + end + + def raise_invalid_application_error + raise(InvalidApplicationError, "invalid application: #{application_name}") + end + + def invalid_application? + unknown_application? || (!cluster.project_type? && project_only_application?) + end + + def unknown_application? + Clusters::Cluster::APPLICATIONS.keys.exclude?(application_name) + end + + # These applications will need extra configuration to enable them to work + # with groups of projects + def project_only_application? + Clusters::Cluster::PROJECT_ONLY_APPLICATIONS.include?(application_name) + end + + def application_name + params[:application] + end + + def create_oauth_application(application, request) + oauth_application_params = { + name: params[:application], + redirect_uri: application.callback_url, + scopes: application.oauth_scopes, + owner: current_user + } + + ::Applications::CreateService.new(current_user, oauth_application_params).execute(request) + end + end + end +end diff --git a/app/services/clusters/applications/check_ingress_ip_address_service.rb b/app/services/clusters/applications/check_ingress_ip_address_service.rb index 0ec06e776a7..e254a0358a0 100644 --- a/app/services/clusters/applications/check_ingress_ip_address_service.rb +++ b/app/services/clusters/applications/check_ingress_ip_address_service.rb @@ -11,9 +11,13 @@ module Clusters def execute return if app.external_ip + return if app.external_hostname return unless try_obtain_lease - app.update!(external_ip: ingress_ip) if ingress_ip + app.external_ip = ingress_ip if ingress_ip + app.external_hostname = ingress_hostname if ingress_hostname + + app.save! if app.changed? end private @@ -25,12 +29,16 @@ module Clusters end def ingress_ip - service.status.loadBalancer.ingress&.first&.ip + ingress_service&.ip + end + + def ingress_hostname + ingress_service&.hostname end - def service + def ingress_service strong_memoize(:ingress_service) do - app.ingress_service + app.ingress_service.status.loadBalancer.ingress&.first end end end diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb index c592d608b89..3c6803d24e6 100644 --- a/app/services/clusters/applications/check_installation_progress_service.rb +++ b/app/services/clusters/applications/check_installation_progress_service.rb @@ -37,7 +37,7 @@ module Clusters end def check_timeout - if timeouted? + if timed_out? begin app.make_errored!("Operation timed out. Check pod logs for #{pod_name} for more details.") end @@ -51,8 +51,8 @@ module Clusters install_command.pod_name end - def timeouted? - Time.now.utc - app.updated_at.to_time.utc > ClusterWaitForAppInstallationWorker::TIMEOUT + def timed_out? + Time.now.utc - app.updated_at.utc > ClusterWaitForAppInstallationWorker::TIMEOUT end def remove_installation_pod diff --git a/app/services/clusters/applications/check_uninstall_progress_service.rb b/app/services/clusters/applications/check_uninstall_progress_service.rb new file mode 100644 index 00000000000..8786d295d6a --- /dev/null +++ b/app/services/clusters/applications/check_uninstall_progress_service.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class CheckUninstallProgressService < BaseHelmService + def execute + return unless app.uninstalling? + + case installation_phase + when Gitlab::Kubernetes::Pod::SUCCEEDED + on_success + when Gitlab::Kubernetes::Pod::FAILED + on_failed + else + check_timeout + end + rescue Kubeclient::HttpError => e + log_error(e) + + app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code }) + end + + private + + def on_success + app.destroy! + rescue StandardError => e + app.make_errored!(_('Application uninstalled but failed to destroy: %{error_message}') % { error_message: e.message }) + ensure + remove_installation_pod + end + + def on_failed + app.make_errored!(_('Operation failed. Check pod logs for %{pod_name} for more details.') % { pod_name: pod_name }) + end + + def check_timeout + if timed_out? + app.make_errored!(_('Operation timed out. Check pod logs for %{pod_name} for more details.') % { pod_name: pod_name }) + else + WaitForUninstallAppWorker.perform_in(WaitForUninstallAppWorker::INTERVAL, app.name, app.id) + end + end + + def pod_name + app.uninstall_command.pod_name + end + + def timed_out? + Time.now.utc - app.updated_at.utc > WaitForUninstallAppWorker::TIMEOUT + end + + def remove_installation_pod + helm_api.delete_pod!(pod_name) + end + + def installation_phase + helm_api.status(pod_name) + end + end + end +end diff --git a/app/services/clusters/applications/create_service.rb b/app/services/clusters/applications/create_service.rb index 92c2c1b9834..f723c42c049 100644 --- a/app/services/clusters/applications/create_service.rb +++ b/app/services/clusters/applications/create_service.rb @@ -2,81 +2,16 @@ module Clusters module Applications - class CreateService - InvalidApplicationError = Class.new(StandardError) - - attr_reader :cluster, :current_user, :params - - def initialize(cluster, user, params = {}) - @cluster = cluster - @current_user = user - @params = params.dup - end - - def execute(request) - create_application.tap do |application| - if application.has_attribute?(:hostname) - application.hostname = params[:hostname] - end - - if application.has_attribute?(:email) - application.email = params[:email] - end - - if application.respond_to?(:oauth_application) - application.oauth_application = create_oauth_application(application, request) - end - - application.save! - - Clusters::Applications::ScheduleInstallationService.new(application).execute - end - end - + class CreateService < Clusters::Applications::BaseService private - def create_application - builder.call(@cluster) + def worker_class(application) + application.updateable? ? ClusterUpgradeAppWorker : ClusterInstallAppWorker end def builder - builders[application_name] || raise(InvalidApplicationError, "invalid application: #{application_name}") - end - - def builders - { - "helm" => -> (cluster) { cluster.application_helm || cluster.build_application_helm }, - "ingress" => -> (cluster) { cluster.application_ingress || cluster.build_application_ingress }, - "cert_manager" => -> (cluster) { cluster.application_cert_manager || cluster.build_application_cert_manager } - }.tap do |hash| - hash.merge!(project_builders) if cluster.project_type? - end - end - - # These applications will need extra configuration to enable them to work - # with groups of projects - def project_builders - { - "prometheus" => -> (cluster) { cluster.application_prometheus || cluster.build_application_prometheus }, - "runner" => -> (cluster) { cluster.application_runner || cluster.build_application_runner }, - "jupyter" => -> (cluster) { cluster.application_jupyter || cluster.build_application_jupyter }, - "knative" => -> (cluster) { cluster.application_knative || cluster.build_application_knative } - } - end - - def application_name - params[:application] - end - - def create_oauth_application(application, request) - oauth_application_params = { - name: params[:application], - redirect_uri: application.callback_url, - scopes: 'api read_user openid', - owner: current_user - } - - ::Applications::CreateService.new(current_user, oauth_application_params).execute(request) + cluster.public_send(:"application_#{application_name}") || # rubocop:disable GitlabSecurity/PublicSend + cluster.public_send(:"build_application_#{application_name}") # rubocop:disable GitlabSecurity/PublicSend end end end diff --git a/app/services/clusters/applications/destroy_service.rb b/app/services/clusters/applications/destroy_service.rb new file mode 100644 index 00000000000..f3a4c4f754a --- /dev/null +++ b/app/services/clusters/applications/destroy_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class DestroyService < ::Clusters::Applications::BaseService + def execute(_request) + instantiate_application.tap do |application| + break unless application.can_uninstall? + + application.make_scheduled! + + Clusters::Applications::UninstallWorker.perform_async(application.name, application.id) + end + end + + private + + def builder + cluster.public_send(:"application_#{application_name}") # rubocop:disable GitlabSecurity/PublicSend + end + end + end +end diff --git a/app/services/clusters/applications/install_service.rb b/app/services/clusters/applications/install_service.rb index 5a65dc4ef59..dffb4ce65ab 100644 --- a/app/services/clusters/applications/install_service.rb +++ b/app/services/clusters/applications/install_service.rb @@ -6,19 +6,26 @@ module Clusters def execute return unless app.scheduled? - begin - app.make_installing! - helm_api.install(install_command) + app.make_installing! - ClusterWaitForAppInstallationWorker.perform_in( - ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) - rescue Kubeclient::HttpError => e - log_error(e) - app.make_errored!("Kubernetes error: #{e.error_code}") - rescue StandardError => e - log_error(e) - app.make_errored!("Can't start installation process.") - end + install + end + + private + + def install + log_event(:begin_install) + helm_api.install(install_command) + + log_event(:schedule_wait_for_installation) + ClusterWaitForAppInstallationWorker.perform_in( + ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) + rescue Kubeclient::HttpError => e + log_error(e) + app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code }) + rescue StandardError => e + log_error(e) + app.make_errored!(_('Failed to install.')) end end end diff --git a/app/services/clusters/applications/patch_service.rb b/app/services/clusters/applications/patch_service.rb new file mode 100644 index 00000000000..fbea18bae6b --- /dev/null +++ b/app/services/clusters/applications/patch_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class PatchService < BaseHelmService + def execute + return unless app.scheduled? + + app.make_updating! + + patch + end + + private + + def patch + log_event(:begin_patch) + helm_api.update(update_command) + + log_event(:schedule_wait_for_patch) + ClusterWaitForAppInstallationWorker.perform_in( + ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) + rescue Kubeclient::HttpError => e + log_error(e) + app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code }) + rescue StandardError => e + log_error(e) + app.make_errored!(_('Failed to update.')) + end + end + end +end diff --git a/app/services/clusters/applications/schedule_installation_service.rb b/app/services/clusters/applications/schedule_installation_service.rb deleted file mode 100644 index 15c93f1e79b..00000000000 --- a/app/services/clusters/applications/schedule_installation_service.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class ScheduleInstallationService - attr_reader :application - - def initialize(application) - @application = application - end - - def execute - application.updateable? ? schedule_upgrade : schedule_install - end - - private - - def schedule_upgrade - application.make_scheduled! - - ClusterUpgradeAppWorker.perform_async(application.name, application.id) - end - - def schedule_install - application.make_scheduled! - - ClusterInstallAppWorker.perform_async(application.name, application.id) - end - end - end -end diff --git a/app/services/clusters/applications/uninstall_service.rb b/app/services/clusters/applications/uninstall_service.rb new file mode 100644 index 00000000000..50c8d806c14 --- /dev/null +++ b/app/services/clusters/applications/uninstall_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class UninstallService < BaseHelmService + def execute + return unless app.scheduled? + + app.make_uninstalling! + uninstall + end + + private + + def uninstall + helm_api.uninstall(app.uninstall_command) + + Clusters::Applications::WaitForUninstallAppWorker.perform_in( + Clusters::Applications::WaitForUninstallAppWorker::INTERVAL, app.name, app.id) + rescue Kubeclient::HttpError => e + log_error(e) + app.make_errored!("Kubernetes error: #{e.error_code}") + rescue StandardError => e + log_error(e) + app.make_errored!('Failed to uninstall.') + end + end + end +end diff --git a/app/services/clusters/applications/update_service.rb b/app/services/clusters/applications/update_service.rb new file mode 100644 index 00000000000..0fa937da865 --- /dev/null +++ b/app/services/clusters/applications/update_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class UpdateService < Clusters::Applications::BaseService + private + + def worker_class(application) + ClusterPatchAppWorker + end + + def builder + cluster.public_send(:"application_#{application_name}") # rubocop:disable GitlabSecurity/PublicSend + end + end + end +end diff --git a/app/services/clusters/applications/upgrade_service.rb b/app/services/clusters/applications/upgrade_service.rb index a0ece1d2635..ac68e64af38 100644 --- a/app/services/clusters/applications/upgrade_service.rb +++ b/app/services/clusters/applications/upgrade_service.rb @@ -6,22 +6,28 @@ module Clusters def execute return unless app.scheduled? - begin - app.make_updating! + app.make_updating! - # install_command works with upgrades too - # as it basically does `helm upgrade --install` - helm_api.update(install_command) + upgrade + end + + private + + def upgrade + # install_command works with upgrades too + # as it basically does `helm upgrade --install` + log_event(:begin_upgrade) + helm_api.update(install_command) - ClusterWaitForAppInstallationWorker.perform_in( - ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) - rescue Kubeclient::HttpError => e - log_error(e) - app.make_update_errored!("Kubernetes error: #{e.error_code}") - rescue StandardError => e - log_error(e) - app.make_update_errored!("Can't start upgrade process.") - end + log_event(:schedule_wait_for_upgrade) + ClusterWaitForAppInstallationWorker.perform_in( + ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) + rescue Kubeclient::HttpError => e + log_error(e) + app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code }) + rescue StandardError => e + log_error(e) + app.make_errored!(_('Failed to upgrade.')) end end end diff --git a/app/services/clusters/build_service.rb b/app/services/clusters/build_service.rb index 8de73831164..b1ac5549e30 100644 --- a/app/services/clusters/build_service.rb +++ b/app/services/clusters/build_service.rb @@ -12,6 +12,8 @@ module Clusters cluster.cluster_type = :project_type when ::Group cluster.cluster_type = :group_type + when Instance + cluster.cluster_type = :instance_type else raise NotImplementedError end diff --git a/app/services/clusters/create_service.rb b/app/services/clusters/create_service.rb index 5a9da053780..886e484caaf 100644 --- a/app/services/clusters/create_service.rb +++ b/app/services/clusters/create_service.rb @@ -38,6 +38,8 @@ module Clusters { cluster_type: :project_type, projects: [clusterable] } when ::Group { cluster_type: :group_type, groups: [clusterable] } + when Instance + { cluster_type: :instance_type } else raise NotImplementedError end diff --git a/app/services/clusters/refresh_service.rb b/app/services/clusters/refresh_service.rb index 7c82b98a33f..3752a306793 100644 --- a/app/services/clusters/refresh_service.rb +++ b/app/services/clusters/refresh_service.rb @@ -21,7 +21,7 @@ module Clusters private_class_method :projects_with_missing_kubernetes_namespaces_for_cluster def self.clusters_with_missing_kubernetes_namespaces_for_project(project) - project.all_clusters.missing_kubernetes_namespace(project.kubernetes_namespaces) + project.clusters.managed.missing_kubernetes_namespace(project.kubernetes_namespaces) end private_class_method :clusters_with_missing_kubernetes_namespaces_for_project diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb index 34593e12bd5..bb34a3d3352 100644 --- a/app/services/commits/create_service.rb +++ b/app/services/commits/create_service.rb @@ -11,6 +11,7 @@ module Commits @start_project = params[:start_project] || @project @start_branch = params[:start_branch] @branch_name = params[:branch_name] + @force = params[:force] || false end def execute @@ -42,10 +43,14 @@ module Commits @start_branch != @branch_name || @start_project != @project end + def force? + !!@force + end + def validate! validate_permissions! validate_on_branch! - validate_branch_existance! + validate_branch_existence! validate_new_branch_name! if different_branch? end @@ -64,14 +69,14 @@ module Commits end end - def validate_branch_existance! - if !project.empty_repo? && different_branch? && repository.branch_exists?(@branch_name) + def validate_branch_existence! + if !project.empty_repo? && different_branch? && repository.branch_exists?(@branch_name) && !force? raise_error("A branch called '#{@branch_name}' already exists. Switch to that branch in order to make changes") end end def validate_new_branch_name! - result = ValidateNewBranchService.new(project, current_user).execute(@branch_name) + result = ValidateNewBranchService.new(project, current_user).execute(@branch_name, force: force?) if result[:status] == :error raise_error("Something went wrong when we tried to create '#{@branch_name}' for you: #{result[:message]}") diff --git a/app/services/compare_service.rb b/app/services/compare_service.rb index 3adf8a0c1a1..3f0aedfbfb2 100644 --- a/app/services/compare_service.rb +++ b/app/services/compare_service.rb @@ -3,7 +3,7 @@ require 'securerandom' # Compare 2 refs for one repo or between repositories -# and return Gitlab::Git::Compare object that responds to commits and diffs +# and return Compare object that responds to commits and diffs class CompareService attr_reader :start_project, :start_ref_name @@ -15,7 +15,7 @@ class CompareService def execute(target_project, target_ref, base_sha: nil, straight: false) raw_compare = target_project.repository.compare_source_branch(target_ref, start_project.repository, start_ref_name, straight: straight) - return unless raw_compare + return unless raw_compare && raw_compare.base && raw_compare.head Compare.new(raw_compare, target_project, diff --git a/app/services/concerns/suggestible.rb b/app/services/concerns/suggestible.rb new file mode 100644 index 00000000000..0cba9bf1b8a --- /dev/null +++ b/app/services/concerns/suggestible.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Suggestible + extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + + # This translates into limiting suggestion changes to `suggestion:-100+100`. + MAX_LINES_CONTEXT = 100.freeze + + def diff_lines + strong_memoize(:diff_lines) do + Gitlab::Diff::SuggestionDiff.new(self).diff_lines + end + end + + def fetch_from_content + diff_file.new_blob_lines_between(from_line, to_line).join + end + + def from_line + real_above = [lines_above, MAX_LINES_CONTEXT].min + [target_line - real_above, 1].max + end + + def to_line + real_below = [lines_below, MAX_LINES_CONTEXT].min + target_line + real_below + end + + def diff_file + raise NotImplementedError + end + + def target_line + raise NotImplementedError + end +end diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb index 6713b6617ae..1c828234f1b 100644 --- a/app/services/concerns/users/participable_service.rb +++ b/app/services/concerns/users/participable_service.rb @@ -28,19 +28,35 @@ module Users end def groups - current_user.authorized_groups.sort_by(&:path).map do |group| - group_as_hash(group) + group_counts = GroupMember + .of_groups(current_user.authorized_groups) + .non_request + .count_users_by_group_id + + current_user.authorized_groups.with_route.sort_by(&:path).map do |group| + group_as_hash(group, group_counts) end end private def user_as_hash(user) - { type: user.class.name, username: user.username, name: user.name, avatar_url: user.avatar_url } + { + type: user.class.name, + username: user.username, + name: user.name, + avatar_url: user.avatar_url + } end - def group_as_hash(group) - { type: group.class.name, username: group.full_path, name: group.full_name, avatar_url: group.avatar_url, count: group.users.count } + def group_as_hash(group, group_counts) + { + type: group.class.name, + username: group.full_path, + name: group.full_name, + avatar_url: group.avatar_url, + count: group_counts.fetch(group.id, 0) + } end end end diff --git a/app/services/concerns/validates_classification_label.rb b/app/services/concerns/validates_classification_label.rb new file mode 100644 index 00000000000..ebcf5c24ff8 --- /dev/null +++ b/app/services/concerns/validates_classification_label.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ValidatesClassificationLabel + def validate_classification_label(record, attribute_name) + return unless ::Gitlab::ExternalAuthorization.enabled? + return unless classification_label_change?(record, attribute_name) + + new_label = params[attribute_name].presence + new_label ||= ::Gitlab::CurrentSettings.current_application_settings + .external_authorization_service_default_label + + unless ::Gitlab::ExternalAuthorization.access_allowed?(current_user, new_label) + reason = rejection_reason_for_label(new_label) + message = s_('ClassificationLabelUnavailable|is unavailable: %{reason}') % { reason: reason } + record.errors.add(attribute_name, message) + end + end + + def rejection_reason_for_label(label) + reason_from_service = ::Gitlab::ExternalAuthorization.rejection_reason(current_user, label).presence + reason_from_service || _("Access to '%{classification_label}' not allowed") % { classification_label: label } + end + + def classification_label_change?(record, attribute_name) + params.key?(attribute_name) || record.new_record? + end +end diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb index 8322a3d74f4..fd41ce54486 100644 --- a/app/services/delete_branch_service.rb +++ b/app/services/delete_branch_service.rb @@ -6,37 +6,25 @@ class DeleteBranchService < BaseService branch = repository.find_branch(branch_name) unless current_user.can?(:push_code, project) - return error('You dont have push access to repo', 405) + return ServiceResponse.error( + message: 'You dont have push access to repo', + http_status: 405) end unless branch - return error('No such branch', 404) + return ServiceResponse.error( + message: 'No such branch', + http_status: 404) end if repository.rm_branch(current_user, branch_name) - success('Branch was deleted') + ServiceResponse.success(message: 'Branch was deleted') else - error('Failed to remove branch') + ServiceResponse.error( + message: 'Failed to remove branch', + http_status: 400) end rescue Gitlab::Git::PreReceiveError => ex - error(ex.message) - end - - def error(message, return_code = 400) - super(message).merge(return_code: return_code) - end - - def success(message) - super().merge(message: message) - end - - def build_push_data(branch) - Gitlab::DataBuilder::Push.build( - project, - current_user, - branch.dereferenced_target.sha, - Gitlab::Git::BLANK_SHA, - "#{Gitlab::Git::BRANCH_REF_PREFIX}#{branch.name}", - []) + ServiceResponse.error(message: ex.message, http_status: 400) end end diff --git a/app/services/error_tracking/list_issues_service.rb b/app/services/error_tracking/list_issues_service.rb index a6c6bec9598..86ab21fa865 100644 --- a/app/services/error_tracking/list_issues_service.rb +++ b/app/services/error_tracking/list_issues_service.rb @@ -18,7 +18,7 @@ module ErrorTracking end if result[:error].present? - return error(result[:error], :bad_request) + return error(result[:error], http_status_from_error_type(result[:error_type])) end success(issues: result[:issues]) @@ -30,6 +30,15 @@ module ErrorTracking private + def http_status_from_error_type(error_type) + case error_type + when ErrorTracking::ProjectErrorTrackingSetting::SENTRY_API_ERROR_TYPE_MISSING_KEYS + :internal_server_error + else + :bad_request + end + end + def project_error_tracking_setting project.error_tracking_setting end diff --git a/app/services/error_tracking/list_projects_service.rb b/app/services/error_tracking/list_projects_service.rb index c6e8be0f2be..8d08f0cda94 100644 --- a/app/services/error_tracking/list_projects_service.rb +++ b/app/services/error_tracking/list_projects_service.rb @@ -15,8 +15,8 @@ module ErrorTracking result = setting.list_sentry_projects rescue Sentry::Client::Error => e return error(e.message, :bad_request) - rescue Sentry::Client::SentryError => e - return error(e.message, :unprocessable_entity) + rescue Sentry::Client::MissingKeysError => e + return error(e.message, :internal_server_error) end success(projects: result[:projects]) @@ -28,8 +28,8 @@ module ErrorTracking (project.error_tracking_setting || project.build_error_tracking_setting).tap do |setting| setting.api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from( api_host: params[:api_host], - organization_slug: nil, - project_slug: nil + organization_slug: 'org', + project_slug: 'proj' ) setting.token = params[:token] diff --git a/app/services/files/delete_service.rb b/app/services/files/delete_service.rb index 0ec1f79d396..f47eb4fccd4 100644 --- a/app/services/files/delete_service.rb +++ b/app/services/files/delete_service.rb @@ -20,7 +20,7 @@ module Files super if file_has_changed?(@file_path, @last_commit_sha) - raise FileChangedError, "You are attempting to delete a file that has been previously updated." + raise FileChangedError, _("You are attempting to delete a file that has been previously updated.") end end end diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb index 927634c2159..c1bc26c330a 100644 --- a/app/services/files/multi_service.rb +++ b/app/services/files/multi_service.rb @@ -46,7 +46,8 @@ module Files author_email: @author_email, author_name: @author_name, start_project: @start_project, - start_branch_name: @start_branch + start_branch_name: @start_branch, + force: force? ) rescue ArgumentError => e raise_error(e) diff --git a/app/services/files/update_service.rb b/app/services/files/update_service.rb index 2b3e96e6c53..54ab07da680 100644 --- a/app/services/files/update_service.rb +++ b/app/services/files/update_service.rb @@ -19,7 +19,7 @@ module Files super if file_has_changed?(@file_path, @last_commit_sha) - raise FileChangedError, "You are attempting to update a file that has changed since you started editing it." + raise FileChangedError, _('You are attempting to update a file that has changed since you started editing it.') end end end diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb new file mode 100644 index 00000000000..d30df34e54b --- /dev/null +++ b/app/services/git/base_hooks_service.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Git + class BaseHooksService < ::BaseService + include Gitlab::Utils::StrongMemoize + + # The N most recent commits to process in a single push payload. + PROCESS_COMMIT_LIMIT = 100 + + def execute + project.repository.after_create if project.empty_repo? + + create_events + create_pipelines + execute_project_hooks + + # Not a hook, but it needs access to the list of changed commits + enqueue_invalidate_cache + + update_remote_mirrors + + push_data + end + + private + + def hook_name + raise NotImplementedError, "Please implement #{self.class}##{__method__}" + end + + def commits + raise NotImplementedError, "Please implement #{self.class}##{__method__}" + end + + def limited_commits + commits.last(PROCESS_COMMIT_LIMIT) + end + + def commits_count + commits.count + end + + def event_message + nil + end + + def invalidated_file_types + [] + end + + def create_events + EventCreateService.new.push(project, current_user, push_data) + end + + def create_pipelines + return unless params.fetch(:create_pipelines, true) + + Ci::CreatePipelineService + .new(project, current_user, push_data) + .execute(:push, pipeline_options) + end + + def execute_project_hooks + project.execute_hooks(push_data, hook_name) + project.execute_services(push_data, hook_name) + end + + def enqueue_invalidate_cache + ProjectCacheWorker.perform_async( + project.id, + invalidated_file_types, + [:commit_count, :repository_size] + ) + end + + def push_data + @push_data ||= Gitlab::DataBuilder::Push.build( + project: project, + user: current_user, + oldrev: params[:oldrev], + newrev: params[:newrev], + ref: params[:ref], + commits: limited_commits, + message: event_message, + commits_count: commits_count, + push_options: params[:push_options] || {} + ) + + # Dependent code may modify the push data, so return a duplicate each time + @push_data.dup + end + + # to be overridden in EE + def pipeline_options + {} + end + + def update_remote_mirrors + return unless project.has_remote_mirror? + + project.mark_stuck_remote_mirrors_as_failed! + project.update_remote_mirrors + end + end +end diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb new file mode 100644 index 00000000000..d21a6bb1b9a --- /dev/null +++ b/app/services/git/branch_hooks_service.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +module Git + class BranchHooksService < ::Git::BaseHooksService + def execute + execute_branch_hooks + + super.tap do + enqueue_update_gpg_signatures + end + end + + private + + def hook_name + :push_hooks + end + + def commits + strong_memoize(:commits) do + if creating_default_branch? + # The most recent PROCESS_COMMIT_LIMIT commits in the default branch + offset = [count_commits_in_branch - PROCESS_COMMIT_LIMIT, 0].max + project.repository.commits(params[:newrev], offset: offset, 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]) + elsif updating_branch? + project.repository.commits_between(params[:oldrev], params[:newrev]) + else # removing branch + [] + end + end + end + + def commits_count + return count_commits_in_branch if creating_default_branch? + + super + end + + def invalidated_file_types + return super unless default_branch? && !creating_branch? + + paths = limited_commits.each_with_object(Set.new) do |commit, set| + commit.raw_deltas.each do |diff| + set << diff.new_path + end + end + + Gitlab::FileDetector.types_in_paths(paths) + end + + def execute_branch_hooks + project.repository.after_push_commit(branch_name) + + branch_create_hooks if creating_branch? + branch_update_hooks if updating_branch? + branch_change_hooks if creating_branch? || updating_branch? + branch_remove_hooks if removing_branch? + end + + def branch_create_hooks + project.repository.after_create_branch + project.after_create_default_branch if default_branch? + end + + 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? + end + + def branch_change_hooks + enqueue_process_commit_messages + end + + def branch_remove_hooks + project.repository.after_remove_branch + end + + # Schedules processing of commit messages + def enqueue_process_commit_messages + # don't process commits for the initial push to the default branch + return if creating_default_branch? + + limited_commits.each do |commit| + next unless commit.matches_cross_reference_regex? + + ProcessCommitWorker.perform_async( + project.id, + current_user.id, + commit.to_hash, + default_branch? + ) + end + end + + def enqueue_update_gpg_signatures + unsigned = GpgSignature.unsigned_commit_shas(limited_commits.map(&:sha)) + return if unsigned.empty? + + signable = Gitlab::Git::Commit.shas_with_signatures(project.repository, unsigned) + return if signable.empty? + + CreateGpgSignatureWorker.perform_async(signable, project.id) + end + + def creating_branch? + Gitlab::Git.blank_ref?(params[:oldrev]) + end + + def updating_branch? + !creating_branch? && !removing_branch? + end + + def removing_branch? + Gitlab::Git.blank_ref?(params[:newrev]) + end + + def creating_default_branch? + creating_branch? && default_branch? + end + + def count_commits_in_branch + strong_memoize(:count_commits_in_branch) do + project.repository.commit_count_for_ref(params[:ref]) + end + end + + def default_branch? + strong_memoize(:default_branch) do + [nil, branch_name].include?(project.default_branch) + end + end + + def branch_name + strong_memoize(:branch_name) { Gitlab::Git.ref_name(params[:ref]) } + end + end +end diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb new file mode 100644 index 00000000000..c4910180787 --- /dev/null +++ b/app/services/git/branch_push_service.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Git + class BranchPushService < ::BaseService + include Gitlab::Access + include Gitlab::Utils::StrongMemoize + + # This method will be called after each git update + # and only if the provided user and project are present in GitLab. + # + # All callbacks for post receive action should be placed here. + # + # Next, this method: + # 1. Creates the push event + # 2. Updates merge requests + # 3. Recognizes cross-references from commit messages + # 4. Executes the project's webhooks + # 5. Executes the project's services + # 6. Checks if the project's main language has changed + # + def execute + return unless Gitlab::Git.branch_ref?(params[:ref]) + + enqueue_update_mrs + enqueue_detect_repository_languages + + execute_related_hooks + perform_housekeeping + + stop_environments + + true + end + + # Update merge requests that may be affected by this push. A new branch + # could cause the last commit of a merge request to change. + def enqueue_update_mrs + UpdateMergeRequestsWorker.perform_async( + project.id, + current_user.id, + params[:oldrev], + params[:newrev], + params[:ref] + ) + end + + def enqueue_detect_repository_languages + return unless default_branch? + + DetectRepositoryLanguagesWorker.perform_async(project.id) + end + + # Only stop environments if the ref is a branch that is being deleted + def stop_environments + return unless removing_branch? + + 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 + + def perform_housekeeping + housekeeping = Projects::HousekeepingService.new(project) + housekeeping.increment! + housekeeping.execute if housekeeping.needed? + rescue Projects::HousekeepingService::LeaseTaken + end + + def removing_branch? + Gitlab::Git.blank_ref?(params[:newrev]) + end + + def branch_name + strong_memoize(:branch_name) { Gitlab::Git.ref_name(params[:ref]) } + end + + def default_branch? + strong_memoize(:default_branch) do + [nil, branch_name].include?(project.default_branch) + end + end + end +end diff --git a/app/services/git/tag_hooks_service.rb b/app/services/git/tag_hooks_service.rb new file mode 100644 index 00000000000..18eb780579f --- /dev/null +++ b/app/services/git/tag_hooks_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Git + class TagHooksService < ::Git::BaseHooksService + private + + def hook_name + :tag_push_hooks + end + + def commits + [tag_commit].compact + end + + def event_message + tag&.message + end + + def tag + strong_memoize(:tag) do + next if Gitlab::Git.blank_ref?(params[:newrev]) + + tag_name = Gitlab::Git.ref_name(params[:ref]) + tag = project.repository.find_tag(tag_name) + + tag if tag && tag.target == params[:newrev] + end + end + + def tag_commit + strong_memoize(:tag_commit) do + project.commit(tag.dereferenced_target) if tag + end + end + end +end diff --git a/app/services/git/tag_push_service.rb b/app/services/git/tag_push_service.rb new file mode 100644 index 00000000000..ee4166dccd0 --- /dev/null +++ b/app/services/git/tag_push_service.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Git + class TagPushService < ::BaseService + def execute + return unless Gitlab::Git.tag_ref?(params[:ref]) + + project.repository.before_push_tag + TagHooksService.new(project, current_user, params).execute + + true + end + end +end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb deleted file mode 100644 index f387c749a21..00000000000 --- a/app/services/git_push_service.rb +++ /dev/null @@ -1,240 +0,0 @@ -# frozen_string_literal: true - -class GitPushService < BaseService - attr_accessor :push_data, :push_commits - include Gitlab::Access - include Gitlab::Utils::StrongMemoize - - # The N most recent commits to process in a single push payload. - PROCESS_COMMIT_LIMIT = 100 - - # This method will be called after each git update - # and only if the provided user and project are present in GitLab. - # - # All callbacks for post receive action should be placed here. - # - # Next, this method: - # 1. Creates the push event - # 2. Updates merge requests - # 3. Recognizes cross-references from commit messages - # 4. Executes the project's webhooks - # 5. Executes the project's services - # 6. Checks if the project's main language has changed - # - def execute - project.repository.after_create if project.empty_repo? - project.repository.after_push_commit(branch_name) - - if push_remove_branch? - project.repository.after_remove_branch - @push_commits = [] - elsif push_to_new_branch? - project.repository.after_create_branch - - # Re-find the pushed commits. - if default_branch? - # Initial push to the default branch. Take the full history of that branch as "newly pushed". - process_default_branch - else - # 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. - @push_commits = project.repository.commits_between(project.default_branch, params[:newrev]) - - # don't process commits for the initial push to the default branch - process_commit_messages - end - elsif push_to_existing_branch? - # Collect data for this git push - @push_commits = project.repository.commits_between(params[:oldrev], params[:newrev]) - - process_commit_messages - - # Update the bare repositories info/attributes file using the contents of the default branches - # .gitattributes file - update_gitattributes if default_branch? - end - - execute_related_hooks - perform_housekeeping - - update_remote_mirrors - update_caches - - update_signatures - end - - def update_gitattributes - project.repository.copy_gitattributes(params[:ref]) - end - - def update_caches - if default_branch? - if push_to_new_branch? - # If this is the initial push into the default branch, the file type caches - # will already be reset as a result of `Project#change_head`. - types = [] - else - paths = Set.new - - last_pushed_commits.each do |commit| - commit.raw_deltas.each do |diff| - paths << diff.new_path - end - end - - types = Gitlab::FileDetector.types_in_paths(paths.to_a) - end - - DetectRepositoryLanguagesWorker.perform_async(@project.id, current_user.id) - else - types = [] - end - - ProjectCacheWorker.perform_async(project.id, types, [:commit_count, :repository_size]) - end - - # rubocop: disable CodeReuse/ActiveRecord - def update_signatures - commit_shas = last_pushed_commits.map(&:sha) - - return if commit_shas.empty? - - shas_with_cached_signatures = GpgSignature.where(commit_sha: commit_shas).pluck(:commit_sha) - commit_shas -= shas_with_cached_signatures - - return if commit_shas.empty? - - commit_shas = Gitlab::Git::Commit.shas_with_signatures(project.repository, commit_shas) - - CreateGpgSignatureWorker.perform_async(commit_shas, project.id) - end - # rubocop: enable CodeReuse/ActiveRecord - - # Schedules processing of commit messages. - def process_commit_messages - default = default_branch? - - last_pushed_commits.each do |commit| - if commit.matches_cross_reference_regex? - ProcessCommitWorker - .perform_async(project.id, current_user.id, commit.to_hash, default) - end - end - end - - protected - - def update_remote_mirrors - return unless project.has_remote_mirror? - - project.mark_stuck_remote_mirrors_as_failed! - project.update_remote_mirrors - end - - def execute_related_hooks - # Update merge requests that may be affected by this push. A new branch - # could cause the last commit of a merge request to change. - # - UpdateMergeRequestsWorker - .perform_async(project.id, current_user.id, params[:oldrev], params[:newrev], params[:ref]) - - EventCreateService.new.push(project, current_user, build_push_data) - Ci::CreatePipelineService.new(project, current_user, build_push_data).execute(:push, pipeline_options) - - project.execute_hooks(build_push_data.dup, :push_hooks) - project.execute_services(build_push_data.dup, :push_hooks) - - if push_remove_branch? - AfterBranchDeleteService - .new(project, current_user) - .execute(branch_name) - end - end - - def perform_housekeeping - housekeeping = Projects::HousekeepingService.new(project) - housekeeping.increment! - housekeeping.execute if housekeeping.needed? - rescue Projects::HousekeepingService::LeaseTaken - end - - def process_default_branch - offset = [push_commits_count_for_ref - PROCESS_COMMIT_LIMIT, 0].max - @push_commits = project.repository.commits(params[:newrev], offset: offset, limit: PROCESS_COMMIT_LIMIT) - - project.after_create_default_branch - end - - def build_push_data - @push_data ||= Gitlab::DataBuilder::Push.build( - project, - current_user, - params[:oldrev], - params[:newrev], - params[:ref], - @push_commits, - commits_count: commits_count, - push_options: params[:push_options] || []) - end - - def push_to_existing_branch? - # Return if this is not a push to a branch (e.g. new commits) - branch_ref? && !Gitlab::Git.blank_ref?(params[:oldrev]) - end - - def push_to_new_branch? - strong_memoize(:push_to_new_branch) do - branch_ref? && Gitlab::Git.blank_ref?(params[:oldrev]) - end - end - - def push_remove_branch? - strong_memoize(:push_remove_branch) do - branch_ref? && Gitlab::Git.blank_ref?(params[:newrev]) - end - end - - def default_branch? - branch_ref? && - (branch_name == project.default_branch || project.default_branch.nil?) - end - - def commit_user(commit) - commit.author || current_user - end - - def branch_name - strong_memoize(:branch_name) do - Gitlab::Git.ref_name(params[:ref]) - end - end - - def branch_ref? - strong_memoize(:branch_ref) do - Gitlab::Git.branch_ref?(params[:ref]) - end - end - - def commits_count - return push_commits_count_for_ref if default_branch? && push_to_new_branch? - - Array(@push_commits).size - end - - def push_commits_count_for_ref - strong_memoize(:push_commits_count_for_ref) do - project.repository.commit_count_for_ref(params[:ref]) - end - end - - def last_pushed_commits - @last_pushed_commits ||= @push_commits.last(PROCESS_COMMIT_LIMIT) - end - - private - - def pipeline_options - {} # to be overridden in EE - end -end diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb deleted file mode 100644 index e39b3603c6c..00000000000 --- a/app/services/git_tag_push_service.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -class GitTagPushService < BaseService - attr_accessor :push_data - - def execute - project.repository.after_create if project.empty_repo? - project.repository.before_push_tag - - @push_data = build_push_data - - EventCreateService.new.push(project, current_user, push_data) - Ci::CreatePipelineService.new(project, current_user, push_data).execute(:push, pipeline_options) - - SystemHooksService.new.execute_hooks(build_system_push_data, :tag_push_hooks) - project.execute_hooks(push_data.dup, :tag_push_hooks) - project.execute_services(push_data.dup, :tag_push_hooks) - - ProjectCacheWorker.perform_async(project.id, [], [:commit_count, :repository_size]) - - true - end - - private - - def build_push_data - commits = [] - message = nil - - unless Gitlab::Git.blank_ref?(params[:newrev]) - tag_name = Gitlab::Git.ref_name(params[:ref]) - tag = project.repository.find_tag(tag_name) - - if tag && tag.target == params[:newrev] - commit = project.commit(tag.dereferenced_target) - commits = [commit].compact - message = tag.message - end - end - - Gitlab::DataBuilder::Push.build( - project, - current_user, - params[:oldrev], - params[:newrev], - params[:ref], - commits, - message, - push_options: params[:push_options] || []) - end - - def build_system_push_data - Gitlab::DataBuilder::Push.build( - project, - current_user, - params[:oldrev], - params[:newrev], - params[:ref], - [], - '') - end - - def pipeline_options - {} # to be overridden in EE - end -end diff --git a/app/services/groups/auto_devops_service.rb b/app/services/groups/auto_devops_service.rb new file mode 100644 index 00000000000..1925e0cc0ea --- /dev/null +++ b/app/services/groups/auto_devops_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Groups + class AutoDevopsService < Groups::BaseService + def execute + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_group, group) + + group.update(auto_devops_enabled: auto_devops_enabled) + end + + private + + def auto_devops_enabled + params[:auto_devops_enabled] + end + end +end diff --git a/app/services/groups/base_service.rb b/app/services/groups/base_service.rb index 8c8acce5ca5..019cd047ae9 100644 --- a/app/services/groups/base_service.rb +++ b/app/services/groups/base_service.rb @@ -7,5 +7,11 @@ module Groups def initialize(group, user, params = {}) @group, @current_user, @params = group, user, params.dup end + + private + + def remove_unallowed_params + # overridden in EE + end end end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index 99ead467f74..e9659f5489a 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -8,6 +8,8 @@ module Groups end def execute + remove_unallowed_params + @group = Group.new(params) after_build_hook(@group, params) @@ -44,13 +46,13 @@ module Groups if @group.subgroup? unless can?(current_user, :create_subgroup, @group.parent) @group.parent = nil - @group.errors.add(:parent_id, 'You don’t have permission to create a subgroup in this group.') + @group.errors.add(:parent_id, s_('CreateGroup|You don’t have permission to create a subgroup in this group.')) return false end else unless can?(current_user, :create_group) - @group.errors.add(:base, 'You don’t have permission to create groups.') + @group.errors.add(:base, s_('CreateGroup|You don’t have permission to create groups.')) return false end @@ -60,12 +62,16 @@ module Groups end def can_use_visibility_level? - unless Gitlab::VisibilityLevel.allowed_for?(current_user, params[:visibility_level]) + unless Gitlab::VisibilityLevel.allowed_for?(current_user, visibility_level) deny_visibility_level(@group) return false end true end + + def visibility_level + params[:visibility].present? ? Gitlab::VisibilityLevel.level_value(params[:visibility]) : params[:visibility_level] + end end end diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb index 641111aeadc..654fe84e3dc 100644 --- a/app/services/groups/destroy_service.rb +++ b/app/services/groups/destroy_service.rb @@ -20,7 +20,7 @@ module Groups end # reload the relation to prevent triggering destroy hooks on the projects again - group.projects.reload + group.projects.reset group.children.each do |group| # This needs to be synchronous since the namespace gets destroyed below diff --git a/app/services/groups/nested_create_service.rb b/app/services/groups/nested_create_service.rb index f01f5656296..01bd685712b 100644 --- a/app/services/groups/nested_create_service.rb +++ b/app/services/groups/nested_create_service.rb @@ -12,7 +12,7 @@ module Groups end def execute - return nil unless group_path + return unless group_path if namespace = namespace_or_group(group_path) return namespace diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index f64e327416a..98e7c311572 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -3,11 +3,11 @@ module Groups class TransferService < Groups::BaseService ERROR_MESSAGES = { - database_not_supported: 'Database is not supported.', - namespace_with_same_path: 'The parent group already has a subgroup with the same path.', - group_is_already_root: 'Group is already a root group.', - same_parent_as_current: 'Group is already associated to the parent group.', - invalid_policies: "You don't have enough permissions." + database_not_supported: s_('TransferGroup|Database is not supported.'), + 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.") }.freeze TransferError = Class.new(StandardError) @@ -26,7 +26,7 @@ module Groups rescue TransferError, ActiveRecord::RecordInvalid, Gitlab::UpdatePathError => e @group.errors.clear - @error = "Transfer failed: " + e.message + @error = s_("TransferGroup|Transfer failed: %{error_message}") % { error_message: e.message } false end @@ -35,7 +35,10 @@ module Groups def proceed_to_transfer Group.transaction do update_group_attributes + ensure_ownership end + + true end def ensure_allowed_transfer @@ -95,6 +98,13 @@ module Groups end # rubocop: enable CodeReuse/ActiveRecord + def ensure_ownership + return if @new_parent_group + return unless @group.owners.empty? + + @group.add_owner(current_user) + end + def raise_transfer_error(message) raise TransferError, ERROR_MESSAGES[message] end diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index 787445180f0..73e1e00dc33 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -6,6 +6,7 @@ module Groups def execute reject_parent_id! + remove_unallowed_params return false unless valid_visibility_level_change?(group, params[:visibility_level]) diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb index a2533683da9..a322a306ba4 100644 --- a/app/services/import/github_service.rb +++ b/app/services/import/github_service.rb @@ -7,7 +7,7 @@ module Import def execute(access_params, provider) unless authorized? - return error('This namespace has already been taken! Please choose another one.', :unprocessable_entity) + return error(_('This namespace has already been taken! Please choose another one.'), :unprocessable_entity) end project = Gitlab::LegacyGithubImport::ProjectCreator diff --git a/app/services/issuable/clone/content_rewriter.rb b/app/services/issuable/clone/content_rewriter.rb index e1e0b75085d..00d7078859d 100644 --- a/app/services/issuable/clone/content_rewriter.rb +++ b/app/services/issuable/clone/content_rewriter.rb @@ -28,6 +28,7 @@ module Issuable new_params = { project: new_entity.project, noteable: new_entity, note: rewrite_content(new_note.note), + note_html: nil, created_at: note.created_at, updated_at: note.updated_at } diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index ef991eaf234..26132f1824a 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -34,14 +34,20 @@ class IssuableBaseService < BaseService end def filter_assignee(issuable) - return unless params[:assignee_id].present? + return if params[:assignee_ids].blank? - assignee_id = params[:assignee_id] + unless issuable.allows_multiple_assignees? + params[:assignee_ids] = params[:assignee_ids].first(1) + end + + assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) } - if assignee_id.to_s == IssuableFinder::NONE - params[:assignee_id] = "" + if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE] + params[:assignee_ids] = [] + elsif assignee_ids.any? + params[:assignee_ids] = assignee_ids else - params.delete(:assignee_id) unless assignee_can_read?(issuable, assignee_id) + params.delete(:assignee_ids) end end @@ -70,26 +76,28 @@ class IssuableBaseService < BaseService end def filter_labels - filter_labels_in_param(:add_label_ids) - filter_labels_in_param(:remove_label_ids) - filter_labels_in_param(:label_ids) - find_or_create_label_ids + params[:add_label_ids] = labels_service.filter_labels_ids_in_param(:add_label_ids) if params[:add_label_ids] + params[:remove_label_ids] = labels_service.filter_labels_ids_in_param(:remove_label_ids) if params[:remove_label_ids] + + if params[:label_ids] + params[:label_ids] = labels_service.filter_labels_ids_in_param(:label_ids) + elsif params[:labels] + params[:label_ids] = labels_service.find_or_create_by_titles.map(&:id) + end end - # rubocop: disable CodeReuse/ActiveRecord def filter_labels_in_param(key) return if params[key].to_a.empty? - params[key] = available_labels.where(id: params[key]).pluck(:id) + params[key] = available_labels.id_in(params[key]).pluck_primary_key end - # rubocop: enable CodeReuse/ActiveRecord def find_or_create_label_ids labels = params.delete(:labels) return unless labels - params[:label_ids] = labels.split(",").map do |label_name| + params[:label_ids] = labels.map do |label_name| label = Labels::FindOrCreateService.new( current_user, parent, @@ -101,12 +109,17 @@ class IssuableBaseService < BaseService end.compact end - def process_label_ids(attributes, existing_label_ids: nil) + def labels_service + @labels_service ||= ::Labels::AvailableLabelsService.new(current_user, parent, params) + end + + def process_label_ids(attributes, existing_label_ids: nil, extra_label_ids: []) label_ids = attributes.delete(:label_ids) add_label_ids = attributes.delete(:add_label_ids) remove_label_ids = attributes.delete(:remove_label_ids) new_label_ids = existing_label_ids || label_ids || [] + new_label_ids |= extra_label_ids if add_label_ids.blank? && remove_label_ids.blank? new_label_ids = label_ids if label_ids @@ -115,11 +128,7 @@ class IssuableBaseService < BaseService new_label_ids -= remove_label_ids if remove_label_ids end - new_label_ids - end - - def available_labels - @available_labels ||= LabelsFinder.new(current_user, project_id: @project.id, include_ancestor_groups: true).execute + new_label_ids.uniq end def handle_quick_actions_on_create(issuable) @@ -145,7 +154,7 @@ class IssuableBaseService < BaseService params.delete(:state_event) params[:author] ||= current_user - params[:label_ids] = issuable.label_ids.to_a + process_label_ids(params) + params[:label_ids] = process_label_ids(params, extra_label_ids: issuable.label_ids.to_a) issuable.assign_attributes(params) @@ -349,7 +358,7 @@ class IssuableBaseService < BaseService end def has_changes?(issuable, old_labels: [], old_assignees: []) - valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] + valid_attrs = [:title, :description, :assignee_ids, :milestone_id, :target_branch] attrs_changed = valid_attrs.any? do |attr| issuable.previous_changes.include?(attr.to_s) @@ -387,4 +396,10 @@ class IssuableBaseService < BaseService def parent project end + + # we need to check this because milestone from milestone_id param is displayed on "new" page + # where private project milestone could leak without this check + def ensure_milestone_available(issuable) + issuable.milestone_id = nil unless issuable.milestone_available? + end end diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index ef08adf4f92..48ed5afbc2a 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -20,7 +20,7 @@ module Issues private def create_assignee_note(issue, old_assignees) - SystemNoteService.change_issue_assignees( + SystemNoteService.change_issuable_assignees( issue, issue.project, current_user, old_assignees) end @@ -31,26 +31,6 @@ module Issues issue.project.execute_services(issue_data, hooks_scope) end - # rubocop: disable CodeReuse/ActiveRecord - def filter_assignee(issuable) - return if params[:assignee_ids].blank? - - unless issuable.allows_multiple_assignees? - params[:assignee_ids] = params[:assignee_ids].take(1) - end - - assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) } - - if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE] - params[:assignee_ids] = [] - elsif assignee_ids.any? - params[:assignee_ids] = assignee_ids - else - params.delete(:assignee_ids) - end - end - # rubocop: enable CodeReuse/ActiveRecord - def update_project_counter_caches?(issue) super || issue.confidential_changed? end diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 3fb2c2b3007..61615ac2058 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -6,7 +6,9 @@ module Issues def execute filter_resolve_discussion_params - @issue = project.issues.new(issue_params) + @issue = project.issues.new(issue_params).tap do |issue| + ensure_milestone_available(issue) + end end def issue_params_with_info_from_discussions diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index e5cc12e6082..805721212ba 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -7,7 +7,7 @@ module Issues return issue unless can?(current_user, :update_issue, issue) close_issue(issue, - commit: commit, + closed_via: commit, notifications: notifications, system_note: system_note) end @@ -17,9 +17,9 @@ 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, commit: nil, notifications: true, system_note: true) + def close_issue(issue, closed_via: nil, notifications: true, system_note: true) if project.jira_tracker? && project.jira_service.active && issue.is_a?(ExternalIssue) - project.jira_service.close_issue(commit, issue) + project.jira_service.close_issue(closed_via, issue) todo_service.close_issue(issue, current_user) return issue end @@ -27,8 +27,11 @@ module Issues if project.issues_enabled? && issue.close issue.update(closed_by: current_user) event_service.close_issue(issue, current_user) - create_note(issue, commit) if system_note - notification_service.async.close_issue(issue, current_user) if notifications + create_note(issue, closed_via) if system_note + + closed_via = _("commit %{commit_id}") % { commit_id: closed_via.id } if closed_via.is_a?(Commit) + + notification_service.async.close_issue(issue, current_user, closed_via: closed_via) if notifications todo_service.close_issue(issue, current_user) execute_hooks(issue, 'close') invalidate_cache_counts(issue, users: issue.assignees) diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 41b6a96b005..334fadadb6f 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -8,11 +8,11 @@ module Issues @target_project = target_project unless issue.can_move?(current_user, @target_project) - raise MoveError, 'Cannot move issue due to insufficient permissions!' + raise MoveError, s_('MoveIssue|Cannot move issue due to insufficient permissions!') end if @project == @target_project - raise MoveError, 'Cannot move issue to project it originates from!' + raise MoveError, s_('MoveIssue|Cannot move issue to project it originates from!') end super diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index cec5b5734c0..cb2337d29d4 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -39,7 +39,7 @@ module Issues if issue.assignees != old_assignees create_assignee_note(issue, old_assignees) notification_service.async.reassigned_issue(issue, current_user, old_assignees) - todo_service.reassigned_issue(issue, current_user, old_assignees) + todo_service.reassigned_issuable(issue, current_user, old_assignees) end if issue.previous_changes.include?('confidential') diff --git a/app/services/labels/available_labels_service.rb b/app/services/labels/available_labels_service.rb new file mode 100644 index 00000000000..fe477d96970 --- /dev/null +++ b/app/services/labels/available_labels_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true +module Labels + class AvailableLabelsService + attr_reader :current_user, :parent, :params + + def initialize(current_user, parent, params) + @current_user = current_user + @parent = parent + @params = params + end + + def find_or_create_by_titles + labels = params.delete(:labels) + + return [] unless labels + + labels = labels.split(',') if labels.is_a?(String) + + labels.map do |label_name| + label = Labels::FindOrCreateService.new( + current_user, + parent, + include_ancestor_groups: true, + title: label_name.strip, + available_labels: available_labels + ).execute + + label + end.compact + end + + def filter_labels_ids_in_param(key) + return [] if params[key].to_a.empty? + + # rubocop:disable CodeReuse/ActiveRecord + available_labels.by_ids(params[key]).pluck(:id) + # rubocop:enable CodeReuse/ActiveRecord + end + + private + + def available_labels + @available_labels ||= LabelsFinder.new(current_user, finder_params).execute + end + + def finder_params + params = { include_ancestor_groups: true } + + case parent + when Group + params[:group_id] = parent.id + params[:only_group_labels] = true + when Project + params[:project_id] = parent.id + end + + params + end + end +end diff --git a/app/services/lfs/file_transformer.rb b/app/services/lfs/file_transformer.rb index 6ecf583cb6a..5239fe1b6e3 100644 --- a/app/services/lfs/file_transformer.rb +++ b/app/services/lfs/file_transformer.rb @@ -24,7 +24,7 @@ module Lfs def new_file(file_path, file_content, encoding: nil) if project.lfs_enabled? && lfs_file?(file_path) - file_content = Base64.decode64(file_content) if encoding == 'base64' + file_content = parse_file_content(file_content, encoding: encoding) lfs_pointer_file = Gitlab::Git::LfsPointerFile.new(file_content) lfs_object = create_lfs_object!(lfs_pointer_file, file_content) @@ -66,5 +66,12 @@ module Lfs def link_lfs_object!(lfs_object) project.lfs_objects << lfs_object end + + def parse_file_content(file_content, encoding: nil) + return file_content.read if file_content.respond_to?(:read) + return Base64.decode64(file_content) if encoding == 'base64' + + file_content + end end end diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index cf710fef52b..d6b17ec10be 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -5,11 +5,11 @@ module Members DEFAULT_LIMIT = 100 def execute(source) - return error('No users specified.') if params[:user_ids].blank? + return error(s_('AddMember|No users specified.')) if params[:user_ids].blank? user_ids = params[:user_ids].split(',').uniq - return error("Too many users specified (limit is #{user_limit})") if + return error(s_("AddMember|Too many users specified (limit is %{user_limit})") % { user_limit: user_limit }) if user_limit && user_ids.size > user_limit members = source.add_users( @@ -23,7 +23,16 @@ module Members members.each do |member| if member.errors.any? - errors << "#{member.user.username}: #{member.errors.full_messages.to_sentence}" + current_error = + # Invited users may not have an associated user + if member.user.present? + "#{member.user.username}: " + else + "" + end + + current_error += member.errors.full_messages.to_sentence + errors << current_error else after_execute(member: member) end diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb index f9717a9426b..c8d5e563cd8 100644 --- a/app/services/members/destroy_service.rb +++ b/app/services/members/destroy_service.rb @@ -45,7 +45,7 @@ module Members def delete_subgroup_members(member) groups = member.group.descendants - GroupMember.in_groups(groups).with_user(member.user).each do |group_member| + GroupMember.of_groups(groups).with_user(member.user).each do |group_member| self.class.new(current_user).execute(group_member, skip_authorization: @skip_auth, skip_subresources: true) end end diff --git a/app/services/merge_requests/add_todo_when_build_fails_service.rb b/app/services/merge_requests/add_todo_when_build_fails_service.rb index 79c43b8e7d5..d3ef892875b 100644 --- a/app/services/merge_requests/add_todo_when_build_fails_service.rb +++ b/app/services/merge_requests/add_todo_when_build_fails_service.rb @@ -7,7 +7,7 @@ module MergeRequests def execute(commit_status) return if commit_status.allow_failure? || commit_status.retried? - commit_status_merge_requests(commit_status) do |merge_request| + pipeline_merge_requests(commit_status.pipeline) do |merge_request| todo_service.merge_request_build_failed(merge_request) end end @@ -16,7 +16,7 @@ module MergeRequests # build is retried # def close(commit_status) - commit_status_merge_requests(commit_status) do |merge_request| + pipeline_merge_requests(commit_status.pipeline) do |merge_request| todo_service.merge_request_build_retried(merge_request) end end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index ac51fee0b3f..2cfed62ce49 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -24,6 +24,11 @@ module MergeRequests end end + def cleanup_environments(merge_request) + Ci::StopEnvironmentsService.new(merge_request.source_project, current_user) + .execute_for_merge_request(merge_request) + end + private def handle_wip_event(merge_request) @@ -49,28 +54,18 @@ module MergeRequests MergeRequestMetricsService.new(merge_request.metrics) end - def create_assignee_note(merge_request) - SystemNoteService.change_assignee( - merge_request, merge_request.project, current_user, merge_request.assignee) + def create_assignee_note(merge_request, old_assignees) + SystemNoteService.change_issuable_assignees( + merge_request, merge_request.project, current_user, old_assignees) end - def create_merge_request_pipeline(merge_request, user) - return unless Feature.enabled?(:ci_merge_request_pipeline, - merge_request.source_project, - default_enabled: true) - - ## - # UpdateMergeRequestsWorker could be retried by an exception. - # MR pipelines should not be recreated in such case. - return if merge_request.merge_request_pipeline_exists? - return if merge_request.has_no_commits? - - Ci::CreatePipelineService - .new(merge_request.source_project, user, ref: merge_request.source_branch) - .execute(:merge_request, - ignore_skip_ci: true, - save_on_errors: false, - merge_request: merge_request) + def create_pipeline_for(merge_request, user) + MergeRequests::CreatePipelineService.new(project, user).execute(merge_request) + end + + def can_use_merge_request_ref?(merge_request) + Feature.enabled?(:ci_use_merge_request_ref, project, default_enabled: true) && + !merge_request.for_fork? end # Returns all origin and fork merge requests from `@project` satisfying passed arguments. @@ -85,22 +80,11 @@ module MergeRequests # rubocop: enable CodeReuse/ActiveRecord def pipeline_merge_requests(pipeline) - merge_requests_for(pipeline.ref).each do |merge_request| + pipeline.all_merge_requests.opened.each do |merge_request| next unless pipeline == merge_request.head_pipeline yield merge_request end end - - def commit_status_merge_requests(commit_status) - merge_requests_for(commit_status.ref).each do |merge_request| - pipeline = merge_request.head_pipeline - - next unless pipeline - next unless pipeline.sha == commit_status.sha - - yield merge_request - end - end end end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index 48419da98ad..109c964e577 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -19,6 +19,7 @@ module MergeRequests merge_request.target_project = find_target_project merge_request.target_branch = find_target_branch merge_request.can_be_created = projects_and_branches_valid? + ensure_milestone_available(merge_request) # compare branches only if branches are valid, otherwise # compare_branches may raise an error diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb index 04527bb9713..b0f6166ea1c 100644 --- a/app/services/merge_requests/close_service.rb +++ b/app/services/merge_requests/close_service.rb @@ -17,6 +17,8 @@ module MergeRequests execute_hooks(merge_request, 'close') invalidate_cache_counts(merge_request, users: merge_request.assignees) merge_request.update_project_counter_caches + cleanup_environments(merge_request) + cancel_auto_merge(merge_request) end merge_request @@ -32,5 +34,9 @@ module MergeRequests merge_request_metrics_service(merge_request).close(close_event) end end + + def cancel_auto_merge(merge_request) + AutoMergeService.new(project, current_user).cancel(merge_request) + end end end diff --git a/app/services/merge_requests/create_pipeline_service.rb b/app/services/merge_requests/create_pipeline_service.rb new file mode 100644 index 00000000000..03246cc1920 --- /dev/null +++ b/app/services/merge_requests/create_pipeline_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module MergeRequests + class CreatePipelineService < MergeRequests::BaseService + def execute(merge_request) + return unless can_create_pipeline_for?(merge_request) + + create_detached_merge_request_pipeline(merge_request) + end + + def create_detached_merge_request_pipeline(merge_request) + if can_use_merge_request_ref?(merge_request) + Ci::CreatePipelineService.new(merge_request.source_project, current_user, + ref: merge_request.ref_path) + .execute(:merge_request_event, merge_request: merge_request) + else + Ci::CreatePipelineService.new(merge_request.source_project, current_user, + ref: merge_request.source_branch) + .execute(:merge_request_event, merge_request: merge_request) + end + end + + def can_create_pipeline_for?(merge_request) + ## + # UpdateMergeRequestsWorker could be retried by an exception. + # pipelines for merge request should not be recreated in such case. + return false if !allow_duplicate && merge_request.find_actual_head_pipeline&.triggered_by_merge_request? + return false if merge_request.has_no_commits? + + true + end + + def allow_duplicate + params[:allow_duplicate] + end + end +end diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 02c2388c05c..06e46595b95 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -25,7 +25,7 @@ module MergeRequests def after_create(issuable) todo_service.new_merge_request(issuable, current_user) issuable.cache_merge_request_closes_issues!(current_user) - create_merge_request_pipeline(issuable, current_user) + create_pipeline_for(issuable, current_user) issuable.update_head_pipeline super diff --git a/app/services/merge_requests/delete_non_latest_diffs_service.rb b/app/services/merge_requests/delete_non_latest_diffs_service.rb index d5929446122..bdb7ec8a7c2 100644 --- a/app/services/merge_requests/delete_non_latest_diffs_service.rb +++ b/app/services/merge_requests/delete_non_latest_diffs_service.rb @@ -8,15 +8,13 @@ module MergeRequests @merge_request = merge_request end - # rubocop: disable CodeReuse/ActiveRecord def execute diffs = @merge_request.non_latest_diffs.with_files diffs.each_batch(of: BATCH_SIZE) do |relation, index| - ids = relation.pluck(:id).map { |id| [id] } + ids = relation.pluck_primary_key.map { |id| [id] } DeleteDiffFilesWorker.bulk_perform_in(index * 5.minutes, ids) end end - # rubocop: enable CodeReuse/ActiveRecord end end diff --git a/app/services/merge_requests/merge_base_service.rb b/app/services/merge_requests/merge_base_service.rb new file mode 100644 index 00000000000..095bdca5472 --- /dev/null +++ b/app/services/merge_requests/merge_base_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module MergeRequests + class MergeBaseService < MergeRequests::BaseService + include Gitlab::Utils::StrongMemoize + + MergeError = Class.new(StandardError) + + attr_reader :merge_request + + # Overridden in EE. + def hooks_validation_pass?(_merge_request) + true + end + + # Overridden in EE. + def hooks_validation_error(_merge_request) + # No-op + end + + def source + if merge_request.squash + squash_sha! + else + merge_request.diff_head_sha + end + end + + private + + # Overridden in EE. + def error_check! + # No-op + end + + def raise_error(message) + raise MergeError, message + end + + def handle_merge_error(*args) + # No-op + end + + def commit_message + params[:commit_message] || + merge_request.default_merge_commit_message + end + + def squash_sha! + strong_memoize(:squash_sha) do + params[:merge_request] = merge_request + squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute + + case squash_result[:status] + when :success + squash_result[:squash_sha] + when :error + raise ::MergeRequests::MergeService::MergeError, squash_result[:message] + end + end + end + end +end diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 449997bcf07..d8a78001b79 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -7,13 +7,7 @@ module MergeRequests # mark merge request as merged and execute all hooks and notifications # Executed when you do merge via GitLab UI # - class MergeService < MergeRequests::BaseService - include Gitlab::Utils::StrongMemoize - - MergeError = Class.new(StandardError) - - attr_reader :merge_request, :source - + class MergeService < MergeRequests::MergeBaseService delegate :merge_jid, :state, to: :@merge_request def execute(merge_request) @@ -24,7 +18,7 @@ module MergeRequests @merge_request = merge_request - error_check! + validate! merge_request.in_locked_state do if commit @@ -38,22 +32,22 @@ module MergeRequests handle_merge_error(log_message: e.message, save_message_on_model: true) end - def source - if merge_request.squash - squash_sha! - else - merge_request.diff_head_sha - end - end + private - # Overridden in EE. - def hooks_validation_pass?(_merge_request) - true + def validate! + authorization_check! + error_check! end - private + def authorization_check! + unless @merge_request.can_be_merged_by?(current_user) + raise_error('You are not allowed to merge this merge request') + end + end def error_check! + super + error = if @merge_request.should_be_rebased? 'Only fast-forward merge is allowed for your project. Please update your source branch' @@ -63,7 +57,7 @@ module MergeRequests 'No source for merge' end - raise MergeError, error if error + raise_error(error) if error end def commit @@ -73,36 +67,20 @@ module MergeRequests if commit_id log_info("Git merge finished on JID #{merge_jid} commit #{commit_id}") else - raise MergeError, 'Conflicts detected during merge' + raise_error('Conflicts detected during merge') end merge_request.update!(merge_commit_sha: commit_id) end - def squash_sha! - strong_memoize(:squash_sha) do - params[:merge_request] = merge_request - squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute - - case squash_result[:status] - when :success - squash_result[:squash_sha] - when :error - raise ::MergeRequests::MergeService::MergeError, squash_result[:message] - end - end - end - def try_merge - message = params[:commit_message] || merge_request.default_merge_commit_message - - repository.merge(current_user, source, merge_request, message) + repository.merge(current_user, source, merge_request, commit_message) rescue Gitlab::Git::PreReceiveError => e - handle_merge_error(log_message: e.message) - raise MergeError, 'Something went wrong during merge pre-receive hook' + raise MergeError, + "Something went wrong during merge pre-receive hook. #{e.message}".strip rescue => e handle_merge_error(log_message: e.message) - raise MergeError, 'Something went wrong during merge' + raise_error('Something went wrong during merge') ensure merge_request.update!(in_progress_merge_commit_sha: nil) end diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb new file mode 100644 index 00000000000..8670b9ccf3d --- /dev/null +++ b/app/services/merge_requests/merge_to_ref_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module MergeRequests + # Performs the merge between source SHA and the target branch. Instead + # of writing the result to the MR target branch, it targets the `target_ref`. + # + # Ideally this should leave the `target_ref` state with the same state the + # target branch would have if we used the regular `MergeService`, but without + # every side-effect that comes with it (MR updates, mails, source branch + # deletion, etc). This service should be kept idempotent (i.e. can + # be executed regardless of the `target_ref` current state). + # + class MergeToRefService < MergeRequests::MergeBaseService + def execute(merge_request) + @merge_request = merge_request + + validate! + + commit_id = commit + + raise_error('Conflicts detected during merge') unless commit_id + + success(commit_id: commit_id) + rescue MergeError, ArgumentError => error + error(error.message) + end + + private + + def validate! + error_check! + end + + def error_check! + super + + error = + if !hooks_validation_pass?(merge_request) + hooks_validation_error(merge_request) + elsif source.blank? + 'No source for merge' + end + + raise_error(error) if error + end + + def target_ref + merge_request.merge_ref_path + end + + def commit + repository.merge_to_ref(current_user, source, merge_request, target_ref, commit_message) + rescue Gitlab::Git::PreReceiveError => error + raise MergeError, error.message + end + end +end diff --git a/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb b/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb deleted file mode 100644 index 973e5b64e88..00000000000 --- a/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -module MergeRequests - class MergeWhenPipelineSucceedsService < MergeRequests::BaseService - # Marks the passed `merge_request` to be merged when the pipeline succeeds or - # updates the params for the automatic merge - def execute(merge_request) - merge_request.merge_params.merge!(params) - - # The service is also called when the merge params are updated. - already_approved = merge_request.merge_when_pipeline_succeeds? - - unless already_approved - merge_request.merge_when_pipeline_succeeds = true - merge_request.merge_user = @current_user - - SystemNoteService.merge_when_pipeline_succeeds(merge_request, @project, @current_user, merge_request.diff_head_commit) - end - - merge_request.save - end - - # Triggers the automatic merge of merge_request once the pipeline succeeds - def trigger(pipeline) - return unless pipeline.success? - - pipeline_merge_requests(pipeline) do |merge_request| - next unless merge_request.merge_when_pipeline_succeeds? - next unless merge_request.mergeable? - - merge_request.merge_async(merge_request.merge_user_id, merge_request.merge_params) - end - end - - # Cancels the automatic merge - def cancel(merge_request) - if merge_request.merge_when_pipeline_succeeds? && merge_request.open? - merge_request.reset_merge_when_pipeline_succeeds - SystemNoteService.cancel_merge_when_pipeline_succeeds(merge_request, @project, @current_user) - - success - else - error("Can't cancel the automatic merge", 406) - end - end - end -end diff --git a/app/services/merge_requests/mergeability_check_service.rb b/app/services/merge_requests/mergeability_check_service.rb new file mode 100644 index 00000000000..ef833774e65 --- /dev/null +++ b/app/services/merge_requests/mergeability_check_service.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module MergeRequests + class MergeabilityCheckService < ::BaseService + include Gitlab::Utils::StrongMemoize + + delegate :project, to: :@merge_request + delegate :repository, to: :project + + def initialize(merge_request) + @merge_request = merge_request + end + + # Updates the MR merge_status. Whenever it switches to a can_be_merged state, + # the merge-ref is refreshed. + # + # Returns a ServiceResponse indicating merge_status is/became can_be_merged + # and the merge-ref is synced. Success in case of being/becoming mergeable, + # error otherwise. + def execute + return ServiceResponse.error(message: 'Invalid argument') unless merge_request + return ServiceResponse.error(message: 'Unsupported operation') if Gitlab::Database.read_only? + + update_merge_status + + unless merge_request.can_be_merged? + return ServiceResponse.error(message: 'Merge request is not mergeable') + end + + unless payload.fetch(:merge_ref_head) + return ServiceResponse.error(message: 'Merge ref was not found') + end + + ServiceResponse.success(payload: payload) + end + + private + + attr_reader :merge_request + + def payload + strong_memoize(:payload) do + { + merge_ref_head: merge_ref_head_payload + } + end + end + + def merge_ref_head_payload + commit = merge_request.merge_ref_head + + return unless commit + + target_id, source_id = commit.parent_ids + + { + commit_id: commit.id, + source_id: source_id, + target_id: target_id + } + end + + def update_merge_status + return unless merge_request.recheck_merge_status? + + if can_git_merge? + merge_to_ref && merge_request.mark_as_mergeable + else + merge_request.mark_as_unmergeable + end + end + + def can_git_merge? + !merge_request.broken? && repository.can_be_merged?(merge_request.diff_head_sha, merge_request.target_branch) + end + + def merge_to_ref + result = MergeRequests::MergeToRefService.new(project, merge_request.author).execute(merge_request) + result[:status] == :success + end + end +end diff --git a/app/services/merge_requests/migrate_external_diffs_service.rb b/app/services/merge_requests/migrate_external_diffs_service.rb new file mode 100644 index 00000000000..16050244637 --- /dev/null +++ b/app/services/merge_requests/migrate_external_diffs_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module MergeRequests + class MigrateExternalDiffsService < ::BaseService + MAX_JOBS = 1000.freeze + + attr_reader :diff + + def self.enqueue! + ids = MergeRequestDiff.ids_for_external_storage_migration(limit: MAX_JOBS) + + MigrateExternalDiffsWorker.bulk_perform_async(ids.map { |id| [id] }) + end + + def initialize(merge_request_diff) + @diff = merge_request_diff + end + + def execute + diff.migrate_files_to_external_storage! + end + end +end diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index f26e3bee06f..c13f7dd5088 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -18,6 +18,7 @@ module MergeRequests invalidate_cache_counts(merge_request, users: merge_request.assignees) merge_request.update_project_counter_caches delete_non_latest_diffs(merge_request) + cleanup_environments(merge_request) end private diff --git a/app/services/merge_requests/push_options_handler_service.rb b/app/services/merge_requests/push_options_handler_service.rb new file mode 100644 index 00000000000..a24163331e8 --- /dev/null +++ b/app/services/merge_requests/push_options_handler_service.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +module MergeRequests + class PushOptionsHandlerService + LIMIT = 10 + + attr_reader :branches, :changes_by_branch, :current_user, :errors, + :project, :push_options, :target_project + + def initialize(project, current_user, changes, push_options) + @project = project + @target_project = @project.default_merge_request_target + @current_user = current_user + @branches = get_branches(changes) + @push_options = push_options + @errors = [] + end + + def execute + validate_service + return self if errors.present? + + branches.each do |branch| + execute_for_branch(branch) + rescue Gitlab::Access::AccessDeniedError + errors << 'User access was denied' + rescue StandardError => e + Gitlab::AppLogger.error(e) + errors << 'An unknown error occurred' + end + + self + end + + private + + def get_branches(raw_changes) + Gitlab::ChangesList.new(raw_changes).map do |changes| + next unless Gitlab::Git.branch_ref?(changes[:ref]) + + # Deleted branch + next if Gitlab::Git.blank_ref?(changes[:newrev]) + + # Default branch + branch_name = Gitlab::Git.branch_name(changes[:ref]) + next if branch_name == target_project.default_branch + + branch_name + end.compact.uniq + end + + def validate_service + errors << 'User is required' if current_user.nil? + + unless target_project.merge_requests_enabled? + errors << "Merge requests are not enabled for project #{target_project.full_path}" + end + + if branches.size > LIMIT + errors << "Too many branches pushed (#{branches.size} were pushed, limit is #{LIMIT})" + end + + if push_options[:target] && !target_project.repository.branch_exists?(push_options[:target]) + errors << "Branch #{push_options[:target]} does not exist" + end + end + + # Returns a Hash of branch => MergeRequest + def merge_requests + @merge_requests ||= MergeRequest.from_project(target_project) + .opened + .from_source_branches(branches) + .index_by(&:source_branch) + end + + def execute_for_branch(branch) + merge_request = merge_requests[branch] + + if merge_request + update!(merge_request) + else + create!(branch) + end + end + + def create!(branch) + unless push_options[:create] + errors << "A merge_request.create push option is required to create a merge request for branch #{branch}" + return + end + + # Use BuildService to assign the standard attributes of a merge request + merge_request = ::MergeRequests::BuildService.new( + project, + current_user, + create_params(branch) + ).execute + + unless merge_request.errors.present? + merge_request = ::MergeRequests::CreateService.new( + project, + current_user, + merge_request.attributes.merge(assignees: merge_request.assignees) + ).execute + end + + collect_errors_from_merge_request(merge_request) unless merge_request.persisted? + end + + def update!(merge_request) + merge_request = ::MergeRequests::UpdateService.new( + target_project, + current_user, + update_params + ).execute(merge_request) + + collect_errors_from_merge_request(merge_request) unless merge_request.valid? + end + + def create_params(branch) + params = { + assignees: [current_user], + source_branch: branch, + source_project: project, + target_branch: push_options[:target] || target_project.default_branch, + target_project: target_project + } + + if push_options.key?(:merge_when_pipeline_succeeds) + params.merge!( + merge_when_pipeline_succeeds: push_options[:merge_when_pipeline_succeeds], + merge_user: current_user + ) + end + + params + end + + def update_params + params = {} + + if push_options.key?(:merge_when_pipeline_succeeds) + params.merge!( + merge_when_pipeline_succeeds: push_options[:merge_when_pipeline_succeeds], + merge_user: current_user + ) + end + + if push_options.key?(:target) + params[:target_branch] = push_options[:target] + end + + params + end + + def collect_errors_from_merge_request(merge_request) + merge_request.errors.full_messages.each do |error| + errors << error + end + end + end +end diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb index 31b3ebf311e..4b9921c28ba 100644 --- a/app/services/merge_requests/rebase_service.rb +++ b/app/services/merge_requests/rebase_service.rb @@ -20,17 +20,7 @@ module MergeRequests return false end - log_prefix = "#{self.class.name} info (#{merge_request.to_reference(full: true)}):" - - Gitlab::GitLogger.info("#{log_prefix} rebase started") - - rebase_sha = repository.rebase(current_user, merge_request) - - Gitlab::GitLogger.info("#{log_prefix} rebased to #{rebase_sha}") - - merge_request.update(rebase_commit_sha: rebase_sha) - - Gitlab::GitLogger.info("#{log_prefix} rebase SHA saved: #{rebase_sha}") + repository.rebase(current_user, merge_request) true rescue => e diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index f712b8863cd..08130a531ee 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -14,13 +14,17 @@ module MergeRequests private def refresh_merge_requests! + # n + 1: https://gitlab.com/gitlab-org/gitlab-ce/issues/60289 Gitlab::GitalyClient.allow_n_plus_1_calls(&method(:find_new_commits)) + # Be sure to close outstanding MRs before reloading them to avoid generating an # empty diff during a manual merge close_upon_missing_source_branch_ref post_merge_manually_merged reload_merge_requests - reset_merge_when_pipeline_succeeds + outdate_suggestions + refresh_pipelines_on_merge_requests + cancel_auto_merge mark_pending_todos_done cache_merge_requests_closing_issues @@ -106,8 +110,6 @@ module MergeRequests end merge_request.mark_as_unchecked - create_merge_request_pipeline(merge_request, current_user) - UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id) end # Upcoming method calls need the refreshed version of @@ -125,8 +127,25 @@ module MergeRequests merge_request.source_branch == @push.branch_name end - def reset_merge_when_pipeline_succeeds - merge_requests_for_source_branch.each(&:reset_merge_when_pipeline_succeeds) + def outdate_suggestions + outdate_service = Suggestions::OutdateService.new + + merge_requests_for_source_branch.each do |merge_request| + outdate_service.execute(merge_request) + end + end + + def refresh_pipelines_on_merge_requests + merge_requests_for_source_branch.each do |merge_request| + create_pipeline_for(merge_request, current_user) + UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id) + end + end + + def cancel_auto_merge + merge_requests_for_source_branch.each do |merge_request| + AutoMergeService.new(project, current_user).cancel(merge_request) + end end def mark_pending_todos_done diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb index f6cbe769ef4..f87005bcb6c 100644 --- a/app/services/merge_requests/reopen_service.rb +++ b/app/services/merge_requests/reopen_service.rb @@ -3,7 +3,7 @@ module MergeRequests class ReopenService < MergeRequests::BaseService def execute(merge_request) - return merge_request unless can?(current_user, :update_merge_request, merge_request) + return merge_request unless can?(current_user, :reopen_merge_request, merge_request) if merge_request.reopen create_event(merge_request) diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb index 9d1a5d5e6d4..88ca3b4f5a8 100644 --- a/app/services/merge_requests/squash_service.rb +++ b/app/services/merge_requests/squash_service.rb @@ -10,10 +10,10 @@ module MergeRequests end if merge_request.squash_in_progress? - return error('Squash task canceled: another squash is already in progress.') + return error(s_('MergeRequests|Squash task canceled: another squash is already in progress.')) end - squash! || error('Failed to squash. Should be done manually.') + squash! || error(s_('MergeRequests|Failed to squash. Should be done manually.')) end private diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 8112c2a4299..6a0f3000ffb 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -16,7 +16,7 @@ module MergeRequests params.delete(:force_remove_source_branch) end - if params[:force_remove_source_branch].present? + if params.has_key?(:force_remove_source_branch) merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch) end @@ -24,13 +24,13 @@ module MergeRequests update_task_event(merge_request) || update(merge_request) end - # rubocop:disable Metrics/AbcSize def handle_changes(merge_request, options) old_associations = options.fetch(:old_associations, {}) old_labels = old_associations.fetch(:labels, []) old_mentioned_users = old_associations.fetch(:mentioned_users, []) + old_assignees = old_associations.fetch(:assignees, []) - if has_changes?(merge_request, old_labels: old_labels) + if has_changes?(merge_request, old_labels: old_labels, old_assignees: old_assignees) todo_service.mark_pending_todos_as_done(merge_request, current_user) end @@ -45,15 +45,10 @@ module MergeRequests merge_request.target_branch) end - if merge_request.previous_changes.include?('assignee_id') - reassigned_merge_request_args = [merge_request, current_user] - - old_assignee_id = merge_request.previous_changes['assignee_id'].first - reassigned_merge_request_args << User.find(old_assignee_id) if old_assignee_id - - create_assignee_note(merge_request) - notification_service.async.reassigned_merge_request(*reassigned_merge_request_args) - todo_service.reassigned_merge_request(merge_request, current_user) + if merge_request.assignees != old_assignees + create_assignee_note(merge_request, old_assignees) + notification_service.async.reassigned_merge_request(merge_request, current_user, old_assignees) + todo_service.reassigned_issuable(merge_request, current_user, old_assignees) end if merge_request.previous_changes.include?('target_branch') || @@ -81,7 +76,6 @@ module MergeRequests ) end end - # rubocop:enable Metrics/AbcSize def handle_task_changes(merge_request) todo_service.mark_pending_todos_as_done(merge_request, current_user) @@ -95,7 +89,7 @@ module MergeRequests merge_request.update(merge_error: nil) if merge_request.head_pipeline && merge_request.head_pipeline.active? - MergeRequests::MergeWhenPipelineSucceedsService.new(project, current_user).execute(merge_request) + AutoMergeService.new(project, current_user).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) else merge_request.merge_async(current_user.id, {}) end diff --git a/app/services/milestones/promote_service.rb b/app/services/milestones/promote_service.rb index cbe5996e8ca..0fe67067eb5 100644 --- a/app/services/milestones/promote_service.rb +++ b/app/services/milestones/promote_service.rb @@ -26,17 +26,15 @@ module Milestones private - # rubocop: disable CodeReuse/ActiveRecord def milestone_ids_for_merge(group_milestone) # Pluck need to be used here instead of select so the array of ids # is persistent after old milestones gets deleted. @milestone_ids_for_merge ||= begin search_params = { title: group_milestone.title, project_ids: group_project_ids, state: 'all' } milestones = MilestonesFinder.new(search_params).execute - milestones.pluck(:id) + milestones.pluck_primary_key end end - # rubocop: enable CodeReuse/ActiveRecord def move_children_to_group_milestone(group_milestone) milestone_ids_for_merge(group_milestone).in_groups_of(100, false) do |milestone_ids| @@ -45,7 +43,7 @@ module Milestones end def check_project_milestone!(milestone) - raise_error('Only project milestones can be promoted.') unless milestone.project_milestone? + raise_error(s_('PromoteMilestone|Only project milestones can be promoted.')) unless milestone.project_milestone? end def clone_project_milestone(milestone) @@ -73,7 +71,7 @@ module Milestones # rubocop: enable CodeReuse/ActiveRecord def group - @group ||= parent.group || raise_error('Project does not belong to a group.') + @group ||= parent.group || raise_error(s_('PromoteMilestone|Project does not belong to a group.')) end # rubocop: disable CodeReuse/ActiveRecord @@ -87,7 +85,7 @@ module Milestones end def raise_error(message) - raise PromoteMilestoneError, "Promotion failed - #{message}" + raise PromoteMilestoneError, s_("PromoteMilestone|Promotion failed - %{message}") % { message: message } end end end diff --git a/app/services/note_summary.rb b/app/services/note_summary.rb index 81f6f92f75c..60a68568833 100644 --- a/app/services/note_summary.rb +++ b/app/services/note_summary.rb @@ -5,7 +5,9 @@ class NoteSummary attr_reader :metadata def initialize(noteable, project, author, body, action: nil, commit_count: nil) - @note = { noteable: noteable, project: project, author: author, note: body } + @note = { noteable: noteable, + created_at: noteable.system_note_timestamp, + project: project, author: author, note: body } @metadata = { action: action, commit_count: commit_count }.compact set_commit_params if note[:noteable].is_a?(Commit) diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 5a6e7338b42..1b46f6d8a72 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -21,7 +21,7 @@ module Notes if quick_actions_service.supported?(note) options = { merge_request_diff_head_sha: merge_request_diff_head_sha } - content, command_params = quick_actions_service.extract_commands(note, options) + content, update_params = quick_actions_service.execute(note, options) only_commands = content.empty? @@ -43,16 +43,17 @@ module Notes Suggestions::CreateService.new(note).execute end - if command_params.present? - quick_actions_service.execute(command_params, note) + if quick_actions_service.commands_executed_count.to_i > 0 + if update_params.present? + quick_actions_service.apply_updates(update_params, note) + note.commands_changes = update_params + end # We must add the error after we call #save because errors are reset # when #save is called if only_commands note.errors.add(:commands_only, 'Commands applied') end - - note.commands_changes = command_params end note diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb index 985a03060bd..0852a708240 100644 --- a/app/services/notes/quick_actions_service.rb +++ b/app/services/notes/quick_actions_service.rb @@ -1,7 +1,18 @@ # frozen_string_literal: true +# QuickActionsService class +# +# Executes quick actions commands extracted from note text +# +# Most commands returns parameters to be applied later +# using QuickActionService#apply_updates +# module Notes class QuickActionsService < BaseService + attr_reader :interpret_service + + delegate :commands_executed_count, to: :interpret_service, allow_nil: true + UPDATE_SERVICES = { 'Issue' => Issues::UpdateService, 'MergeRequest' => MergeRequests::UpdateService, @@ -25,18 +36,21 @@ module Notes self.class.supported?(note) end - def extract_commands(note, options = {}) + def execute(note, options = {}) return [note.note, {}] unless supported?(note) - QuickActions::InterpretService.new(project, current_user, options) - .execute(note.note, note.noteable) + @interpret_service = QuickActions::InterpretService.new(project, current_user, options) + + @interpret_service.execute(note.note, note.noteable) end - def execute(command_params, note) - return if command_params.empty? + # Applies updates extracted to note#noteable + # The update parameters are extracted on self#execute + def apply_updates(update_params, note) + return if update_params.empty? return unless supported?(note) - self.class.noteable_update_service(note).new(note.parent, current_user, command_params).execute(note.noteable) + self.class.noteable_update_service(note).new(note.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 d2052bed646..384d1dd2e50 100644 --- a/app/services/notes/update_service.rb +++ b/app/services/notes/update_service.rb @@ -22,7 +22,7 @@ module Notes # We need to refresh the previous suggestions call cache # in order to get the new records. - note.reload + note.reset end note diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index 56f11b31110..ca3f0b73096 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -135,7 +135,7 @@ module NotificationRecipientService global_users_ids = user_ids_with_project_level_global.concat(user_ids_with_group_level_global) user_ids += user_ids_with_global_level_custom(global_users_ids, custom_action) - add_recipients(user_scope.where(id: user_ids), :watch, nil) + add_recipients(user_scope.where(id: user_ids), :custom, nil) end # rubocop: enable CodeReuse/ActiveRecord @@ -247,15 +247,15 @@ module NotificationRecipientService attr_reader :target attr_reader :current_user attr_reader :action - attr_reader :previous_assignee + attr_reader :previous_assignees attr_reader :skip_current_user - def initialize(target, current_user, action:, custom_action: nil, previous_assignee: nil, skip_current_user: true) + def initialize(target, current_user, action:, custom_action: nil, previous_assignees: nil, skip_current_user: true) @target = target @current_user = current_user @action = action @custom_action = custom_action - @previous_assignee = previous_assignee + @previous_assignees = previous_assignees @skip_current_user = skip_current_user end @@ -270,11 +270,7 @@ module NotificationRecipientService # Re-assign is considered as a mention of the new assignee case custom_action - when :reassign_merge_request - add_recipients(previous_assignee, :mention, nil) - add_recipients(target.assignee, :mention, NotificationReason::ASSIGNED) - when :reassign_issue - previous_assignees = Array(previous_assignee) + when :reassign_merge_request, :reassign_issue add_recipients(previous_assignees, :mention, nil) add_recipients(target.assignees, :mention, NotificationReason::ASSIGNED) end @@ -287,17 +283,11 @@ module NotificationRecipientService # receive them, too. add_mentions(current_user, target: target) - # Add the assigned users, if any - assignees = case custom_action - when :new_issue - target.assignees - else - target.assignee - end - # We use the `:participating` notification level in order to match existing legacy behavior as captured # in existing specs (notification_service_spec.rb ~ line 507) - add_recipients(assignees, :participating, NotificationReason::ASSIGNED) if assignees + if target.is_a?(Issuable) + add_recipients(target.assignees, :participating, NotificationReason::ASSIGNED) + end add_labels_subscribers end @@ -401,7 +391,7 @@ module NotificationRecipientService def build! return [] unless project - add_recipients(project.team.maintainers, :watch, nil) + add_recipients(project.team.maintainers, :mention, nil) end def acting_user diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 1a65561dd70..5aa804666f0 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -89,14 +89,14 @@ class NotificationService # * project team members with notification level higher then Participating # * users with custom level checked with "close issue" # - def close_issue(issue, current_user) - close_resource_email(issue, current_user, :closed_issue_email) + def close_issue(issue, current_user, closed_via: nil) + close_resource_email(issue, current_user, :closed_issue_email, closed_via: closed_via) end # When we reassign an issue we should send an email to: # - # * issue old assignee if their notification level is not Disabled - # * issue new assignee if their notification level is not Disabled + # * issue old assignees if their notification level is not Disabled + # * issue new assignees if their notification level is not Disabled # * users with custom level checked with "reassign issue" # def reassigned_issue(issue, current_user, previous_assignees = []) @@ -104,7 +104,7 @@ class NotificationService issue, current_user, action: "reassign", - previous_assignee: previous_assignees + previous_assignees: previous_assignees ) previous_assignee_ids = previous_assignees.map(&:id) @@ -140,7 +140,7 @@ class NotificationService # When create a merge request we should send an email to: # # * mr author - # * mr assignee if their notification level is not Disabled + # * mr assignees if their notification level is not Disabled # * project team members with notification level higher then Participating # * watchers of the mr's labels # * users with custom level checked with "new merge request" @@ -184,23 +184,25 @@ class NotificationService # When we reassign a merge_request we should send an email to: # - # * merge_request old assignee if their notification level is not Disabled - # * merge_request assignee if their notification level is not Disabled + # * merge_request old assignees if their notification level is not Disabled + # * merge_request new assignees if their notification level is not Disabled # * users with custom level checked with "reassign merge request" # - def reassigned_merge_request(merge_request, current_user, previous_assignee = nil) + def reassigned_merge_request(merge_request, current_user, previous_assignees = []) recipients = NotificationRecipientService.build_recipients( merge_request, current_user, action: "reassign", - previous_assignee: previous_assignee + previous_assignees: previous_assignees ) + previous_assignee_ids = previous_assignees.map(&:id) + recipients.each do |recipient| mailer.reassigned_merge_request_email( recipient.user.id, merge_request.id, - previous_assignee&.id, + previous_assignee_ids, current_user.id, recipient.reason ).deliver_later @@ -236,7 +238,7 @@ class NotificationService merge_request, current_user, :merged_merge_request_email, - skip_current_user: !merge_request.merge_when_pipeline_succeeds? + skip_current_user: !merge_request.auto_merge_enabled? ) end @@ -502,7 +504,7 @@ class NotificationService end end - def close_resource_email(target, current_user, method, skip_current_user: true) + def close_resource_email(target, current_user, method, skip_current_user: true, closed_via: nil) action = method == :merged_merge_request_email ? "merge" : "close" recipients = NotificationRecipientService.build_recipients( @@ -513,7 +515,7 @@ class NotificationService ) recipients.each do |recipient| - mailer.send(method, recipient.user.id, target.id, current_user.id, recipient.reason).deliver_later + mailer.send(method, recipient.user.id, target.id, current_user.id, reason: recipient.reason, closed_via: closed_via).deliver_later end end diff --git a/app/services/pages_domains/create_acme_order_service.rb b/app/services/pages_domains/create_acme_order_service.rb new file mode 100644 index 00000000000..c600f497fa5 --- /dev/null +++ b/app/services/pages_domains/create_acme_order_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module PagesDomains + class CreateAcmeOrderService + attr_reader :pages_domain + + def initialize(pages_domain) + @pages_domain = pages_domain + end + + def execute + lets_encrypt_client = Gitlab::LetsEncrypt::Client.new + order = lets_encrypt_client.new_order(pages_domain.domain) + + challenge = order.new_challenge + + private_key = OpenSSL::PKey::RSA.new(4096) + saved_order = pages_domain.acme_orders.create!( + url: order.url, + expires_at: order.expires, + private_key: private_key.to_pem, + + challenge_token: challenge.token, + challenge_file_content: challenge.file_content + ) + + challenge.request_validation + saved_order + end + end +end diff --git a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb new file mode 100644 index 00000000000..2dfe1a3d8ca --- /dev/null +++ b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module PagesDomains + class ObtainLetsEncryptCertificateService + attr_reader :pages_domain + + def initialize(pages_domain) + @pages_domain = pages_domain + end + + def execute + pages_domain.acme_orders.expired.delete_all + acme_order = pages_domain.acme_orders.first + + unless acme_order + ::PagesDomains::CreateAcmeOrderService.new(pages_domain).execute + return + end + + api_order = ::Gitlab::LetsEncrypt::Client.new.load_order(acme_order.url) + + # https://tools.ietf.org/html/rfc8555#section-7.1.6 - statuses diagram + case api_order.status + when 'ready' + api_order.request_certificate(private_key: acme_order.private_key, domain: pages_domain.domain) + when 'valid' + save_certificate(acme_order.private_key, api_order) + acme_order.destroy! + # when 'invalid' + # TODO: implement error handling + end + end + + private + + def save_certificate(private_key, api_order) + certificate = api_order.certificate + pages_domain.update!(key: private_key, certificate: certificate) + end + end +end diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index c1655c38095..2b4c4ae68e2 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -17,7 +17,7 @@ class PreviewMarkdownService < BaseService private def explain_quick_actions(text) - return text, [] unless %w(Issue MergeRequest Commit).include?(commands_target_type) + return text, [] unless %w(Issue MergeRequest Commit).include?(target_type) quick_actions_service = QuickActions::InterpretService.new(project, current_user) quick_actions_service.explain(text, find_commands_target) @@ -30,22 +30,36 @@ class PreviewMarkdownService < BaseService end def find_suggestions(text) - return [] unless params[:preview_suggestions] + return [] unless preview_sugestions? - Banzai::SuggestionsParser.parse(text) + position = Gitlab::Diff::Position.new(new_path: params[:file_path], + new_line: params[:line].to_i, + base_sha: params[:base_sha], + head_sha: params[:head_sha], + start_sha: params[:start_sha]) + + Gitlab::Diff::SuggestionsParser.parse(text, position: position, + project: project, + supports_suggestion: params[:preview_suggestions]) + end + + def preview_sugestions? + params[:preview_suggestions] && + target_type == 'MergeRequest' && + Ability.allowed?(current_user, :download_code, project) end def find_commands_target QuickActions::TargetService .new(project, current_user) - .execute(commands_target_type, commands_target_id) + .execute(target_type, target_id) end - def commands_target_type - params[:quick_actions_target_type] + def target_type + params[:target_type] end - def commands_target_id - params[:quick_actions_target_id] + def target_id + params[:target_id] end end diff --git a/app/services/projects/cleanup_service.rb b/app/services/projects/cleanup_service.rb index 12103ea34b5..5972bfd4071 100644 --- a/app/services/projects/cleanup_service.rb +++ b/app/services/projects/cleanup_service.rb @@ -18,9 +18,6 @@ module Projects # per rewritten object, with the old and new SHAs space-separated. It can be # used to update or remove content that references the objects that BFG has # altered - # - # Currently, only the project repository is modified by this service, but we - # may wish to modify other data sources in the future. def execute apply_bfg_object_map! @@ -41,10 +38,52 @@ module Projects raise NoUploadError unless project.bfg_object_map.exists? project.bfg_object_map.open do |io| - repository_cleaner.apply_bfg_object_map(io) + repository_cleaner.apply_bfg_object_map_stream(io) do |response| + cleanup_diffs(response) + end + end + end + + def cleanup_diffs(response) + old_commit_shas = extract_old_commit_shas(response.entries) + + ActiveRecord::Base.transaction do + cleanup_merge_request_diffs(old_commit_shas) + cleanup_note_diff_files(old_commit_shas) end end + def extract_old_commit_shas(batch) + batch.lazy.select { |entry| entry.type == :COMMIT }.map(&:old_oid).force + end + + def cleanup_merge_request_diffs(old_commit_shas) + merge_request_diffs = MergeRequestDiff + .by_project_id(project.id) + .by_commit_sha(old_commit_shas) + + # It's important to run the ActiveRecord callbacks here + merge_request_diffs.destroy_all # rubocop:disable Cop/DestroyAll + + # TODO: ensure the highlight cache is removed immediately. It's too hard + # to calculate the Redis keys at present. + # + # https://gitlab.com/gitlab-org/gitlab-ce/issues/61115 + end + + def cleanup_note_diff_files(old_commit_shas) + # Pluck the IDs instead of running the query twice to ensure we clear the + # cache for exactly the note diffs we remove + ids = NoteDiffFile + .referencing_sha(old_commit_shas, project_id: project.id) + .pluck_primary_key + + NoteDiffFile.id_in(ids).delete_all + + # A highlighted version of the diff is stored in redis. Remove it now. + Gitlab::DiscussionsDiff::HighlightCache.clear_multiple(ids) + end + def repository_cleaner @repository_cleaner ||= Gitlab::Git::RepositoryCleaner.new(repository.raw) end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index d03137b63b2..9f335cceb67 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -2,6 +2,8 @@ module Projects class CreateService < BaseService + include ValidatesClassificationLabel + def initialize(user, params) @current_user, @params = user, params.dup @skip_wiki = @params.delete(:skip_wiki) @@ -45,6 +47,8 @@ module Projects relations_block&.call(@project) yield(@project) if block_given? + validate_classification_label(@project, :external_authorization_classification_label) + # If the block added errors, don't try to save the project return @project if @project.errors.any? @@ -96,8 +100,6 @@ module Projects current_user.invalidate_personal_projects_count create_readme if @initialize_with_readme - - configure_group_clusters_for_project end # Refresh the current user's authorizations inline (so they can access the @@ -123,10 +125,6 @@ module Projects Files::CreateService.new(@project, current_user, commit_attrs).execute end - def configure_group_clusters_for_project - ClusterProjectConfigureWorker.perform_async(@project.id) - end - def skip_wiki? !@project.feature_available?(:wiki, current_user) || @skip_wiki end @@ -155,8 +153,8 @@ module Projects log_message << " Project ID: #{@project.id}" if @project&.id Rails.logger.error(log_message) - if @project - @project.import_state.mark_as_failed(message) if @project.persisted? && @project.import? + if @project && @project.persisted? && @project.import_state + @project.import_state.mark_as_failed(message) end @project diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index b14b31302f5..d8e670e40ce 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -61,11 +61,11 @@ module Projects flush_caches(@project) unless rollback_repository(removal_path(repo_path), repo_path) - raise_error('Failed to restore project repository. Please contact the administrator.') + raise_error(s_('DeleteProject|Failed to restore project repository. Please contact the administrator.')) end unless rollback_repository(removal_path(wiki_path), wiki_path) - raise_error('Failed to restore wiki repository. Please contact the administrator.') + raise_error(s_('DeleteProject|Failed to restore wiki repository. Please contact the administrator.')) end end @@ -81,11 +81,11 @@ module Projects def trash_repositories! unless remove_repository(repo_path) - raise_error('Failed to remove project repository. Please try again or contact administrator.') + raise_error(s_('DeleteProject|Failed to remove project repository. Please try again or contact administrator.')) end unless remove_repository(wiki_path) - raise_error('Failed to remove wiki repository. Please try again or contact administrator.') + raise_error(s_('DeleteProject|Failed to remove wiki repository. Please try again or contact administrator.')) end end @@ -148,7 +148,7 @@ module Projects def attempt_destroy_transaction(project) unless remove_registry_tags - raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.') + raise_error(s_('DeleteProject|Failed to remove some tags in project container registry. Please try again or contact administrator.')) end project.leave_pool_repository diff --git a/app/services/projects/detect_repository_languages_service.rb b/app/services/projects/detect_repository_languages_service.rb index 4a837a4fb6a..d3680637217 100644 --- a/app/services/projects/detect_repository_languages_service.rb +++ b/app/services/projects/detect_repository_languages_service.rb @@ -2,7 +2,7 @@ module Projects class DetectRepositoryLanguagesService < BaseService - attr_reader :detected_repository_languages, :programming_languages + attr_reader :programming_languages # rubocop: disable CodeReuse/ActiveRecord def execute @@ -25,9 +25,11 @@ module Projects RepositoryLanguage.table_name, detection.insertions(matching_programming_languages) ) + + set_detected_repository_languages end - project.repository_languages.reload + project.repository_languages.reset end # rubocop: enable CodeReuse/ActiveRecord @@ -56,5 +58,11 @@ module Projects retry end # rubocop: enable CodeReuse/ActiveRecord + + def set_detected_repository_languages + return if project.detected_repository_languages? + + project.update_column(:detected_repository_languages, true) + end end end diff --git a/app/services/projects/download_service.rb b/app/services/projects/download_service.rb index dd297c9ba43..aba175eb79b 100644 --- a/app/services/projects/download_service.rb +++ b/app/services/projects/download_service.rb @@ -11,7 +11,7 @@ module Projects end def execute - return nil unless valid_url?(@url) + return unless valid_url?(@url) uploader = FileUploader.new(@project) uploader.download!(@url) diff --git a/app/services/projects/fetch_statistics_increment_service.rb b/app/services/projects/fetch_statistics_increment_service.rb new file mode 100644 index 00000000000..8644e6bf313 --- /dev/null +++ b/app/services/projects/fetch_statistics_increment_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Projects + class FetchStatisticsIncrementService + attr_reader :project + + def initialize(project) + @project = project + end + + def execute + increment_fetch_count_sql = <<~SQL + INSERT INTO #{table_name} (project_id, date, fetch_count) + VALUES (#{project.id}, '#{Date.today}', 1) + SQL + + increment_fetch_count_sql += if Gitlab::Database.postgresql? + "ON CONFLICT (project_id, date) DO UPDATE SET fetch_count = #{table_name}.fetch_count + 1" + else + "ON DUPLICATE KEY UPDATE fetch_count = #{table_name}.fetch_count + 1" + end + + ActiveRecord::Base.connection.execute(increment_fetch_count_sql) + end + + private + + def table_name + ProjectDailyStatistic.table_name + end + end +end diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index fc234bafc57..0b4ab7b8e4d 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -36,18 +36,22 @@ module Projects def fork_new_project new_params = { - visibility_level: allowed_visibility_level, - description: @project.description, - name: target_name, - path: target_path, - shared_runners_enabled: @project.shared_runners_enabled, - namespace_id: target_namespace.id, - fork_network: fork_network, + visibility_level: allowed_visibility_level, + description: @project.description, + name: target_name, + path: target_path, + shared_runners_enabled: @project.shared_runners_enabled, + namespace_id: target_namespace.id, + fork_network: fork_network, + # We need to set default_git_depth to 0 for the forked project when + # @project.default_git_depth is nil in order to keep the same behaviour + # and not get ProjectCiCdSetting::DEFAULT_GIT_DEPTH set on create + ci_cd_settings_attributes: { default_git_depth: @project.default_git_depth || 0 }, # We need to assign the fork network membership after the project has # been instantiated to avoid ActiveRecord trying to create it when # initializing the project, as that would cause a foreign key constraint # exception. - relations_block: -> (project) { build_fork_network_member(project) } + relations_block: -> (project) { build_fork_network_member(project) } } if @project.avatar.present? && @project.avatar.image? diff --git a/app/services/projects/git_deduplication_service.rb b/app/services/projects/git_deduplication_service.rb new file mode 100644 index 00000000000..74d469ecf37 --- /dev/null +++ b/app/services/projects/git_deduplication_service.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Projects + class GitDeduplicationService < BaseService + include ExclusiveLeaseGuard + + LEASE_TIMEOUT = 86400 + + delegate :pool_repository, to: :project + attr_reader :project + + def initialize(project) + @project = project + end + + def execute + try_obtain_lease do + unless project.has_pool_repository? + disconnect_git_alternates + break + end + + if source_project? && pool_can_fetch_from_source? + fetch_from_source + end + + project.link_pool_repository if same_storage_as_pool?(project.repository) + end + end + + private + + def disconnect_git_alternates + project.repository.disconnect_alternates + end + + def pool_can_fetch_from_source? + project.git_objects_poolable? && + same_storage_as_pool?(pool_repository.source_project.repository) + end + + def same_storage_as_pool?(repository) + pool_repository.object_pool.repository.storage == repository.storage + end + + def fetch_from_source + project.pool_repository.object_pool.fetch + end + + def source_project? + return unless project.has_pool_repository? + + project.pool_repository.source_project == project + end + + def lease_timeout + LEASE_TIMEOUT + end + + def lease_key + "git_deduplication:#{project.id}" + end + end +end diff --git a/app/services/projects/group_links/create_service.rb b/app/services/projects/group_links/create_service.rb index 1392775f805..e3d5bea0852 100644 --- a/app/services/projects/group_links/create_service.rb +++ b/app/services/projects/group_links/create_service.rb @@ -4,13 +4,19 @@ module Projects module GroupLinks class CreateService < BaseService def execute(group) - return false unless group + return error('Not Found', 404) unless group && can?(current_user, :read_namespace, group) - project.project_group_links.create( + link = project.project_group_links.new( group: group, group_access: params[:link_group_access], expires_at: params[:expires_at] ) + + if link.save + success(link: link) + else + error(link.errors.full_messages.to_sentence, 409) + end end end end diff --git a/app/services/projects/hashed_storage/base_attachment_service.rb b/app/services/projects/hashed_storage/base_attachment_service.rb new file mode 100644 index 00000000000..828ab616bab --- /dev/null +++ b/app/services/projects/hashed_storage/base_attachment_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Projects + module HashedStorage + AttachmentMigrationError = Class.new(StandardError) + + AttachmentCannotMoveError = Class.new(StandardError) + + class BaseAttachmentService < BaseService + # Returns the disk_path value before the execution + attr_reader :old_disk_path + + # Returns the disk_path value after the execution + attr_reader :new_disk_path + + # Returns the logger currently in use + attr_reader :logger + + # Return whether this operation was skipped or not + # + # @return [Boolean] true if skipped of false otherwise + def skipped? + @skipped + end + + protected + + def move_folder!(old_path, new_path) + unless File.directory?(old_path) + logger.info("Skipped attachments move from '#{old_path}' to '#{new_path}', source path doesn't exist or is not a directory (PROJECT_ID=#{project.id})") + @skipped = true + + return true + end + + if File.exist?(new_path) + logger.error("Cannot move attachments from '#{old_path}' to '#{new_path}', target path already exist (PROJECT_ID=#{project.id})") + raise AttachmentCannotMoveError, "Target path '#{new_path}' already exists" + end + + # Create base path folder on the new storage layout + FileUtils.mkdir_p(File.dirname(new_path)) + + FileUtils.mv(old_path, new_path) + logger.info("Project attachments moved from '#{old_path}' to '#{new_path}' (PROJECT_ID=#{project.id})") + + true + end + end + end +end diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb index 761c81d776f..f97a28b8c3b 100644 --- a/app/services/projects/hashed_storage/base_repository_service.rb +++ b/app/services/projects/hashed_storage/base_repository_service.rb @@ -2,11 +2,8 @@ module Projects module HashedStorage - # Returned when there is an error with the Hashed Storage migration - RepositoryMigrationError = Class.new(StandardError) - - # Returned when there is an error with the Hashed Storage rollback - RepositoryRollbackError = Class.new(StandardError) + # Returned when repository can't be made read-only because there is already a git transfer in progress + RepositoryInUseError = Class.new(StandardError) class BaseRepositoryService < BaseService include Gitlab::ShellAdapter @@ -38,7 +35,10 @@ module Projects # project was not originally empty. if !from_exists && !to_exists logger.warn "Can't find a repository on either source or target paths for #{project.full_path} (ID=#{project.id}) ..." - return false + + # We return true so we still reflect the change in the database. + # Next time the repository is (re)created it will be under the new storage layout + return true elsif !from_exists # Repository have been moved already. return true @@ -52,6 +52,16 @@ module Projects move_repository(new_disk_path, old_disk_path) move_repository("#{new_disk_path}.wiki", old_wiki_disk_path) end + + def try_to_set_repository_read_only! + # Mitigate any push operation to start during migration + unless project.set_repository_read_only! + migration_error = "Target repository '#{old_disk_path}' cannot be made read-only as there is a git transfer in progress" + logger.error migration_error + + raise RepositoryInUseError, migration_error + end + end end end end diff --git a/app/services/projects/hashed_storage/migrate_attachments_service.rb b/app/services/projects/hashed_storage/migrate_attachments_service.rb index 03e0685d2cd..3d0b8f58612 100644 --- a/app/services/projects/hashed_storage/migrate_attachments_service.rb +++ b/app/services/projects/hashed_storage/migrate_attachments_service.rb @@ -2,62 +2,37 @@ module Projects module HashedStorage - AttachmentMigrationError = Class.new(StandardError) - - class MigrateAttachmentsService < BaseService - attr_reader :logger, :old_disk_path, :new_disk_path - + class MigrateAttachmentsService < BaseAttachmentService def initialize(project, old_disk_path, logger: nil) @project = project @logger = logger || Rails.logger @old_disk_path = old_disk_path - @new_disk_path = project.disk_path @skipped = false end def execute origin = FileUploader.absolute_base_dir(project) - # It's possible that old_disk_path does not match project.disk_path. For example, that happens when we rename a project + # It's possible that old_disk_path does not match project.disk_path. + # For example, that happens when we rename a project origin.sub!(/#{Regexp.escape(project.full_path)}\z/, old_disk_path) project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:attachments] target = FileUploader.absolute_base_dir(project) - result = move_folder!(origin, target) - project.save! - - if result && block_given? - yield - end - - result - end - - def skipped? - @skipped - end + @new_disk_path = project.disk_path - private + result = move_folder!(origin, target) - def move_folder!(old_path, new_path) - unless File.directory?(old_path) - logger.info("Skipped attachments migration from '#{old_path}' to '#{new_path}', source path doesn't exist or is not a directory (PROJECT_ID=#{project.id})") - @skipped = true - return true - end + if result + project.save!(validate: false) - if File.exist?(new_path) - logger.error("Cannot migrate attachments from '#{old_path}' to '#{new_path}', target path already exist (PROJECT_ID=#{project.id})") - raise AttachmentMigrationError, "Target path '#{new_path}' already exist" + yield if block_given? + else + # Rollback changes + project.rollback! end - # Create hashed storage base path folder - FileUtils.mkdir_p(File.dirname(new_path)) - - FileUtils.mv(old_path, new_path) - logger.info("Migrated project attachments from '#{old_path}' to '#{new_path}' (PROJECT_ID=#{project.id})") - - true + result end end end diff --git a/app/services/projects/hashed_storage/migrate_repository_service.rb b/app/services/projects/hashed_storage/migrate_repository_service.rb index 9c672283c7e..e8393128d58 100644 --- a/app/services/projects/hashed_storage/migrate_repository_service.rb +++ b/app/services/projects/hashed_storage/migrate_repository_service.rb @@ -15,7 +15,7 @@ module Projects result = move_repository(old_disk_path, new_disk_path) if move_wiki - result &&= move_repository("#{old_wiki_disk_path}", "#{new_disk_path}.wiki") + result &&= move_repository(old_wiki_disk_path, "#{new_disk_path}.wiki") end if result @@ -27,7 +27,7 @@ module Projects end project.repository_read_only = false - project.save! + project.save!(validate: false) if result && block_given? yield @@ -35,18 +35,6 @@ module Projects result end - - private - - def try_to_set_repository_read_only! - # Mitigate any push operation to start during migration - unless project.set_repository_read_only! - migration_error = "Target repository '#{old_disk_path}' cannot be made read-only as there is a git transfer in progress" - logger.error migration_error - - raise RepositoryMigrationError, migration_error - end - end end end end diff --git a/app/services/projects/hashed_storage/rollback_attachments_service.rb b/app/services/projects/hashed_storage/rollback_attachments_service.rb new file mode 100644 index 00000000000..5c6b92f965c --- /dev/null +++ b/app/services/projects/hashed_storage/rollback_attachments_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Projects + module HashedStorage + class RollbackAttachmentsService < BaseAttachmentService + def initialize(project, logger: nil) + @project = project + @logger = logger || Rails.logger + @old_disk_path = project.disk_path + end + + def execute + origin = FileUploader.absolute_base_dir(project) + project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository] + target = FileUploader.absolute_base_dir(project) + + @new_disk_path = FileUploader.base_dir(project) + + result = move_folder!(origin, target) + + if result + project.save!(validate: false) + + yield if block_given? + else + # Rollback changes + project.rollback! + end + + result + end + end + end +end diff --git a/app/services/projects/hashed_storage/rollback_repository_service.rb b/app/services/projects/hashed_storage/rollback_repository_service.rb new file mode 100644 index 00000000000..67733f4770b --- /dev/null +++ b/app/services/projects/hashed_storage/rollback_repository_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Projects + module HashedStorage + class RollbackRepositoryService < BaseRepositoryService + def execute + try_to_set_repository_read_only! + + @old_storage_version = project.storage_version + project.storage_version = nil + project.ensure_storage_path_exists + + @new_disk_path = project.disk_path + + result = move_repository(old_disk_path, new_disk_path) + + if move_wiki + result &&= move_repository(old_wiki_disk_path, "#{new_disk_path}.wiki") + end + + if result + project.write_repository_config + project.track_project_repository + else + rollback_folder_move + project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository] + end + + project.repository_read_only = false + project.save!(validate: false) + + if result && block_given? + yield + end + + result + end + end + end +end diff --git a/app/services/projects/hashed_storage/rollback_service.rb b/app/services/projects/hashed_storage/rollback_service.rb new file mode 100644 index 00000000000..25767f5de5e --- /dev/null +++ b/app/services/projects/hashed_storage/rollback_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Projects + module HashedStorage + class RollbackService < BaseService + attr_reader :logger, :old_disk_path + + def initialize(project, old_disk_path, logger: nil) + @project = project + @old_disk_path = old_disk_path + @logger = logger || Rails.logger + end + + def execute + # Rollback attachments from Hashed Storage to Legacy + if project.hashed_storage?(:attachments) + return false unless rollback_attachments + end + + # Rollback repository from Hashed Storage to Legacy + if project.hashed_storage?(:repository) + rollback_repository + end + end + + private + + def rollback_attachments + HashedStorage::RollbackAttachmentsService.new(project, logger: logger).execute + end + + def rollback_repository + HashedStorage::RollbackRepositoryService.new(project, old_disk_path, logger: logger).execute + end + end + end +end diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb index 2f6dc4207dd..9428575591e 100644 --- a/app/services/projects/housekeeping_service.rb +++ b/app/services/projects/housekeeping_service.rb @@ -11,6 +11,7 @@ module Projects class HousekeepingService < BaseService # Timeout set to 24h LEASE_TIMEOUT = 86400 + PACK_REFS_PERIOD = 6 class LeaseTaken < StandardError def to_s @@ -18,8 +19,9 @@ module Projects end end - def initialize(project) + def initialize(project, task = nil) @project = project + @task = task end def execute @@ -69,17 +71,21 @@ module Projects end def task + return @task if @task + if pushes_since_gc % gc_period == 0 :gc elsif pushes_since_gc % full_repack_period == 0 :full_repack - else + elsif pushes_since_gc % repack_period == 0 :incremental_repack + else + :pack_refs end end def period_match? - [gc_period, full_repack_period, repack_period].any? { |period| pushes_since_gc % period == 0 } + [gc_period, full_repack_period, repack_period, PACK_REFS_PERIOD].any? { |period| pushes_since_gc % period == 0 } end def housekeeping_enabled? diff --git a/app/services/projects/import_error_filter.rb b/app/services/projects/import_error_filter.rb index a0fc5149bb4..737b794484d 100644 --- a/app/services/projects/import_error_filter.rb +++ b/app/services/projects/import_error_filter.rb @@ -4,7 +4,7 @@ module Projects # Used by project imports, it removes any potential paths # included in an error message that could be stored in the DB class ImportErrorFilter - ERROR_MESSAGE_FILTER = /[^\s]*#{File::SEPARATOR}[^\s]*(?=(\s|\z))/ + ERROR_MESSAGE_FILTER = /[^\s]*#{File::SEPARATOR}[^\s]*(?=(\s|\z))/.freeze FILTER_MESSAGE = '[FILTERED]' def self.filter_message(message) diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index 7214e9efaf6..073c14040ce 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -27,13 +27,13 @@ module Projects rescue Gitlab::UrlBlocker::BlockedUrlError => e Gitlab::Sentry.track_acceptable_exception(e, extra: { project_path: project.full_path, importer: project.import_type }) - error("Error importing repository #{project.safe_import_url} into #{project.full_path} - #{e.message}") + error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") % { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: e.message }) rescue => e message = Projects::ImportErrorFilter.filter_message(e.message) Gitlab::Sentry.track_acceptable_exception(e, extra: { project_path: project.full_path, importer: project.import_type }) - error("Error importing repository #{project.safe_import_url} into #{project.full_path} - #{message}") + error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") % { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: message }) end private @@ -43,7 +43,7 @@ module Projects begin Gitlab::UrlBlocker.validate!(project.import_url, ports: Project::VALID_IMPORT_PORTS) rescue Gitlab::UrlBlocker::BlockedUrlError => e - raise e, "Blocked import URL: #{e.message}" + raise e, s_("ImportProjects|Blocked import URL: %{message}") % { message: e.message } end end @@ -61,7 +61,7 @@ module Projects def create_repository unless project.create_repository - raise Error, 'The repository could not be created.' + raise Error, s_('ImportProjects|The repository could not be created.') end end @@ -94,16 +94,13 @@ module Projects return unless project.lfs_enabled? - lfs_objects_to_download = Projects::LfsPointers::LfsImportService.new(project).execute + result = Projects::LfsPointers::LfsImportService.new(project).execute - lfs_objects_to_download.each do |lfs_download_object| - Projects::LfsPointers::LfsDownloadService.new(project, lfs_download_object) - .execute + if result[:status] == :error + # To avoid aborting the importing process, we silently fail + # if any exception raises. + Gitlab::AppLogger.error("The Lfs import process failed. #{result[:message]}") end - rescue => e - # Right now, to avoid aborting the importing process, we silently fail - # if any exception raises. - Rails.logger.error("The Lfs import process failed. #{e.message}") end def import_data @@ -112,7 +109,7 @@ module Projects project.repository.expire_content_cache unless project.gitlab_project_import? unless importer.execute - raise Error, 'The remote data could not be imported.' + raise Error, s_('ImportProjects|The remote data could not be imported.') end end diff --git a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb index 7998976b00a..9b72480d18b 100644 --- a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb +++ b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb @@ -21,9 +21,9 @@ module Projects # This method accepts two parameters: # - oids: hash of oids to query. The structure is { lfs_file_oid => lfs_file_size } # - # Returns a hash with the structure { lfs_file_oids => download_link } + # Returns an array of LfsDownloadObject def execute(oids) - return {} unless project&.lfs_enabled? && remote_uri && oids.present? + return [] unless project&.lfs_enabled? && remote_uri && oids.present? get_download_links(oids) end @@ -37,22 +37,30 @@ module Projects raise DownloadLinksError, response.message unless response.success? - parse_response_links(response['objects']) + # Since the LFS Batch API may return a Content-Ttpe of + # application/vnd.git-lfs+json + # (https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md#requests), + # HTTParty does not know this is actually JSON. + data = JSON.parse(response.body) + + raise DownloadLinksError, "LFS Batch API did return any objects" unless data.is_a?(Hash) && data.key?('objects') + + parse_response_links(data['objects']) + rescue JSON::ParserError + raise DownloadLinksError, "LFS Batch API response is not JSON" end def parse_response_links(objects_response) objects_response.each_with_object([]) do |entry, link_list| - begin - link = entry.dig('actions', DOWNLOAD_ACTION, 'href') + link = entry.dig('actions', DOWNLOAD_ACTION, 'href') - raise DownloadLinkNotFound unless link + raise DownloadLinkNotFound unless link - link_list << LfsDownloadObject.new(oid: entry['oid'], - size: entry['size'], - link: add_credentials(link)) - rescue DownloadLinkNotFound, Addressable::URI::InvalidURIError - log_error("Link for Lfs Object with oid #{entry['oid']} not found or invalid.") - end + link_list << LfsDownloadObject.new(oid: entry['oid'], + size: entry['size'], + link: add_credentials(link)) + rescue DownloadLinkNotFound, Addressable::URI::InvalidURIError + log_error("Link for Lfs Object with oid #{entry['oid']} not found or invalid.") end end diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb index 398f00a598d..a009f479d5d 100644 --- a/app/services/projects/lfs_pointers/lfs_download_service.rb +++ b/app/services/projects/lfs_pointers/lfs_download_service.rb @@ -75,17 +75,15 @@ module Projects create_tmp_storage_dir File.open(tmp_filename, 'wb') do |file| - begin - yield file - rescue StandardError => e - # If the lfs file is successfully downloaded it will be removed - # when it is added to the project's lfs files. - # Nevertheless if any excetion raises the file would remain - # in the file system. Here we ensure to remove it - File.unlink(file) if File.exist?(file) - - raise e - end + yield file + rescue StandardError => e + # If the lfs file is successfully downloaded it will be removed + # when it is added to the project's lfs files. + # Nevertheless if any excetion raises the file would remain + # in the file system. Here we ensure to remove it + File.unlink(file) if File.exist?(file) + + raise e end end diff --git a/app/services/projects/lfs_pointers/lfs_import_service.rb b/app/services/projects/lfs_pointers/lfs_import_service.rb index 9215fa0a7bf..2afcce7099b 100644 --- a/app/services/projects/lfs_pointers/lfs_import_service.rb +++ b/app/services/projects/lfs_pointers/lfs_import_service.rb @@ -1,95 +1,23 @@ # frozen_string_literal: true -# This service manages the whole worflow of discovering the Lfs files in a -# repository, linking them to the project and downloading (and linking) the non -# existent ones. +# This service is responsible of managing the retrieval of the lfs objects, +# and call the service LfsDownloadService, which performs the download +# for each of the retrieved lfs objects module Projects module LfsPointers class LfsImportService < BaseService - include Gitlab::Utils::StrongMemoize - - HEAD_REV = 'HEAD'.freeze - LFS_ENDPOINT_PATTERN = /^\t?url\s*=\s*(.+)$/.freeze - LFS_BATCH_API_ENDPOINT = '/info/lfs/objects/batch'.freeze - - LfsImportError = Class.new(StandardError) - def execute - return {} unless project&.lfs_enabled? + return success unless project&.lfs_enabled? - if external_lfs_endpoint? - # If the endpoint host is different from the import_url it means - # that the repo is using a third party service for storing the LFS files. - # In this case, we have to disable lfs in the project - disable_lfs! + lfs_objects_to_download = LfsObjectDownloadListService.new(project).execute - return {} + lfs_objects_to_download.each do |lfs_download_object| + LfsDownloadService.new(project, lfs_download_object).execute end - get_download_links - rescue LfsDownloadLinkListService::DownloadLinksError => e - raise LfsImportError, "The LFS objects download list couldn't be imported. Error: #{e.message}" - end - - private - - def external_lfs_endpoint? - lfsconfig_endpoint_uri && lfsconfig_endpoint_uri.host != import_uri.host - end - - def disable_lfs! - project.update(lfs_enabled: false) - end - - # rubocop: disable CodeReuse/ActiveRecord - def get_download_links - existent_lfs = LfsListService.new(project).execute - linked_oids = LfsLinkService.new(project).execute(existent_lfs.keys) - - # Retrieving those oids not linked and which we need to download - not_linked_lfs = existent_lfs.except(*linked_oids) - - LfsDownloadLinkListService.new(project, remote_uri: current_endpoint_uri).execute(not_linked_lfs) - end - # rubocop: enable CodeReuse/ActiveRecord - - def lfsconfig_endpoint_uri - strong_memoize(:lfsconfig_endpoint_uri) do - # Retrieveing the blob data from the .lfsconfig file - data = project.repository.lfsconfig_for(HEAD_REV) - # Parsing the data to retrieve the url - parsed_data = data&.match(LFS_ENDPOINT_PATTERN) - - if parsed_data - URI.parse(parsed_data[1]).tap do |endpoint| - endpoint.user ||= import_uri.user - endpoint.password ||= import_uri.password - end - end - end - rescue URI::InvalidURIError - raise LfsImportError, 'Invalid URL in .lfsconfig file' - end - - def import_uri - @import_uri ||= URI.parse(project.import_url) - rescue URI::InvalidURIError - raise LfsImportError, 'Invalid project import URL' - end - - def current_endpoint_uri - (lfsconfig_endpoint_uri || default_endpoint_uri) - end - - # The import url must end with '.git' here we ensure it is - def default_endpoint_uri - @default_endpoint_uri ||= begin - import_uri.dup.tap do |uri| - path = uri.path.gsub(%r(/$), '') - path += '.git' unless path.ends_with?('.git') - uri.path = path + LFS_BATCH_API_ENDPOINT - end - end + success + rescue => e + error(e.message) end end end diff --git a/app/services/projects/lfs_pointers/lfs_link_service.rb b/app/services/projects/lfs_pointers/lfs_link_service.rb index 8401f3d1d89..e3c956250f0 100644 --- a/app/services/projects/lfs_pointers/lfs_link_service.rb +++ b/app/services/projects/lfs_pointers/lfs_link_service.rb @@ -6,9 +6,9 @@ module Projects class LfsLinkService < BaseService # Accept an array of oids to link # - # Returns a hash with the same structure with oids linked + # Returns an array with the oid of the existent lfs objects def execute(oids) - return {} unless project&.lfs_enabled? + return [] unless project&.lfs_enabled? # Search and link existing LFS Object link_existing_lfs_objects(oids) diff --git a/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb b/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb new file mode 100644 index 00000000000..5ba0f50f2ff --- /dev/null +++ b/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +# This service manages the whole worflow of discovering the Lfs files in a +# repository, linking them to the project and downloading (and linking) the non +# existent ones. +module Projects + module LfsPointers + class LfsObjectDownloadListService < BaseService + include Gitlab::Utils::StrongMemoize + + HEAD_REV = 'HEAD'.freeze + LFS_ENDPOINT_PATTERN = /^\t?url\s*=\s*(.+)$/.freeze + LFS_BATCH_API_ENDPOINT = '/info/lfs/objects/batch'.freeze + + LfsObjectDownloadListError = Class.new(StandardError) + + def execute + return [] unless project&.lfs_enabled? + + if external_lfs_endpoint? + # If the endpoint host is different from the import_url it means + # that the repo is using a third party service for storing the LFS files. + # In this case, we have to disable lfs in the project + disable_lfs! + + return [] + end + + # Getting all Lfs pointers already in the database and linking them to the project + linked_oids = LfsLinkService.new(project).execute(lfs_pointers_in_repository.keys) + # Retrieving those oids not present in the database which we need to download + missing_oids = lfs_pointers_in_repository.except(*linked_oids) # rubocop: disable CodeReuse/ActiveRecord + # Downloading the required information and gathering it inside a LfsDownloadObject for each oid + LfsDownloadLinkListService.new(project, remote_uri: current_endpoint_uri).execute(missing_oids) + rescue LfsDownloadLinkListService::DownloadLinksError => e + raise LfsObjectDownloadListError, "The LFS objects download list couldn't be imported. Error: #{e.message}" + end + + private + + def external_lfs_endpoint? + lfsconfig_endpoint_uri && lfsconfig_endpoint_uri.host != import_uri.host + end + + def disable_lfs! + unless project.update(lfs_enabled: false) + raise LfsDownloadLinkListService::DownloadLinksError, "Invalid project state" + end + end + + # Retrieves all lfs pointers in the repository + def lfs_pointers_in_repository + @lfs_pointers_in_repository ||= LfsListService.new(project).execute + end + + def lfsconfig_endpoint_uri + strong_memoize(:lfsconfig_endpoint_uri) do + # Retrieveing the blob data from the .lfsconfig file + data = project.repository.lfsconfig_for(HEAD_REV) + # Parsing the data to retrieve the url + parsed_data = data&.match(LFS_ENDPOINT_PATTERN) + + if parsed_data + URI.parse(parsed_data[1]).tap do |endpoint| + endpoint.user ||= import_uri.user + endpoint.password ||= import_uri.password + end + end + end + rescue URI::InvalidURIError + raise LfsObjectDownloadListError, 'Invalid URL in .lfsconfig file' + end + + def import_uri + @import_uri ||= URI.parse(project.import_url) + rescue URI::InvalidURIError + raise LfsObjectDownloadListError, 'Invalid project import URL' + end + + def current_endpoint_uri + (lfsconfig_endpoint_uri || default_endpoint_uri) + end + + # The import url must end with '.git' here we ensure it is + def default_endpoint_uri + @default_endpoint_uri ||= begin + import_uri.dup.tap do |uri| + path = uri.path.gsub(%r(/$), '') + path += '.git' unless path.ends_with?('.git') + uri.path = path + LFS_BATCH_API_ENDPOINT + end + end + end + end + end +end diff --git a/app/services/projects/move_project_group_links_service.rb b/app/services/projects/move_project_group_links_service.rb index 36afcd0c503..cf4b291c761 100644 --- a/app/services/projects/move_project_group_links_service.rb +++ b/app/services/projects/move_project_group_links_service.rb @@ -26,7 +26,7 @@ module Projects # Remove remaining project group links from source_project def remove_remaining_project_group_links - source_project.reload.project_group_links.destroy_all # rubocop: disable DestroyAll + source_project.reset.project_group_links.destroy_all # rubocop: disable DestroyAll end def group_links_in_target_project diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb index abd6d8de750..48eddb0e8d0 100644 --- a/app/services/projects/operations/update_service.rb +++ b/app/services/projects/operations/update_service.rb @@ -12,7 +12,37 @@ module Projects private def project_update_params - params.slice(:error_tracking_setting_attributes) + error_tracking_params.merge(metrics_setting_params) + end + + def metrics_setting_params + attribs = params[:metrics_setting_attributes] + return {} unless attribs + + destroy = attribs[:external_dashboard_url].blank? + + { metrics_setting_attributes: attribs.merge(_destroy: destroy) } + end + + def error_tracking_params + settings = params[:error_tracking_setting_attributes] + return {} if settings.blank? + + api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from( + api_host: settings[:api_host], + project_slug: settings.dig(:project, :slug), + organization_slug: settings.dig(:project, :organization_slug) + ) + + { + error_tracking_setting_attributes: { + api_url: api_url, + token: settings[:token], + enabled: settings[:enabled], + project_name: settings.dig(:project, :name), + organization_name: settings.dig(:project, :organization_name) + } + } end end end diff --git a/app/services/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb index 633a263af7b..a2f36d2bd1b 100644 --- a/app/services/projects/propagate_service_template.rb +++ b/app/services/projects/propagate_service_template.rb @@ -80,7 +80,7 @@ module Projects value = value.is_a?(Hash) ? value.to_json : value service_hash[ActiveRecord::Base.connection.quote_column_name(key)] = - ActiveRecord::Base.sanitize(value) + ActiveRecord::Base.connection.quote(value) end end end diff --git a/app/services/projects/repository_languages_service.rb b/app/services/projects/repository_languages_service.rb new file mode 100644 index 00000000000..05f43c2264b --- /dev/null +++ b/app/services/projects/repository_languages_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Projects + class RepositoryLanguagesService < BaseService + def execute + perform_language_detection unless project.detected_repository_languages? + persisted_repository_languages + end + + private + + def perform_language_detection + if persisted_repository_languages.blank? + ::DetectRepositoryLanguagesWorker.perform_async(project.id) + else + project.update_column(:detected_repository_languages, true) + end + end + + def persisted_repository_languages + project.repository_languages + end + end +end diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 5da1e39a1fb..233dcf37e35 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -17,11 +17,11 @@ module Projects @new_namespace = new_namespace if @new_namespace.blank? - raise TransferError, 'Please select a new namespace for your project.' + raise TransferError, s_('TransferProject|Please select a new namespace for your project.') end unless allowed_transfer?(current_user, project) - raise TransferError, 'Transfer failed, please contact an admin.' + raise TransferError, s_('TransferProject|Transfer failed, please contact an admin.') end transfer(project) @@ -30,7 +30,7 @@ module Projects true rescue Projects::TransferService::TransferError => ex - project.reload + project.reset project.errors.add(:new_namespace, ex.message) false end @@ -45,16 +45,15 @@ module Projects @old_namespace = project.namespace if Project.where(namespace_id: @new_namespace.try(:id)).where('path = ? or name = ?', project.path, project.name).exists? - raise TransferError.new("Project with same name or path in target namespace already exists") + raise TransferError.new(s_("TransferProject|Project with same name or path in target namespace already exists")) end if project.has_container_registry_tags? # We currently don't support renaming repository if it contains tags in container registry - raise TransferError.new('Project cannot be transferred, because tags are present in its container registry') + raise TransferError.new(s_('TransferProject|Project cannot be transferred, because tags are present in its container registry')) end attempt_transfer_transaction - configure_group_clusters_for_project end # rubocop: enable CodeReuse/ActiveRecord @@ -122,7 +121,7 @@ module Projects def rollback_side_effects rollback_folder_move - project.reload + project.reset update_namespace_and_visibility(@old_namespace) update_repository_configuration(@old_path) end @@ -145,7 +144,7 @@ module Projects # Move main repository unless move_repo_folder(@old_path, @new_path) - raise TransferError.new("Cannot move project") + raise TransferError.new(s_("TransferProject|Cannot move project")) end # Disk path is changed; we need to ensure we reload it @@ -164,9 +163,5 @@ module Projects @new_namespace.full_path ) end - - def configure_group_clusters_for_project - ClusterProjectConfigureWorker.perform_async(project.id) - end end end diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 6856009b395..2bc04470342 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -3,6 +3,7 @@ module Projects class UpdateService < BaseService include UpdateVisibilityLevel + include ValidatesClassificationLabel ValidationError = Class.new(StandardError) @@ -14,6 +15,8 @@ module Projects yield if block_given? + validate_classification_label(project, :external_authorization_classification_label) + # If the block added errors, don't try to save the project return update_failed! if project.errors.any? @@ -39,15 +42,15 @@ module Projects def validate! unless valid_visibility_level_change?(project, params[:visibility_level]) - raise ValidationError.new('New visibility level not allowed!') + raise ValidationError.new(s_('UpdateProject|New visibility level not allowed!')) end if renaming_project_with_container_registry_tags? - raise ValidationError.new('Cannot rename project because it contains container registry tags!') + raise ValidationError.new(s_('UpdateProject|Cannot rename project because it contains container registry tags!')) end if changing_default_branch? - raise ValidationError.new("Could not set the default branch") unless project.change_head(params[:default_branch]) + raise ValidationError.new(s_("UpdateProject|Could not set the default branch")) unless project.change_head(params[:default_branch]) end end @@ -61,6 +64,7 @@ module Projects if project.previous_changes.include?(:visibility_level) && project.private? # don't enqueue immediately to prevent todos removal in case of a mistake + TodosDestroyer::ConfidentialIssueWorker.perform_in(Todo::WAIT_FOR_DELETE, nil, project.id) TodosDestroyer::ProjectPrivateWorker.perform_in(Todo::WAIT_FOR_DELETE, project.id) elsif (project_changed_feature_keys & todos_features_changes).present? TodosDestroyer::PrivateFeaturesWorker.perform_in(Todo::WAIT_FOR_DELETE, project.id) @@ -76,10 +80,7 @@ module Projects end def after_rename_service(project) - # The path slug the project was using, before the rename took place. - path_before = project.previous_changes['path'].first - - AfterRenameService.new(project, path_before: path_before, full_path_before: project.full_path_was) + AfterRenameService.new(project, path_before: project.path_before_last_save, full_path_before: project.full_path_before_last_save) end def changing_pages_related_config? @@ -88,7 +89,7 @@ module Projects def update_failed! model_errors = project.errors.full_messages.to_sentence - error_message = model_errors.presence || 'Project could not be updated!' + error_message = model_errors.presence || s_('UpdateProject|Project could not be updated!') error(error_message) end diff --git a/app/services/projects/update_statistics_service.rb b/app/services/projects/update_statistics_service.rb new file mode 100644 index 00000000000..28677a398f3 --- /dev/null +++ b/app/services/projects/update_statistics_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Projects + class UpdateStatisticsService < BaseService + def execute + return unless project + + Rails.logger.info("Updating statistics for project #{project.id}") + + project.statistics.refresh!(only: statistics.map(&:to_sym)) + end + + private + + def statistics + params[:statistics] + end + end +end diff --git a/app/services/prometheus/proxy_service.rb b/app/services/prometheus/proxy_service.rb new file mode 100644 index 00000000000..c5d2b84878b --- /dev/null +++ b/app/services/prometheus/proxy_service.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module Prometheus + class ProxyService < BaseService + include ReactiveCaching + include Gitlab::Utils::StrongMemoize + + 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_lifetime = 1.minute + self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } + + attr_accessor :proxyable, :method, :path, :params + + PROXY_SUPPORT = { + 'query' => { + method: ['GET'], + params: %w(query time timeout) + }, + 'query_range' => { + method: ['GET'], + params: %w(query start end step timeout) + } + }.freeze + + def self.from_cache(proxyable_class_name, proxyable_id, method, path, params) + proxyable_class = begin + proxyable_class_name.constantize + rescue NameError + nil + end + return unless proxyable_class + + proxyable = proxyable_class.find(proxyable_id) + + new(proxyable, method, path, params) + end + + # proxyable can be any model which responds to .prometheus_adapter + # like Environment. + def initialize(proxyable, method, path, params) + @proxyable = proxyable + @path = path + + # Convert ActionController::Parameters to hash because reactive_cache_worker + # does not play nice with ActionController::Parameters. + @params = filter_params(params, path).to_hash + + @method = method + end + + def id + nil + end + + def execute + return cannot_proxy_response unless can_proxy? + return no_prometheus_response unless can_query? + + with_reactive_cache(*cache_key) do |result| + result + end + end + + def calculate_reactive_cache(proxyable_class_name, proxyable_id, method, path, params) + return no_prometheus_response unless can_query? + + response = prometheus_client_wrapper.proxy(path, params) + + success(http_status: response.code, body: response.body) + rescue Gitlab::PrometheusClient::Error => err + service_unavailable_response(err) + end + + def cache_key + [@proxyable.class.name, @proxyable.id, @method, @path, @params] + end + + private + + def service_unavailable_response(exception) + error(exception.message, :service_unavailable) + end + + def no_prometheus_response + error('No prometheus server found', :service_unavailable) + end + + def cannot_proxy_response + error('Proxy support for this API is not available currently') + end + + def prometheus_adapter + strong_memoize(:prometheus_adapter) do + @proxyable.prometheus_adapter + end + end + + def prometheus_client_wrapper + prometheus_adapter&.prometheus_client_wrapper + end + + def can_query? + prometheus_adapter&.can_query? + end + + def filter_params(params, path) + params.slice(*PROXY_SUPPORT.dig(path, :params)) + end + + def can_proxy? + PROXY_SUPPORT.dig(@path, :method)&.include?(@method) + end + end +end diff --git a/app/services/push_event_payload_service.rb b/app/services/push_event_payload_service.rb index bb1259787af..fe366ac225b 100644 --- a/app/services/push_event_payload_service.rb +++ b/app/services/push_event_payload_service.rb @@ -46,7 +46,7 @@ class PushEventPayloadService def commit_title commit = @push_data.fetch(:commits).last - return nil unless commit && commit[:message] + return unless commit && commit[:message] raw_msg = commit[:message] diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index 5c58caee8cd..8ff73522e5f 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -4,16 +4,24 @@ module QuickActions class InterpretService < BaseService include Gitlab::Utils::StrongMemoize include Gitlab::QuickActions::Dsl + include Gitlab::QuickActions::IssueActions + include Gitlab::QuickActions::IssueAndMergeRequestActions + include Gitlab::QuickActions::IssuableActions + include Gitlab::QuickActions::MergeRequestActions + include Gitlab::QuickActions::CommitActions + include Gitlab::QuickActions::CommonActions - attr_reader :issuable + attr_reader :quick_action_target - SHRUG = '¯\\_(ツ)_/¯'.freeze - TABLEFLIP = '(╯°□°)╯︵ ┻━┻'.freeze + # Counts how many commands have been executed. + # Used to display relevant feedback on UI when a note + # with only commands has been processed. + attr_accessor :commands_executed_count - # Takes an issuable and returns an array of all the available commands + # Takes an quick_action_target and returns an array of all the available commands # represented with .to_h - def available_commands(issuable) - @issuable = issuable + def available_commands(quick_action_target) + @quick_action_target = quick_action_target self.class.command_definitions.map do |definition| next unless definition.available?(self) @@ -24,10 +32,10 @@ module QuickActions # Takes a text and interprets the commands that are extracted from it. # Returns the content without commands, and hash of changes to be applied to a record. - def execute(content, issuable, only: nil) + def execute(content, quick_action_target, only: nil) return [content, {}] unless current_user.can?(:use_quick_actions) - @issuable = issuable + @quick_action_target = quick_action_target @updates = {} content, commands = extractor.extract_commands(content, only: only) @@ -38,10 +46,10 @@ module QuickActions # Takes a text and interprets the commands that are extracted from it. # Returns the content without commands, and array of changes explained. - def explain(content, issuable) + def explain(content, quick_action_target) return [content, []] unless current_user.can?(:use_quick_actions) - @issuable = issuable + @quick_action_target = quick_action_target content, commands = extractor.extract_commands(content) commands = explain_commands(commands) @@ -54,598 +62,6 @@ module QuickActions Gitlab::QuickActions::Extractor.new(self.class.command_definitions) end - desc do - "Close this #{issuable.to_ability_name.humanize(capitalize: false)}" - end - explanation do - "Closes this #{issuable.to_ability_name.humanize(capitalize: false)}." - end - condition do - issuable.is_a?(Issuable) && - issuable.persisted? && - issuable.open? && - current_user.can?(:"update_#{issuable.to_ability_name}", issuable) - end - command :close do - @updates[:state_event] = 'close' - end - - desc do - "Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}" - end - explanation do - "Reopens this #{issuable.to_ability_name.humanize(capitalize: false)}." - end - condition do - issuable.is_a?(Issuable) && - issuable.persisted? && - issuable.closed? && - current_user.can?(:"update_#{issuable.to_ability_name}", issuable) - end - command :reopen do - @updates[:state_event] = 'reopen' - end - - desc 'Merge (when the pipeline succeeds)' - explanation 'Merges this merge request when the pipeline succeeds.' - condition do - last_diff_sha = params && params[:merge_request_diff_head_sha] - issuable.is_a?(MergeRequest) && - issuable.persisted? && - issuable.mergeable_with_quick_action?(current_user, autocomplete_precheck: !last_diff_sha, last_diff_sha: last_diff_sha) - end - command :merge do - @updates[:merge] = params[:merge_request_diff_head_sha] - end - - desc 'Change title' - explanation do |title_param| - "Changes the title to \"#{title_param}\"." - end - params '<New title>' - condition do - issuable.persisted? && - current_user.can?(:"update_#{issuable.to_ability_name}", issuable) - end - command :title do |title_param| - @updates[:title] = title_param - end - - desc 'Assign' - # rubocop: disable CodeReuse/ActiveRecord - explanation do |users| - users = issuable.allows_multiple_assignees? ? users : users.take(1) - "Assigns #{users.map(&:to_reference).to_sentence}." - end - # rubocop: enable CodeReuse/ActiveRecord - params do - issuable.allows_multiple_assignees? ? '@user1 @user2' : '@user' - end - condition do - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - parse_params do |assignee_param| - extract_users(assignee_param) - end - command :assign do |users| - next if users.empty? - - if issuable.allows_multiple_assignees? - @updates[:assignee_ids] ||= issuable.assignees.map(&:id) - @updates[:assignee_ids] += users.map(&:id) - else - @updates[:assignee_ids] = [users.first.id] - end - end - - desc do - if issuable.allows_multiple_assignees? - 'Remove all or specific assignee(s)' - else - 'Remove assignee' - end - end - explanation do |users = nil| - assignees = issuable.assignees - assignees &= users if users.present? && issuable.allows_multiple_assignees? - "Removes #{'assignee'.pluralize(assignees.size)} #{assignees.map(&:to_reference).to_sentence}." - end - params do - issuable.allows_multiple_assignees? ? '@user1 @user2' : '' - end - condition do - issuable.is_a?(Issuable) && - issuable.persisted? && - issuable.assignees.any? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - parse_params do |unassign_param| - # When multiple users are assigned, all will be unassigned if multiple assignees are no longer allowed - extract_users(unassign_param) if issuable.allows_multiple_assignees? - end - command :unassign do |users = nil| - if issuable.allows_multiple_assignees? && users&.any? - @updates[:assignee_ids] ||= issuable.assignees.map(&:id) - @updates[:assignee_ids] -= users.map(&:id) - else - @updates[:assignee_ids] = [] - end - end - - desc 'Set milestone' - explanation do |milestone| - "Sets the milestone to #{milestone.to_reference}." if milestone - end - params '%"milestone"' - condition do - current_user.can?(:"admin_#{issuable.to_ability_name}", project) && - find_milestones(project, state: 'active').any? - end - parse_params do |milestone_param| - extract_references(milestone_param, :milestone).first || - find_milestones(project, title: milestone_param.strip).first - end - command :milestone do |milestone| - @updates[:milestone_id] = milestone.id if milestone - end - - desc 'Remove milestone' - explanation do - "Removes #{issuable.milestone.to_reference(format: :name)} milestone." - end - condition do - issuable.is_a?(Issuable) && - issuable.persisted? && - issuable.milestone_id? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :remove_milestone do - @updates[:milestone_id] = nil - end - - desc 'Add label(s)' - explanation do |labels_param| - labels = find_label_references(labels_param) - - "Adds #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? - end - params '~label1 ~"label 2"' - condition do - parent && - current_user.can?(:"admin_#{issuable.to_ability_name}", parent) && - find_labels.any? - end - command :label do |labels_param| - label_ids = find_label_ids(labels_param) - - if label_ids.any? - @updates[:add_label_ids] ||= [] - @updates[:add_label_ids] += label_ids - - @updates[:add_label_ids].uniq! - end - end - - desc 'Remove all or specific label(s)' - explanation do |labels_param = nil| - if labels_param.present? - labels = find_label_references(labels_param) - "Removes #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? - else - 'Removes all labels.' - end - end - params '~label1 ~"label 2"' - condition do - issuable.is_a?(Issuable) && - issuable.persisted? && - issuable.labels.any? && - current_user.can?(:"admin_#{issuable.to_ability_name}", parent) - end - command :unlabel do |labels_param = nil| - if labels_param.present? - label_ids = find_label_ids(labels_param) - - if label_ids.any? - @updates[:remove_label_ids] ||= [] - @updates[:remove_label_ids] += label_ids - - @updates[:remove_label_ids].uniq! - end - else - @updates[:label_ids] = [] - end - end - - desc 'Replace all label(s)' - explanation do |labels_param| - labels = find_label_references(labels_param) - "Replaces all labels with #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? - end - params '~label1 ~"label 2"' - condition do - issuable.is_a?(Issuable) && - issuable.persisted? && - issuable.labels.any? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :relabel do |labels_param| - label_ids = find_label_ids(labels_param) - - if label_ids.any? - @updates[:label_ids] ||= [] - @updates[:label_ids] += label_ids - - @updates[:label_ids].uniq! - end - end - - desc 'Copy labels and milestone from other issue or merge request' - explanation do |source_issuable| - "Copy labels and milestone from #{source_issuable.to_reference}." - end - params '#issue | !merge_request' - condition do - [MergeRequest, Issue].include?(issuable.class) && - current_user.can?(:"update_#{issuable.to_ability_name}", issuable) - end - parse_params do |issuable_param| - extract_references(issuable_param, :issue).first || - extract_references(issuable_param, :merge_request).first - end - command :copy_metadata do |source_issuable| - if source_issuable.present? && source_issuable.project.id == issuable.project.id - @updates[:add_label_ids] = source_issuable.labels.map(&:id) - @updates[:milestone_id] = source_issuable.milestone.id if source_issuable.milestone - end - end - - desc 'Add a todo' - explanation 'Adds a todo.' - condition do - issuable.is_a?(Issuable) && - issuable.persisted? && - !TodoService.new.todo_exist?(issuable, current_user) - end - command :todo do - @updates[:todo_event] = 'add' - end - - desc 'Mark todo as done' - explanation 'Marks todo as done.' - condition do - issuable.persisted? && - TodoService.new.todo_exist?(issuable, current_user) - end - command :done do - @updates[:todo_event] = 'done' - end - - desc 'Subscribe' - explanation do - "Subscribes to this #{issuable.to_ability_name.humanize(capitalize: false)}." - end - condition do - issuable.is_a?(Issuable) && - issuable.persisted? && - !issuable.subscribed?(current_user, project) - end - command :subscribe do - @updates[:subscription_event] = 'subscribe' - end - - desc 'Unsubscribe' - explanation do - "Unsubscribes from this #{issuable.to_ability_name.humanize(capitalize: false)}." - end - condition do - issuable.is_a?(Issuable) && - issuable.persisted? && - issuable.subscribed?(current_user, project) - end - command :unsubscribe do - @updates[:subscription_event] = 'unsubscribe' - end - - desc 'Set due date' - explanation do |due_date| - "Sets the due date to #{due_date.to_s(:medium)}." if due_date - end - params '<in 2 days | this Friday | December 31st>' - condition do - issuable.respond_to?(:due_date) && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - parse_params do |due_date_param| - Chronic.parse(due_date_param).try(:to_date) - end - command :due do |due_date| - @updates[:due_date] = due_date if due_date - end - - desc 'Remove due date' - explanation 'Removes the due date.' - condition do - issuable.persisted? && - issuable.respond_to?(:due_date) && - issuable.due_date? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :remove_due_date do - @updates[:due_date] = nil - end - - desc 'Toggle the Work In Progress status' - explanation do - verb = issuable.work_in_progress? ? 'Unmarks' : 'Marks' - noun = issuable.to_ability_name.humanize(capitalize: false) - "#{verb} this #{noun} as Work In Progress." - end - condition do - issuable.respond_to?(:work_in_progress?) && - # Allow it to mark as WIP on MR creation page _or_ through MR notes. - (issuable.new_record? || current_user.can?(:"update_#{issuable.to_ability_name}", issuable)) - end - command :wip do - @updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip' - end - - desc 'Toggle emoji award' - explanation do |name| - "Toggles :#{name}: emoji award." if name - end - params ':emoji:' - condition do - issuable.is_a?(Issuable) && - issuable.persisted? - end - parse_params do |emoji_param| - match = emoji_param.match(Banzai::Filter::EmojiFilter.emoji_pattern) - match[1] if match - end - command :award do |name| - if name && issuable.user_can_award?(current_user) - @updates[:emoji_award] = name - end - end - - desc 'Set time estimate' - explanation do |time_estimate| - time_estimate = Gitlab::TimeTrackingFormatter.output(time_estimate) - - "Sets time estimate to #{time_estimate}." if time_estimate - end - params '<1w 3d 2h 14m>' - condition do - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - parse_params do |raw_duration| - Gitlab::TimeTrackingFormatter.parse(raw_duration) - end - command :estimate do |time_estimate| - if time_estimate - @updates[:time_estimate] = time_estimate - end - end - - desc 'Add or subtract spent time' - explanation do |time_spent, time_spent_date| - if time_spent - if time_spent > 0 - verb = 'Adds' - value = time_spent - else - verb = 'Subtracts' - value = -time_spent - end - - "#{verb} #{Gitlab::TimeTrackingFormatter.output(value)} spent time." - end - end - params '<time(1h30m | -1h30m)> <date(YYYY-MM-DD)>' - condition do - issuable.is_a?(TimeTrackable) && - current_user.can?(:"admin_#{issuable.to_ability_name}", issuable) - end - parse_params do |raw_time_date| - Gitlab::QuickActions::SpendTimeAndDateSeparator.new(raw_time_date).execute - end - command :spend do |time_spent, time_spent_date| - if time_spent - @updates[:spend_time] = { - duration: time_spent, - user_id: current_user.id, - spent_at: time_spent_date - } - end - end - - desc 'Remove time estimate' - explanation 'Removes time estimate.' - condition do - issuable.persisted? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :remove_estimate do - @updates[:time_estimate] = 0 - end - - desc 'Remove spent time' - explanation 'Removes spent time.' - condition do - issuable.persisted? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :remove_time_spent do - @updates[:spend_time] = { duration: :reset, user_id: current_user.id } - end - - desc "Append the comment with #{SHRUG}" - params '<Comment>' - substitution :shrug do |comment| - "#{comment} #{SHRUG}" - end - - desc "Append the comment with #{TABLEFLIP}" - params '<Comment>' - substitution :tableflip do |comment| - "#{comment} #{TABLEFLIP}" - end - - desc "Lock the discussion" - explanation "Locks the discussion" - condition do - [MergeRequest, Issue].include?(issuable.class) && - issuable.persisted? && - !issuable.discussion_locked? && - current_user.can?(:"admin_#{issuable.to_ability_name}", issuable) - end - command :lock do - @updates[:discussion_locked] = true - end - - desc "Unlock the discussion" - explanation "Unlocks the discussion" - condition do - [MergeRequest, Issue].include?(issuable.class) && - issuable.persisted? && - issuable.discussion_locked? && - current_user.can?(:"admin_#{issuable.to_ability_name}", issuable) - end - command :unlock do - @updates[:discussion_locked] = false - end - - # This is a dummy command, so that it appears in the autocomplete commands - desc 'CC' - params '@user' - command :cc - - desc 'Set target branch' - explanation do |branch_name| - "Sets target branch to #{branch_name}." - end - params '<Local branch name>' - condition do - issuable.respond_to?(:target_branch) && - (current_user.can?(:"update_#{issuable.to_ability_name}", issuable) || - issuable.new_record?) - end - parse_params do |target_branch_param| - target_branch_param.strip - end - command :target_branch do |branch_name| - @updates[:target_branch] = branch_name if project.repository.branch_exists?(branch_name) - end - - desc 'Move issue from one column of the board to another' - explanation do |target_list_name| - label = find_label_references(target_list_name).first - "Moves issue to #{label} column in the board." if label - end - params '~"Target column"' - condition do - issuable.is_a?(Issue) && - current_user.can?(:"update_#{issuable.to_ability_name}", issuable) && - issuable.project.boards.count == 1 - end - # rubocop: disable CodeReuse/ActiveRecord - command :board_move do |target_list_name| - label_ids = find_label_ids(target_list_name) - - if label_ids.size == 1 - label_id = label_ids.first - - # Ensure this label corresponds to a list on the board - next unless Label.on_project_boards(issuable.project_id).where(id: label_id).exists? - - @updates[:remove_label_ids] = - issuable.labels.on_project_boards(issuable.project_id).where.not(id: label_id).pluck(:id) - @updates[:add_label_ids] = [label_id] - end - end - # rubocop: enable CodeReuse/ActiveRecord - - desc 'Mark this issue as a duplicate of another issue' - explanation do |duplicate_reference| - "Marks this issue as a duplicate of #{duplicate_reference}." - end - params '#issue' - condition do - issuable.is_a?(Issue) && - issuable.persisted? && - current_user.can?(:"update_#{issuable.to_ability_name}", issuable) - end - command :duplicate do |duplicate_param| - canonical_issue = extract_references(duplicate_param, :issue).first - - if canonical_issue.present? - @updates[:canonical_issue_id] = canonical_issue.id - end - end - - desc 'Move this issue to another project.' - explanation do |path_to_project| - "Moves this issue to #{path_to_project}." - end - params 'path/to/project' - condition do - issuable.is_a?(Issue) && - issuable.persisted? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :move do |target_project_path| - target_project = Project.find_by_full_path(target_project_path) - - if target_project.present? - @updates[:target_project] = target_project - end - end - - desc 'Make issue confidential.' - explanation do - 'Makes this issue confidential' - end - condition do - issuable.is_a?(Issue) && current_user.can?(:"admin_#{issuable.to_ability_name}", issuable) - end - command :confidential do - @updates[:confidential] = true - end - - desc 'Tag this commit.' - explanation do |tag_name, message| - with_message = %{ with "#{message}"} if message.present? - "Tags this commit to #{tag_name}#{with_message}." - end - params 'v1.2.3 <message>' - parse_params do |tag_name_and_message| - tag_name_and_message.split(' ', 2) - end - condition do - issuable.is_a?(Commit) && current_user.can?(:push_code, project) - end - command :tag do |tag_name, message| - @updates[:tag_name] = tag_name - @updates[:tag_message] = message - end - - desc 'Create a merge request.' - explanation do |branch_name = nil| - branch_text = branch_name ? "branch '#{branch_name}'" : 'a branch' - "Creates #{branch_text} and a merge request to resolve this issue" - end - params "<branch name>" - condition do - issuable.is_a?(Issue) && current_user.can?(:create_merge_request_in, project) && current_user.can?(:push_code, project) - end - command :create_merge_request do |branch_name = nil| - @updates[:create_merge_request] = { - branch_name: branch_name, - issue_iid: issuable.iid - } - end - # rubocop: disable CodeReuse/ActiveRecord def extract_users(params) return [] if params.nil? @@ -675,19 +91,32 @@ module QuickActions def group strong_memoize(:group) do - issuable.group if issuable.respond_to?(:group) + quick_action_target.group if quick_action_target.respond_to?(:group) end end def find_labels(labels_params = nil) + extract_references(labels_params, :label) | find_labels_by_name_no_tilde(labels_params) + end + + def find_labels_by_name_no_tilde(labels_params) + return Label.none if label_with_tilde?(labels_params) + finder_params = { include_ancestor_groups: true } finder_params[:project_id] = project.id if project finder_params[:group_id] = group.id if group - finder_params[:name] = labels_params.split if labels_params + finder_params[:name] = extract_label_names(labels_params) if labels_params - result = LabelsFinder.new(current_user, finder_params).execute + LabelsFinder.new(current_user, finder_params).execute + end + + def label_with_tilde?(labels_params) + labels_params&.include?('~') + end - extract_references(labels_params, :label) | result + def extract_label_names(labels_params) + # '"A" "A B C" A B' => ["A", "A B C", "A", "B"] + labels_params.scan(/"([^"]+)"|([^ ]+)/).flatten.compact end def find_label_references(labels_param) diff --git a/app/services/releases/concerns.rb b/app/services/releases/concerns.rb index a04bb8f9e14..ff6b696ca96 100644 --- a/app/services/releases/concerns.rb +++ b/app/services/releases/concerns.rb @@ -15,7 +15,7 @@ module Releases end def name - params[:name] + params[:name] || tag_name end def description diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb index c6e143d440d..a271a7e5e49 100644 --- a/app/services/releases/create_service.rb +++ b/app/services/releases/create_service.rb @@ -15,6 +15,10 @@ module Releases create_release(tag) end + def find_or_build_release + release || build_release(existing_tag) + end + private def ensure_tag @@ -38,7 +42,17 @@ module Releases end def create_release(tag) - release = project.releases.create!( + release = build_release(tag) + + release.save! + + success(tag: tag, release: release) + rescue => e + error(e.message, 400) + end + + def build_release(tag) + project.releases.build( name: name, description: description, author: current_user, @@ -46,10 +60,6 @@ module Releases sha: tag.dereferenced_target.sha, links_attributes: params.dig(:assets, 'links') || [] ) - - success(tag: tag, release: release) - rescue => e - error(e.message, 400) end end end diff --git a/app/services/releases/destroy_service.rb b/app/services/releases/destroy_service.rb index 8c2bc3b4e6e..f9f6101abdd 100644 --- a/app/services/releases/destroy_service.rb +++ b/app/services/releases/destroy_service.rb @@ -5,7 +5,6 @@ module Releases include Releases::Concerns def execute - return error('Tag does not exist', 404) unless existing_tag return error('Release does not exist', 404) unless release return error('Access Denied', 403) unless allowed? diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb index 039d6e2ebad..b45e567079b 100644 --- a/app/services/resource_events/change_labels_service.rb +++ b/app/services/resource_events/change_labels_service.rb @@ -12,7 +12,7 @@ module ResourceEvents label_hash = { resource_column(resource) => resource.id, user_id: user.id, - created_at: Time.now + created_at: resource.system_note_timestamp } labels = added_labels.map do |label| diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index d6af26d949d..f711839e389 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -23,7 +23,8 @@ module Search def allowed_scopes strong_memoize(:allowed_scopes) do - %w[issues merge_requests milestones] + allowed_scopes = %w[issues merge_requests milestones] + allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true) end end diff --git a/app/services/search/group_service.rb b/app/services/search/group_service.rb index 34803d005e3..6f3b5f00b86 100644 --- a/app/services/search/group_service.rb +++ b/app/services/search/group_service.rb @@ -11,6 +11,12 @@ module Search @group = group end + def execute + Gitlab::GroupSearchResults.new( + current_user, projects, group, params[:search], default_project_filter: default_project_filter + ) + end + def projects return Project.none unless group return @projects if defined? @projects diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index f223c8be103..32d5cd7ddb2 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -16,7 +16,12 @@ module Search end def scope - @scope ||= %w[notes issues merge_requests milestones wiki_blobs commits].delete(params[:scope]) { 'blobs' } + @scope ||= begin + allowed_scopes = %w[notes issues merge_requests milestones wiki_blobs commits] + allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true) + + allowed_scopes.delete(params[:scope]) { 'blobs' } + end end end end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index e0cbfac2420..302510341ac 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -52,6 +52,10 @@ class SearchService @search_objects ||= search_results.objects(scope, params[:page]) end + def display_options + @display_options ||= search_results.display_options(scope) + end + private def search_service diff --git a/app/services/service_response.rb b/app/services/service_response.rb new file mode 100644 index 00000000000..f3437ba16de --- /dev/null +++ b/app/services/service_response.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class ServiceResponse + def self.success(message: nil, payload: {}) + new(status: :success, message: message, payload: payload) + end + + def self.error(message:, payload: {}, http_status: nil) + new(status: :error, message: message, payload: payload, http_status: http_status) + end + + attr_reader :status, :message, :http_status, :payload + + def initialize(status:, message: nil, payload: {}, http_status: nil) + self.status = status + self.message = message + self.payload = payload + self.http_status = http_status + end + + def success? + status == :success + end + + def error? + status == :error + end + + private + + attr_writer :status, :message, :http_status, :payload +end diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb index 1f720fc835f..8ba50e22b09 100644 --- a/app/services/suggestions/apply_service.rb +++ b/app/services/suggestions/apply_service.rb @@ -7,7 +7,7 @@ module Suggestions end def execute(suggestion) - unless suggestion.appliable? + unless suggestion.appliable?(cached: false) return error('Suggestion is not appliable') end @@ -15,7 +15,13 @@ module Suggestions return error('The file has been changed') end - params = file_update_params(suggestion) + diff_file = suggestion.diff_file + + unless diff_file + return error('The file was not found') + end + + params = file_update_params(suggestion, diff_file) result = ::Files::UpdateService.new(suggestion.project, @current_user, params).execute if result[:status] == :success @@ -38,8 +44,8 @@ module Suggestions suggestion.position.head_sha == suggestion.noteable.source_branch_sha end - def file_update_params(suggestion) - blob = suggestion.diff_file.new_blob + def file_update_params(suggestion, diff_file) + blob = diff_file.new_blob file_path = suggestion.file_path branch_name = suggestion.branch file_content = new_file_content(suggestion, blob) diff --git a/app/services/suggestions/create_service.rb b/app/services/suggestions/create_service.rb index 77e958cbe0c..1d3338c1b45 100644 --- a/app/services/suggestions/create_service.rb +++ b/app/services/suggestions/create_service.rb @@ -9,48 +9,24 @@ module Suggestions def execute return unless @note.supports_suggestion? - suggestions = Banzai::SuggestionsParser.parse(@note.note) - - # For single line suggestion we're only looking forward to - # change the line receiving the comment. Though, in - # https://gitlab.com/gitlab-org/gitlab-ce/issues/53310 - # we'll introduce a ```suggestion:L<x>-<y>, so this will - # slightly change. - comment_line = @note.position.new_line + suggestions = Gitlab::Diff::SuggestionsParser.parse(@note.note, + project: @note.project, + position: @note.position) rows = suggestions.map.with_index do |suggestion, index| - from_content = changing_lines(comment_line, comment_line) - - # The parsed suggestion doesn't have information about the correct - # ending characters (we may have a line break, or not), so we take - # this information from the last line being changed (last - # characters). - endline_chars = line_break_chars(from_content.lines.last) - to_content = "#{suggestion}#{endline_chars}" + creation_params = + suggestion.to_hash.slice(:from_content, + :to_content, + :lines_above, + :lines_below) - { - note_id: @note.id, - from_content: from_content, - to_content: to_content, - relative_order: index - } + creation_params.merge!(note_id: @note.id, relative_order: index) end rows.in_groups_of(100, false) do |rows| Gitlab::Database.bulk_insert('suggestions', rows) end end - - private - - def changing_lines(from_line, to_line) - @note.diff_file.new_blob_lines_between(from_line, to_line).join - end - - def line_break_chars(line) - match = /\r\n|\r|\n/.match(line) - match[0] if match - end end end diff --git a/app/services/suggestions/outdate_service.rb b/app/services/suggestions/outdate_service.rb new file mode 100644 index 00000000000..a33aac9f6b5 --- /dev/null +++ b/app/services/suggestions/outdate_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Suggestions + class OutdateService + def execute(merge_request) + # rubocop: disable CodeReuse/ActiveRecord + suggestions = merge_request.suggestions.active.includes(:note) + + suggestions.find_in_batches(batch_size: 100) do |group| + outdatable_suggestion_ids = group.select do |suggestion| + suggestion.outdated?(cached: false) + end.map(&:id) + + Suggestion.where(id: outdatable_suggestion_ids).update_all(outdated: true) + end + # rubocop: enable CodeReuse/ActiveRecord + end + end +end diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb index bd3907cdf8e..858e04f43b2 100644 --- a/app/services/system_hooks_service.rb +++ b/app/services/system_hooks_service.rb @@ -47,7 +47,7 @@ class SystemHooksService case event when :rename - data[:old_username] = model.username_was + data[:old_username] = model.username_before_last_save when :failed_login data[:state] = model.state end @@ -58,8 +58,8 @@ class SystemHooksService if event == :rename data.merge!( - old_path: model.path_was, - old_full_path: model.full_path_was + old_path: model.path_before_last_save, + old_full_path: model.full_path_before_last_save ) end when GroupMember diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index ea8ac7e4656..1390f7cdf46 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -25,7 +25,7 @@ module SystemNoteService text_parts = ["added #{commits_text}"] text_parts << commits_list(noteable, new_commits, existing_commits, oldrev) - text_parts << "[Compare with previous version](#{diff_comparison_url(noteable, project, oldrev)})" + text_parts << "[Compare with previous version](#{diff_comparison_path(noteable, project, oldrev)})" body = text_parts.join("\n\n") @@ -41,7 +41,7 @@ module SystemNoteService # # Returns the created Note object def tag_commit(noteable, project, author, tag_name) - link = url_helpers.project_tag_url(project, id: tag_name) + link = url_helpers.project_tag_path(project, id: tag_name) body = "tagged commit #{noteable.sha} to [`#{tag_name}`](#{link})" create_note(NoteSummary.new(noteable, project, author, body, action: 'tag')) @@ -69,7 +69,7 @@ module SystemNoteService # Called when the assignees of an Issue is changed or removed # - # issue - Issue object + # issuable - Issuable object (responds to assignees) # project - Project owning noteable # author - User performing the change # assignees - Users being assigned, or nil @@ -85,9 +85,9 @@ module SystemNoteService # "assigned to @user1 and @user2" # # Returns the created Note object - def change_issue_assignees(issue, project, author, old_assignees) - unassigned_users = old_assignees - issue.assignees - added_users = issue.assignees.to_a - old_assignees + 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 = [] text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any? @@ -95,7 +95,7 @@ module SystemNoteService body = text_parts.join(' and ') - create_note(NoteSummary.new(issue, project, author, body, action: 'assignee')) + create_note(NoteSummary.new(issuable, project, author, body, action: 'assignee')) end # Called when the milestone of a Noteable is changed @@ -258,7 +258,7 @@ module SystemNoteService body = "created #{issue.to_reference} to continue this discussion" note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body) - note = Note.create(note_attributes.merge(system: true)) + note = Note.create(note_attributes.merge(system: true, created_at: issue.system_note_timestamp)) note.system_note_metadata = SystemNoteMetadata.new(action: 'discussion') note @@ -272,7 +272,7 @@ module SystemNoteService text_parts = ["changed this line in"] if version_params = merge_request.version_params_for(diff_refs) line_code = change_position.line_code(project.repository) - url = url_helpers.diffs_project_merge_request_url(project, merge_request, version_params.merge(anchor: line_code)) + url = url_helpers.diffs_project_merge_request_path(project, merge_request, version_params.merge(anchor: line_code)) text_parts << "[version #{version_index} of the diff](#{url})" else @@ -405,7 +405,7 @@ module SystemNoteService # # "created branch `201-issue-branch-button`" def new_issue_branch(issue, project, author, branch) - link = url_helpers.project_compare_url(project, from: project.default_branch, to: branch) + link = url_helpers.project_compare_path(project, from: project.default_branch, to: branch) body = "created branch [`#{branch}`](#{link}) to address this issue" @@ -668,10 +668,10 @@ module SystemNoteService @url_helpers ||= Gitlab::Routing.url_helpers end - def diff_comparison_url(merge_request, project, oldrev) + def diff_comparison_path(merge_request, project, oldrev) diff_id = merge_request.merge_request_diff.id - url_helpers.diffs_project_merge_request_url( + url_helpers.diffs_project_merge_request_path( project, merge_request, diff_id: diff_id, diff --git a/app/services/tags/destroy_service.rb b/app/services/tags/destroy_service.rb index cab507946b4..4f6ae07be7d 100644 --- a/app/services/tags/destroy_service.rb +++ b/app/services/tags/destroy_service.rb @@ -41,12 +41,11 @@ module Tags def build_push_data(tag) Gitlab::DataBuilder::Push.build( - project, - current_user, - tag.dereferenced_target.sha, - Gitlab::Git::BLANK_SHA, - "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}", - []) + project: project, + user: current_user, + oldrev: tag.dereferenced_target.sha, + newrev: Gitlab::Git::BLANK_SHA, + ref: "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}") end end end diff --git a/app/services/test_hooks/project_service.rb b/app/services/test_hooks/project_service.rb index 7e14ddcd017..a71278e8b8b 100644 --- a/app/services/test_hooks/project_service.rb +++ b/app/services/test_hooks/project_service.rb @@ -11,7 +11,7 @@ module TestHooks private def push_events_data - throw(:validation_error, 'Ensure the project has at least one commit.') if project.empty_repo? + throw(:validation_error, s_('TestHooks|Ensure the project has at least one commit.')) if project.empty_repo? Gitlab::DataBuilder::Push.build_sample(project, current_user) end @@ -20,14 +20,14 @@ module TestHooks def note_events_data note = project.notes.first - throw(:validation_error, 'Ensure the project has notes.') unless note.present? + throw(:validation_error, s_('TestHooks|Ensure the project has notes.')) unless note.present? Gitlab::DataBuilder::Note.build(note, current_user) end def issues_events_data issue = project.issues.first - throw(:validation_error, 'Ensure the project has issues.') unless issue.present? + throw(:validation_error, s_('TestHooks|Ensure the project has issues.')) unless issue.present? issue.to_hook_data(current_user) end @@ -36,29 +36,29 @@ module TestHooks def merge_requests_events_data merge_request = project.merge_requests.first - throw(:validation_error, 'Ensure the project has merge requests.') unless merge_request.present? + throw(:validation_error, s_('TestHooks|Ensure the project has merge requests.')) unless merge_request.present? merge_request.to_hook_data(current_user) end def job_events_data build = project.builds.first - throw(:validation_error, 'Ensure the project has CI jobs.') unless build.present? + throw(:validation_error, s_('TestHooks|Ensure the project has CI jobs.')) unless build.present? Gitlab::DataBuilder::Build.build(build) end def pipeline_events_data pipeline = project.ci_pipelines.first - throw(:validation_error, 'Ensure the project has CI pipelines.') unless pipeline.present? + throw(:validation_error, s_('TestHooks|Ensure the project has CI pipelines.')) unless pipeline.present? Gitlab::DataBuilder::Pipeline.build(pipeline) end def wiki_page_events_data - page = project.wiki.pages.first + page = project.wiki.list_pages(limit: 1).first if !project.wiki_enabled? || page.blank? - throw(:validation_error, 'Ensure the wiki is enabled and has pages.') + throw(:validation_error, s_('TestHooks|Ensure the wiki is enabled and has pages.')) end Gitlab::DataBuilder::WikiPage.build(page, current_user, 'create') diff --git a/app/services/test_hooks/system_service.rb b/app/services/test_hooks/system_service.rb index 082830c5538..fedf9c6799b 100644 --- a/app/services/test_hooks/system_service.rb +++ b/app/services/test_hooks/system_service.rb @@ -18,7 +18,7 @@ module TestHooks def merge_requests_events_data merge_request = MergeRequest.of_projects(current_user.projects.select(:id)).first - throw(:validation_error, 'Ensure one of your projects has merge requests.') unless merge_request.present? + throw(:validation_error, s_('TestHooks|Ensure one of your projects has merge requests.')) unless merge_request.present? merge_request.to_hook_data(current_user) end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index f357dc37fe7..0ea230a44a1 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -49,12 +49,12 @@ class TodoService todo_users.each(&:update_todos_count_cache) end - # When we reassign an issue we should: + # When we reassign an issuable we should: # - # * create a pending todo for new assignee if issue is assigned + # * create a pending todo for new assignee if issuable is assigned # - def reassigned_issue(issue, current_user, old_assignees = []) - create_assignment_todo(issue, current_user, old_assignees) + def reassigned_issuable(issuable, current_user, old_assignees = []) + create_assignment_todo(issuable, current_user, old_assignees) end # When create a merge request we should: @@ -82,14 +82,6 @@ class TodoService mark_pending_todos_as_done(merge_request, current_user) end - # When we reassign a merge request we should: - # - # * creates a pending todo for new assignee if merge request is assigned - # - def reassigned_merge_request(merge_request, current_user) - create_assignment_todo(merge_request, current_user) - end - # When merge a merge request we should: # # * mark all pending todos related to the target for the current user as done diff --git a/app/services/todos/destroy/base_service.rb b/app/services/todos/destroy/base_service.rb index f3f1dbb5698..7378f10e7c4 100644 --- a/app/services/todos/destroy/base_service.rb +++ b/app/services/todos/destroy/base_service.rb @@ -13,7 +13,7 @@ module Todos # rubocop: disable CodeReuse/ActiveRecord def without_authorized(items) - items.where('user_id NOT IN (?)', authorized_users) + items.where('todos.user_id NOT IN (?)', authorized_users) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/todos/destroy/confidential_issue_service.rb b/app/services/todos/destroy/confidential_issue_service.rb index 6276e332448..6cdd8c16894 100644 --- a/app/services/todos/destroy/confidential_issue_service.rb +++ b/app/services/todos/destroy/confidential_issue_service.rb @@ -2,36 +2,55 @@ module Todos module Destroy + # Service class for deleting todos that belongs to confidential issues. + # It deletes todos for users that are not at least reporters, issue author or assignee. + # + # Accepts issue_id or project_id as argument. + # When issue_id is passed it deletes matching todos for one confidential issue. + # When project_id is passed it deletes matching todos for all confidential issues of the project. class ConfidentialIssueService < ::Todos::Destroy::BaseService extend ::Gitlab::Utils::Override - attr_reader :issue + attr_reader :issues # rubocop: disable CodeReuse/ActiveRecord - def initialize(issue_id) - @issue = Issue.find_by(id: issue_id) + def initialize(issue_id: nil, project_id: nil) + @issues = + if issue_id + Issue.where(id: issue_id) + elsif project_id + project_confidential_issues(project_id) + end end # rubocop: enable CodeReuse/ActiveRecord private + def project_confidential_issues(project_id) + project = Project.find(project_id) + + project.issues.confidential_only + end + override :todos # rubocop: disable CodeReuse/ActiveRecord def todos - Todo.where(target: issue) - .where('user_id != ?', issue.author_id) - .where('user_id NOT IN (?)', issue.assignees.select(:id)) + Todo.joins_issue_and_assignees + .where(target: issues) + .where('issues.confidential = ?', true) + .where('todos.user_id != issues.author_id') + .where('todos.user_id != issue_assignees.user_id') end # rubocop: enable CodeReuse/ActiveRecord override :todos_to_remove? def todos_to_remove? - issue&.confidential? + issues&.any?(&:confidential?) end override :project_ids def project_ids - issue.project_id + issues&.distinct&.select(:project_id) end override :authorized_users diff --git a/app/services/todos/destroy/entity_leave_service.rb b/app/services/todos/destroy/entity_leave_service.rb index ebfb20132d0..4743e9b02ce 100644 --- a/app/services/todos/destroy/entity_leave_service.rb +++ b/app/services/todos/destroy/entity_leave_service.rb @@ -37,8 +37,8 @@ module Todos private def enqueue_private_features_worker - project_ids.each do |project_id| - TodosDestroyer::PrivateFeaturesWorker.perform_async(project_id, user.id) + projects.each do |project| + TodosDestroyer::PrivateFeaturesWorker.perform_async(project.id, user.id) end end @@ -62,9 +62,8 @@ module Todos end # rubocop: enable CodeReuse/ActiveRecord - override :project_ids # rubocop: disable CodeReuse/ActiveRecord - def project_ids + def projects condition = case entity when Project { id: entity.id } @@ -72,13 +71,13 @@ module Todos { namespace_id: non_member_groups } end - Project.where(condition).select(:id) + Project.where(condition) end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord def non_authorized_projects - project_ids.where('id NOT IN (?)', user.authorized_projects.select(:id)) + projects.where('id NOT IN (?)', user.authorized_projects.select(:id)) end # rubocop: enable CodeReuse/ActiveRecord @@ -110,7 +109,7 @@ module Todos authorized_reporter_projects = user .authorized_projects(Gitlab::Access::REPORTER).select(:id) - Issue.where(project_id: project_ids, confidential: true) + Issue.where(project_id: projects, confidential: true) .where('project_id NOT IN(?)', authorized_reporter_projects) .where('author_id != ?', user.id) .where('id NOT IN (?)', assigned_ids) diff --git a/app/services/update_deployment_service.rb b/app/services/update_deployment_service.rb index aa7fcca1e2a..49a7d0178f4 100644 --- a/app/services/update_deployment_service.rb +++ b/app/services/update_deployment_service.rb @@ -27,6 +27,8 @@ class UpdateDeploymentService deployment.tap(&:update_merge_request_metrics!) end + + deployment end private diff --git a/app/services/upload_service.rb b/app/services/upload_service.rb index 41ca95b3b6f..403944557a2 100644 --- a/app/services/upload_service.rb +++ b/app/services/upload_service.rb @@ -6,7 +6,7 @@ class UploadService end def execute - return nil unless @file && @file.size <= max_attachment_size + return unless @file && @file.size <= max_attachment_size uploader = @uploader_class.new(@model, nil, @uploader_context) uploader.store!(@file) diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb index e50840a9158..33444c2a7dc 100644 --- a/app/services/users/activity_service.rb +++ b/app/services/users/activity_service.rb @@ -30,7 +30,7 @@ module Users return if @user.last_activity_on == today - lease = Gitlab::ExclusiveLease.new("acitvity_service:#{@user.id}", + lease = Gitlab::ExclusiveLease.new("activity_service:#{@user.id}", timeout: LEASE_TIMEOUT) return unless lease.try_obtain diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb index 3f503f3da28..30f7743c56e 100644 --- a/app/services/users/build_service.rb +++ b/app/services/users/build_service.rb @@ -26,7 +26,7 @@ module Users end end - identity_attrs = params.slice(:extern_uid, :provider) + identity_attrs = params.slice(*identity_params) unless identity_attrs.empty? user.identities.build(identity_attrs) @@ -37,6 +37,10 @@ module Users private + def identity_params + [:extern_uid, :provider] + end + def can_create_user? (current_user.nil? && Gitlab::CurrentSettings.allow_signup?) || current_user&.admin? end diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb index 04fd6e37501..a66b6627e40 100644 --- a/app/services/users/migrate_to_ghost_user_service.rb +++ b/app/services/users/migrate_to_ghost_user_service.rb @@ -33,7 +33,7 @@ module Users end end - user.reload + user.reset end private diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb index fe5a82e23fa..4a26d2be2af 100644 --- a/app/services/users/refresh_authorized_projects_service.rb +++ b/app/services/users/refresh_authorized_projects_service.rb @@ -25,7 +25,7 @@ module Users # We need an up to date User object that has access to all relations that # may have been created earlier. The only way to ensure this is to reload # the User object. - user.reload + user.reset end def execute @@ -84,7 +84,7 @@ module Users # Since we batch insert authorization rows, Rails' associations may get # out of sync. As such we force a reload of the User object. - user.reload + user.reset end def fresh_access_levels_per_project diff --git a/app/services/validate_new_branch_service.rb b/app/services/validate_new_branch_service.rb index c19e2ec2043..3f4a59e5cee 100644 --- a/app/services/validate_new_branch_service.rb +++ b/app/services/validate_new_branch_service.rb @@ -3,14 +3,14 @@ require_relative 'base_service' class ValidateNewBranchService < BaseService - def execute(branch_name) + def execute(branch_name, force: false) valid_branch = Gitlab::GitRefValidator.validate(branch_name) unless valid_branch return error('Branch name is invalid') end - if project.repository.branch_exists?(branch_name) + if project.repository.branch_exists?(branch_name) && !force return error('Branch already exists') end diff --git a/app/services/verify_pages_domain_service.rb b/app/services/verify_pages_domain_service.rb index 07f7391f877..b53c3145caf 100644 --- a/app/services/verify_pages_domain_service.rb +++ b/app/services/verify_pages_domain_service.rb @@ -8,6 +8,7 @@ class VerifyPagesDomainService < BaseService # How long verification lasts for VERIFICATION_PERIOD = 7.days + REMOVAL_DELAY = 1.week.freeze attr_reader :domain @@ -36,7 +37,7 @@ class VerifyPagesDomainService < BaseService # Prevent any pre-existing grace period from being truncated reverify = [domain.enabled_until, VERIFICATION_PERIOD.from_now].compact.max - domain.assign_attributes(verified_at: Time.now, enabled_until: reverify) + domain.assign_attributes(verified_at: Time.now, enabled_until: reverify, remove_at: nil) domain.save!(validate: false) if was_disabled @@ -49,18 +50,20 @@ class VerifyPagesDomainService < BaseService end def unverify_domain! - if domain.verified? - domain.assign_attributes(verified_at: nil) - domain.save!(validate: false) + was_verified = domain.verified? - notify(:verification_failed) - end + domain.assign_attributes(verified_at: nil) + domain.remove_at ||= REMOVAL_DELAY.from_now unless domain.enabled? + domain.save!(validate: false) + + notify(:verification_failed) if was_verified error("Couldn't verify #{domain.domain}") end def disable_domain! domain.assign_attributes(verified_at: nil, enabled_until: nil) + domain.remove_at ||= REMOVAL_DELAY.from_now domain.save!(validate: false) notify(:disabled) diff --git a/app/uploaders/attachment_uploader.rb b/app/uploaders/attachment_uploader.rb index 0a166335b4e..b488bba00e9 100644 --- a/app/uploaders/attachment_uploader.rb +++ b/app/uploaders/attachment_uploader.rb @@ -9,6 +9,6 @@ class AttachmentUploader < GitlabUploader private def dynamic_segment - File.join(model.class.to_s.underscore, mounted_as.to_s, model.id.to_s) + File.join(model.class.underscore, mounted_as.to_s, model.id.to_s) end end diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index c0165759203..9af59b0aceb 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -25,6 +25,6 @@ class AvatarUploader < GitlabUploader private def dynamic_segment - File.join(model.class.to_s.underscore, mounted_as.to_s, model.id.to_s) + File.join(model.class.underscore, mounted_as.to_s, model.id.to_s) end end diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb index a7f8615e9ba..236b7ed2b3d 100644 --- a/app/uploaders/file_mover.rb +++ b/app/uploaders/file_mover.rb @@ -11,6 +11,8 @@ class FileMover end def execute + return unless valid? + move if update_markdown @@ -21,6 +23,12 @@ class FileMover private + def valid? + Pathname.new(temp_file_path).realpath.to_path.start_with?( + (Pathname(temp_file_uploader.root) + temp_file_uploader.base_dir).to_path + ) + end + def move FileUtils.mkdir_p(File.dirname(file_path)) FileUtils.move(temp_file_path, file_path) diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index e90599f2505..1c7582533ad 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -14,8 +14,8 @@ class FileUploader < GitlabUploader include ObjectStorage::Concern prepend ObjectStorage::Extension::RecordsUploads - MARKDOWN_PATTERN = %r{\!?\[.*?\]\(/uploads/(?<secret>[0-9a-f]{32})/(?<file>.*?)\)} - DYNAMIC_PATH_PATTERN = %r{.*(?<secret>\h{32})/(?<identifier>.*)} + MARKDOWN_PATTERN = %r{\!?\[.*?\]\(/uploads/(?<secret>[0-9a-f]{32})/(?<file>.*?)\)}.freeze + DYNAMIC_PATH_PATTERN = %r{.*(?<secret>\h{32})/(?<identifier>.*)}.freeze after :remove, :prune_store_dir @@ -109,12 +109,20 @@ class FileUploader < GitlabUploader def upload_path if file_storage? # Legacy path relative to project.full_path - File.join(dynamic_segment, identifier) + local_storage_path(identifier) else - File.join(store_dir, identifier) + remote_storage_path(identifier) end end + def local_storage_path(file_identifier) + File.join(dynamic_segment, file_identifier) + end + + def remote_storage_path(file_identifier) + File.join(store_dir, file_identifier) + end + def store_dirs { Store::LOCAL => File.join(base_dir, dynamic_segment), diff --git a/app/uploaders/import_export_uploader.rb b/app/uploaders/import_export_uploader.rb index 716922bc017..104d5d3b3dd 100644 --- a/app/uploaders/import_export_uploader.rb +++ b/app/uploaders/import_export_uploader.rb @@ -7,10 +7,6 @@ class ImportExportUploader < AttachmentUploader EXTENSION_WHITELIST end - def move_to_store - true - end - def move_to_cache false end diff --git a/app/uploaders/legacy_artifact_uploader.rb b/app/uploaders/legacy_artifact_uploader.rb deleted file mode 100644 index a9afc104ed1..00000000000 --- a/app/uploaders/legacy_artifact_uploader.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -class LegacyArtifactUploader < GitlabUploader - extend Workhorse::UploadPath - include ObjectStorage::Concern - - ObjectNotReadyError = Class.new(StandardError) - - storage_options Gitlab.config.artifacts - - alias_method :upload, :model - - def store_dir - dynamic_segment - end - - private - - def dynamic_segment - raise ObjectNotReadyError, 'Build is not ready' unless model.id - - File.join(model.created_at.utc.strftime('%Y_%m'), model.project_id.to_s, model.id.to_s) - end -end diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb index dad6e85fb56..0a44d60778d 100644 --- a/app/uploaders/object_storage.rb +++ b/app/uploaders/object_storage.rb @@ -117,7 +117,7 @@ module ObjectStorage next unless uploader next unless uploader.exists? - next unless send(:"#{mounted_as}_changed?") # rubocop:disable GitlabSecurity/PublicSend + next unless send(:"saved_change_to_#{mounted_as}?") # rubocop:disable GitlabSecurity/PublicSend mount end.keys @@ -278,8 +278,12 @@ module ObjectStorage self.class.object_store_credentials end + # Set ACL of uploaded objects to not-public (fog-aws)[1] or no ACL at all + # (fog-google). Value is ignored by other supported backends (fog-aliyun, + # fog-openstack, fog-rackspace) + # [1]: https://github.com/fog/fog-aws/blob/daa50bb3717a462baf4d04d0e0cbfc18baacb541/lib/fog/aws/models/storage/file.rb#L152-L159 def fog_public - false + nil end def delete_migrated_file(migrated_file) diff --git a/app/uploaders/personal_file_uploader.rb b/app/uploaders/personal_file_uploader.rb index 272837aa6ce..b43162f0935 100644 --- a/app/uploaders/personal_file_uploader.rb +++ b/app/uploaders/personal_file_uploader.rb @@ -6,21 +6,18 @@ class PersonalFileUploader < FileUploader options.storage_path end - def self.base_dir(model, store = nil) - base_dirs(model)[store || Store::LOCAL] - end - - def self.base_dirs(model) - { - Store::LOCAL => File.join(options.base_dir, model_path_segment(model)), - Store::REMOTE => model_path_segment(model) - } + def self.base_dir(model, _store = nil) + # base_dir is the path seen by the user when rendering Markdown, so + # it should be the same for both local and object storage. It is + # typically prefaced with uploads/-/system, but that prefix + # is omitted in the path stored on disk. + File.join(options.base_dir, model_path_segment(model)) end def self.model_path_segment(model) return 'temp/' unless model - File.join(model.class.to_s.underscore, model.id.to_s) + File.join(model.class.underscore, model.id.to_s) end def object_store @@ -40,8 +37,61 @@ class PersonalFileUploader < FileUploader store_dirs[object_store] end + # A personal snippet path is stored using FileUploader#upload_path. + # + # The format for the path: + # + # Local storage: :random_hex/:filename. + # Object storage: personal_snippet/:id/:random_hex/:filename. + # + # upload_paths represent the possible paths for a given identifier, + # which will vary depending on whether the file is stored in local or + # object storage. upload_path should match an element in upload_paths. + # + # base_dir represents the path seen by the user in Markdown, and it + # should always be prefixed with uploads/-/system. + # + # store_dirs represent the paths that are actually used on disk. For + # object storage, this should omit the prefix /uploads/-/system. + # + # For example, consider the requested path /uploads/-/system/personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20/file.png. + # + # For local storage: + # + # File on disk: /opt/gitlab/embedded/service/gitlab-rails/public/uploads/-/system/personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20/file.png. + # + # base_dir: uploads/-/system/personal_snippet/172 + # upload_path: ff4ad5c2e40b39ae57cda51577317d20/file.png + # upload_paths: ["ff4ad5c2e40b39ae57cda51577317d20/file.png", "personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20/file.png"]. + # store_dirs: + # => {1=>"uploads/-/system/personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20", 2=>"personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20"} + # + # For object storage: + # + # upload_path: personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20/file.png + def upload_paths(identifier) + [ + local_storage_path(identifier), + File.join(remote_storage_base_path, identifier) + ] + end + + def store_dirs + { + Store::LOCAL => File.join(base_dir, dynamic_segment), + Store::REMOTE => remote_storage_base_path + } + end + private + # To avoid prefacing the remote storage path with `/uploads/-/system`, + # we just drop that part so that the destination path will be + # personal_snippet/:id/:random_hex/:filename. + def remote_storage_base_path + File.join(self.class.model_path_segment(model), dynamic_segment) + end + def secure_url File.join('/', base_dir, secret, file.filename) end diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb index 9a243e07936..00b51f92b12 100644 --- a/app/uploaders/records_uploads.rb +++ b/app/uploaders/records_uploads.rb @@ -46,6 +46,10 @@ module RecordsUploads File.join(store_dir, filename.to_s) end + def filename + upload&.path ? File.basename(upload.path) : super + end + private # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/validators/addressable_url_validator.rb b/app/validators/addressable_url_validator.rb new file mode 100644 index 00000000000..273e15ef925 --- /dev/null +++ b/app/validators/addressable_url_validator.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +# AddressableUrlValidator +# +# Custom validator for URLs. This is a stricter version of UrlValidator - it also checks +# for using the right protocol, but it actually parses the URL checking for any syntax errors. +# The regex is also different from `URI` as we use `Addressable::URI` here. +# +# By default, only URLs for the HTTP(S) schemes will be considered valid. +# Provide a `:schemes` option to configure accepted schemes. +# +# Example: +# +# class User < ActiveRecord::Base +# validates :personal_url, addressable_url: true +# +# validates :ftp_url, addressable_url: { schemes: %w(ftp) } +# +# validates :git_url, addressable_url: { schemes: %w(http https ssh git) } +# end +# +# This validator can also block urls pointing to localhost or the local network to +# protect against Server-side Request Forgery (SSRF), or check for the right port. +# +# Configuration options: +# * <tt>message</tt> - A custom error message (default is: "must be a valid URL"). +# * <tt>schemes</tt> - Array of URI schemes. Default: +['http', 'https']+ +# * <tt>allow_localhost</tt> - Allow urls pointing to +localhost+. Default: +true+ +# * <tt>allow_local_network</tt> - Allow urls pointing to private network addresses. Default: +true+ +# * <tt>allow_blank</tt> - Allow urls to be +blank+. Default: +false+ +# * <tt>allow_nil</tt> - Allow urls to be +nil+. Default: +false+ +# * <tt>ports</tt> - Allowed ports. Default: +all+. +# * <tt>enforce_user</tt> - Validate user format. Default: +false+ +# * <tt>enforce_sanitization</tt> - Validate that there are no html/css/js tags. Default: +false+ +# +# Example: +# class User < ActiveRecord::Base +# validates :personal_url, addressable_url: { allow_localhost: false, allow_local_network: false} +# +# validates :web_url, addressable_url: { ports: [80, 443] } +# end +class AddressableUrlValidator < ActiveModel::EachValidator + attr_reader :record + + BLOCKER_VALIDATE_OPTIONS = { + schemes: %w(http https), + ports: [], + allow_localhost: true, + allow_local_network: true, + ascii_only: false, + enforce_user: false, + enforce_sanitization: false + }.freeze + + DEFAULT_OPTIONS = BLOCKER_VALIDATE_OPTIONS.merge({ + message: 'must be a valid URL' + }).freeze + + def initialize(options) + options.reverse_merge!(DEFAULT_OPTIONS) + + super(options) + end + + def validate_each(record, attribute, value) + @record = record + + unless value.present? + record.errors.add(attribute, options.fetch(:message)) + return + end + + value = strip_value!(record, attribute, value) + + Gitlab::UrlBlocker.validate!(value, blocker_args) + rescue Gitlab::UrlBlocker::BlockedUrlError => e + record.errors.add(attribute, "is blocked: #{e.message}") + end + + private + + def strip_value!(record, attribute, value) + new_value = value.strip + return value if new_value == value + + record.public_send("#{attribute}=", new_value) # rubocop:disable GitlabSecurity/PublicSend + end + + def current_options + options.map do |option, value| + [option, value.is_a?(Proc) ? value.call(record) : value] + end.to_h + end + + def blocker_args + current_options.slice(*BLOCKER_VALIDATE_OPTIONS.keys).tap do |args| + if self.class.allow_setting_local_requests? + args[:allow_localhost] = args[:allow_local_network] = true + end + end + end + + def self.allow_setting_local_requests? + # We cannot use Gitlab::CurrentSettings as ApplicationSetting itself + # uses UrlValidator to validate urls. This ends up in a cycle + # when Gitlab::CurrentSettings creates an ApplicationSetting which then + # calls this validator. + # + # See https://gitlab.com/gitlab-org/gitlab-ee/issues/9833 + ApplicationSetting.current&.allow_local_requests_from_hooks_and_services? + end +end diff --git a/app/validators/cluster_name_validator.rb b/app/validators/cluster_name_validator.rb index 85fd63f08e5..79c9c67ae58 100644 --- a/app/validators/cluster_name_validator.rb +++ b/app/validators/cluster_name_validator.rb @@ -5,7 +5,9 @@ # Custom validator for ClusterName. class ClusterNameValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) - if record.managed? + if record.provided_by_user? + record.errors.add(attribute, " has to be present") unless value.present? + else if record.persisted? && record.name_changed? record.errors.add(attribute, " can not be changed because it's synchronized with provider") end @@ -17,10 +19,6 @@ class ClusterNameValidator < ActiveModel::EachValidator unless value =~ Gitlab::Regex.kubernetes_namespace_regex record.errors.add(attribute, Gitlab::Regex.kubernetes_namespace_regex_message) end - else - unless value.present? - record.errors.add(attribute, " has to be present") - end end end end diff --git a/app/validators/devise_email_validator.rb b/app/validators/devise_email_validator.rb new file mode 100644 index 00000000000..6ca921ca7fa --- /dev/null +++ b/app/validators/devise_email_validator.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# DeviseEmailValidator +# +# Custom validator for email formats. It asserts that there are no +# @ symbols or whitespaces in either the localpart or the domain, and that +# there is a single @ symbol separating the localpart and the domain. +# +# The available options are: +# - regexp: Email regular expression used to validate email formats as instance of Regexp class. +# If provided value has different type then a new Rexexp class instance is created using the value. +# Default: +Devise.email_regexp+ +# +# Example: +# class User < ActiveRecord::Base +# validates :personal_email, devise_email: true +# +# validates :public_email, devise_email: { regexp: Devise.email_regexp } +# end +class DeviseEmailValidator < ActiveModel::EachValidator + DEFAULT_OPTIONS = { + regexp: Devise.email_regexp + }.freeze + + def initialize(options) + options.reverse_merge!(DEFAULT_OPTIONS) + + raise ArgumentError, "Expected 'regexp' argument of type class Regexp" unless options[:regexp].is_a?(Regexp) + + super(options) + end + + def validate_each(record, attribute, value) + record.errors.add(attribute, :invalid) unless value =~ options[:regexp] + end +end diff --git a/app/validators/email_validator.rb b/app/validators/email_validator.rb deleted file mode 100644 index 9459edb7515..00000000000 --- a/app/validators/email_validator.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class EmailValidator < ActiveModel::EachValidator - def validate_each(record, attribute, value) - record.errors.add(attribute, :invalid) unless value =~ Devise.email_regexp - end -end diff --git a/app/validators/public_url_validator.rb b/app/validators/public_url_validator.rb index 3ff880deedd..91847c5d866 100644 --- a/app/validators/public_url_validator.rb +++ b/app/validators/public_url_validator.rb @@ -2,7 +2,7 @@ # PublicUrlValidator # -# Custom validator for URLs. This validator works like UrlValidator but +# Custom validator for URLs. This validator works like AddressableUrlValidator but # it blocks by default urls pointing to localhost or the local network. # # This validator accepts the same params UrlValidator does. @@ -12,17 +12,20 @@ # class User < ActiveRecord::Base # validates :personal_url, public_url: true # -# validates :ftp_url, public_url: { protocols: %w(ftp) } +# validates :ftp_url, public_url: { schemes: %w(ftp) } # # validates :git_url, public_url: { allow_localhost: true, allow_local_network: true} # end # -class PublicUrlValidator < UrlValidator - private +class PublicUrlValidator < AddressableUrlValidator + DEFAULT_OPTIONS = { + allow_localhost: false, + allow_local_network: false + }.freeze - def default_options - # By default block all urls pointing to localhost or the local network - super.merge(allow_localhost: false, - allow_local_network: false) + def initialize(options) + options.reverse_merge!(DEFAULT_OPTIONS) + + super(options) end end diff --git a/app/validators/sha_validator.rb b/app/validators/sha_validator.rb new file mode 100644 index 00000000000..77e7cfa4f6b --- /dev/null +++ b/app/validators/sha_validator.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ShaValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return if value.blank? || Commit.valid_hash?(value) + + record.errors.add(attribute, 'is not a valid SHA') + end +end diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb deleted file mode 100644 index 3fd015c3cf5..00000000000 --- a/app/validators/url_validator.rb +++ /dev/null @@ -1,104 +0,0 @@ -# frozen_string_literal: true - -# UrlValidator -# -# Custom validator for URLs. -# -# By default, only URLs for the HTTP(S) protocols will be considered valid. -# Provide a `:protocols` option to configure accepted protocols. -# -# Example: -# -# class User < ActiveRecord::Base -# validates :personal_url, url: true -# -# validates :ftp_url, url: { protocols: %w(ftp) } -# -# validates :git_url, url: { protocols: %w(http https ssh git) } -# end -# -# This validator can also block urls pointing to localhost or the local network to -# protect against Server-side Request Forgery (SSRF), or check for the right port. -# -# The available options are: -# - protocols: Allowed protocols. Default: http and https -# - allow_localhost: Allow urls pointing to localhost. Default: true -# - allow_local_network: Allow urls pointing to private network addresses. Default: true -# - ports: Allowed ports. Default: all. -# - enforce_user: Validate user format. Default: false -# - enforce_sanitization: Validate that there are no html/css/js tags. Default: false -# -# Example: -# class User < ActiveRecord::Base -# validates :personal_url, url: { allow_localhost: false, allow_local_network: false} -# -# validates :web_url, url: { ports: [80, 443] } -# end -class UrlValidator < ActiveModel::EachValidator - DEFAULT_PROTOCOLS = %w(http https).freeze - - attr_reader :record - - def validate_each(record, attribute, value) - @record = record - - unless value.present? - record.errors.add(attribute, 'must be a valid URL') - return - end - - value = strip_value!(record, attribute, value) - - Gitlab::UrlBlocker.validate!(value, blocker_args) - rescue Gitlab::UrlBlocker::BlockedUrlError => e - record.errors.add(attribute, "is blocked: #{e.message}") - end - - private - - def strip_value!(record, attribute, value) - new_value = value.strip - return value if new_value == value - - record.public_send("#{attribute}=", new_value) # rubocop:disable GitlabSecurity/PublicSend - end - - def default_options - # By default the validator doesn't block any url based on the ip address - { - protocols: DEFAULT_PROTOCOLS, - ports: [], - allow_localhost: true, - allow_local_network: true, - ascii_only: false, - enforce_user: false, - enforce_sanitization: false - } - end - - def current_options - options = self.options.map do |option, value| - [option, value.is_a?(Proc) ? value.call(record) : value] - end.to_h - - default_options.merge(options) - end - - def blocker_args - current_options.slice(*default_options.keys).tap do |args| - if allow_setting_local_requests? - args[:allow_localhost] = args[:allow_local_network] = true - end - end - end - - def allow_setting_local_requests? - # We cannot use Gitlab::CurrentSettings as ApplicationSetting itself - # uses UrlValidator to validate urls. This ends up in a cycle - # when Gitlab::CurrentSettings creates an ApplicationSetting which then - # calls this validator. - # - # See https://gitlab.com/gitlab-org/gitlab-ee/issues/9833 - ApplicationSetting.current&.allow_local_requests_from_hooks_and_services? - end -end diff --git a/app/validators/x509_certificate_credentials_validator.rb b/app/validators/x509_certificate_credentials_validator.rb new file mode 100644 index 00000000000..d2f18e956c3 --- /dev/null +++ b/app/validators/x509_certificate_credentials_validator.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +# X509CertificateCredentialsValidator +# +# Custom validator to check if certificate-attribute was signed using the +# private key stored in an attrebute. +# +# This can be used as an `ActiveModel::Validator` as follows: +# +# validates_with X509CertificateCredentialsValidator, +# certificate: :client_certificate, +# pkey: :decrypted_private_key, +# pass: :decrypted_passphrase +# +# +# Required attributes: +# - certificate: The name of the accessor that returns the certificate to check +# - pkey: The name of the accessor that returns the private key +# Optional: +# - pass: The name of the accessor that returns the passphrase to decrypt the +# private key +class X509CertificateCredentialsValidator < ActiveModel::Validator + def initialize(*args) + super + + # We can't validate if we don't have a private key or certificate attributes + # in which case this validator is useless. + if options[:pkey].nil? || options[:certificate].nil? + raise 'Provide at least `certificate` and `pkey` attribute names' + end + end + + def validate(record) + unless certificate = read_certificate(record) + record.errors.add(options[:certificate], _('is not a valid X509 certificate.')) + end + + unless private_key = read_private_key(record) + record.errors.add(options[:pkey], _('could not read private key, is the passphrase correct?')) + end + + return if private_key.nil? || certificate.nil? + + unless certificate.public_key.fingerprint == private_key.public_key.fingerprint + record.errors.add(options[:pkey], _('private key does not match certificate.')) + end + end + + private + + def read_private_key(record) + OpenSSL::PKey.read(pkey(record).to_s, pass(record).to_s) + rescue OpenSSL::PKey::PKeyError, ArgumentError + # When the primary key could not be read, an ArgumentError is raised. + # This hapens when the passed key is not valid or the passphrase is incorrect + nil + end + + def read_certificate(record) + OpenSSL::X509::Certificate.new(certificate(record).to_s) + rescue OpenSSL::X509::CertificateError + nil + end + + # rubocop:disable GitlabSecurity/PublicSend + # + # Allowing `#public_send` here because we don't want the validator to really + # care about the names of the attributes or where they come from. + # + # The credentials are mostly stored encrypted so we need to go through the + # accessors to get the values, `read_attribute` bypasses those. + def certificate(record) + record.public_send(options[:certificate]) + end + + def pkey(record) + record.public_send(options[:pkey]) + end + + def pass(record) + return unless options[:pass] + + record.public_send(options[:pass]) + end + # rubocop:enable GitlabSecurity/PublicSend +end diff --git a/app/views/abuse_reports/new.html.haml b/app/views/abuse_reports/new.html.haml index 92ae40512c5..c6781e91cfd 100644 --- a/app/views/abuse_reports/new.html.haml +++ b/app/views/abuse_reports/new.html.haml @@ -1,22 +1,24 @@ -- page_title _("Report abuse to GitLab") +- page_title _("Report abuse to admin") %h3.page-title - = _("Report abuse to GitLab") + = _("Report abuse to admin") %p - = _("Please use this form to report users to GitLab who create spam issues, comments or behave inappropriately.") + = _("Please use this form to report to the admin users who create spam issues, comments or behave inappropriately.") %p - = _("A member of GitLab's abuse team will review your report as soon as possible.") + = _("A member of the abuse team will review your report as soon as possible.") %hr = form_for @abuse_report, html: { class: 'js-quick-submit js-requires-input'} do |f| = form_errors(@abuse_report) = f.hidden_field :user_id .form-group.row - = f.label :user_id, class: 'col-sm-2 col-form-label' + .col-sm-2.col-form-label + = f.label :user_id .col-sm-10 - name = "#{@abuse_report.user.name} (@#{@abuse_report.user.username})" = text_field_tag :user_name, name, class: "form-control", readonly: true .form-group.row - = f.label :message, class: 'col-sm-2 col-form-label' + .col-sm-2.col-form-label + = f.label :message .col-sm-10 = f.text_area :message, class: "form-control", rows: 2, required: true, value: sanitize(@ref_url) .form-text.text-muted diff --git a/app/views/admin/appearances/_system_header_footer_form.html.haml b/app/views/admin/appearances/_system_header_footer_form.html.haml index ca9d6adebeb..4301ebd05af 100644 --- a/app/views/admin/appearances/_system_header_footer_form.html.haml +++ b/app/views/admin/appearances/_system_header_footer_form.html.haml @@ -13,6 +13,15 @@ .form-group = form.label :footer_message, _('Footer message'), class: 'col-form-label label-bold' = form.text_area :footer_message, placeholder: _('State your message to activate'), class: "form-control js-autosize" + .form-group + .form-check + = form.check_box :email_header_and_footer_enabled, class: 'form-check-input' + = form.label :email_header_and_footer_enabled, class: 'label-bold' do + = _('Enable header and footer in emails') + + .hint + = _('Add header and footer to emails. Please note that color settings will only be applied within the application interface') + .form-group.js-toggle-colors-container %button.btn.btn-link.js-toggle-colors-link{ type: 'button' } = _('Customize colors') diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index 65a24854583..9ed4bc44aae 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -6,32 +6,35 @@ .form-check = f.check_box :gravatar_enabled, class: 'form-check-input' = f.label :gravatar_enabled, class: 'form-check-label' do - Gravatar enabled + = _('Gravatar enabled') .form-group = f.label :default_projects_limit, class: 'label-bold' = f.number_field :default_projects_limit, class: 'form-control' .form-group - = f.label :max_attachment_size, 'Maximum attachment size (MB)', class: 'label-bold' + = f.label :max_attachment_size, _('Maximum attachment size (MB)'), class: 'label-bold' = f.number_field :max_attachment_size, class: 'form-control' + + = render_if_exists 'admin/application_settings/repository_size_limit_setting', form: f + .form-group - = f.label :receive_max_input_size, 'Maximum push size (MB)', class: 'label-light' + = f.label :receive_max_input_size, _('Maximum push size (MB)'), class: 'label-light' = f.number_field :receive_max_input_size, class: 'form-control qa-receive-max-input-size-field' .form-group - = f.label :session_expire_delay, 'Session duration (minutes)', class: 'label-light' + = f.label :session_expire_delay, _('Session duration (minutes)'), class: 'label-light' = f.number_field :session_expire_delay, class: 'form-control' - %span.form-text.text-muted#session_expire_delay_help_block GitLab restart is required to apply changes + %span.form-text.text-muted#session_expire_delay_help_block= _('GitLab restart is required to apply changes') .form-group - = f.label :user_oauth_applications, 'User OAuth applications', class: 'label-bold' + = f.label :user_oauth_applications, _('User OAuth applications'), class: 'label-bold' .form-check = f.check_box :user_oauth_applications, class: 'form-check-input' = f.label :user_oauth_applications, class: 'form-check-label' do - Allow users to register any application to use GitLab as an OAuth provider + = _('Allow users to register any application to use GitLab as an OAuth provider') .form-group - = f.label :user_default_external, 'New users set to external', class: 'label-bold' + = f.label :user_default_external, _('New users set to external'), class: 'label-bold' .form-check = f.check_box :user_default_external, class: 'form-check-input' = f.label :user_default_external, class: 'form-check-label' do - Newly registered users will by default be external + = _('Newly registered users will by default be external') .prepend-top-10 = _('Internal users') = f.text_field :user_default_internal_regex, placeholder: _('Regex pattern'), class: 'form-control prepend-top-5' @@ -40,10 +43,12 @@ = link_to _('More information'), help_page_path('user/permissions', anchor: 'external-users-permissions'), target: '_blank' .form-group - = f.label :user_show_add_ssh_key_message, 'Prompt users to upload SSH keys', class: 'label-bold' + = f.label :user_show_add_ssh_key_message, _('Prompt users to upload SSH keys'), class: 'label-bold' .form-check = f.check_box :user_show_add_ssh_key_message, class: 'form-check-input' = f.label :user_show_add_ssh_key_message, class: 'form-check-label' do - Inform users without uploaded SSH keys that they can't push over SSH until one is added + = _("Inform users without uploaded SSH keys that they can't push over SSH until one is added") + + = render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f - = 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/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index c99d7e9b8e9..b8c481df0d2 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -8,7 +8,7 @@ .form-check = f.check_box :auto_devops_enabled, class: 'form-check-input' = f.label :auto_devops_enabled, class: 'form-check-label' do - Default to Auto DevOps pipeline for all projects + = s_('CICD|Default to Auto DevOps pipeline for all projects') .form-text.text-muted = s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.') = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank' @@ -21,34 +21,31 @@ .form-check = f.check_box :shared_runners_enabled, class: 'form-check-input' = f.label :shared_runners_enabled, class: 'form-check-label' do - Enable shared runners for new projects + = s_("AdminSettings|Enable shared runners for new projects") + + = render_if_exists 'admin/application_settings/shared_runners_minutes_setting', form: f + .form-group = f.label :shared_runners_text, class: 'label-bold' = f.text_area :shared_runners_text, class: 'form-control', rows: 4 - .form-text.text-muted Markdown enabled + .form-text.text-muted= _("Markdown enabled") .form-group - = f.label :max_artifacts_size, 'Maximum artifacts size (MB)', class: 'label-bold' + = f.label :max_artifacts_size, _('Maximum artifacts size (MB)'), class: 'label-bold' = f.number_field :max_artifacts_size, class: 'form-control' .form-text.text-muted - Set the maximum file size for each job's artifacts + = _("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') .form-group - = f.label :default_artifacts_expire_in, 'Default artifacts expiration', class: 'label-bold' + = f.label :default_artifacts_expire_in, _('Default artifacts expiration'), class: 'label-bold' = f.text_field :default_artifacts_expire_in, class: 'form-control' .form-text.text-muted - Set the default expiration time for each job's artifacts. - 0 for unlimited. - The default unit is in seconds, but you can define an alternative. For example: - <code>4 mins 2 sec</code>, <code>2h42min</code>. + = _("Set the default expiration time for each job's artifacts. 0 for unlimited. The default unit is in seconds, but you can define an alternative. For example: <code>4 mins 2 sec</code>, <code>2h42min</code>.").html_safe = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration') .form-group - = f.label :archive_builds_in_human_readable, 'Archive jobs', class: 'label-bold' + = f.label :archive_builds_in_human_readable, _('Archive jobs'), class: 'label-bold' = f.text_field :archive_builds_in_human_readable, class: 'form-control', placeholder: 'never' .form-text.text-muted - Set the duration for which the jobs will be considered as old and expired. - Once that time passes, the jobs will be archived and no longer able to be - retried. Make it empty to never expire jobs. It has to be no less than 1 day, - for example: <code>15 days</code>, <code>1 month</code>, <code>2 years</code>. + = _("Set the duration for which the jobs will be considered as old and expired. Once that time passes, the jobs will be archived and no longer able to be retried. Make it empty to never expire jobs. It has to be no less than 1 day, for example: <code>15 days</code>, <code>1 month</code>, <code>2 years</code>.").html_safe .form-group .form-check = f.check_box :protected_ci_variables, class: 'form-check-input' @@ -57,4 +54,4 @@ .form-text.text-muted = s_('AdminSettings|When creating a new environment variable it will be protected by default.') - = f.submit 'Save changes', class: "btn btn-success" + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml index 60a6be731ea..3f30c75fbb6 100644 --- a/app/views/admin/application_settings/_email.html.haml +++ b/app/views/admin/application_settings/_email.html.haml @@ -6,20 +6,16 @@ .form-check = f.check_box :email_author_in_body, class: 'form-check-input' = f.label :email_author_in_body, class: 'form-check-label' do - Include author name in notification email body + = _('Include author name in notification email body') .form-text.text-muted - Some email servers do not support overriding the email sender name. - Enable this option to include the name of the author of the issue, - merge request or comment in the email body instead. + = _('Some email servers do not support overriding the email sender name. Enable this option to include the name of the author of the issue, merge request or comment in the email body instead.') .form-group .form-check = f.check_box :html_emails_enabled, class: 'form-check-input' = f.label :html_emails_enabled, class: 'form-check-label' do - Enable HTML emails + = _('Enable HTML emails') .form-text.text-muted - By default GitLab sends emails in HTML and plain text formats so mail - clients can choose what format to use. Disable this option if you only - want to send emails in plain text format. + = _('By default GitLab sends emails in HTML and plain text formats so mail clients can choose what format to use. Disable this option if you only want to send emails in plain text format.') .form-group = f.label :commit_email_hostname, _('Custom hostname (for private commit emails)'), class: 'label-bold' = f.text_field :commit_email_hostname, class: 'form-control' @@ -27,4 +23,6 @@ - commit_email_hostname_docs_link = link_to _('Learn more'), help_page_path('user/admin_area/settings/email', anchor: 'custom-private-commit-email-hostname'), target: '_blank' = _("This setting will update the hostname that is used to generate private commit emails. %{learn_more}").html_safe % { learn_more: commit_email_hostname_docs_link } - = f.submit 'Save changes', class: "btn btn-success" + = render_if_exists 'admin/application_settings/email_additional_text_setting', form: f + + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml new file mode 100644 index 00000000000..7587ecbf9d3 --- /dev/null +++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml @@ -0,0 +1,51 @@ +%section.settings.as-external-auth.no-animate#js-external-auth-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('External authentication') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('External Classification Policy Authorization') + .settings-content + + = form_for @application_setting, url: admin_application_settings_path(anchor: 'js-external-auth-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .form-check + = f.check_box :external_authorization_service_enabled, class: 'form-check-input' + = f.label :external_authorization_service_enabled, class: 'form-check-label' do + = _('Enable classification control using an external service') + %span.form-text.text-muted + = external_authorization_description + = link_to icon('question-circle'), help_page_path('user/admin_area/settings/external_authorization') + .form-group + = f.label :external_authorization_service_url, _('Service URL'), class: 'label-bold' + = f.text_field :external_authorization_service_url, class: 'form-control' + %span.form-text.text-muted + = external_authorization_url_help_text + .form-group + = f.label :external_authorization_service_timeout, _('External authorization request timeout'), class: 'label-bold' + = f.number_field :external_authorization_service_timeout, class: 'form-control', min: 0.001, max: 10, step: 0.001 + %span.form-text.text-muted + = external_authorization_timeout_help_text + = f.label :external_auth_client_cert, _('Client authentication certificate'), class: 'label-bold' + = f.text_area :external_auth_client_cert, class: 'form-control' + %span.form-text.text-muted + = external_authorization_client_certificate_help_text + .form-group + = f.label :external_auth_client_key, _('Client authentication key'), class: 'label-bold' + = f.text_area :external_auth_client_key, class: 'form-control' + %span.form-text.text-muted + = external_authorization_client_key_help_text + .form-group + = f.label :external_auth_client_key_pass, _('Client authentication key password'), class: 'label-bold' + = f.password_field :external_auth_client_key_pass, class: 'form-control' + %span.form-text.text-muted + = external_authorization_client_pass_help_text + .form-group + = f.label :external_authorization_service_default_label, _('Default classification label'), class: 'label-bold' + = f.text_field :external_authorization_service_default_label, class: 'form-control' + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml index 70c8c74cc5d..aa491c735d1 100644 --- a/app/views/admin/application_settings/_help_page.html.haml +++ b/app/views/admin/application_settings/_help_page.html.haml @@ -2,18 +2,20 @@ = form_errors(@application_setting) %fieldset + = render_if_exists 'admin/application_settings/help_text_setting', form: f + .form-group = f.label :help_page_text, class: 'label-bold' = f.text_area :help_page_text, class: 'form-control', rows: 4 - .form-text.text-muted Markdown enabled + .form-text.text-muted= _('Markdown enabled') .form-group .form-check = f.check_box :help_page_hide_commercial_content, class: 'form-check-input' = f.label :help_page_hide_commercial_content, class: 'form-check-label' do - Hide marketing-related entries from help + = _('Hide marketing-related entries from help') .form-group - = f.label :help_page_support_url, 'Support page URL', class: 'label-bold' + = f.label :help_page_support_url, _('Support page URL'), class: 'label-bold' = f.text_field :help_page_support_url, class: 'form-control', placeholder: 'http://company.example.com/getting-help', :'aria-describedby' => 'support_help_block' - %span.form-text.text-muted#support_help_block Alternate support URL for help page + %span.form-text.text-muted#support_help_block= _('Alternate support URL for help page') - = f.submit 'Save changes', class: "btn btn-success" + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_logging.html.haml b/app/views/admin/application_settings/_logging.html.haml index 41b787515b5..1da5f6fccd6 100644 --- a/app/views/admin/application_settings/_logging.html.haml +++ b/app/views/admin/application_settings/_logging.html.haml @@ -1,6 +1,12 @@ = form_for @application_setting, url: admin_application_settings_path(anchor: 'js-logging-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) + %p + %strong + NOTE: + These settings will be removed from the UI in a GitLab 12.0 release and made available within gitlab.yml. + In addition, you will be able to define a Sentry Environment to differentiate between multiple deployments. For example, development, staging, and production. + %fieldset .form-group .form-check diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml index f4bfb5af385..dd56bb99a06 100644 --- a/app/views/admin/application_settings/_outbound.html.haml +++ b/app/views/admin/application_settings/_outbound.html.haml @@ -8,4 +8,12 @@ = f.label :allow_local_requests_from_hooks_and_services, class: 'form-check-label' do Allow requests to the local network from hooks and services + .form-group + .form-check + = f.check_box :dns_rebinding_protection_enabled, class: 'form-check-input' + = f.label :dns_rebinding_protection_enabled, class: 'form-check-label' do + = _('Enforce DNS rebinding attack protection') + %span.form-text.text-muted + = _('Resolves IP addresses once and uses them to submit requests') + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml index ad5c8d4da22..77795dbf913 100644 --- a/app/views/admin/application_settings/_pages.html.haml +++ b/app/views/admin/application_settings/_pages.html.haml @@ -5,16 +5,32 @@ .form-group = f.label :max_pages_size, 'Maximum size of pages (MB)', class: 'label-bold' = f.number_field :max_pages_size, class: 'form-control' - .form-text.text-muted 0 for unlimited + .form-text.text-muted + = _("0 for unlimited") .form-group .form-check = f.check_box :pages_domain_verification_enabled, class: 'form-check-input' = f.label :pages_domain_verification_enabled, class: 'form-check-label' do - Require users to prove ownership of custom domains + = _("Require users to prove ownership of custom domains") .form-text.text-muted - Domain verification is an essential security measure for public GitLab - sites. Users are required to demonstrate they control a domain before - it is enabled + = _("Domain verification is an essential security measure for public GitLab sites. Users are required to demonstrate they control a domain before it is enabled") = link_to icon('question-circle'), help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') + - if Feature.enabled?(:pages_auto_ssl) + %h5 + = _("Configure Let's Encrypt") + %p + - lets_encrypt_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: "https://letsencrypt.org/" } + = _("%{lets_encrypt_link_start}Let's Encrypt%{lets_encrypt_link_end} is a free, automated, and open certificate authority (CA), that give digital certificates in order to enable HTTPS (SSL/TLS) for websites.").html_safe % { lets_encrypt_link_start: lets_encrypt_link_start, lets_encrypt_link_end: '</a>'.html_safe } + .form-group + = f.label :lets_encrypt_notification_email, _("Email"), class: 'label-bold' + = f.text_field :lets_encrypt_notification_email, class: 'form-control' + .form-text.text-muted + = _("A Let's Encrypt account will be configured for this GitLab installation using your email address. You will receive emails to warn of expiring certificates.") + .form-group + .form-check + = 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 } - = f.submit 'Save changes', class: "btn btn-success" + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml index 44ac8d94764..f992d531ea5 100644 --- a/app/views/admin/application_settings/_performance_bar.html.haml +++ b/app/views/admin/application_settings/_performance_bar.html.haml @@ -5,10 +5,10 @@ .form-group .form-check = f.check_box :performance_bar_enabled, class: 'form-check-input' - = f.label :performance_bar_enabled, class: 'form-check-label' do + = f.label :performance_bar_enabled, class: 'form-check-label qa-enable-performance-bar-checkbox' do Enable the Performance Bar .form-group = f.label :performance_bar_allowed_group_path, 'Allowed group', class: 'label-bold' = f.text_field :performance_bar_allowed_group_path, class: 'form-control', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path - = f.submit 'Save changes', class: "btn btn-success" + = f.submit 'Save changes', class: 'btn btn-success qa-save-changes-button' diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml index 615aa6317b0..f2f2cd1282a 100644 --- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml +++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml @@ -3,13 +3,15 @@ %fieldset .form-group - = f.label :mirror_available, 'Enable mirror configuration', class: 'label-bold' + = f.label :mirror_available, _('Enable mirror configuration'), class: 'label-bold' .form-check = f.check_box :mirror_available, class: 'form-check-input' = f.label :mirror_available, class: 'form-check-label' do - Allow mirrors to be set up for projects + = _('Allow mirrors to be set up for projects') %span.form-text.text-muted - If disabled, only admins will be able to set up mirrors in projects. + = _('If disabled, only admins will be able to set up mirrors in projects.') = link_to icon('question-circle'), help_page_path('workflow/repository_mirroring') - = f.submit 'Save changes', class: "btn btn-success" + = render_if_exists 'admin/application_settings/mirror_settings', form: f + + = f.submit _('Save changes'), class: "btn btn-success" 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 0725ffb7f6c..03ef2924617 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -5,6 +5,9 @@ .form-group = f.label :default_branch_protection, class: 'label-bold' = f.select :default_branch_protection, options_for_select(Gitlab::Access.protection_options, @application_setting.default_branch_protection), {}, class: 'form-control' + .form-group + = f.label s_('ProjectCreationLevel|Default project creation protection'), class: 'label-bold' + = f.select :default_project_creation, options_for_select(Gitlab::Access.project_creation_options, @application_setting.default_project_creation), {}, class: 'form-control' .form-group.visibility-level-setting = f.label :default_project_visibility, class: 'label-bold' = render('shared/visibility_radios', model_method: :default_project_visibility, form: f, selected_level: @application_setting.default_project_visibility, form_model: Project.new) @@ -22,32 +25,33 @@ .form-check = level %span.form-text.text-muted#restricted-visibility-help - Selected levels cannot be used by non-admin users for groups, projects or snippets. - If the public level is restricted, user profiles are only visible to logged in users. + = _('Selected levels cannot be used by non-admin users for groups, projects or snippets. If the public level is restricted, user profiles are only visible to logged in users.') .form-group = f.label :import_sources, class: 'label-bold' = hidden_field_tag 'application_setting[import_sources][]' - import_sources_checkboxes('import-sources-help', class: 'form-check-input').each do |source| .form-check= source %span.form-text.text-muted#import-sources-help - Enabled sources for code import during project creation. OmniAuth must be configured for GitHub + = _('Enabled sources for code import during project creation. OmniAuth must be configured for GitHub') = link_to "(?)", help_page_path("integration/github") , Bitbucket = link_to "(?)", help_page_path("integration/bitbucket") and GitLab.com = link_to "(?)", help_page_path("integration/gitlab") + = render_if_exists 'admin/application_settings/ldap_access_setting', form: f + .form-group .form-check = f.check_box :project_export_enabled, class: 'form-check-input' = f.label :project_export_enabled, class: 'form-check-label' do - Project export enabled + = _('Project export enabled') .form-group - %label.label-bold Enabled Git access protocols + %label.label-bold= _('Enabled Git access protocols') = 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. + = _('Allow only the selected protocols to be used for Git access.') - ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type| - field_name = :"#{type}_key_restriction" @@ -55,4 +59,4 @@ = f.label field_name, "#{type.upcase} SSH keys", class: 'label-bold' = f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control' - = f.submit 'Save changes', class: "btn btn-success" + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml index f50aca32bdf..d5ba6abe7af 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -24,7 +24,7 @@ .settings-content = render 'prometheus' -%section.settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded_by_default?) } +%section.settings.qa-performance-bar-settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 = _('Profiling - Performance bar') diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml index fc9dd29b8ca..31f18ba0d56 100644 --- a/app/views/admin/application_settings/show.html.haml +++ b/app/views/admin/application_settings/show.html.haml @@ -68,7 +68,7 @@ .settings-content = render 'terms' -= render_if_exists 'admin/application_settings/external_authorization_service_form', expanded: expanded_by_default? += render 'admin/application_settings/external_authorization_service_form', expanded: expanded_by_default? %section.settings.as-terminal.no-animate#js-terminal-settings{ class: ('expanded' if expanded_by_default?) } .settings-header diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml index 12690343f6e..21e84016c66 100644 --- a/app/views/admin/applications/_form.html.haml +++ b/app/views/admin/applications/_form.html.haml @@ -2,13 +2,15 @@ = form_errors(application) = content_tag :div, class: 'form-group row' do - = f.label :name, class: 'col-sm-2 col-form-label' + .col-sm-2.col-form-label + = f.label :name .col-sm-10 = f.text_field :name, class: 'form-control' = doorkeeper_errors_for application, :name = content_tag :div, class: 'form-group row' do - = f.label :redirect_uri, class: 'col-sm-2 col-form-label' + .col-sm-2.col-form-label + = f.label :redirect_uri .col-sm-10 = f.text_area :redirect_uri, class: 'form-control' = doorkeeper_errors_for application, :redirect_uri @@ -21,14 +23,16 @@ for local tests = content_tag :div, class: 'form-group row' do - = f.label :trusted, class: 'col-sm-2 col-form-label pt-0' + .col-sm-2.col-form-label.pt-0 + = f.label :trusted .col-sm-10 = f.check_box :trusted %span.form-text.text-muted Trusted applications are automatically authorized on GitLab OAuth flow. .form-group.row - = f.label :scopes, class: 'col-sm-2 col-form-label pt-0' + .col-sm-2.col-form-label.pt-0 + = f.label :scopes .col-sm-10 = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml index df3eeba907c..180066723f1 100644 --- a/app/views/admin/applications/show.html.haml +++ b/app/views/admin/applications/show.html.haml @@ -11,7 +11,7 @@ %td .clipboard-group .input-group - %input.label.label-monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true } + %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") %tr @@ -20,7 +20,7 @@ %td .clipboard-group .input-group - %input.label.label-monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true } + %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") %tr diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index c465d9f51d6..c8ee87c6212 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -10,28 +10,34 @@ = form_errors(@broadcast_message) .form-group.row - = f.label :message, class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :message .col-sm-10 = f.text_area :message, class: "form-control js-autosize", required: true, + dir: 'auto', data: { preview_path: preview_admin_broadcast_messages_path } .form-group.row.js-toggle-colors-container .col-sm-10.offset-sm-2 = link_to 'Customize colors', '#', class: 'js-toggle-colors-link' .form-group.row.js-toggle-colors-container.toggle-colors.hide - = f.label :color, "Background Color", class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :color, "Background Color" .col-sm-10 = f.color_field :color, class: "form-control" .form-group.row.js-toggle-colors-container.toggle-colors.hide - = f.label :font, "Font Color", class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :font, "Font Color" .col-sm-10 = f.color_field :font, class: "form-control" .form-group.row - = f.label :starts_at, _("Starts at (UTC)"), class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :starts_at, _("Starts at (UTC)") .col-sm-10.datetime-controls = f.datetime_select :starts_at, {}, class: 'form-control form-control-inline' .form-group.row - = f.label :ends_at, _("Ends at (UTC)"), class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :ends_at, _("Ends at (UTC)") .col-sm-10.datetime-controls = f.datetime_select :ends_at, {}, class: 'form-control form-control-inline' .form-actions diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml index 9ef58faf8cc..eb4dfdf2858 100644 --- a/app/views/admin/broadcast_messages/index.html.haml +++ b/app/views/admin/broadcast_messages/index.html.haml @@ -32,7 +32,7 @@ %td = message.ends_at %td - = link_to icon('pencil-square-o'), edit_admin_broadcast_message_path(message), title: 'Edit', class: 'btn btn-sm' - = link_to icon('times'), admin_broadcast_message_path(message), method: :delete, remote: true, title: 'Remove', class: 'js-remove-tr btn btn-sm btn-danger' + = link_to sprite_icon('pencil-square'), edit_admin_broadcast_message_path(message), title: 'Edit', class: 'btn' + = link_to sprite_icon('remove'), admin_broadcast_message_path(message), method: :delete, remote: true, title: 'Remove', class: 'js-remove-tr btn btn-danger' = paginate @broadcast_messages, theme: 'gitlab' diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 6756299cf43..581f6ae0714 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -22,9 +22,10 @@ %h3.text-center Users: = approximate_count_with_delimiters(@counts, User) - = render_if_exists 'admin/dashboard/users_statistics' %hr - = link_to 'New user', new_admin_user_path, class: "btn btn-success" + .btn-group.d-flex{ role: 'group' } + = link_to 'New user', new_admin_user_path, class: "btn btn-success" + = render_if_exists 'admin/dashboard/users_statistics' .col-sm-4 .info-well.dark-well .well-segment.well-centered @@ -162,7 +163,7 @@ %span.float-right #{Rails::VERSION::STRING} %p - = Gitlab::Database.adapter_name + = Gitlab::Database.human_adapter_name %span.float-right = Gitlab::Database.version %p diff --git a/app/views/admin/deploy_keys/edit.html.haml b/app/views/admin/deploy_keys/edit.html.haml index 7c04ef03947..99d8af65068 100644 --- a/app/views/admin/deploy_keys/edit.html.haml +++ b/app/views/admin/deploy_keys/edit.html.haml @@ -1,10 +1,10 @@ -- page_title 'Edit Deploy Key' -%h3.page-title Edit public deploy key +- page_title _('Edit Deploy Key') +%h3.page-title= _('Edit public deploy key') %hr %div = form_for [:admin, @deploy_key], html: { class: 'deploy-key-form' } do |f| = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key } .form-actions - = f.submit 'Save changes', class: 'btn-success btn' - = link_to 'Cancel', admin_deploy_keys_path, class: 'btn btn-cancel' + = f.submit _('Save changes'), class: 'btn-success btn' + = link_to _('Cancel'), admin_deploy_keys_path, class: 'btn btn-cancel' diff --git a/app/views/admin/deploy_keys/index.html.haml b/app/views/admin/deploy_keys/index.html.haml index 01013be06d6..9fffa97f969 100644 --- a/app/views/admin/deploy_keys/index.html.haml +++ b/app/views/admin/deploy_keys/index.html.haml @@ -1,19 +1,19 @@ -- page_title "Deploy Keys" +- page_title _('Deploy Keys') %h3.page-title.deploy-keys-title - Public deploy keys (#{@deploy_keys.count}) + = _('Public deploy keys (%{deploy_keys_count})') % { deploy_keys_count: @deploy_keys.count } .float-right - = link_to 'New deploy key', new_admin_deploy_key_path, class: 'btn btn-success btn-sm btn-inverted' + = link_to _('New deploy key'), new_admin_deploy_key_path, class: 'btn btn-success btn-sm btn-inverted' - if @deploy_keys.any? .table-holder.deploy-keys-list %table.table %thead %tr - %th.col-sm-2 Title - %th.col-sm-4 Fingerprint - %th.col-sm-2 Projects with write access - %th.col-sm-2 Added at + %th.col-sm-2= _('Title') + %th.col-sm-4= _('Fingerprint') + %th.col-sm-2= _('Projects with write access') + %th.col-sm-2= _('Added at') %th.col-sm-2 %tbody - @deploy_keys.each do |deploy_key| @@ -27,8 +27,8 @@ = link_to project.full_name, admin_project_path(project), class: 'label deploy-project-label' %td %span.cgray - added #{time_ago_with_tooltip(deploy_key.created_at)} + = _('added %{created_at_timeago}').html_safe % { created_at_timeago: time_ago_with_tooltip(deploy_key.created_at) } %td .float-right - = link_to 'Edit', edit_admin_deploy_key_path(deploy_key), class: 'btn btn-sm' - = link_to 'Remove', admin_deploy_key_path(deploy_key), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-sm btn-remove delete-key' + = link_to _('Edit'), edit_admin_deploy_key_path(deploy_key), class: 'btn btn-sm' + = link_to _('Remove'), admin_deploy_key_path(deploy_key), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn btn-sm btn-remove delete-key' diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 5e05568e384..dd01ef8a29f 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -2,13 +2,14 @@ = form_errors(@group) = render 'shared/group_form', f: f - = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group + = render_if_exists 'shared/old_repository_size_limit_setting', form: f, type: :group = render_if_exists 'admin/namespace_plan', f: f .form-group.row.group-description-holder - = f.label :avatar, _("Group avatar"), class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :avatar, _("Group avatar") .col-sm-10 - = render 'shared/choose_group_avatar_button', f: f + = render 'shared/choose_avatar_button', f: f = render 'shared/old_visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group, with_label: false diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index 00d255846f9..f524d35d79e 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -44,12 +44,10 @@ %li %span.light= _('Storage:') - - counter_storage = storage_counter(@group.storage_size) - - counter_repositories = storage_counter(@group.repository_size) - - counter_build_artifacts = storage_counter(@group.build_artifacts_size) - - counter_lfs_objects = storage_counter(@group.lfs_objects_size) - %strong - = _("%{counter_storage} (%{counter_repositories} repositories, %{counter_build_artifacts} build artifacts, %{counter_lfs_objects} LFS)") % { counter_storage: counter_storage, counter_repositories: counter_repositories, counter_build_artifacts: counter_build_artifacts, counter_lfs_objects: counter_lfs_objects } + %strong= storage_counter(@group.storage_size) + ( + = storage_counters_details(@group) + ) %li %span.light= _('Group Git LFS status:') diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml index 0f5e97e288a..ac56e354a4d 100644 --- a/app/views/admin/health_check/show.html.haml +++ b/app/views/admin/health_check/show.html.haml @@ -23,7 +23,7 @@ %code= liveness_url(token: Gitlab::CurrentSettings.health_check_access_token) %li %code= metrics_url(token: Gitlab::CurrentSettings.health_check_access_token) - + = render_if_exists 'admin/health_check/health_check_url' %hr .card .card-header diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml index 3ab7990d9e2..40a7014e143 100644 --- a/app/views/admin/identities/_form.html.haml +++ b/app/views/admin/identities/_form.html.haml @@ -2,12 +2,14 @@ = form_errors(@identity) .form-group.row - = f.label :provider, class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :provider .col-sm-10 - values = Gitlab::Auth::OAuth::Provider.providers.map { |name| ["#{Gitlab::Auth::OAuth::Provider.label_for(name)} (#{name})", name] } = f.select :provider, values, { allow_blank: false }, class: 'form-control' .form-group.row - = f.label :extern_uid, _("Identifier"), class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :extern_uid, _("Identifier") .col-sm-10 = f.text_field :extern_uid, class: 'form-control', required: true diff --git a/app/views/admin/labels/_form.html.haml b/app/views/admin/labels/_form.html.haml index 5e7b4817461..299d0a12e6c 100644 --- a/app/views/admin/labels/_form.html.haml +++ b/app/views/admin/labels/_form.html.haml @@ -2,15 +2,18 @@ = form_errors(@label) .form-group.row - = f.label :title, class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :title .col-sm-10 = f.text_field :title, class: "form-control", required: true .form-group.row - = f.label :description, class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :description .col-sm-10 = f.text_field :description, class: "form-control js-quick-submit" .form-group.row - = f.label :color, _("Background color"), class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :color, _("Background color") .col-sm-10 .input-group .input-group-prepend @@ -21,10 +24,7 @@ %br = _("Or you can choose one of the suggested colors below") - .suggest-colors - - suggested_colors.each do |color| - = link_to '#', style: "background-color: #{color}", data: { color: color } do - + = render_suggested_colors .form-actions = f.submit _('Save'), class: 'btn btn-success js-save-button' diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml index dbb7224f5f9..6d934654c5d 100644 --- a/app/views/admin/labels/_label.html.haml +++ b/app/views/admin/labels/_label.html.haml @@ -1,5 +1,5 @@ %li.label-list-item{ id: dom_id(label) } - = render "shared/label_row", label: label + = render "shared/label_row", label: label.present(issuable_subject: nil) .label-actions-list = link_to edit_admin_label_path(label), class: 'btn btn-transparent label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do = sprite_icon('pencil') diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml index 5bc695aa7b5..2f7ad35eb3e 100644 --- a/app/views/admin/projects/_projects.html.haml +++ b/app/views/admin/projects/_projects.html.haml @@ -7,17 +7,17 @@ = link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn" %button.delete-project-button.btn.btn-danger{ data: { toggle: 'modal', target: '#delete-project-modal', - delete_project_url: project_path(project), + delete_project_url: admin_project_path(project), project_name: project.name }, type: 'button' } = s_('AdminProjects|Delete') .stats %span.badge.badge-pill - = storage_counter(project.statistics.storage_size) + = storage_counter(project.statistics&.storage_size) - if project.archived %span.badge.badge-warning archived .title - = link_to(admin_namespace_project_path(project.namespace, project)) do + = link_to(admin_project_path(project)) do .dash-project-avatar .avatar-container.rect-avatar.s40 = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40) diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index 46bb57c78a8..b88b760536d 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -7,7 +7,7 @@ .top-area.scrolling-tabs-container.inner-page-scroll-tabs .prepend-top-default .search-holder - = render 'shared/projects/search_form', autofocus: true, icon: true + = render 'shared/projects/search_form', autofocus: true, icon: true, admin_view: true .dropdown - toggle_text = 'Namespace' - if params[:namespace_id].present? diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 03cce4745aa..e23accc1ea9 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -73,16 +73,11 @@ = @project.repository.relative_path %li - %span.light Storage used: - %strong= storage_counter(@project.statistics.storage_size) - ( - = storage_counter(@project.statistics.repository_size) - repository, - = storage_counter(@project.statistics.build_artifacts_size) - build artifacts, - = storage_counter(@project.statistics.lfs_objects_size) - LFS - ) + %span.light= _('Storage:') + %strong= storage_counter(@project.statistics&.storage_size) + - if @project.statistics + = surround '(', ')' do + = storage_counters_details(@project.statistics) %li %span.light last commit: @@ -105,6 +100,8 @@ %span.light archived: %strong project is read-only + = render_if_exists "shared_runner_status", project: @project + %li %span.light access: %strong @@ -120,7 +117,8 @@ .card-body = form_for @project, url: transfer_admin_project_path(@project), method: :put do |f| .form-group.row - = f.label :new_namespace_id, "Namespace", class: 'col-form-label col-sm-3' + .col-sm-3.col-form-label + = f.label :new_namespace_id, "Namespace" .col-sm-9 .dropdown = dropdown_toggle('Search for Namespace', { toggle: 'dropdown', field_name: 'new_namespace_id' }, { toggle_class: 'js-namespace-select large' }) diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml index 4641986cb56..423472324fe 100644 --- a/app/views/admin/runners/_runner.html.haml +++ b/app/views/admin/runners/_runner.html.haml @@ -49,8 +49,8 @@ .table-section.section-10.section-wrap .table-mobile-header{ role: 'rowheader' }= _('Tags') .table-mobile-content - - runner.tag_list.sort.each do |tag| - %span.badge.badge-primary + - runner.tags.map(&:name).sort.each do |tag| + %span.badge.badge-primary.str-truncated.has-tooltip{ title: tag } = tag .table-section.section-10 diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index 81380587fd2..2e23b748edb 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -92,6 +92,25 @@ = button_tag class: %w[btn btn-link] do = runner_type.titleize + #js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + - Ci::Runner::AVAILABLE_TYPES.each do |runner_type| + %li.filter-dropdown-item{ data: { value: runner_type } } + = button_tag class: %w[btn btn-link] do + = runner_type.titleize + + #js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'none' } } + %button.btn.btn-link + = _('No Tag') + %li.divider.droplab-item-ignore + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.js-data-value + %span.dropdown-light-content + {{name}} + = button_tag class: %w[clear-search hidden] do = icon('times') .filter-dropdown-container diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml index 12e24ddef02..77729636f9d 100644 --- a/app/views/admin/users/_access_levels.html.haml +++ b/app/views/admin/users/_access_levels.html.haml @@ -1,18 +1,20 @@ %fieldset %legend Access .form-group.row - .col-sm-2.text-right - = f.label :projects_limit, class: 'col-form-label' - .col-sm-10= f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control' + .col-sm-2.col-form-label + = f.label :projects_limit + .col-sm-10 + = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control' .form-group.row - .col-sm-2.text-right - = f.label :can_create_group, class: 'col-form-label' - .col-sm-10= f.check_box :can_create_group + .col-sm-2.col-form-label + = f.label :can_create_group + .col-sm-10 + = f.check_box :can_create_group .form-group.row - .col-sm-2.text-right - = f.label :access_level, class: 'col-form-label' + .col-sm-2.col-form-label + = f.label :access_level .col-sm-10 - editing_current_user = (current_user == @user) @@ -22,6 +24,8 @@ %p.light Regular users have access to their groups and projects + = render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user + = f.radio_button :access_level, :admin, disabled: editing_current_user = label_tag :admin, class: 'font-weight-bold' do Admin @@ -32,8 +36,8 @@ You cannot remove your own admin rights. .form-group.row - .col-sm-2.text-right - = f.label :external, class: 'col-form-label' + .col-sm-2.col-form-label + = f.label :external .hidden{ data: user_internal_regex_data } .col-sm-10 = f.check_box :external do diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index 296ef073144..3281718071c 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -5,20 +5,20 @@ %fieldset %legend Account .form-group.row - .col-sm-2.text-right - = f.label :name, class: 'col-form-label' + .col-sm-2.col-form-label + = f.label :name .col-sm-10 = f.text_field :name, required: true, autocomplete: 'off', class: 'form-control' %span.help-inline * required .form-group.row - .col-sm-2.text-right - = f.label :username, class: 'col-form-label' + .col-sm-2.col-form-label + = f.label :username .col-sm-10 = f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control' %span.help-inline * required .form-group.row - .col-sm-2.text-right - = f.label :email, class: 'col-form-label' + .col-sm-2.col-form-label + = f.label :email .col-sm-10 = f.text_field :email, required: true, autocomplete: 'off', class: 'form-control' %span.help-inline * required @@ -27,8 +27,8 @@ %fieldset %legend Password .form-group.row - .col-sm-2.text-right - = f.label :password, class: 'col-form-label' + .col-sm-2.col-form-label + = f.label :password .col-sm-10 %strong Reset link will be generated and sent to the user. @@ -38,40 +38,52 @@ %fieldset %legend Password .form-group.row - .col-sm-2.text-right - = f.label :password, class: 'col-form-label' - .col-sm-10= f.password_field :password, disabled: f.object.force_random_password, class: 'form-control' + .col-sm-2.col-form-label + = f.label :password + .col-sm-10 + = f.password_field :password, disabled: f.object.force_random_password, class: 'form-control' .form-group.row - .col-sm-2.text-right - = f.label :password_confirmation, class: 'col-form-label' - .col-sm-10= f.password_field :password_confirmation, disabled: f.object.force_random_password, class: 'form-control' + .col-sm-2.col-form-label + = f.label :password_confirmation + .col-sm-10 + = f.password_field :password_confirmation, disabled: f.object.force_random_password, class: 'form-control' = render partial: 'access_levels', locals: { f: f } + = render_if_exists 'admin/users/namespace_plan_fieldset', f: f + + = render_if_exists 'admin/users/limits', f: f + %fieldset %legend Profile .form-group.row - .col-sm-2.text-right - = f.label :avatar, class: 'col-form-label' + .col-sm-2.col-form-label + = f.label :avatar .col-sm-10 = f.file_field :avatar .form-group.row - .col-sm-2.text-right - = f.label :skype, class: 'col-form-label' - .col-sm-10= f.text_field :skype, class: 'form-control' + .col-sm-2.col-form-label + = f.label :skype + .col-sm-10 + = f.text_field :skype, class: 'form-control' .form-group.row - .col-sm-2.text-right - = f.label :linkedin, class: 'col-form-label' - .col-sm-10= f.text_field :linkedin, class: 'form-control' + .col-sm-2.col-form-label + = f.label :linkedin + .col-sm-10 + = f.text_field :linkedin, class: 'form-control' .form-group.row - .col-sm-2.text-right - = f.label :twitter, class: 'col-form-label' - .col-sm-10= f.text_field :twitter, class: 'form-control' + .col-sm-2.col-form-label + = f.label :twitter + .col-sm-10 + = f.text_field :twitter, class: 'form-control' .form-group.row - .col-sm-2.text-right - = f.label :website_url, 'Website', class: 'col-form-label' - .col-sm-10= f.text_field :website_url, class: 'form-control' + .col-sm-2.col-form-label + = f.label :website_url + .col-sm-10 + = f.text_field :website_url, class: 'form-control' + + = render_if_exists 'admin/users/admin_notes', f: f .form-actions - if @user.new_record? diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml index a733f420d11..e7dde7985fd 100644 --- a/app/views/admin/users/_head.html.haml +++ b/app/views/admin/users/_head.html.haml @@ -6,6 +6,7 @@ %span.cred (Internal) - if @user.admin %span.cred (Admin) + = render_if_exists 'admin/users/audtior_user_badge' .float-right - if impersonation_enabled? && @user != current_user && @user.can?(:log_in) diff --git a/app/views/admin/users/_user_detail.html.haml b/app/views/admin/users/_user_detail.html.haml index 3319b4bad3a..13d10dcd625 100644 --- a/app/views/admin/users/_user_detail.html.haml +++ b/app/views/admin/users/_user_detail.html.haml @@ -6,7 +6,7 @@ = 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 } - = render_if_exists 'admin/users/user_detail_note', user: user + = render_if_exists 'admin/users/user_listing_note', user: user - user_badges_in_admin_section(user).each do |badge| - css_badge = "badge badge-#{badge[:variant]}" if badge[:variant].present? diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index a74e052707f..dcd6f7c8078 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -53,6 +53,8 @@ - else Disabled + = render_if_exists 'admin/namespace_plan_info', namespace: @user.namespace + %li %span.light External User: %strong @@ -117,6 +119,13 @@ %strong = @user.sign_in_count + %li + %span.light= _("Highest role:") + %strong + = Gitlab::Access.human_access_with_none(@user.highest_role) + + = render_if_exists 'admin/users/using_license_seat', user: @user + - if @user.ldap_user? %li %span.light LDAP uid: @@ -129,6 +138,8 @@ %strong = link_to @user.created_by.name, [:admin, @user.created_by] + = render_if_exists partial: "namespaces/shared_runner_status", locals: { namespace: @user.namespace } + .col-md-6 - unless @user == current_user - unless @user.confirmed? @@ -141,6 +152,9 @@ %p This user has an unconfirmed email address#{email}. You may force a confirmation. %br = link_to 'Confirm user', confirm_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?' } + + = render_if_exists 'admin/users/user_detail_note' + - if @user.blocked? .card.border-info .card-header.bg-info.text-white diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml index 8d9c083d223..60ca7e4e267 100644 --- a/app/views/award_emoji/_awards_block.html.haml +++ b/app/views/award_emoji/_awards_block.html.haml @@ -13,7 +13,7 @@ %button.btn.award-control.has-tooltip.js-add-award{ type: 'button', 'aria-label': _('Add reaction'), data: { title: _('Add reaction') } } - %span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face') - %span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley') - %span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile') + %span{ class: "award-control-icon award-control-icon-neutral" }= sprite_icon('slight-smile') + %span{ class: "award-control-icon award-control-icon-positive" }= sprite_icon('smiley') + %span{ class: "award-control-icon award-control-icon-super-positive" }= sprite_icon('smile') = icon('spinner spin', class: "award-control-icon award-control-icon-loading") diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml index c787d7420b7..369b0f7e62c 100644 --- a/app/views/ci/status/_dropdown_graph_badge.html.haml +++ b/app/views/ci/status/_dropdown_graph_badge.html.haml @@ -6,14 +6,14 @@ - tooltip = "#{subject.name} - #{status.status_tooltip}" - if status.has_details? - = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do + = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item d-flex', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do %span{ class: klass }= sprite_icon(status.icon) - %span.ci-build-text= subject.name + %span.ci-build-text.text-truncate.mw-70p.gl-pl-1= subject.name - else - .menu-item.mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } } + .menu-item.mini-pipeline-graph-dropdown-item.d-flex{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } } %span{ class: klass }= sprite_icon(status.icon) - %span.ci-build-text= subject.name + %span.ci-build-text.text-truncate.mw-70p.gl-pl-1= subject.name - if status.has_action? = link_to status.action_path, class: "ci-action-icon-container ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do diff --git a/app/views/ci/status/_icon.html.haml b/app/views/ci/status/_icon.html.haml new file mode 100644 index 00000000000..f38bdb2e5ed --- /dev/null +++ b/app/views/ci/status/_icon.html.haml @@ -0,0 +1,16 @@ +- status = local_assigns.fetch(:status) +- size = local_assigns.fetch(:size, 16) +- type = local_assigns.fetch(:type, 'pipeline') +- tooltip_placement = local_assigns.fetch(:tooltip_placement, "left") +- path = local_assigns.fetch(:path, status.has_details? ? status.details_path : nil) +- css_classes = "ci-status-link ci-status-icon ci-status-icon-#{status.group} has-tooltip" +- title = s_("PipelineStatusTooltip|Pipeline: %{ci_status}") % {ci_status: status.label} +- if type == 'commit' + - title = s_("PipelineStatusTooltip|Commit: %{ci_status}") % {ci_status: status.label} + +- if path + = link_to path, class: css_classes, title: title, data: { placement: tooltip_placement } do + = sprite_icon(status.icon, size: size) +- else + %span{ class: css_classes, title: title, data: { placement: tooltip_placement } } + = sprite_icon(status.icon, size: size) diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml index 90c59bec975..0b5c1a806b2 100644 --- a/app/views/ci/variables/_content.html.haml +++ b/app/views/ci/variables/_content.html.haml @@ -1,3 +1,3 @@ -= _('Environment variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use environment variables for passwords, secret keys, or whatever you want.') += _('Environment variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. Additionally, they can be masked so they are hidden in job logs, though they must match certain regexp requirements to do so. You can use environment variables for passwords, secret keys, or whatever you want.') = _('You may also add variables that are made available to the running application by prepending the variable key with <code>K8S_SECRET_</code>.').html_safe = link_to _('More information'), help_page_path('ci/variables/README', anchor: 'variables') diff --git a/app/views/ci/variables/_header.html.haml b/app/views/ci/variables/_header.html.haml index cb7779e2175..dbfa0a9e5a1 100644 --- a/app/views/ci/variables/_header.html.haml +++ b/app/views/ci/variables/_header.html.haml @@ -1,7 +1,7 @@ - expanded = local_assigns.fetch(:expanded) %h4 - = _('Environment variables') + = _('Variables') = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'variables'), target: '_blank', rel: 'noopener noreferrer' %button.btn.btn-default.js-settings-toggle{ type: 'button' } diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index dc9ccb6cc39..94102b4dcd0 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -6,10 +6,11 @@ = s_('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } .row - .col-lg-12.js-ci-variable-list-section{ data: { save_endpoint: save_endpoint } } + .col-lg-12.js-ci-variable-list-section{ data: { save_endpoint: save_endpoint, maskable_regex: ci_variable_maskable_regex } } .hide.alert.alert-danger.js-ci-variable-error-box %ul.ci-variable-list + = render 'ci/variables/variable_header' - @variables.each.each do |variable| = render 'ci/variables/variable_row', form_field: 'variables', variable: variable = render 'ci/variables/variable_row', form_field: 'variables' diff --git a/app/views/ci/variables/_variable_header.html.haml b/app/views/ci/variables/_variable_header.html.haml new file mode 100644 index 00000000000..d3b7a5ae883 --- /dev/null +++ b/app/views/ci/variables/_variable_header.html.haml @@ -0,0 +1,16 @@ +- only_key_value = local_assigns.fetch(:only_key_value, false) + +%li.ci-variable-row.m-0.d-none.d-sm-block + .d-flex.w-100.align-items-center.pb-2 + .bold.table-section.section-15.append-right-10 + = s_('CiVariables|Type') + .bold.table-section.section-15.append-right-10 + = s_('CiVariables|Key') + .bold.table-section.section-15.append-right-10 + = s_('CiVariables|Value') + - unless only_key_value + .bold.table-section.section-20 + = s_('CiVariables|State') + .bold.table-section.section-20 + = s_('CiVariables|Masked') + = render_if_exists 'ci/variables/environment_scope_header' diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml index 16a7527c8ce..ed4bd5ae19e 100644 --- a/app/views/ci/variables/_variable_row.html.haml +++ b/app/views/ci/variables/_variable_row.html.haml @@ -3,35 +3,45 @@ - only_key_value = local_assigns.fetch(:only_key_value, false) - id = variable&.id +- variable_type = variable&.variable_type - key = variable&.key - value = variable&.value - is_protected_default = ci_variable_protected_by_default? - is_protected = ci_variable_protected?(variable, only_key_value) +- is_masked_default = false +- is_masked = ci_variable_masked?(variable, only_key_value) - id_input_name = "#{form_field}[variables_attributes][][id]" - destroy_input_name = "#{form_field}[variables_attributes][][_destroy]" +- variable_type_input_name = "#{form_field}[variables_attributes][][variable_type]" - key_input_name = "#{form_field}[variables_attributes][][key]" - value_input_name = "#{form_field}[variables_attributes][][secret_value]" - protected_input_name = "#{form_field}[variables_attributes][][protected]" +- masked_input_name = "#{form_field}[variables_attributes][][masked]" %li.js-row.ci-variable-row{ data: { is_persisted: "#{!id.nil?}" } } - .ci-variable-row-body + .ci-variable-row-body.border-bottom %input.js-ci-variable-input-id{ type: "hidden", name: id_input_name, value: id } %input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name } - %input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control{ type: "text", + %select.js-ci-variable-input-variable-type.ci-variable-body-item.form-control.select-control.custom-select.table-section.section-15{ name: variable_type_input_name } + = options_for_select(ci_variable_type_options, variable_type) + %input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control.table-section.section-15{ type: "text", name: key_input_name, value: key, placeholder: s_('CiVariables|Input variable key') } - .ci-variable-body-item + .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) } - = '*' * 20 + = '*' * 17 %textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control{ class: ('hide' if id), rows: 1, name: value_input_name, placeholder: s_('CiVariables|Input variable value') } = value + %p.masking-validation-error.gl-field-error.hide + = s_("CiVariables|Cannot use Masked Variable with current value") + = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'masked-variables'), target: '_blank', rel: 'noopener noreferrer' - unless only_key_value - .ci-variable-body-item.ci-variable-protected-item + .ci-variable-body-item.ci-variable-protected-item.table-section.section-20.mr-0.border-top-0 .append-right-default = s_("CiVariable|Protected") %button{ type: 'button', @@ -45,6 +55,20 @@ %span.toggle-icon = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') + .ci-variable-body-item.ci-variable-masked-item.table-section.section-20.mr-0.border-top-0 + .append-right-default + = s_("CiVariable|Masked") + %button{ type: 'button', + class: "js-project-feature-toggle project-feature-toggle qa-variable-masked #{'is-checked' if is_masked}", + "aria-label": s_("CiVariable|Toggle masked") } + %input{ type: "hidden", + class: 'js-ci-variable-input-masked js-project-feature-toggle-input', + name: masked_input_name, + value: is_masked, + data: { default: is_masked_default.to_s } } + %span.toggle-icon + = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') + = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') = render_if_exists 'ci/variables/environment_scope', form_field: form_field, variable: variable - %button.js-row-remove-button.ci-variable-row-remove-button{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') } - = icon('minus-circle') + %button.js-row-remove-button.ci-variable-row-remove-button.table-section.section-5.border-top-0{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') } + = icon('minus-circle') diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml index 7037c80aa6b..8005dcbf65f 100644 --- a/app/views/clusters/clusters/_advanced_settings.html.haml +++ b/app/views/clusters/clusters/_advanced_settings.html.haml @@ -1,5 +1,5 @@ - if can?(current_user, :admin_cluster, @cluster) - - if @cluster.managed? + - unless @cluster.provided_by_user? .append-bottom-20 %label.append-bottom-10 = s_('ClusterIntegration|Google Kubernetes Engine') diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml index 160c5f009a7..a5de67be96b 100644 --- a/app/views/clusters/clusters/_banner.html.haml +++ b/app/views/clusters/clusters/_banner.html.haml @@ -5,5 +5,17 @@ .hidden.js-cluster-creating.bs-callout.bs-callout-info{ role: 'alert' } = s_('ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine...') +.hidden.row.js-cluster-api-unreachable.bs-callout.bs-callout-warning{ role: 'alert' } + .col-11 + = s_('ClusterIntegration|Your cluster API is unreachable. Please ensure your API URL is correct.') + .col-1.p-0 + %button.js-close-banner.close.cluster-application-banner-close.h-100.m-0= "×" + +.hidden.js-cluster-authentication-failure.row.js-cluster-api-unreachable.bs-callout.bs-callout-warning{ role: 'alert' } + .col-11 + = s_('ClusterIntegration|There was a problem authenticating with your cluster. Please ensure your CA Certificate and Token are valid.') + .col-1.p-0 + %button.js-close-banner.close.cluster-application-banner-close.h-100.m-0= "×" + .hidden.js-cluster-success.bs-callout.bs-callout-success{ role: 'alert' } = s_("ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details") diff --git a/app/views/clusters/clusters/_form.html.haml b/app/views/clusters/clusters/_form.html.haml index 9fb91a39387..455322b2089 100644 --- a/app/views/clusters/clusters/_form.html.haml +++ b/app/views/clusters/clusters/_form.html.haml @@ -33,11 +33,11 @@ - auto_devops_url = help_page_path('topics/autodevops/index') - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url } = s_('ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{auto_devops_start}Auto DevOps%{auto_devops_end}. The domain should have a wildcard DNS configured matching the domain.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe } - - if @cluster.application_ingress_external_ip.present? + %span{ :class => ["js-ingress-domain-help-text", ("hide" unless @cluster.application_ingress_external_ip.present?)] } = s_('ClusterIntegration|Alternatively') - %code #{@cluster.application_ingress_external_ip}.nip.io + %code{ :class => "js-ingress-domain-snippet" } #{@cluster.application_ingress_external_ip}.nip.io = s_('ClusterIntegration| can be used instead of a custom domain.') - - custom_domain_url = help_page_path('user/project/clusters/index', anchor: 'pointing-your-dns-at-the-cluster-ip') + - custom_domain_url = help_page_path('user/project/clusters/index', anchor: 'pointing-your-dns-at-the-external-endpoint') - custom_domain_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: custom_domain_url } = s_('ClusterIntegration| %{custom_domain_start}More information%{custom_domain_end}.').html_safe % { custom_domain_start: custom_domain_start, custom_domain_end: '</a>'.html_safe } diff --git a/app/views/clusters/clusters/_sidebar.html.haml b/app/views/clusters/clusters/_sidebar.html.haml index 6e4415c21a9..60ccad5b943 100644 --- a/app/views/clusters/clusters/_sidebar.html.haml +++ b/app/views/clusters/clusters/_sidebar.html.haml @@ -4,3 +4,5 @@ = clusterable.sidebar_text %p = clusterable.learn_more_link + += render_if_exists 'clusters/multiple_clusters_message' diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml index 8ed4666e79a..70e2eaeaf3b 100644 --- a/app/views/clusters/clusters/gcp/_form.html.haml +++ b/app/views/clusters/clusters/gcp/_form.html.haml @@ -7,23 +7,27 @@ - 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} + - 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 } %p= link_to('Select a different Google account', @authorize_url) -= form_for @gcp_cluster, html: { class: 'js-gke-cluster-creation prepend-top-20', data: { token: token_in_session } }, url: clusterable.create_gcp_clusters_path, as: :cluster do |field| - = form_errors(@gcp_cluster) - .form-group - = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold' - = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name') - .form-group - = field.label :environment_scope, s_('ClusterIntegration|Environment scope'), class: 'label-bold' - = field.text_field :environment_scope, class: 'form-control', readonly: !has_multiple_clusters?, placeholder: s_('ClusterIntegration|Environment scope') += bootstrap_form_for @gcp_cluster, html: { class: 'gl-show-field-errors js-gke-cluster-creation prepend-top-20', + data: { token: token_in_session } }, url: clusterable.create_gcp_clusters_path, as: :cluster do |field| + = field.text_field :name, required: true, title: s_('ClusterIntegration|Cluster name is required.'), + label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold' + - if has_multiple_clusters? + = field.form_group :environment_scope, label: { text: s_('ClusterIntegration|Environment scope'), + class: 'label-bold' } do + = field.text_field :environment_scope, required: true, class: 'form-control', + title: 'Environment scope is required.', wrapper: false + .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.") = field.fields_for :provider_gcp, @gcp_cluster.provider_gcp do |provider_gcp_field| .form-group - = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project'), class: 'label-bold' + = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project'), + class: 'label-bold' .js-gcp-project-id-dropdown-entry-point{ data: { docsUrl: 'https://console.cloud.google.com/home/dashboard' } } = provider_gcp_field.hidden_field :gcp_project_id .dropdown @@ -45,9 +49,9 @@ %p.form-text.text-muted = s_('ClusterIntegration|Learn more about %{help_link_start}zones%{help_link_end}.').html_safe % { help_link_start: help_link_start % { url: zones_link_url }, help_link_end: help_link_end } - .form-group - = provider_gcp_field.label :num_nodes, s_('ClusterIntegration|Number of nodes'), class: 'label-bold' - = provider_gcp_field.text_field :num_nodes, class: 'form-control', placeholder: '3' + = provider_gcp_field.number_field :num_nodes, required: true, placeholder: '3', + title: s_('ClusterIntegration|Number of nodes must be a numerical value.'), + label: s_('ClusterIntegration|Number of nodes'), label_class: 'label-bold' .form-group = provider_gcp_field.label :machine_type, s_('ClusterIntegration|Machine type'), class: 'label-bold' @@ -62,13 +66,21 @@ = 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 - .form-check - = provider_gcp_field.check_box :legacy_abac, { class: 'form-check-input' }, false, true - = provider_gcp_field.label :legacy_abac, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' - .form-text.text-muted - = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') - = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') - = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'role-based-access-control-rbac-core-only'), target: '_blank' + = provider_gcp_field.check_box :legacy_abac, { label: s_('ClusterIntegration|RBAC-enabled cluster'), + label_class: 'label-bold' }, false, true + .form-text.text-muted + = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') + = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') + = link_to _('More information'), help_page_path('user/project/clusters/index.md', + anchor: 'role-based-access-control-rbac-core-only'), target: '_blank' - .form-group - = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true + .form-group + = field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'), + label_class: 'label-bold' } + .form-text.text-muted + = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.') + = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank' + + .form-group + = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), + class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 1ef76ef801e..4dfbb310142 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -4,7 +4,7 @@ - page_title _('Kubernetes Cluster') - manage_prometheus_path = edit_project_service_path(@cluster.project, 'prometheus') if @project -- expanded = Rails.env.test? +- expanded = expanded_by_default? - status_path = clusterable.cluster_status_cluster_path(@cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster) .edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path, @@ -15,15 +15,17 @@ install_runner_path: clusterable.install_applications_cluster_path(@cluster, :runner), install_jupyter_path: clusterable.install_applications_cluster_path(@cluster, :jupyter), install_knative_path: clusterable.install_applications_cluster_path(@cluster, :knative), + update_knative_path: clusterable.update_applications_cluster_path(@cluster, :knative), toggle_status: @cluster.enabled? ? 'true': 'false', - has_rbac: @cluster.platform_kubernetes_rbac? ? 'true': 'false', + has_rbac: has_rbac_enabled?(@cluster) ? 'true': 'false', cluster_type: @cluster.cluster_type, cluster_status: @cluster.status_name, cluster_status_reason: @cluster.status_reason, 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-ip-address'), - ingress_dns_help_path: help_page_path('topics/autodevops/quick_start_guide.md', anchor: 'point-dns-at-cluster-ip'), - manage_prometheus_path: manage_prometheus_path } } + ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-endpoint'), + ingress_dns_help_path: help_page_path('user/project/clusters/index.md', anchor: 'manually-determining-the-external-endpoint'), + manage_prometheus_path: manage_prometheus_path, + cluster_id: @cluster.id } } .js-cluster-application-notice .flash-container @@ -33,6 +35,8 @@ = render 'banner' = render 'form' + = render_if_exists 'projects/clusters/prometheus_graphs' + .cluster-applications-table#js-cluster-applications %section.settings#js-cluster-details{ class: ('expanded' if expanded) } @@ -50,5 +54,5 @@ %button.btn.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p= s_("ClusterIntegration|Advanced options on this Kubernetes cluster's integration") - .settings-content + .settings-content#advanced-settings-section = render 'advanced_settings' diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml index 9793c77fc2b..f2fc5ac93fb 100644 --- a/app/views/clusters/clusters/user/_form.html.haml +++ b/app/views/clusters/clusters/user/_form.html.haml @@ -1,39 +1,55 @@ -= form_for @user_cluster, url: clusterable.create_user_clusters_path, as: :cluster do |field| - = form_errors(@user_cluster) - .form-group - = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold' - = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name') +- more_info_link = link_to _('More information'), help_page_path('user/project/clusters/index.md', + anchor: 'adding-an-existing-kubernetes-cluster'), target: '_blank' +- rbac_help_link = link_to _('More information'), help_page_path('user/project/clusters/index.md', + anchor: 'role-based-access-control-rbac-core-only'), target: '_blank' + +- api_url_help_text = s_('ClusterIntegration|The URL used to access the Kubernetes API.') +- ca_cert_help_text = s_('ClusterIntegration|The Kubernetes certificate used to authenticate to the cluster.') +- token_help_text = s_('ClusterIntegration|A service token scoped to %{code}kube-system%{end_code} with %{code}cluster-admin%{end_code} privileges.').html_safe % { code: '<code>'.html_safe, end_code: '</code>'.html_safe } +- rbac_help_text = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') + ' ' +- rbac_help_text << s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') + += bootstrap_form_for @user_cluster, html: { class: 'gl-show-field-errors' }, + url: clusterable.create_user_clusters_path, as: :cluster do |field| + = field.text_field :name, required: true, title: s_('ClusterIntegration|Cluster name is required.'), + label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold' - if has_multiple_clusters? - .form-group - = field.label :environment_scope, s_('ClusterIntegration|Environment scope'), class: 'label-bold' - = field.text_field :environment_scope, class: 'form-control', placeholder: s_('ClusterIntegration|Environment scope') + = field.text_field :environment_scope, required: true, title: 'Environment scope is required.', + label: s_('ClusterIntegration|Environment scope'), label_class: 'label-bold', + help: s_("ClusterIntegration|Choose which of your environments will use this cluster.") = field.fields_for :platform_kubernetes, @user_cluster.platform_kubernetes do |platform_kubernetes_field| - .form-group - = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL'), class: 'label-bold' - = platform_kubernetes_field.text_field :api_url, class: 'form-control', placeholder: s_('ClusterIntegration|API URL') + = platform_kubernetes_field.url_field :api_url, required: true, + title: s_('ClusterIntegration|API URL should be a valid http/https url.'), + label: s_('ClusterIntegration|API URL'), label_class: 'label-bold', + help: '%{help_text} %{help_link}'.html_safe % { help_text: api_url_help_text, help_link: more_info_link } - .form-group - = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate'), class: 'label-bold' - = platform_kubernetes_field.text_area :ca_cert, class: 'form-control', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)') + = platform_kubernetes_field.text_area :ca_cert, + placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), + label: s_('ClusterIntegration|CA Certificate'), label_class: 'label-bold', + help: '%{help_text} %{help_link}'.html_safe % { help_text: ca_cert_help_text, help_link: more_info_link } - .form-group - = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token'), class: 'label-bold' - = platform_kubernetes_field.text_field :token, class: 'form-control', placeholder: s_('ClusterIntegration|Service token'), autocomplete: 'off' + = platform_kubernetes_field.text_field :token, required: true, + title: s_('ClusterIntegration|Service token is required.'), label: s_('ClusterIntegration|Service Token'), + autocomplete: 'off', label_class: 'label-bold', + help: '%{help_text} %{help_link}'.html_safe % { help_text: token_help_text, help_link: more_info_link } - if @user_cluster.allow_user_defined_namespace? - .form-group - = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)'), class: 'label-bold' - = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace') - - .form-group - .form-check - = platform_kubernetes_field.check_box :authorization_type, { class: 'form-check-input qa-rbac-checkbox' }, 'rbac', 'abac' - = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' - .form-text.text-muted - = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') - = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') - = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'role-based-access-control-rbac-core-only'), target: '_blank' - - .form-group - = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), class: 'btn btn-success' + = platform_kubernetes_field.text_field :namespace, + label: s_('ClusterIntegration|Project namespace (optional, unique)'), label_class: 'label-bold' + + = platform_kubernetes_field.form_group :authorization_type, + { help: '%{help_text} %{help_link}'.html_safe % { help_text: rbac_help_text, help_link: rbac_help_link } } do + = platform_kubernetes_field.check_box :authorization_type, + { class: 'qa-rbac-checkbox', label: s_('ClusterIntegration|RBAC-enabled cluster'), + label_class: 'label-bold', inline: true }, 'rbac', 'abac' + + .form-group + = field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'), + label_class: 'label-bold' } + .form-text.text-muted + = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.') + = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank' + + .form-group + = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), class: 'btn btn-success' diff --git a/app/views/clusters/platforms/kubernetes/_form.html.haml b/app/views/clusters/platforms/kubernetes/_form.html.haml index 4a452b83112..c1727cf9079 100644 --- a/app/views/clusters/platforms/kubernetes/_form.html.haml +++ b/app/views/clusters/platforms/kubernetes/_form.html.haml @@ -1,58 +1,58 @@ -= form_for cluster, url: update_cluster_url_path, as: :cluster do |field| - = form_errors(cluster) - - .form-group - - if cluster.managed? - %label.append-bottom-10{ for: 'cluster-name' } - = s_('ClusterIntegration|Kubernetes cluster name') - .input-group - %input.form-control.cluster-name.js-select-on-focus{ value: cluster.name, readonly: true } - %span.input-group-append - = clipboard_button(text: cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), class: 'input-group-text btn-default') - - else - = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold' - .input-group - = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name') += bootstrap_form_for cluster, url: update_cluster_url_path, html: { class: 'gl-show-field-errors' }, + as: :cluster do |field| + - copy_name_btn = clipboard_button(text: cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), + class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields? + = field.text_field :name, class: 'js-select-on-focus cluster-name', required: true, + title: s_('ClusterIntegration|Cluster name is required.'), + readonly: cluster.read_only_kubernetes_platform_fields?, + label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold', + input_group_class: 'gl-field-error-anchor', append: copy_name_btn = field.fields_for :platform_kubernetes, platform do |platform_field| - .form-group - = platform_field.label :api_url, s_('ClusterIntegration|API URL') - .input-group - = platform_field.text_field :api_url, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|API URL'), readonly: cluster.managed? - - if cluster.managed? - %span.input-group-append - = clipboard_button(text: platform.api_url, title: s_('ClusterIntegration|Copy API URL'), class: 'input-group-text btn-default') + - copy_api_url = clipboard_button(text: platform.api_url, title: s_('ClusterIntegration|Copy API URL'), + class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields? + = platform_field.text_field :api_url, class: 'js-select-on-focus', required: true, + title: s_('ClusterIntegration|API URL should be a valid http/https url.'), + readonly: cluster.read_only_kubernetes_platform_fields?, + label: s_('ClusterIntegration|API URL'), label_class: 'label-bold', + input_group_class: 'gl-field-error-anchor', append: copy_api_url - .form-group - = platform_field.label :ca_cert, s_('ClusterIntegration|CA Certificate') - .input-group - = platform_field.text_area :ca_cert, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), readonly: cluster.managed? - - if cluster.managed? - %span.input-group-append.clipboard-addon - = clipboard_button(text: platform.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), class: 'input-group-text btn-blank') + - copy_ca_cert_btn = clipboard_button(text: platform.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), + class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields? + = platform_field.text_area :ca_cert, class: 'js-select-on-focus', rows: '5', + readonly: cluster.read_only_kubernetes_platform_fields?, + placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), + label: s_('ClusterIntegration|CA Certificate'), label_class: 'label-bold', + input_group_class: 'gl-field-error-anchor', append: copy_ca_cert_btn - .form-group - = platform_field.label :token, s_('ClusterIntegration|Token') - .input-group - = platform_field.text_field :token, class: 'form-control js-cluster-token js-select-on-focus', type: 'password', placeholder: s_('ClusterIntegration|Token'), readonly: cluster.managed? - %span.input-group-append - %button.btn.btn-default.input-group-text.js-show-cluster-token{ type: 'button' } - = s_('ClusterIntegration|Show') - - if cluster.managed? - = clipboard_button(text: platform.token, title: s_('ClusterIntegration|Copy Token'), class: 'btn-default') + - show_token_btn = (platform_field.button s_('ClusterIntegration|Show'), + type: 'button', class: 'js-show-cluster-token btn btn-default') + - copy_token_btn = clipboard_button(text: platform.token, title: s_('ClusterIntegration|Copy Service Token'), + class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields? + + = platform_field.text_field :token, type: 'password', class: 'js-select-on-focus js-cluster-token', + required: true, title: s_('ClusterIntegration|Service token is required.'), + readonly: cluster.read_only_kubernetes_platform_fields?, + label: s_('ClusterIntegration|Service Token'), label_class: 'label-bold', + input_group_class: 'gl-field-error-anchor', append: show_token_btn + copy_token_btn - if cluster.allow_user_defined_namespace? - .form-group - = platform_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)') - = platform_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace') + = platform_field.text_field :namespace, label: s_('ClusterIntegration|Project namespace (optional, unique)'), + label_class: 'label-bold' + + = platform_field.form_group :authorization_type do + = platform_field.check_box :authorization_type, { disabled: true, label: s_('ClusterIntegration|RBAC-enabled cluster'), + label_class: 'label-bold', inline: true }, 'rbac', 'abac' + .form-text.text-muted + = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') + = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') .form-group - .form-check - = platform_field.check_box :authorization_type, { class: 'form-check-input', disabled: true }, 'rbac', 'abac' - = platform_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' - .form-text.text-muted - = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') - = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') + = field.check_box :managed, { disabled: true, label: s_('ClusterIntegration|GitLab-managed cluster'), + label_class: 'label-bold' } + .form-text.text-muted + = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.') + = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank' .form-group = field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success' diff --git a/app/views/dashboard/_activity_head.html.haml b/app/views/dashboard/_activity_head.html.haml index ec1a3fef435..3f39555a1d4 100644 --- a/app/views/dashboard/_activity_head.html.haml +++ b/app/views/dashboard/_activity_head.html.haml @@ -1,4 +1,4 @@ -.page-title-holder +.page-title-holder.d-flex.align-items-center %h1.page-title= _('Activity') .top-area diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml index 8ab5dc37f34..b2fadb77418 100644 --- a/app/views/dashboard/_groups_head.html.haml +++ b/app/views/dashboard/_groups_head.html.haml @@ -1,4 +1,4 @@ -.page-title-holder +.page-title-holder.d-flex.align-items-center %h1.page-title= _('Groups') - if current_user.can_create_group? diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml index ae67192cbcd..97a446dbeec 100644 --- a/app/views/dashboard/_projects_head.html.haml +++ b/app/views/dashboard/_projects_head.html.haml @@ -1,29 +1,35 @@ +- project_tab_filter = local_assigns.fetch(:project_tab_filter, "") +- feature_project_list_filter_bar = Feature.enabled?(:project_list_filter_bar) + = content_for :flash_message do = render 'shared/project_limit' -.page-title-holder +.page-title-holder.d-flex.align-items-center %h1.page-title= _('Projects') - if current_user.can_create_project? .page-title-controls - = link_to "New project", new_project_path, class: "btn btn-success" + = link_to _("New project"), new_project_path, class: "btn btn-success" .top-area.scrolling-tabs-container.inner-page-scroll-tabs .fade-left= icon('angle-left') .fade-right= icon('angle-right') - %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs + %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs{ class: ('border-0' if feature_project_list_filter_bar) } = nav_link(page: [dashboard_projects_path, root_path]) do = link_to dashboard_projects_path, class: 'shortcuts-activity', data: {placement: 'right'} do - Your projects + = _("Your projects") %span.badge.badge-pill= limited_counter_with_delimiter(@total_user_projects_count) = nav_link(page: starred_dashboard_projects_path) do = link_to starred_dashboard_projects_path, data: {placement: 'right'} do - Starred projects + = _("Starred projects") %span.badge.badge-pill= limited_counter_with_delimiter(@total_starred_projects_count) = nav_link(page: [explore_root_path, trending_explore_projects_path, starred_explore_projects_path, explore_projects_path]) do = link_to explore_root_path, data: {placement: 'right'} do - Explore projects - - .nav-controls - = render 'shared/projects/search_form' - = render 'shared/projects/dropdown' + = _("Explore projects") + - unless feature_project_list_filter_bar + .nav-controls + = render 'shared/projects/search_form' + = render 'shared/projects/dropdown' +- if feature_project_list_filter_bar + .project-filters + = render 'shared/projects/search_bar', project_tab_filter: project_tab_filter diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml index a05d0190efb..34aca40d0d1 100644 --- a/app/views/dashboard/_snippets_head.html.haml +++ b/app/views/dashboard/_snippets_head.html.haml @@ -1,4 +1,4 @@ -.page-title-holder +.page-title-holder.d-flex.align-items-center %h1.page-title= _('Snippets') - if current_user && current_user.snippets.any? || @snippets.any? diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml index 4dbda5c754b..b1c192d7bad 100644 --- a/app/views/dashboard/activity.html.haml +++ b/app/views/dashboard/activity.html.haml @@ -5,7 +5,7 @@ = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") -= render_if_exists "shared/gold_trial_callout" += render_dashboard_gold_trial(current_user) - page_title "Activity" - header_title "Activity", activity_dashboard_path diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml index db856ef7d7b..2f9dbf87d95 100644 --- a/app/views/dashboard/groups/_groups.html.haml +++ b/app/views/dashboard/groups/_groups.html.haml @@ -1,4 +1,4 @@ .js-groups-list-holder #js-groups-tree{ data: { hide_projects: 'true', endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } } - .loading-container.text-center - = icon('spinner spin 2x', class: 'loading-animation prepend-top-20') + .loading-container.text-center.prepend-top-20 + .spinner.spinner-md diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml index 19b06ba5cdd..d1d8d970b59 100644 --- a/app/views/dashboard/groups/index.html.haml +++ b/app/views/dashboard/groups/index.html.haml @@ -2,7 +2,7 @@ - page_title "Groups" - header_title "Groups", dashboard_groups_path -= render_if_exists "shared/gold_trial_callout" += render_dashboard_gold_trial(current_user) = render 'dashboard/groups_head' - if params[:filter].blank? && @groups.empty? diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml index afd46412fab..b3ee5034204 100644 --- a/app/views/dashboard/issues.html.haml +++ b/app/views/dashboard/issues.html.haml @@ -4,9 +4,9 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{current_user.name} issues") -= render_if_exists "shared/gold_trial_callout" += render_dashboard_gold_trial(current_user) -.page-title-holder +.page-title-holder.d-flex.align-items-center %h1.page-title= _('Issues') - if current_user diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml index 3e5f13b92e3..3956f03a3c8 100644 --- a/app/views/dashboard/merge_requests.html.haml +++ b/app/views/dashboard/merge_requests.html.haml @@ -2,9 +2,9 @@ - page_title _("Merge Requests") - @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username) -= render_if_exists "shared/gold_trial_callout" += render_dashboard_gold_trial(current_user) -.page-title-holder +.page-title-holder.d-flex.align-items-center %h1.page-title= _('Merge Requests') - if current_user diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml index 13822d36f15..37ba2143eba 100644 --- a/app/views/dashboard/milestones/index.html.haml +++ b/app/views/dashboard/milestones/index.html.haml @@ -2,7 +2,7 @@ - page_title 'Milestones' - header_title 'Milestones', dashboard_milestones_path -.page-title-holder +.page-title-holder.d-flex.align-items-center %h1.page-title= _('Milestones') - if current_user diff --git a/app/views/dashboard/projects/_nav.html.haml b/app/views/dashboard/projects/_nav.html.haml index da3cf5807b0..f9b61bf1f3e 100644 --- a/app/views/dashboard/projects/_nav.html.haml +++ b/app/views/dashboard/projects/_nav.html.haml @@ -1,6 +1,21 @@ -.nav-block - %ul.nav-links.mobile-separator.nav.nav-tabs - = nav_link(html_options: { class: ("active" unless params[:personal].present?) }) do - = link_to s_('DashboardProjects|All'), dashboard_projects_path - = nav_link(html_options: { class: ("active" if params[:personal].present?) }) do - = link_to s_('DashboardProjects|Personal'), filter_projects_path(personal: true) +- inactive_class = 'btn p-2' +- active_class = 'btn p-2 active' +- project_tab_filter = local_assigns.fetch(:project_tab_filter, "") +- is_explore_trending = project_tab_filter == :explore_trending +- feature_project_list_filter_bar = Feature.enabled?(:project_list_filter_bar) + +.nav-block{ class: ("w-100" if feature_project_list_filter_bar) } + - if feature_project_list_filter_bar + .btn-group.button-filter-group.d-flex.m-0.p-0 + - if project_tab_filter == :explore || is_explore_trending + = link_to s_('DashboardProjects|Trending'), trending_explore_projects_path, class: is_explore_trending ? active_class : inactive_class + = link_to s_('DashboardProjects|All'), explore_projects_path, class: is_explore_trending ? inactive_class : active_class + - else + = link_to s_('DashboardProjects|All'), dashboard_projects_path, class: params[:personal].present? ? inactive_class : active_class + = link_to s_('DashboardProjects|Personal'), filter_projects_path(personal: true), class: params[:personal].present? ? active_class : inactive_class + - else + %ul.nav-links.mobile-separator.nav.nav-tabs + = nav_link(html_options: { class: ("active" unless params[:personal].present?) }) do + = link_to s_('DashboardProjects|All'), dashboard_projects_path + = nav_link(html_options: { class: ("active" if params[:personal].present?) }) do + = link_to s_('DashboardProjects|Personal'), filter_projects_path(personal: true) diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml index 18a82feb189..8933c5d7227 100644 --- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml +++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml @@ -1,4 +1,4 @@ -.blank-state-parent-container +.blank-state-parent-container{ class: ('has-start-trial-container' if has_start_trial?) } .section-container.section-welcome{ class: "#{ 'section-admin-welcome' if current_user.admin? }" } .container.section-body .row @@ -7,7 +7,12 @@ Welcome to GitLab %p.blank-state-text Code, test, and deploy together - - if current_user.admin? - = render "blank_state_admin_welcome" - - else - = render "blank_state_welcome" + .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" diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index 446b4715b2d..0298f539b4b 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -4,7 +4,7 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") -= render_if_exists "shared/gold_trial_callout" += render_dashboard_gold_trial(current_user) - page_title "Projects" - header_title "Projects", dashboard_projects_path @@ -13,7 +13,7 @@ = render "projects/last_push" - if show_projects?(@projects, params) = render 'dashboard/projects_head' - = render 'nav' + = render 'nav' unless Feature.enabled?(:project_list_filter_bar) = render 'projects' - else = render "zero_authorized_projects" diff --git a/app/views/dashboard/projects/starred.html.haml b/app/views/dashboard/projects/starred.html.haml index 3a45f6df017..0fcc6894b68 100644 --- a/app/views/dashboard/projects/starred.html.haml +++ b/app/views/dashboard/projects/starred.html.haml @@ -4,11 +4,11 @@ - page_title _("Starred Projects") - header_title _("Projects"), dashboard_projects_path -= render_if_exists "shared/gold_trial_callout" += render_dashboard_gold_trial(current_user) %div{ class: container_class } = render "projects/last_push" - = render 'dashboard/projects_head' + = render 'dashboard/projects_head', project_tab_filter: :starred - if params[:filter_projects] || any_projects?(@projects) = render 'projects' diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index efe1fb99efc..db6e40a6fd0 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -34,7 +34,7 @@ = todo_due_date(todo) .todo-body - .todo-note + .todo-note.break-word .md = first_line_in_markdown(todo, :body, 150, project: todo.project) diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 47729321961..8212fb8bb33 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -2,9 +2,9 @@ - page_title "Todos" - header_title "Todos", dashboard_todos_path -= render_if_exists "shared/gold_trial_callout" += render_dashboard_gold_trial(current_user) -.page-title-holder +.page-title-holder.d-flex.align-items-center %h1.page-title= _('Todos') - if current_user.todos.any? @@ -34,7 +34,7 @@ = icon('spinner spin') .todos-filters - .row-content-block.second-block + .issues-details-filters.row-content-block.second-block = form_tag todos_filter_path(without: [:project_id, :author_id, :type, :action_id]), method: :get, class: 'filter-form d-sm-flex' do .filter-categories.flex-fill .filter-item.inline diff --git a/app/views/devise/confirmations/new.html.haml b/app/views/devise/confirmations/new.html.haml index 73e70dc63e5..f8aa3cf98dc 100644 --- a/app/views/devise/confirmations/new.html.haml +++ b/app/views/devise/confirmations/new.html.haml @@ -3,7 +3,7 @@ .login-body = form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f| .devise-errors - = devise_error_messages! + = render "devise/shared/error_messages", resource: resource .form-group = f.label :email = f.email_field :email, class: "form-control", required: true, title: 'Please provide a valid email address.' diff --git a/app/views/devise/mailer/email_changed.html.haml b/app/views/devise/mailer/email_changed.html.haml index 5398430fdfd..3689e9c5f61 100644 --- a/app/views/devise/mailer/email_changed.html.haml +++ b/app/views/devise/mailer/email_changed.html.haml @@ -2,7 +2,7 @@ - if @resource.try(:unconfirmed_email?) %p - We're contacting you to notify you that your email is being changed to #{@resource.reload.unconfirmed_email}. + We're contacting you to notify you that your email is being changed to #{@resource.reset.unconfirmed_email}. - else %p We're contacting you to notify you that your email has been changed to #{@resource.email}. diff --git a/app/views/devise/mailer/email_changed.text.erb b/app/views/devise/mailer/email_changed.text.erb index 18137389e7b..69155db7246 100644 --- a/app/views/devise/mailer/email_changed.text.erb +++ b/app/views/devise/mailer/email_changed.text.erb @@ -1,7 +1,7 @@ Hello, <%= @resource.name %>! <% if @resource.try(:unconfirmed_email?) %> -We're contacting you to notify you that your email is being changed to <%= @resource.reload.unconfirmed_email %>. +We're contacting you to notify you that your email is being changed to <%= @resource.reset.unconfirmed_email %>. <% else %> We're contacting you to notify you that your email has been changed to <%= @resource.email %>. <% end %> diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml index dd1edb5fdc9..09ea7716a47 100644 --- a/app/views/devise/passwords/edit.html.haml +++ b/app/views/devise/passwords/edit.html.haml @@ -3,7 +3,7 @@ .login-body = form_for(resource, as: resource_name, url: password_path(:user), html: { method: :put, class: 'gl-show-field-errors' }) do |f| .devise-errors - = devise_error_messages! + = render "devise/shared/error_messages", resource: resource = f.hidden_field :reset_password_token .form-group = f.label 'New password', for: "user_password" diff --git a/app/views/devise/passwords/new.html.haml b/app/views/devise/passwords/new.html.haml index 99ce13adf74..fe999851605 100644 --- a/app/views/devise/passwords/new.html.haml +++ b/app/views/devise/passwords/new.html.haml @@ -3,7 +3,7 @@ .login-body = form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f| .devise-errors - = devise_error_messages! + = render "devise/shared/error_messages", resource: resource .form-group = f.label :email = f.email_field :email, class: "form-control", required: true, value: params[:user_email], autofocus: true, title: 'Please provide a valid email address.' diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index f379e71ae5b..5a1388ac7a1 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -1,7 +1,7 @@ <h2>Edit <%= resource_name.to_s.humanize %></h2> <%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %> - <%= devise_error_messages! %> + <%= render "devise/shared/error_messages", resource: resource %> <div><%= f.label :email %><br /> <%= f.email_field :email %></div> diff --git a/app/views/devise/shared/_signin_box.html.haml b/app/views/devise/shared/_signin_box.html.haml index ec968e435cd..f8f36a8bfff 100644 --- a/app/views/devise/shared/_signin_box.html.haml +++ b/app/views/devise/shared/_signin_box.html.haml @@ -3,17 +3,21 @@ .login-box.tab-pane{ id: "crowd", role: 'tabpanel', class: active_when(form_based_auth_provider_has_active_class?(:crowd)) } .login-body = render 'devise/sessions/new_crowd' + + = render_if_exists 'devise/sessions/new_kerberos_tab' + - @ldap_servers.each_with_index do |server, i| .login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i.zero? && form_based_auth_provider_has_active_class?(:ldapmain)) } .login-body = render 'devise/sessions/new_ldap', server: server + + = render_if_exists 'devise/sessions/new_smartcard' + - if password_authentication_enabled_for_web? .login-box.tab-pane{ id: 'login-pane', role: 'tabpanel' } .login-body = render 'devise/sessions/new_base' - = render_if_exists 'devise/sessions/new_smartcard' - - elsif password_authentication_enabled_for_web? .login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' } .login-body diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 9c7ca6ebbd4..5eba819172b 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -1,27 +1,29 @@ +- max_name_length = 128 +- max_username_length = 255 #register-pane.tab-pane.login-box{ role: 'tabpanel' } .login-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 - = devise_error_messages! + = render "devise/shared/error_messages", resource: resource .name.form-group - = f.label :name, 'Full name', class: 'label-bold' - = f.text_field :name, class: "form-control top qa-new-user-name js-block-emoji", required: true, title: _("This field is required.") + = f.label :name, _('Full name'), class: 'label-bold' + = f.text_field :name, class: "form-control top qa-new-user-name js-block-emoji js-validate-length", :data => { :max_length => max_name_length, :max_length_message => s_("SignUp|Name is too long (maximum is %{max_length} characters).") % { max_length: max_name_length } }, required: true, title: _("This field is required.") .username.form-group = f.label :username, class: 'label-bold' - = f.text_field :username, class: "form-control middle qa-new-user-username js-block-emoji", pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.") - %p.validation-error.hide Username is already taken. - %p.validation-success.hide Username is available. - %p.validation-pending.hide Checking username availability... + = f.text_field :username, class: "form-control middle qa-new-user-username js-block-emoji js-validate-length", :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 } }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.") + %p.validation-error.field-validation.hide= _('Username is already taken.') + %p.validation-success.field-validation.hide= _('Username is available.') + %p.validation-pending.field-validation.hide= _('Checking username availability...') .form-group = f.label :email, class: 'label-bold' - = f.email_field :email, class: "form-control middle qa-new-user-email", required: true, title: "Please provide a valid email address." + = f.email_field :email, class: "form-control middle qa-new-user-email", required: true, title: _("Please provide a valid email address.") .form-group = f.label :email_confirmation, class: 'label-bold' - = f.email_field :email_confirmation, class: "form-control middle qa-new-user-email-confirmation", required: true, title: "Please retype the email address." + = f.email_field :email_confirmation, class: "form-control middle qa-new-user-email-confirmation", required: true, title: _("Please retype the email address.") .form-group.append-bottom-20#password-strength = f.label :password, class: 'label-bold' - = f.password_field :password, class: "form-control bottom qa-new-user-password", required: true, pattern: ".{#{@minimum_password_length},}", title: "Minimum length is #{@minimum_password_length} characters." - %p.gl-field-hint.text-secondary Minimum length is #{@minimum_password_length} characters + = f.password_field :password, class: "form-control bottom qa-new-user-password", required: true, pattern: ".{#{@minimum_password_length},}", title: _("Minimum length is %{minimum_password_length} characters.") % { minimum_password_length: @minimum_password_length } + %p.gl-field-hint.text-secondary= _('Minimum length is %{minimum_password_length} characters') % { minimum_password_length: @minimum_password_length } - if Gitlab::CurrentSettings.current_application_settings.enforce_terms? .form-group = check_box_tag :terms_opt_in, '1', false, required: true, class: 'qa-new-user-accept-terms' @@ -29,8 +31,9 @@ - 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 %div - if Gitlab::Recaptcha.enabled? = recaptcha_tags .submit-container - = f.submit "Register", class: "btn-register btn qa-new-user-register-button" + = f.submit _("Register"), class: "btn-register btn qa-new-user-register-button" diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml index aee05b6c81c..b1a9470cf1c 100644 --- a/app/views/devise/shared/_tabs_ldap.html.haml +++ b/app/views/devise/shared/_tabs_ldap.html.haml @@ -2,6 +2,7 @@ - if crowd_enabled? %li.nav-item = link_to "Crowd", "#crowd", class: "nav-link #{active_when(form_based_auth_provider_has_active_class?(:crowd))}", 'data-toggle' => 'tab' + = render_if_exists "devise/shared/kerberos_tab" - @ldap_servers.each_with_index do |server, i| %li.nav-item = link_to server['label'], "##{server['provider_name']}", class: "nav-link #{active_when(i.zero? && form_based_auth_provider_has_active_class?(:ldapmain))} qa-ldap-tab", 'data-toggle' => 'tab' diff --git a/app/views/devise/unlocks/new.html.haml b/app/views/devise/unlocks/new.html.haml index b2f48a4e0bf..1167f1718d6 100644 --- a/app/views/devise/unlocks/new.html.haml +++ b/app/views/devise/unlocks/new.html.haml @@ -3,7 +3,7 @@ .login-body = form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f| .devise-errors - = devise_error_messages! + = render "devise/shared/error_messages", resource: resource .form-group.append-bottom-20 = f.label :email = f.email_field :email, class: 'form-control', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', title: 'Please provide a valid email address.' diff --git a/app/views/discussions/_diff_discussion.html.haml b/app/views/discussions/_diff_discussion.html.haml index 6b8dd156874..5a47040874f 100644 --- a/app/views/discussions/_diff_discussion.html.haml +++ b/app/views/discussions/_diff_discussion.html.haml @@ -4,6 +4,6 @@ -# Text diff discussions - expanded = local_assigns.fetch(:expanded, true) %tr.notes_holder{ class: ('hide' unless expanded) } - %td.notes_content{ colspan: 3 } + %td.notes-content{ colspan: 3 } .content{ class: ('hide' unless expanded) } = render partial: "discussions/notes", collection: discussions, as: :discussion, locals: { disable_collapse_class: true } diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index 44c898e0fac..8a3c841de0b 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -11,8 +11,8 @@ = render "projects/diffs/file_header", diff_file: diff_file, url: discussion_path(discussion), show_toggle: false - if diff_file.text? - .diff-content.code.js-syntax-highlight - %table + .diff-content + %table.code.js-syntax-highlight - if expanded - discussions = { discussion.original_line_code => [discussion] } = render partial: "projects/diffs/line", diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index 30b00ca86b3..0a5541c3e82 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -19,20 +19,24 @@ .discussion-reply-holder - if can_create_note? + %a.user-avatar-link.d-none.d-sm-block{ href: user_path(current_user) } + = image_tag avatar_icon_for_user(current_user), alt: current_user.to_reference, class: 'avatar s40' - if discussion.potentially_resolvable? - line_type = local_assigns.fetch(:line_type, nil) - .btn-group.discussion-with-resolve-btn{ role: "group" } - .btn-group{ role: "group" } - = link_to_reply_discussion(discussion, line_type) + .discussion-with-resolve-btn + .btn-group.discussion-with-resolve-btn{ role: "group" } + .btn-group{ role: "group" } + = link_to_reply_discussion(discussion, line_type) - = render "discussions/resolve_all", discussion: discussion + = render "discussions/resolve_all", discussion: discussion - .btn-group.discussion-actions - = render "discussions/new_issue_for_discussion", discussion: discussion, merge_request: discussion.noteable - = render "discussions/jump_to_next", discussion: discussion + .btn-group.discussion-actions + = render "discussions/new_issue_for_discussion", discussion: discussion, merge_request: discussion.noteable + = render "discussions/jump_to_next", discussion: discussion - else - = link_to_reply_discussion(discussion) + .discussion-with-resolve-btn + = link_to_reply_discussion(discussion) - elsif !current_user .disabled-comment.text-center Please diff --git a/app/views/discussions/_parallel_diff_discussion.html.haml b/app/views/discussions/_parallel_diff_discussion.html.haml index 2e621c4082d..03b428714b9 100644 --- a/app/views/discussions/_parallel_diff_discussion.html.haml +++ b/app/views/discussions/_parallel_diff_discussion.html.haml @@ -1,17 +1,17 @@ - expanded = [*discussions_left, *discussions_right].any?(&:expanded?) %tr.notes_holder{ class: ('hide' unless expanded) } - if discussions_left - %td.notes_content.parallel.old{ colspan: 2 } + %td.notes-content.parallel.old{ colspan: 2 } .content{ class: ('hide' unless discussions_left.any?(&:expanded?)) } = render partial: "discussions/notes", collection: discussions_left, as: :discussion, line_type: 'old', locals: { disable_collapse_class: true } - else - %td.notes_content.parallel.old{ colspan: 2 } + %td.notes-content.parallel.old{ colspan: 2 } .content - if discussions_right - %td.notes_content.parallel.new{ colspan: 2 } + %td.notes-content.parallel.new{ colspan: 2 } .content{ class: ('hide' unless discussions_right.any?(&:expanded?)) } = render partial: "discussions/notes", collection: discussions_right, as: :discussion, line_type: 'new', locals: { disable_collapse_class: true } - else - %td.notes_content.parallel.new{ colspan: 2 } + %td.notes-content.parallel.new{ colspan: 2 } .content diff --git a/app/views/doorkeeper/applications/index.html.haml b/app/views/doorkeeper/applications/index.html.haml index 1f5c70a6c6e..5d85d9e431f 100644 --- a/app/views/doorkeeper/applications/index.html.haml +++ b/app/views/doorkeeper/applications/index.html.haml @@ -52,7 +52,7 @@ .oauth-authorized-applications.prepend-top-20.append-bottom-default - if user_oauth_applications? %h5 - = _("Authorized applications (%{size})") % { size: @authorized_tokens.size } + = _("Authorized applications (%{size})") % { size: @authorized_apps.size + @authorized_anonymous_tokens.size } - if @authorized_tokens.any? .table-responsive diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml index cac00f9c854..6750732ab67 100644 --- a/app/views/doorkeeper/applications/show.html.haml +++ b/app/views/doorkeeper/applications/show.html.haml @@ -14,7 +14,7 @@ %td .clipboard-group .input-group - %input.label.label-monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true } + %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") %tr @@ -23,7 +23,7 @@ %td .clipboard-group .input-group - %input.label.label-monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true } + %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") %tr diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml index 2fcb1d1fd2b..222175c818a 100644 --- a/app/views/events/_event.html.haml +++ b/app/views/events/_event.html.haml @@ -3,11 +3,11 @@ .event-item-timestamp #{time_ago_with_tooltip(event.created_at)} - - if event.created_project? + - if event.created_project_action? = render "events/event/created_project", event: event - - elsif event.push? + - elsif event.push_action? = render "events/event/push", event: event - - elsif event.commented? + - elsif event.commented_action? = render "events/event/note", event: event - else = render "events/event/common", event: event diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index 96d6553a2ac..b02fdb4b638 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -11,7 +11,8 @@ = link_to [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip event-target-link append-right-4', title: event.target_title do = event.target.reference_link_text - unless event.milestone? - %span.event-target-title.append-right-4= """.html_safe + event.target.title + """.html_safe + %span.event-target-title.append-right-4{ dir: "auto" } + = """.html_safe + event.target.title + """.html_safe - else %span.event-type.d-inline-block.append-right-4{ class: event.action_name } = event_action_name(event) diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml index 90ed8e41d32..7e2103287f7 100644 --- a/app/views/events/event/_note.html.haml +++ b/app/views/events/event/_note.html.haml @@ -7,7 +7,8 @@ %span.event-type.d-inline-block.append-right-4{ class: event.action_name } = event.action_name = event_note_title_html(event) - %span.event-target-title.append-right-4= """.html_safe + event.target.title + """.html_safe + %span.event-target-title.append-right-4{ dir: "auto" } + = """.html_safe + event.target.title + """.html_safe = render "events/event_scope", event: event diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index 69914fccc48..21c418cb0e4 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -32,7 +32,8 @@ - from_label = from = link_to project_compare_path(project, from: from, to: event.commit_to) do - Compare #{from_label}...#{truncate_sha(event.commit_to)} + %span Compare + %span.commit-sha #{from_label}...#{truncate_sha(event.commit_to)} - if create_mr %span diff --git a/app/views/explore/groups/_groups.html.haml b/app/views/explore/groups/_groups.html.haml index ff57b39e947..a3249275d5e 100644 --- a/app/views/explore/groups/_groups.html.haml +++ b/app/views/explore/groups/_groups.html.haml @@ -1,4 +1,4 @@ .js-groups-list-holder #js-groups-tree{ data: { hide_projects: 'true', endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } } - .loading-container.text-center - = icon('spinner spin 2x', class: 'loading-animation prepend-top-20') + .loading-container.text-center.prepend-top-20 + .spinner.spinner-md diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml index 869be4e8581..fd86d07fc86 100644 --- a/app/views/explore/groups/index.html.haml +++ b/app/views/explore/groups/index.html.haml @@ -2,7 +2,7 @@ - page_title _("Groups") - header_title _("Groups"), dashboard_groups_path -= render_if_exists "shared/gold_trial_callout" += render_dashboard_gold_trial(current_user) - if current_user = render 'dashboard/groups_head' diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml index f518205f14c..d00a3d266d8 100644 --- a/app/views/explore/projects/_filter.html.haml +++ b/app/views/explore/projects/_filter.html.haml @@ -1,8 +1,12 @@ +- has_label = local_assigns.fetch(:has_label, false) +- feature_project_list_filter_bar = Feature.enabled?(:project_list_filter_bar) + - if current_user - .dropdown + .dropdown.js-project-filter-dropdown-wrap{ class: ('d-flex flex-grow-1 flex-shrink-1' if feature_project_list_filter_bar) } %button.dropdown-menu-toggle{ href: '#', "data-toggle" => "dropdown", 'data-display' => 'static' } - = icon('globe', class: 'mt-1') - %span.light.ml-3= _("Visibility:") + - unless has_label + = icon('globe', class: 'mt-1') + %span.light.ml-3= _("Visibility:") - if params[:visibility_level].present? = visibility_level_label(params[:visibility_level].to_i) - else diff --git a/app/views/explore/projects/index.html.haml b/app/views/explore/projects/index.html.haml index d18dec7bd8e..341ad681c7c 100644 --- a/app/views/explore/projects/index.html.haml +++ b/app/views/explore/projects/index.html.haml @@ -2,12 +2,12 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path -= render_if_exists "shared/gold_trial_callout" += render_dashboard_gold_trial(current_user) - if current_user - = render 'dashboard/projects_head' + = render 'dashboard/projects_head', project_tab_filter: :explore - else = render 'explore/head' -= render 'explore/projects/nav' += render 'explore/projects/nav' unless Feature.enabled?(:project_list_filter_bar) && current_user = render 'projects', projects: @projects diff --git a/app/views/explore/projects/starred.html.haml b/app/views/explore/projects/starred.html.haml index d18dec7bd8e..ec92852ddde 100644 --- a/app/views/explore/projects/starred.html.haml +++ b/app/views/explore/projects/starred.html.haml @@ -2,12 +2,12 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path -= render_if_exists "shared/gold_trial_callout" += render_dashboard_gold_trial(current_user) - if current_user - = render 'dashboard/projects_head' + = render 'dashboard/projects_head', project_tab_filter: :starred - else = render 'explore/head' -= render 'explore/projects/nav' += render 'explore/projects/nav' unless Feature.enabled?(:project_list_filter_bar) && current_user = render 'projects', projects: @projects diff --git a/app/views/explore/projects/trending.html.haml b/app/views/explore/projects/trending.html.haml index d18dec7bd8e..ed508fa2506 100644 --- a/app/views/explore/projects/trending.html.haml +++ b/app/views/explore/projects/trending.html.haml @@ -2,12 +2,12 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path -= render_if_exists "shared/gold_trial_callout" += render_dashboard_gold_trial(current_user) - if current_user - = render 'dashboard/projects_head' + = render 'dashboard/projects_head', project_tab_filter: :explore_trending - else = render 'explore/head' -= render 'explore/projects/nav' += render 'explore/projects/nav' unless Feature.enabled?(:project_list_filter_bar) && current_user = render 'projects', projects: @projects diff --git a/app/views/groups/_archived_projects.html.haml b/app/views/groups/_archived_projects.html.haml index ed79f5790f0..48e9f630050 100644 --- a/app/views/groups/_archived_projects.html.haml +++ b/app/views/groups/_archived_projects.html.haml @@ -4,5 +4,5 @@ %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } .js-groups-list-holder - .loading-container.text-center - = icon('spinner spin 2x', class: 'loading-animation prepend-top-20') + .loading-container.text-center.prepend-top-20 + .spinner.spinner-md diff --git a/app/views/groups/_create_chat_team.html.haml b/app/views/groups/_create_chat_team.html.haml index f950968030f..561e68a9155 100644 --- a/app/views/groups/_create_chat_team.html.haml +++ b/app/views/groups/_create_chat_team.html.haml @@ -1,8 +1,9 @@ .form-group - = f.label :create_chat_team, class: 'col-form-label' do - %span.mattermost-icon - = custom_icon('icon_mattermost') - Mattermost + .col-sm-2.col-form-label + = f.label :create_chat_team do + %span.mattermost-icon + = custom_icon('icon_mattermost') + Mattermost .col-sm-10 .form-check.js-toggle-container .js-toggle-button.form-check-input= f.check_box(:create_chat_team, { checked: true }, true, false) diff --git a/app/views/groups/_group_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml index ff59013ed67..b8f632d11d3 100644 --- a/app/views/groups/_group_admin_settings.html.haml +++ b/app/views/groups/_group_admin_settings.html.haml @@ -1,5 +1,6 @@ .form-group.row - = f.label :lfs_enabled, 'Large File Storage', class: 'col-form-label col-sm-2 pt-0' + .col-sm-2.col-form-label.pt-0 + = f.label :lfs_enabled, 'Large File Storage' .col-sm-10 .form-check = f.check_box :lfs_enabled, checked: @group.lfs_enabled?, class: 'form-check-input' @@ -9,9 +10,15 @@ = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs') %br/ %span.descr This setting can be overridden in each project. +.form-group.row + .col-sm-2.col-form-label + = f.label s_('ProjectCreationLevel|Allowed to create projects') + .col-sm-10 + = f.select :project_creation_level, options_for_select(::Gitlab::Access.project_creation_options, @group.project_creation_level), {}, class: 'form-control' .form-group.row - = f.label :require_two_factor_authentication, 'Two-factor authentication', class: 'col-form-label col-sm-2 pt-0' + .col-sm-2.col-form-label.pt-0 + = f.label :require_two_factor_authentication, 'Two-factor authentication' .col-sm-10 .form-check = f.check_box :require_two_factor_authentication, class: 'form-check-input' diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index 39c0c113793..4daf3683eaf 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -13,7 +13,7 @@ = visibility_level_icon(@group.visibility_level, fw: false, options: {class: 'icon'}) .home-panel-metadata.d-flex.align-items-center.text-secondary %span - = _("Group") + = _("Group ID: %{group_id}") % { group_id: @group.id } - if current_user %span.access-request-links.prepend-left-8 = render 'shared/members/access_request_links', source: @group @@ -47,7 +47,7 @@ %strong= new_subgroup_label %span= s_("GroupsTree|Create a subgroup in this group.") - else - = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success" + = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success prepend-top-default" - if @group.description.present? .group-home-desc.mt-1 diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml index 4eb8367f633..2769b69add3 100644 --- a/app/views/groups/_shared_projects.html.haml +++ b/app/views/groups/_shared_projects.html.haml @@ -4,5 +4,5 @@ %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } .js-groups-list-holder - .loading-container.text-center - = icon('spinner spin 2x', class: 'loading-animation prepend-top-20') + .loading-container.text-center.prepend-top-20 + .spinner.spinner-md diff --git a/app/views/groups/_subgroups_and_projects.html.haml b/app/views/groups/_subgroups_and_projects.html.haml index d53c8026df8..784f5ac233e 100644 --- a/app/views/groups/_subgroups_and_projects.html.haml +++ b/app/views/groups/_subgroups_and_projects.html.haml @@ -4,5 +4,5 @@ %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } .js-groups-list-holder - .loading-container.text-center - = icon('spinner spin 2x', class: 'loading-animation prepend-top-20') + .loading-container.text-center.prepend-top-20 + .spinner.spinner-md diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 39d0f620283..0c8f86c2822 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -1,6 +1,6 @@ - breadcrumb_title "General Settings" - @content_class = "limit-container-width" unless fluid_layout -- expanded = Rails.env.test? +- expanded = expanded_by_default? %section.settings.gs-general.no-animate#js-general-settings{ class: ('expanded') } @@ -25,6 +25,8 @@ .settings-content = render 'groups/settings/permissions' += render_if_exists 'groups/insights', expanded: expanded + %section.settings.no-animate#js-badge-settings{ class: ('expanded' if expanded) } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' } diff --git a/app/views/groups/group_members/_new_group_member.html.haml b/app/views/groups/group_members/_new_group_member.html.haml index c8cdc2cc3e4..8b511f6866f 100644 --- a/app/views/groups/group_members/_new_group_member.html.haml +++ b/app/views/groups/group_members/_new_group_member.html.haml @@ -1,7 +1,7 @@ = form_for @group_member, url: group_group_members_path(@group), html: { class: 'users-project-form users-group-form' } do |f| .row .col-md-4.col-lg-6 - = users_select_tag(:user_ids, multiple: true, class: 'input-clamp', scope: :all, email_user: true) + = users_select_tag(:user_ids, group_member_select_options) .form-text.text-muted.append-bottom-10 Search for members by name, username, or email, or invite new ones using their email address. diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 2af3e861587..021c0b6c429 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -14,6 +14,8 @@ = render 'shared/members/requests', membership_source: @group, requesters: @requesters + = render_if_exists 'groups/group_members/ldap_sync' + .clearfix %h5.member.existing-title Existing members @@ -22,7 +24,7 @@ %span.flex-project-title Members with access to %strong= @group.name - %span.badge= @members.total_count + %span.badge.badge-pill= @members.total_count = form_tag group_group_members_path(@group), method: :get, class: 'form-inline member-search-form flex-project-members-form' do .form-group .position-relative.append-right-8 diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml index 5cf3193bc62..a8358704b03 100644 --- a/app/views/groups/labels/index.html.haml +++ b/app/views/groups/labels/index.html.haml @@ -1,7 +1,6 @@ - @no_container = true -- page_title "Labels" +- page_title 'Labels' - can_admin_label = can?(current_user, :admin_label, @group) -- issuables = ['issues', 'merge requests'] - search = params[:search] - subscribed = params[:subscribed] - labels_or_filters = @labels.exists? || search.present? || subscribed.present? @@ -14,11 +13,11 @@ .labels-container.prepend-top-5 - if @labels.any? .text-muted - = _('Labels can be applied to %{features}. Group labels are available for any project within the group.') % { features: issuables.to_sentence } + = _('Labels can be applied to %{features}. Group labels are available for any project within the group.') % { features: issuable_types.to_sentence } .other-labels %h5= _('Labels') %ul.content-list.manage-labels-list.js-other-labels - = render partial: 'shared/label', subject: @group, collection: @labels, as: :label, locals: { use_label_priority: false } + = render partial: 'shared/label', collection: @labels, as: :label, locals: { use_label_priority: false, subject: @group } = paginate @labels, theme: 'gitlab' - elsif search.present? .nothing-here-block diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml index 51dcc9d0cda..06e05d898d6 100644 --- a/app/views/groups/new.html.haml +++ b/app/views/groups/new.html.haml @@ -3,7 +3,7 @@ - page_title _('New Group') - header_title _("Groups"), dashboard_groups_path -.page-title-holder +.page-title-holder.d-flex.align-items-center %h1.page-title= _('New group') .row.prepend-top-default .col-lg-3.profile-settings-sidebar @@ -27,7 +27,7 @@ .form-group.group-description-holder.col-sm-12 = f.label :avatar, _("Group avatar"), class: 'label-bold' %div - = render 'shared/choose_group_avatar_button', f: f + = render 'shared/choose_avatar_button', f: f .form-group.col-sm-12 %label.label-bold diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml index 9ed71d19d32..e12748666c8 100644 --- a/app/views/groups/settings/_general.html.haml +++ b/app/views/groups/settings/_general.html.haml @@ -17,17 +17,17 @@ = f.label :description, _('Group description (optional)'), class: 'label-bold' = f.text_area :description, class: 'form-control', rows: 3, maxlength: 250 - = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group + = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group - .form-group.prepend-top-default.append-bottom-20 - .avatar-container.rect-avatar.s90 - = group_icon(@group, alt: '', class: 'avatar group-avatar s90') - = f.label :avatar, _('Group avatar'), class: 'label-bold d-block' - = render 'shared/choose_group_avatar_button', f: f - - if @group.avatar? - %hr - = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-danger btn-inverted' + .form-group.prepend-top-default.append-bottom-20 + .avatar-container.rect-avatar.s90 + = group_icon(@group, alt: '', class: 'avatar group-avatar s90') + = f.label :avatar, _('Group avatar'), class: 'label-bold d-block' + = render 'shared/choose_avatar_button', f: f + - if @group.avatar? + %hr + = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link' - = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group + = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group = f.submit _('Save changes'), class: 'btn btn-success mt-4 js-dirty-submit' diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index 6b0a6e7ed99..0a14830c666 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -18,6 +18,7 @@ %span.descr.text-muted= share_with_group_lock_help_text(@group) = render 'groups/settings/lfs', f: f + = render 'groups/settings/project_creation_level', f: f, group: @group = render 'groups/settings/two_factor_auth', f: f = render_if_exists 'groups/member_lock_setting', f: f, group: @group diff --git a/app/views/groups/settings/_project_creation_level.html.haml b/app/views/groups/settings/_project_creation_level.html.haml new file mode 100644 index 00000000000..9f711e6aade --- /dev/null +++ b/app/views/groups/settings/_project_creation_level.html.haml @@ -0,0 +1,3 @@ +.form-group + = f.label s_('ProjectCreationLevel|Allowed to create projects'), class: 'label-bold' + = f.select :project_creation_level, options_for_select(::Gitlab::Access.project_creation_options, group.project_creation_level), {}, class: 'form-control' diff --git a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml new file mode 100644 index 00000000000..e7efc0237c8 --- /dev/null +++ b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml @@ -0,0 +1,15 @@ += form_for group, url: update_auto_devops_group_settings_ci_cd_path(group), method: :patch do |f| + = form_errors(group) + %fieldset + .form-group + .card.auto-devops-card + .card-body + .form-check + = f.check_box :auto_devops_enabled, class: 'form-check-input', checked: group.auto_devops_enabled? + = f.label :auto_devops_enabled, class: 'form-check-label' do + %strong= s_('GroupSettings|Default to Auto DevOps pipeline for all projects within this group') + %span.badge.badge-info#auto-devops-badge= badge_for_auto_devops_scope(group) + .form-text.text-muted + = s_('GroupSettings|The Auto DevOps pipeline will run if no alternative CI configuration file is found.') + = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank' + = f.submit _('Save changes'), class: 'btn btn-success prepend-top-15' diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index d9332e36ef5..d21496ee0aa 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -1,7 +1,7 @@ - breadcrumb_title "CI / CD Settings" - page_title "CI / CD" -- expanded = Rails.env.test? +- expanded = expanded_by_default? %section.settings#ci-variables.no-animate{ class: ('expanded' if expanded) } .settings-header @@ -19,3 +19,17 @@ = _('Register and see your runners for this group.') .settings-content = render 'groups/runners/index' + +%section.settings#auto-devops-settings.no-animate{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Auto DevOps') + %button.btn.btn-default.js-settings-toggle{ type: "button" } + = expanded ? _('Collapse') : _('Expand') + %p + - auto_devops_url = help_page_path('topics/autodevops/index') + - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url } + = s_('GroupSettings|Auto DevOps will automatically build, test and deploy your application based on a predefined Continuous Integration and Delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end}').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe } + + .settings-content + = render 'groups/settings/ci_cd/auto_devops_form', group: @group diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 77fe88dacb7..255a9ad038c 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -9,7 +9,7 @@ = render 'groups/home_panel' .groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } - .top-area.group-nav-container + .top-area.group-nav-container.justify-content-between .scrolling-tabs-container.inner-page-scroll-tabs .fade-left= icon('angle-left') .fade-right= icon('angle-right') diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index 28ffb2dd63c..efb3815b257 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -356,6 +356,18 @@ %td.shortcut %kbd l %td Change Label + %tr + %td.shortcut + %kbd ] + \/ + %kbd j + %td Move to next file + %tr + %td.shortcut + %kbd [ + \/ + %kbd k + %td Move to previous file %tbody.hidden-shortcut{ style: 'display:none' } %tr %th diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml index dfa5d820ce9..50933c7d434 100644 --- a/app/views/help/index.html.haml +++ b/app/views/help/index.html.haml @@ -1,11 +1,11 @@ %div - if Gitlab::CurrentSettings.help_page_text.present? - = markdown_field(Gitlab::CurrentSettings.current_application_settings, :help_page_text) + .prepend-top-default.md + = markdown_field(Gitlab::CurrentSettings.current_application_settings, :help_page_text) %hr %h1 - GitLab - Community Edition + = default_brand_title - if user_signed_in? %span= link_to_version = version_status_badge @@ -24,12 +24,13 @@ Used by more than 100,000 organizations, GitLab is the most popular solution to manage git repositories on-premises. %br Read more about GitLab at #{link_to promo_host, promo_url, target: '_blank', rel: 'noopener noreferrer'}. - %p= link_to 'Check the current instance configuration ', help_instance_configuration_url - %hr + +%p= link_to 'Check the current instance configuration ', help_instance_configuration_url +%hr .row.prepend-top-default .col-md-8 - .documentation-index.wiki + .documentation-index.md = markdown(@help_index) .col-md-4 .card diff --git a/app/views/help/instance_configuration.html.haml b/app/views/help/instance_configuration.html.haml index f09e3825a4b..99576d45f76 100644 --- a/app/views/help/instance_configuration.html.haml +++ b/app/views/help/instance_configuration.html.haml @@ -1,5 +1,5 @@ - page_title 'Instance Configuration' -.wiki.documentation +.documentation.md %h1 Instance Configuration %p diff --git a/app/views/help/show.html.haml b/app/views/help/show.html.haml index c07c148a12a..dce27dee9be 100644 --- a/app/views/help/show.html.haml +++ b/app/views/help/show.html.haml @@ -1,3 +1,3 @@ - page_title @path.split("/").reverse.map(&:humanize) -.documentation.wiki.prepend-top-default +.documentation.md.prepend-top-default = markdown @markdown diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index 506f580b246..cdc894ee5a0 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -70,7 +70,7 @@ .cover-title John Smith - .cover-desc + .cover-desc.cgray = lorem .cover-controls @@ -513,7 +513,7 @@ %h2#markdown Markdown %h4 - %code .md or .wiki and others + %code .md Markdown rendering has a bit different css and presented in next UI elements: diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml index 9280f12e187..40609fddbde 100644 --- a/app/views/import/bitbucket_server/status.html.haml +++ b/app/views/import/bitbucket_server/status.html.haml @@ -29,7 +29,7 @@ %tr %th= _('From Bitbucket Server') %th= _('To GitLab') - %th= _(' Status') + %th= _('Status') %tbody - @already_added_projects.each do |project| %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" } diff --git a/app/views/import/gitea/new.html.haml b/app/views/import/gitea/new.html.haml index a88b04eccbb..c4670869c93 100644 --- a/app/views/import/gitea/new.html.haml +++ b/app/views/import/gitea/new.html.haml @@ -2,18 +2,18 @@ - header_title _("Projects"), root_path %h3.page-title - = custom_icon('go_logo') + = custom_icon('gitea_logo') = _('Import Projects from Gitea') %p - - link_to_personal_token = link_to(_('Personal Access Token'), 'https://github.com/gogits/go-gogs-client/wiki#access-token') + - link_to_personal_token = link_to(_('Personal Access Token'), 'https://docs.gitea.io/en-us/api-usage/#authentication-via-the-api') = _('To get started, please enter your Gitea Host URL and a %{link_to_personal_token}.').html_safe % { link_to_personal_token: link_to_personal_token } = form_tag personal_access_token_import_gitea_path do .form-group.row = label_tag :gitea_host_url, _('Gitea Host URL'), class: 'col-form-label col-sm-2' .col-sm-4 - = text_field_tag :gitea_host_url, nil, placeholder: 'https://try.gitea.io', class: 'form-control' + = text_field_tag :gitea_host_url, nil, placeholder: 'https://gitea.com', class: 'form-control' .form-group.row = label_tag :personal_access_token, _('Personal Access Token'), class: 'col-form-label col-sm-2' .col-sm-4 diff --git a/app/views/import/gitea/status.html.haml b/app/views/import/gitea/status.html.haml index 88244fde16b..ef0693e73c3 100644 --- a/app/views/import/gitea/status.html.haml +++ b/app/views/import/gitea/status.html.haml @@ -1,7 +1,7 @@ - page_title _("Gitea Import") - header_title _("Projects"), root_path %h3.page-title - = custom_icon('go_logo') + = custom_icon('gitea_logo') = _('Import Projects from Gitea') = render 'import/githubish_status', provider: 'gitea' diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml index cf32c5c9387..72e5934574a 100644 --- a/app/views/import/github/new.html.haml +++ b/app/views/import/github/new.html.haml @@ -22,6 +22,8 @@ = text_field_tag :personal_access_token, '', class: 'form-control append-right-8', placeholder: _('Personal Access Token'), size: 40 = submit_tag _('List your GitHub repositories'), class: 'btn btn-success' + = render_if_exists 'import/github/ci_cd_only' + - unless github_import_configured? %hr %p diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml index 5e4595d930b..a19c8911559 100644 --- a/app/views/import/gitlab_projects/new.html.haml +++ b/app/views/import/gitlab_projects/new.html.haml @@ -7,28 +7,7 @@ %hr = form_tag import_gitlab_project_path, class: 'new_project', multipart: true do - .row - .form-group.project-name.col-sm-12 - = label_tag :name, _('Project name'), class: 'label-bold' - = text_field_tag :name, @name, placeholder: "My awesome project", class: "js-project-name form-control input-lg", autofocus: true - .form-group.col-12.col-sm-6 - = label_tag :namespace_id, _('Project URL'), class: 'label-bold' - .form-group - .input-group - - if current_user.can_select_namespace? - .input-group-prepend.has-tooltip{ title: root_url } - .input-group-text - = root_url - = select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace', tabindex: 1 - - - else - .input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' } - .input-group-text.border-0 - #{user_url(current_user.username)}/ - = hidden_field_tag :namespace_id, value: current_user.namespace_id - .form-group.col-12.col-sm-6.project-path - = label_tag :path, _('Project slug'), class: 'label-bold' - = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control", tabindex: 2, required: true + = render 'import/shared/new_project_form' .row .form-group.col-md-12 diff --git a/app/views/import/manifest/new.html.haml b/app/views/import/manifest/new.html.haml index 056e4922b9e..df00c4d2179 100644 --- a/app/views/import/manifest/new.html.haml +++ b/app/views/import/manifest/new.html.haml @@ -4,9 +4,5 @@ %h3.page-title = _('Manifest file import') -- if @errors.present? - .alert.alert-danger - - @errors.each do |error| - = error - += render 'import/shared/errors' = render 'form' diff --git a/app/views/import/phabricator/new.html.haml b/app/views/import/phabricator/new.html.haml new file mode 100644 index 00000000000..811e126579e --- /dev/null +++ b/app/views/import/phabricator/new.html.haml @@ -0,0 +1,25 @@ +- title = _('Phabricator Server Import') +- page_title title +- breadcrumb_title title +- header_title _("Projects"), root_path + +%h3.page-title + = icon 'issues', text: _('Import tasks from Phabricator into issues') + += render 'import/shared/errors' + += form_tag import_phabricator_path, class: 'new_project', method: :post do + = render 'import/shared/new_project_form' + + %h4.prepend-top-0= _('Enter in your Phabricator Server URL and personal access token below') + + .form-group.row + = label_tag :phabricator_server_url, _('Phabricator Server URL'), class: 'col-form-label col-md-2' + .col-md-4 + = text_field_tag :phabricator_server_url, params[:phabricator_server_url], class: 'form-control append-right-8', placeholder: 'https://your-phabricator-server', size: 40 + .form-group.row + = label_tag :api_token, _('API Token'), class: 'col-form-label col-md-2' + .col-md-4 + = password_field_tag :api_token, params[:api_token], class: 'form-control append-right-8', placeholder: _('Personal Access Token'), size: 40 + .form-actions + = submit_tag _('Import tasks'), class: 'btn btn-success' diff --git a/app/views/import/shared/_errors.html.haml b/app/views/import/shared/_errors.html.haml new file mode 100644 index 00000000000..de60c15351f --- /dev/null +++ b/app/views/import/shared/_errors.html.haml @@ -0,0 +1,4 @@ +- if @errors.present? + .alert.alert-danger + - @errors.each do |error| + = error diff --git a/app/views/import/shared/_new_project_form.html.haml b/app/views/import/shared/_new_project_form.html.haml new file mode 100644 index 00000000000..4d13d4f2869 --- /dev/null +++ b/app/views/import/shared/_new_project_form.html.haml @@ -0,0 +1,21 @@ +.row + .form-group.project-name.col-sm-12 + = label_tag :name, _('Project name'), class: 'label-bold' + = text_field_tag :name, @name, placeholder: "My awesome project", class: "js-project-name form-control input-lg", autofocus: true + .form-group.col-12.col-sm-6 + = label_tag :namespace_id, _('Project URL'), class: 'label-bold' + .form-group + .input-group.flex-nowrap + - if current_user.can_select_namespace? + .input-group-prepend.flex-shrink-0.has-tooltip{ title: root_url } + .input-group-text + = root_url + = select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace', tabindex: 1 + - else + .input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' } + .input-group-text.border-0 + #{user_url(current_user.username)}/ + = hidden_field_tag :namespace_id, value: current_user.namespace_id + .form-group.col-12.col-sm-6.project-path + = label_tag :path, _('Project slug'), class: 'label-bold' + = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control", tabindex: 2, required: true diff --git a/app/views/issues/_issue.atom.builder b/app/views/issues/_issue.atom.builder index 21cf6d0dd65..94c32df7c60 100644 --- a/app/views/issues/_issue.atom.builder +++ b/app/views/issues/_issue.atom.builder @@ -12,6 +12,7 @@ xml.entry do xml.summary issue.title xml.description issue.description if issue.description + xml.content issue.description if issue.description xml.milestone issue.milestone.title if issue.milestone xml.due_date issue.due_date if issue.due_date diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 11e83ddfe64..c357207054b 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -77,3 +77,4 @@ = render 'layouts/google_analytics' if extra_config.has_key?('google_analytics_id') = render 'layouts/piwik' if extra_config.has_key?('piwik_url') && extra_config.has_key?('piwik_site_id') + = render_if_exists 'layouts/snowplow' diff --git a/app/views/layouts/_mailer.html.haml b/app/views/layouts/_mailer.html.haml index 26fd34347ec..6e8294d6adc 100644 --- a/app/views/layouts/_mailer.html.haml +++ b/app/views/layouts/_mailer.html.haml @@ -52,6 +52,7 @@ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#6b4fbb;height:4px;font-size:4px;line-height:4px;" } %tr.header %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } + = html_header_message = header_logo %tr %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" } @@ -63,6 +64,8 @@ %tbody = yield + = render_if_exists 'layouts/mailer/additional_text' + %tr.footer %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } %img{ alt: "GitLab", height: "33", src: image_url('mailers/gitlab_footer_logo.gif'), style: "display:block;margin:0 auto 1em;", width: "90" }/ @@ -72,3 +75,6 @@ = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} · %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link } = yield :additional_footer + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } + = html_footer_message diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 1b2a4cd6780..006334ade07 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -5,8 +5,10 @@ = render 'shared/outdated_browser' .mobile-overlay .alert-wrapper + = render_if_exists "layouts/header/ee_license_banner" = render "layouts/broadcast" = render "layouts/header/read_only_banner" + = render "layouts/nav/classification_level_banner" = yield :flash_message = render "shared/ping_consent" - unless @hide_breadcrumbs diff --git a/app/views/layouts/_piwik.html.haml b/app/views/layouts/_piwik.html.haml index a888e8ae187..473b14ce626 100644 --- a/app/views/layouts/_piwik.html.haml +++ b/app/views/layouts/_piwik.html.haml @@ -7,7 +7,7 @@ (function() { var u="//#{extra_config.piwik_url}/"; _paq.push(['setTrackerUrl', u+'piwik.php']); - _paq.push(['setSiteId', #{extra_config.piwik_site_id}]); + _paq.push(['setSiteId', "#{extra_config.piwik_site_id}"]); var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s); })(); diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index a6023a1cbb9..496ec3c78b0 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -16,7 +16,7 @@ mr_path: merge_requests_dashboard_path }, aria: { label: _('Search or jump to…') } %button.hidden.js-dropdown-search-toggle{ type: 'button', data: { toggle: 'dropdown' } } - .dropdown-menu.dropdown-select + .dropdown-menu.dropdown-select.js-dashboard-search-options = dropdown_content do %ul %li.dropdown-menu-empty-item diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 043cca6ad38..c38f96f302a 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -10,4 +10,6 @@ = render 'layouts/page', sidebar: sidebar, nav: nav = footer_message + = render_if_exists "shared/onboarding_guide" + = yield :scripts_body diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 2f3c13aaf6e..ff3410f6268 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -10,15 +10,17 @@ .container.navless-container .content = render "layouts/flash" - .row.append-bottom-15 - .col-sm-7.brand-holder - %h1 + .row.mt-3 + .col-sm-12 + %h1.mb-3.font-weight-normal = brand_title + .row.mb-3 + .col-sm-7.order-12.order-sm-1.brand-holder = brand_image - if current_appearance&.description? = brand_text - else - %h3 + %h3.mt-sm-0 = _('Open source software to collaborate on code') %p @@ -26,7 +28,10 @@ - if Gitlab::CurrentSettings.sign_in_text.present? = markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text) - .col-sm-5.new-session-forms-container + + = render_if_exists 'layouts/devise_help_text' + + .col-sm-5.order-1.order-sm-12.new-session-forms-container = yield %hr.footer-fixed diff --git a/app/views/layouts/empty_mailer.html.haml b/app/views/layouts/empty_mailer.html.haml new file mode 100644 index 00000000000..a25dcefd445 --- /dev/null +++ b/app/views/layouts/empty_mailer.html.haml @@ -0,0 +1,5 @@ += html_header_message + += yield + += html_footer_message diff --git a/app/views/layouts/empty_mailer.text.erb b/app/views/layouts/empty_mailer.text.erb new file mode 100644 index 00000000000..6ab0dbead07 --- /dev/null +++ b/app/views/layouts/empty_mailer.text.erb @@ -0,0 +1,5 @@ +<%= text_header_message %> + +<%= yield -%> + +<%= text_footer_message %> diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index a9b85889846..f8b7d0c530a 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -17,6 +17,10 @@ - if logo_text.present? %span.logo-text.d-none.d-lg-block.prepend-left-8 = logo_text + - if Gitlab.com? + = link_to 'https://next.gitlab.com', class: 'label-link js-canary-badge canary-badge bg-transparent hidden', target: :_blank do + %span.color-label.has-tooltip.badge.badge-pill.green-badge + = _('Next') - if current_user = render "layouts/nav/dashboard" @@ -38,7 +42,7 @@ = link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues', aria: { label: _('Issues') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = sprite_icon('issues', size: 16) - issues_count = assigned_issuables_count(:issues) - %span.badge.badge-pill.issues-count{ class: ('hidden' if issues_count.zero?) } + %span.badge.badge-pill.issues-count.green-badge{ class: ('hidden' if issues_count.zero?) } = number_with_delimiter(issues_count) - if header_link?(:merge_requests) = nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter" }) do diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index cd9128c452b..5643a508ddc 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -2,8 +2,13 @@ - if current_user_menu?(:help) %li = link_to _("Help"), help_path + = render_if_exists "shared/learn_gitlab_menu_item" %li.divider %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' + = render_if_exists 'shared/user_dropdown_instance_review' + - if Gitlab.com? + %li.js-canary-link + = link_to _("Switch to GitLab Next"), "https://next.gitlab.com/" diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml index 5a66b02c048..438340464bd 100644 --- a/app/views/layouts/header/_new_dropdown.haml +++ b/app/views/layouts/header/_new_dropdown.haml @@ -38,4 +38,4 @@ %li= link_to _('New project'), new_project_path, class: 'qa-global-new-project-link' - if current_user.can_create_group? %li= link_to _('New group'), new_group_path - %li= link_to _('New snippet'), new_snippet_path + %li= link_to _('New snippet'), new_snippet_path, class: 'qa-global-new-snippet-link' diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb index 8e11174f8d7..1a06ea68bcd 100644 --- a/app/views/layouts/mailer.text.erb +++ b/app/views/layouts/mailer.text.erb @@ -1,4 +1,9 @@ +<%= text_header_message %> + <%= yield -%> -- <%# signature marker %> <%= _("You're receiving this email because of your account on %{host}.") % { host: Gitlab.config.gitlab.host } %> +<%= render_if_exists 'layouts/mailer/additional_text' %> + +<%= text_footer_message %> diff --git a/app/views/layouts/nav/_classification_level_banner.html.haml b/app/views/layouts/nav/_classification_level_banner.html.haml new file mode 100644 index 00000000000..cc4caf079b8 --- /dev/null +++ b/app/views/layouts/nav/_classification_level_banner.html.haml @@ -0,0 +1,5 @@ +- if ::Gitlab::ExternalAuthorization.enabled? && @project + = content_for :header_content do + %span.badge.color-label.classification-label.has-tooltip{ title: s_('ExternalAuthorizationService|Classification label') } + = sprite_icon('lock-open', size: 8, css_class: 'inline') + = @project.external_authorization_classification_label diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index f659c89dd30..54028dc8554 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -3,7 +3,7 @@ %ul.list-unstyled.navbar-sub-nav - if dashboard_nav_link?(:projects) = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown", data: { track_label: "projects_dropdown", track_event: "click_dropdown" } }) do - %button{ type: 'button', data: { toggle: "dropdown" } } + %button.btn{ type: 'button', data: { toggle: "dropdown" } } = _('Projects') = sprite_icon('angle-down', css_class: 'caret-down') .dropdown-menu.frequent-items-dropdown-menu @@ -11,7 +11,7 @@ - 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" } }) do - %button{ type: 'button', data: { toggle: "dropdown" } } + %button.btn{ type: 'button', data: { toggle: "dropdown" } } = _('Groups') = sprite_icon('angle-down', css_class: 'caret-down') .dropdown-menu.frequent-items-dropdown-menu @@ -19,17 +19,17 @@ - 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', title: _('Activity') 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', title: _('Milestones') 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', title: _('Snippets') do + = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets qa-snippets-link' do = _('Snippets') - if any_dashboard_nav_link?([:groups, :milestones, :activity, :snippets]) @@ -41,47 +41,47 @@ %ul - if dashboard_nav_link?(:activity) = nav_link(path: 'dashboard#activity') do - = link_to activity_dashboard_path, title: _('Activity') do + = link_to activity_dashboard_path do = _('Activity') - if dashboard_nav_link?(:milestones) = nav_link(controller: 'dashboard/milestones') do - = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: _('Milestones') do + = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones' do = _('Milestones') - if dashboard_nav_link?(:snippets) = nav_link(controller: 'dashboard/snippets') do - = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: _('Snippets') do + = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets' do = _('Snippets') - - = render_if_exists 'dashboard/operations/nav_link' + %li.dropdown.d-lg-none + = render_if_exists 'dashboard/operations/nav_link_list' - if can?(current_user, :read_instance_statistics) - = nav_link(controller: [:conversational_development_index, :cohorts]) do - = link_to instance_statistics_root_path, title: _('Instance Statistics'), aria: { label: _('Instance Statistics') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = nav_link(controller: [:conversational_development_index, :cohorts], html_options: { class: 'd-lg-none' }) 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: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = link_to admin_root_path, class: 'd-lg-none admin-icon qa-admin-area-link' do = _('Admin Area') - if Gitlab::Sherlock.enabled? %li - = link_to sherlock_transactions_path, class: 'admin-icon', title: _('Sherlock Transactions'), - data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = link_to sherlock_transactions_path, class: 'd-lg-none admin-icon' do = _('Sherlock Transactions') -# Shortcut to Dashboard > Projects - if dashboard_nav_link?(:projects) %li.hidden - = link_to dashboard_projects_path, title: _('Projects'), class: 'dashboard-shortcuts-projects' do + = 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', title: _('Web IDE') do + = link_to '#', class: 'dashboard-shortcuts-web-ide' do = _('Web IDE') - = render_if_exists 'dashboard/operations/nav_link' + %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 @@ -95,3 +95,4 @@ = 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 2fdd65f639b..83fe871285a 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -48,7 +48,7 @@ %span = _('Gitaly Servers') - = nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles)) do + = nav_link(controller: admin_monitoring_nav_links) do = link_to admin_system_info_path do .nav-icon-container = sprite_icon('monitor') @@ -81,6 +81,7 @@ = link_to admin_requests_profiles_path, title: _('Requests Profiles') do %span = _('Requests Profiles') + = render_if_exists 'layouts/nav/ee/admin/new_monitoring_sidebar' = nav_link(controller: :broadcast_messages) do = link_to admin_broadcast_messages_path do @@ -132,6 +133,21 @@ = _('Abuse Reports') %span.badge.badge-pill.count.merge_counter.js-merge-counter.fly-out-badge= number_with_delimiter(AbuseReport.count(:all)) + = render_if_exists 'layouts/nav/sidebar/licenses_link' + + - if instance_clusters_enabled? + = nav_link(controller: :clusters) do + = link_to admin_clusters_path do + .nav-icon-container + = sprite_icon('cloud-gear') + %span.nav-item-name + = _('Kubernetes') + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(controller: :clusters, html_options: { class: "fly-out-top-item" } ) do + = link_to admin_clusters_path do + %strong.fly-out-top-item-name + = _('Kubernetes') + - if akismet_enabled? = nav_link(controller: :spam_logs) do = link_to admin_spam_logs_path do @@ -145,6 +161,10 @@ %strong.fly-out-top-item-name = _('Spam Logs') + = render_if_exists 'layouts/nav/sidebar/push_rules_link' + + = render_if_exists 'layouts/nav/ee/admin/geo_sidebar' + = nav_link(controller: :deploy_keys) do = link_to admin_deploy_keys_path do .nav-icon-container @@ -220,7 +240,7 @@ = _('Repository') - if template_exists?('admin/application_settings/templates') = nav_link(path: 'application_settings#templates') do - = link_to templates_admin_application_settings_path, title: _('Templates') do + = link_to templates_admin_application_settings_path, title: _('Templates'), class: 'qa-admin-settings-template-item' do %span = _('Templates') = nav_link(path: 'application_settings#ci_cd') do @@ -232,7 +252,7 @@ %span = _('Reporting') = nav_link(path: 'application_settings#metrics_and_profiling') do - = link_to metrics_and_profiling_admin_application_settings_path, title: _('Metrics and profiling') do + = link_to metrics_and_profiling_admin_application_settings_path, title: _('Metrics and profiling'), class: 'qa-admin-settings-metrics-and-profiling-item' do %span = _('Metrics and profiling') = nav_link(path: 'application_settings#network') do diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 21ea9f3b2f3..0fc5ebbea7e 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -1,6 +1,5 @@ - issues_count = group_issues_count(state: 'opened') - merge_requests_count = group_merge_requests_count(state: 'opened') -- issues_sub_menu_items = ['groups#issues', 'labels#index', 'milestones#index', 'boards#index', 'boards#show'] .nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } .nav-sidebar-inner-scroll @@ -20,13 +19,14 @@ = _('Overview') %ul.sidebar-sub-level-items - = nav_link(path: ['groups#show', 'groups#activity', 'groups#subgroups'], html_options: { class: "fly-out-top-item" } ) do + = nav_link(path: ['groups#show', 'groups#details', 'groups#activity', 'groups#subgroups'], html_options: { class: "fly-out-top-item" } ) do = link_to group_path(@group) do %strong.fly-out-top-item-name = _('Overview') %li.divider.fly-out-top-item - = nav_link(path: ['groups#show', 'groups#subgroups'], html_options: { class: 'home' }) do - = link_to group_path(@group), title: _('Group details') do + + = nav_link(path: ['groups#show', 'groups#details', 'groups#subgroups'], html_options: { class: 'home' }) do + = link_to details_group_path(@group), title: _('Group details') do %span = _('Details') @@ -40,14 +40,17 @@ - if group_sidebar_link?(:contribution_analytics) = nav_link(path: 'analytics#show') do - = link_to group_analytics_path(@group), title: 'Contribution Analytics', data: {placement: 'right'} do + = link_to group_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right' } do %span - Contribution Analytics + = _('Contribution Analytics') + + = render_if_exists 'layouts/nav/group_insights_link' + = render_if_exists 'groups/sidebar/dependency_proxy' # EE-specific = render_if_exists "layouts/nav/ee/epic_link", group: @group - if group_sidebar_link?(:issues) - = nav_link(path: issues_sub_menu_items) do + = nav_link(path: group_issues_sub_menu_items) do = link_to issues_group_path(@group) do .nav-icon-container = sprite_icon('issues') diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml index 1e3bb8f1224..7dd33f3c641 100644 --- a/app/views/layouts/nav/sidebar/_profile.html.haml +++ b/app/views/layouts/nav/sidebar/_profile.html.haml @@ -4,7 +4,7 @@ = link_to profile_path, title: _('Profile Settings') do .avatar-container.s40.settings-avatar = image_tag avatar_icon_for_user(current_user, 40), class: "avatar s40 avatar-tile", alt: current_user.name - .sidebar-context-title User Settings + .sidebar-context-title= _('User Settings') %ul.sidebar-top-level-items = nav_link(path: 'profiles#show', html_options: {class: 'home'}) do = link_to profile_path do @@ -28,6 +28,8 @@ = link_to profile_account_path do %strong.fly-out-top-item-name = _('Account') + + = render_if_exists 'layouts/nav/sidebar/profile_billing_link' = nav_link(controller: 'oauth/applications') do = link_to applications_profile_path do .nav-icon-container @@ -151,4 +153,6 @@ %strong.fly-out-top-item-name = _('Authentication Log') + = render_if_exists 'layouts/nav/sidebar/profile_pipeline_quota_link' + = render 'shared/sidebar_toggle_button' diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 7b492efeb09..399305baec1 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -41,6 +41,8 @@ = link_to project_cycle_analytics_path(@project), title: _('Cycle Analytics'), class: 'shortcuts-project-cycle-analytics' do %span= _('Cycle Analytics') + = render_if_exists 'layouts/nav/project_insights_link' + - if project_nav_tab? :files = nav_link(controller: sidebar_repository_paths) do = link_to project_tree_path(@project), class: 'shortcuts-tree qa-project-menu-repo' do @@ -268,6 +270,8 @@ %span= _("Got it!") = sprite_icon('thumb-up') + = render_if_exists 'layouts/nav/sidebar/project_feature_flags_link' + - if project_nav_tab? :container_registry = nav_link(controller: %w[projects/registry/repositories]) do = link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry' do @@ -281,7 +285,9 @@ %strong.fly-out-top-item-name = _('Registry') - - if project_nav_tab?(:wiki) + = render_if_exists 'layouts/nav/sidebar/project_packages_link' + + - if project_nav_tab? :wiki - wiki_url = project_wiki_path(@project, :home) = nav_link(controller: :wikis) do = link_to wiki_url, class: 'shortcuts-wiki qa-wiki-link' do @@ -355,12 +361,12 @@ = link_to project_settings_repository_path(@project), title: _('Repository') do %span = _('Repository') - - if @project.feature_available?(:builds, current_user) + - if !@project.archived? && @project.feature_available?(:builds, current_user) = nav_link(controller: :ci_cd) do = link_to project_settings_ci_cd_path(@project), title: _('CI / CD') do %span = _('CI / CD') - - if settings_operations_available? + - if !@project.archived? && settings_operations_available? = nav_link(controller: [:operations]) do = link_to project_settings_operations_path(@project), title: _('Operations') do = _('Operations') diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml index 1c3e05e07f4..de487a94d40 100644 --- a/app/views/layouts/notify.html.haml +++ b/app/views/layouts/notify.html.haml @@ -7,6 +7,7 @@ = yield :head %body .content + = html_header_message = yield .footer{ style: "margin-top: 10px;" } %p @@ -30,3 +31,6 @@ adjust your notification settings. = email_action @target_url + + = render_if_exists 'layouts/email_additional_text' + = html_footer_message diff --git a/app/views/layouts/notify.text.erb b/app/views/layouts/notify.text.erb index 9dc490efa9a..0ee30c2a6cf 100644 --- a/app/views/layouts/notify.text.erb +++ b/app/views/layouts/notify.text.erb @@ -1,3 +1,5 @@ +<%= text_header_message %> + <%= yield -%> -- <%# signature marker %> @@ -10,3 +12,6 @@ <% end -%> <%= "You're receiving this email because #{notification_reason_text(@reason)}." %> +<%= render_if_exists 'layouts/mailer/additional_text' %> + +<%= text_footer_message -%> diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb index 5a67214059c..fae8fa3ccf3 100644 --- a/app/views/notify/_note_email.text.erb +++ b/app/views/notify/_note_email.text.erb @@ -1,5 +1,6 @@ <% note = local_assigns.fetch(:note, @note) -%> <% diff_limit = local_assigns.fetch(:diff_limit, nil) -%> +<% target_url = local_assigns.fetch(:target_url, @target_url) -%> <% discussion = note.discussion if note.part_of_discussion? -%> <% if discussion && !discussion.individual_note? -%> @@ -13,6 +14,9 @@ <%= " on #{discussion.file_path}" -%> <% end -%> <%= ":" -%> +<% if discussion.diff_discussion? || !discussion.new_discussion? -%> +<%= " #{target_url}" -%> +<% end -%> <% elsif Gitlab::CurrentSettings.email_author_in_body -%> diff --git a/app/views/notify/_reassigned_issuable_email.html.haml b/app/views/notify/_reassigned_issuable_email.html.haml new file mode 100644 index 00000000000..4ab40ff2659 --- /dev/null +++ b/app/views/notify/_reassigned_issuable_email.html.haml @@ -0,0 +1,10 @@ +%p + Assignee changed + - if previous_assignees.any? + from + %strong= sanitize_name(previous_assignees.map(&:name).to_sentence) + to + - if issuable.assignees.any? + %strong= sanitize_name(issuable.assignee_list) + - else + %strong Unassigned diff --git a/app/views/notify/_removal_notification.html.haml b/app/views/notify/_removal_notification.html.haml new file mode 100644 index 00000000000..590e0d569aa --- /dev/null +++ b/app/views/notify/_removal_notification.html.haml @@ -0,0 +1,9 @@ +- if @domain.remove_at + %p + Unless you verify your domain by + %strong= @domain.remove_at.strftime('%F %T,') + it will be removed from your GitLab project. +- else + %p + If you no longer wish to use this domain with GitLab Pages, please remove it + from your GitLab project and delete any related DNS records. diff --git a/app/views/notify/closed_issue_email.html.haml b/app/views/notify/closed_issue_email.html.haml index eb148d72da1..d3733ab3a09 100644 --- a/app/views/notify/closed_issue_email.html.haml +++ b/app/views/notify/closed_issue_email.html.haml @@ -1,2 +1,2 @@ %p - Issue was closed by #{sanitize_name(@updated_by.name)} + = _("Issue was closed by %{name} %{reason}").html_safe % { name: sanitize_name(@updated_by.name), reason: closure_reason_text(@closed_via, format: formats.first) } diff --git a/app/views/notify/closed_issue_email.text.haml b/app/views/notify/closed_issue_email.text.haml index b1f0a3f37ec..ff2548a4b42 100644 --- a/app/views/notify/closed_issue_email.text.haml +++ b/app/views/notify/closed_issue_email.text.haml @@ -1,3 +1,3 @@ -Issue was closed by #{sanitize_name(@updated_by.name)} += _("Issue was closed by %{name} %{reason}").html_safe % { name: sanitize_name(@updated_by.name), reason: closure_reason_text(@closed_via, format: formats.first) } Issue ##{@issue.iid}: #{project_issue_url(@issue.project, @issue)} diff --git a/app/views/notify/closed_merge_request_email.text.haml b/app/views/notify/closed_merge_request_email.text.haml index 1094d584a1c..6e84f9fb355 100644 --- a/app/views/notify/closed_merge_request_email.text.haml +++ b/app/views/notify/closed_merge_request_email.text.haml @@ -5,4 +5,4 @@ Merge Request url: #{project_merge_request_url(@merge_request.target_project, @m = merge_path_description(@merge_request, 'to') Author: #{sanitize_name(@merge_request.author_name)} -Assignee: #{sanitize_name(@merge_request.assignee_name)} += assignees_label(@merge_request) diff --git a/app/views/notify/issue_due_email.html.haml b/app/views/notify/issue_due_email.html.haml index e81144b8fcb..08bc98ca05c 100644 --- a/app/views/notify/issue_due_email.html.haml +++ b/app/views/notify/issue_due_email.html.haml @@ -3,7 +3,7 @@ - if @issue.assignees.any? %p - Assignee: #{@issue.assignee_list} + = assignees_label(@issue) %p This issue is due on: #{@issue.due_date.to_s(:medium)} diff --git a/app/views/notify/issue_due_email.text.erb b/app/views/notify/issue_due_email.text.erb index 3c7a57a8a2e..ae50b703fe3 100644 --- a/app/views/notify/issue_due_email.text.erb +++ b/app/views/notify/issue_due_email.text.erb @@ -2,6 +2,6 @@ The following issue is due on <%= @issue.due_date %>: Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %> Author: <%= @issue.author_name %> -Assignee: <%= @issue.assignee_list %> +<%= assignees_label(@issue) %> <%= @issue.description %> diff --git a/app/views/notify/issue_moved_email.html.haml b/app/views/notify/issue_moved_email.html.haml index 472c31e9a5e..b766cb1a523 100644 --- a/app/views/notify/issue_moved_email.html.haml +++ b/app/views/notify/issue_moved_email.html.haml @@ -1,6 +1,9 @@ %p Issue was moved to another project. -%p - New issue: - = link_to project_issue_url(@new_project, @new_issue) do - = @new_issue.title +- if @can_access_project + %p + New issue: + = link_to project_issue_url(@new_project, @new_issue) do + = @new_issue.title +- else + You don't have access to the project. diff --git a/app/views/notify/issue_moved_email.text.erb b/app/views/notify/issue_moved_email.text.erb index 66ede43635b..985e689aa9d 100644 --- a/app/views/notify/issue_moved_email.text.erb +++ b/app/views/notify/issue_moved_email.text.erb @@ -1,4 +1,8 @@ Issue was moved to another project. +<% if @can_access_project %> New issue location: <%= project_issue_url(@new_project, @new_issue) %> +<% else %> +You don't have access to the project. +<% end %> diff --git a/app/views/notify/links/ci/builds/_build.text.erb b/app/views/notify/links/ci/builds/_build.text.erb index 773ae8174e9..afb02f97e5a 100644 --- a/app/views/notify/links/ci/builds/_build.text.erb +++ b/app/views/notify/links/ci/builds/_build.text.erb @@ -1 +1 @@ -Job #<%= build.id %> ( <%= pipeline_job_url(pipeline, build) %> ) +Job #<%= build.id %> ( <%= raw_project_job_url(pipeline.project, build) %> ) diff --git a/app/views/notify/member_access_granted_email.html.haml b/app/views/notify/member_access_granted_email.html.haml index 18dec806539..1c50dba9c97 100644 --- a/app/views/notify/member_access_granted_email.html.haml +++ b/app/views/notify/member_access_granted_email.html.haml @@ -1,3 +1,10 @@ +- link_end = '</a>'.html_safe +- source_type = member_source.model_name.singular +- leave_link = polymorphic_url([member_source], leave: 1) +- source_link = link_to(member_source.human_name, member_source.web_url, target: '_blank', rel: 'noopener noreferrer') + %p - You have been granted #{member.human_access} access to the - #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}. + = _('You have been granted %{access_level} access to the %{source_link} %{source_type}.').html_safe % { access_level: member.human_access, source_link: source_link, source_type: source_type } +%p + - leave_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: leave_link } + = _('If this was a mistake you can %{leave_link_start}leave the %{source_type}%{link_end}.').html_safe % { source_type: source_type, leave_link_start: leave_link_start, link_end: link_end } diff --git a/app/views/notify/member_access_granted_email.text.erb b/app/views/notify/member_access_granted_email.text.erb index a9fb3a589a5..445009bb413 100644 --- a/app/views/notify/member_access_granted_email.text.erb +++ b/app/views/notify/member_access_granted_email.text.erb @@ -1,3 +1,8 @@ -You have been granted <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>. +<% source_type = member_source.model_name.singular %> +<%= _('You have been granted %{access_level} access to the %{source_name} %{source_type}.') % { access_level: member.human_access, source_name: member_source.human_name, source_type: source_type } %> <%= member_source.web_url %> + +<%= _('If this was a mistake you can leave the %{source_type}.') % { source_type: source_type } %> + +<%= polymorphic_url([member_source], leave: 1) %> diff --git a/app/views/notify/merge_request_status_email.text.haml b/app/views/notify/merge_request_status_email.text.haml index b9b9e0c3ad7..e3b24bbd405 100644 --- a/app/views/notify/merge_request_status_email.text.haml +++ b/app/views/notify/merge_request_status_email.text.haml @@ -5,4 +5,4 @@ Merge Request url: #{project_merge_request_url(@merge_request.target_project, @m = merge_path_description(@merge_request, 'to') Author: #{sanitize_name(@merge_request.author_name)} -Assignee: #{sanitize_name(@merge_request.assignee_name)} += assignees_label(@merge_request) diff --git a/app/views/notify/merge_request_unmergeable_email.text.haml b/app/views/notify/merge_request_unmergeable_email.text.haml index 0c7bf1bb044..e9708a297d7 100644 --- a/app/views/notify/merge_request_unmergeable_email.text.haml +++ b/app/views/notify/merge_request_unmergeable_email.text.haml @@ -5,4 +5,4 @@ Merge Request url: #{project_merge_request_url(@merge_request.target_project, @m = merge_path_description(@merge_request, 'to') Author: #{sanitize_name(@merge_request.author_name)} -Assignee: #{sanitize_name(@merge_request.assignee_name)} += assignees_label(@merge_request) diff --git a/app/views/notify/merged_merge_request_email.text.haml b/app/views/notify/merged_merge_request_email.text.haml index 045a43cbc84..d623e701a30 100644 --- a/app/views/notify/merged_merge_request_email.text.haml +++ b/app/views/notify/merged_merge_request_email.text.haml @@ -5,4 +5,4 @@ Merge Request url: #{project_merge_request_url(@merge_request.target_project, @m = merge_path_description(@merge_request, 'to') Author: #{sanitize_name(@merge_request.author_name)} -Assignee: #{sanitize_name(@merge_request.assignee_name)} += assignees_label(@merge_request) diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml index e6cdaf85c0d..8aa7939dd0b 100644 --- a/app/views/notify/new_issue_email.html.haml +++ b/app/views/notify/new_issue_email.html.haml @@ -4,7 +4,7 @@ - if @issue.assignees.any? %p - Assignee: #{@issue.assignee_list} + = assignees_label(@issue) - if @issue.description %div diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb index 58a2bcbe5eb..ff258711b48 100644 --- a/app/views/notify/new_issue_email.text.erb +++ b/app/views/notify/new_issue_email.text.erb @@ -2,6 +2,6 @@ New Issue was created. Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %> Author: <%= sanitize_name(@issue.author_name) %> -Assignee: <%= @issue.assignee_list %> +<%= assignees_label(@issue) %> <%= @issue.description %> diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb index 173091e4a80..8e95063b40f 100644 --- a/app/views/notify/new_mention_in_issue_email.text.erb +++ b/app/views/notify/new_mention_in_issue_email.text.erb @@ -2,6 +2,6 @@ You have been mentioned in an issue. Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %> Author: <%= sanitize_name(@issue.author_name) %> -Assignee: <%= sanitize_name(@issue.assignee_list) %> +<%= assignees_label(@issue) %> <%= @issue.description %> diff --git a/app/views/notify/new_mention_in_merge_request_email.text.erb b/app/views/notify/new_mention_in_merge_request_email.text.erb index 96a4f3f9eac..3c78e257a88 100644 --- a/app/views/notify/new_mention_in_merge_request_email.text.erb +++ b/app/views/notify/new_mention_in_merge_request_email.text.erb @@ -4,6 +4,6 @@ You have been mentioned in Merge Request <%= @merge_request.to_reference %> <%= merge_path_description(@merge_request, 'to') %> Author: <%= sanitize_name(@merge_request.author_name) %> -Assignee: <%= sanitize_name(@merge_request.assignee_name) %> += assignees_label(@merge_request) <%= @merge_request.description %> diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml index db23447dd39..9ab648e2a64 100644 --- a/app/views/notify/new_merge_request_email.html.haml +++ b/app/views/notify/new_merge_request_email.html.haml @@ -3,11 +3,11 @@ #{link_to @merge_request.author_name, user_url(@merge_request.author)} created a merge request: %p.details - != merge_path_description(@merge_request, '→') + = merge_path_description(@merge_request, '→') -- if @merge_request.assignee_id.present? +- if @merge_request.assignees.any? %p - Assignee: #{sanitize_name(@merge_request.assignee_name)} + = assignees_label(@merge_request) = render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter diff --git a/app/views/notify/new_merge_request_email.text.erb b/app/views/notify/new_merge_request_email.text.erb index 754f4bca1cd..e6c42f1cf5f 100644 --- a/app/views/notify/new_merge_request_email.text.erb +++ b/app/views/notify/new_merge_request_email.text.erb @@ -4,7 +4,7 @@ New Merge Request <%= @merge_request.to_reference %> <%= merge_path_description(@merge_request, 'to') %> Author: <%= @merge_request.author_name %> -Assignee: <%= @merge_request.assignee_name %> +<%= assignees_label(@merge_request) %> <%= render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter %> <%= @merge_request.description %> diff --git a/app/views/notify/new_user_email.html.haml b/app/views/notify/new_user_email.html.haml index dfbb5c75bd3..ec135ae994f 100644 --- a/app/views/notify/new_user_email.html.haml +++ b/app/views/notify/new_user_email.html.haml @@ -13,4 +13,5 @@ %p = link_to "Click here to set your password", edit_password_url(@user, reset_password_token: @token) %p - = raw reset_token_expire_message + This link is valid for #{password_reset_token_valid_time}. + After it expires, you can #{link_to("request a new one", new_user_password_url(user_email: @user.email))}. diff --git a/app/views/notify/new_user_email.text.erb b/app/views/notify/new_user_email.text.erb index f3f20f3bfba..7e0db75472d 100644 --- a/app/views/notify/new_user_email.text.erb +++ b/app/views/notify/new_user_email.text.erb @@ -1,10 +1,17 @@ Hi <%= sanitize_name(@user.name) %>! +<% if Gitlab::CurrentSettings.allow_signup? %> +Your account has been created successfully. +<% else %> The Administrator created an account for you. Now you are a member of the company GitLab application. +<% end %> login.................. <%= @user.email %> + <% if @user.created_by_id %> - <%= link_to "Click here to set your password", edit_password_url(@user, :reset_password_token => @token) %> +Click here to set your password: +<%= edit_password_url(@user, :reset_password_token => @token) %> - <%= reset_token_expire_message %> +This link is valid for <%= password_reset_token_valid_time %>. After it expires, you can request a new one here: +<%= new_user_password_url(user_email: @user.email) %> <% end %> diff --git a/app/views/notify/pages_domain_disabled_email.html.haml b/app/views/notify/pages_domain_disabled_email.html.haml index 34ce4238a12..224b79bfde8 100644 --- a/app/views/notify/pages_domain_disabled_email.html.haml +++ b/app/views/notify/pages_domain_disabled_email.html.haml @@ -10,6 +10,4 @@ If this domain has been disabled in error, please follow = link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') to verify and re-enable your domain. -%p - If you no longer wish to use this domain with GitLab Pages, please remove it - from your GitLab project and delete any related DNS records. += render 'removal_notification' diff --git a/app/views/notify/pages_domain_verification_failed_email.html.haml b/app/views/notify/pages_domain_verification_failed_email.html.haml index 0bb0eb09fd5..03b298f8e7c 100644 --- a/app/views/notify/pages_domain_verification_failed_email.html.haml +++ b/app/views/notify/pages_domain_verification_failed_email.html.haml @@ -12,6 +12,4 @@ Please visit = link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') for more information about custom domain verification. -%p - If you no longer wish to use this domain with GitLab Pages, please remove it - from your GitLab project and delete any related DNS records. += render 'removal_notification' diff --git a/app/views/notify/reassigned_issue_email.html.haml b/app/views/notify/reassigned_issue_email.html.haml index 6d25488a7e2..6b088927623 100644 --- a/app/views/notify/reassigned_issue_email.html.haml +++ b/app/views/notify/reassigned_issue_email.html.haml @@ -1,10 +1 @@ -%p - Assignee changed - - if @previous_assignees.any? - from - %strong= sanitize_name(@previous_assignees.map(&:name).to_sentence) - to - - if @issue.assignees.any? - %strong= @issue.assignee_list - - else - %strong Unassigned += render 'reassigned_issuable_email', issuable: @issue, previous_assignees: @previous_assignees diff --git a/app/views/notify/reassigned_merge_request_email.html.haml b/app/views/notify/reassigned_merge_request_email.html.haml index e4f19bc3200..0aefca6b14a 100644 --- a/app/views/notify/reassigned_merge_request_email.html.haml +++ b/app/views/notify/reassigned_merge_request_email.html.haml @@ -1,10 +1 @@ -%p - Assignee changed - - if @previous_assignee - from - %strong= sanitize_name(@previous_assignee.name) - to - - if @merge_request.assignee_id - %strong= sanitize_name(@merge_request.assignee_name) - - else - %strong Unassigned += render 'reassigned_issuable_email', issuable: @merge_request, previous_assignees: @previous_assignees diff --git a/app/views/notify/reassigned_merge_request_email.text.erb b/app/views/notify/reassigned_merge_request_email.text.erb index 96c770b5219..82ec7aa0fa4 100644 --- a/app/views/notify/reassigned_merge_request_email.text.erb +++ b/app/views/notify/reassigned_merge_request_email.text.erb @@ -2,5 +2,5 @@ Reassigned Merge Request <%= @merge_request.iid %> <%= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) %> -Assignee changed <%= "from #{sanitize_name(@previous_assignee.name)}" if @previous_assignee -%> - to <%= "#{@merge_request.assignee_id ? sanitize_name(@merge_request.assignee_name) : 'Unassigned'}" %> +Assignee changed <%= "from #{sanitize_name(@previous_assignees.map(&:name).to_sentence)}" if @previous_assignees.any? -%> + to <%= "#{@merge_request.assignees.any? ? @merge_request.assignee_list : 'Unassigned'}" %> diff --git a/app/views/profiles/_email_settings.html.haml b/app/views/profiles/_email_settings.html.haml new file mode 100644 index 00000000000..fb4da08e129 --- /dev/null +++ b/app/views/profiles/_email_settings.html.haml @@ -0,0 +1,16 @@ +- form = local_assigns.fetch(:form) +- readonly = @user.read_only_attribute?(:email) +- email_change_disabled = local_assigns.fetch(:email_change_disabled, nil) +- read_only_help_text = readonly ? s_("Profiles|Your email address was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:email) } : user_email_help_text(@user) +- help_text = email_change_disabled ? s_("Your account uses dedicated credentials for the \"%{group_name}\" group and can only be updated through SSO.") % { group_name: @user.managing_group.name } : read_only_help_text + += form.text_field :email, required: true, class: 'input-lg', value: (@user.email unless @user.temp_oauth_email?), help: help_text.html_safe, readonly: readonly || email_change_disabled += form.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email), + { help: s_("Profiles|This email will be displayed on your public profile"), include_blank: s_("Profiles|Do not show on profile") }, + control_class: 'select2 input-lg', disabled: email_change_disabled +- commit_email_link_url = help_page_path('user/profile/index', anchor: 'commit-email', target: '_blank') +- commit_email_link_start = '<a href="%{url}">'.html_safe % { url: commit_email_link_url } +- commit_email_docs_link = s_('Profiles|This email will be used for web based operations, such as edits and merges. %{commit_email_link_start}Learn more%{commit_email_link_end}').html_safe % { commit_email_link_start: commit_email_link_start, commit_email_link_end: '</a>'.html_safe } += form.select :commit_email, options_for_select(commit_email_select_options(@user), selected: selected_commit_email(@user)), + { help: commit_email_docs_link }, + control_class: 'select2 input-lg', disabled: email_change_disabled diff --git a/app/views/profiles/_event_table.html.haml b/app/views/profiles/_event_table.html.haml index 9f525547dd9..977ff30d5a6 100644 --- a/app/views/profiles/_event_table.html.haml +++ b/app/views/profiles/_event_table.html.haml @@ -1,14 +1,12 @@ %h5.prepend-top-0 - History of authentications + = _('History of authentications') %ul.content-list - events.each do |event| %li %span.description = audit_icon(event.details[:with], class: "append-right-5") - Signed in with - = event.details[:with] - authentication + = _('Signed in with %{authentication} authentication') % { authentication: event.details[:with]} %span.float-right= time_ago_with_tooltip(event.created_at) = paginate events, theme: "gitlab" diff --git a/app/views/profiles/accounts/_providers.html.haml b/app/views/profiles/accounts/_providers.html.haml new file mode 100644 index 00000000000..068f9cc70f7 --- /dev/null +++ b/app/views/profiles/accounts/_providers.html.haml @@ -0,0 +1,21 @@ +%label.label-bold + = s_('Profiles|Connected Accounts') + %p= s_('Profiles|Click on icon to activate signin with one of the following services') + - providers.each do |provider| + - unlink_allowed = unlink_provider_allowed?(provider) + - link_allowed = link_provider_allowed?(provider) + - if unlink_allowed || link_allowed + .provider-btn-group + .provider-btn-image + = provider_image_tag(provider) + - if auth_active?(provider) + - if unlink_allowed + = link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'provider-btn' do + = s_('Profiles|Disconnect') + - else + %a.provider-btn + = s_('Profiles|Active') + - elsif link_allowed + = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn not-active' do + = s_('Profiles|Connect') + = render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: group_saml_identities diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index ee2c5a13b8a..e6380817c8f 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -29,24 +29,7 @@ %p = s_('Profiles|Activate signin with one of the following services') .col-lg-8 - %label.label-bold - = s_('Profiles|Connected Accounts') - %p= s_('Profiles|Click on icon to activate signin with one of the following services') - - button_based_providers.each do |provider| - .provider-btn-group - .provider-btn-image - = provider_image_tag(provider) - - if auth_active?(provider) - - if unlink_allowed?(provider) - = link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'provider-btn' do - = s_('Profiles|Disconnect') - - else - %a.provider-btn - = s_('Profiles|Active') - - else - = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn not-active' do - = s_('Profiles|Connect') - = render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: local_assigns[:group_saml_identities] + = render 'providers', providers: button_based_providers, group_saml_identities: local_assigns[:group_saml_identities] %hr - if current_user.can_change_username? .row.prepend-top-default diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/profiles/active_sessions/_active_session.html.haml index 23ef31a0c85..bb31049111c 100644 --- a/app/views/profiles/active_sessions/_active_session.html.haml +++ b/app/views/profiles/active_sessions/_active_session.html.haml @@ -8,24 +8,19 @@ %div %strong= active_session.ip_address - if is_current_session - %div This is your current session + %div + = _('This is your current session') - else %div - Last accessed on + = _('Last accessed on') = l(active_session.updated_at, format: :short) %div %strong= active_session.browser - on + = s_('ProfileSession|on') %strong= active_session.os %div - %strong Signed in - on + %strong= _('Signed in') + = s_('ProfileSession|on') = l(active_session.created_at, format: :short) - - - unless is_current_session - .float-right - = link_to profile_active_session_path(active_session.session_id), data: { confirm: 'Are you sure? The device will be signed out of GitLab.' }, method: :delete, class: "btn btn-danger prepend-left-10" do - %span.sr-only Revoke - Revoke diff --git a/app/views/profiles/active_sessions/index.html.haml b/app/views/profiles/active_sessions/index.html.haml index 8688a52843d..d651319fc3f 100644 --- a/app/views/profiles/active_sessions/index.html.haml +++ b/app/views/profiles/active_sessions/index.html.haml @@ -1,4 +1,4 @@ -- page_title 'Active Sessions' +- page_title _('Active Sessions') - @content_class = "limit-container-width" unless fluid_layout .row.prepend-top-default @@ -6,7 +6,7 @@ %h4.prepend-top-0 = page_title %p - This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize. + = _('This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize.') .col-lg-8 .append-bottom-default diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml index a924369050b..275c0428d34 100644 --- a/app/views/profiles/audit_log.html.haml +++ b/app/views/profiles/audit_log.html.haml @@ -1,4 +1,4 @@ -- page_title "Authentication log" +- page_title _('Authentication log') - @content_class = "limit-container-width" unless fluid_layout .row.prepend-top-default @@ -6,6 +6,6 @@ %h4.prepend-top-0 = page_title %p - This is a security log of important events involving your account. + = _('This is a security log of important events involving your account.') .col-lg-8 = render 'event_table', events: @events diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml index 9e82e47c1e1..ff67f92ad07 100644 --- a/app/views/profiles/chat_names/_chat_name.html.haml +++ b/app/views/profiles/chat_names/_chat_name.html.haml @@ -21,7 +21,7 @@ - if chat_name.last_used_at = time_ago_with_tooltip(chat_name.last_used_at) - else - Never + = _('Never') %td - = link_to 'Remove', profile_chat_name_path(chat_name), method: :delete, class: 'btn btn-danger float-right', data: { confirm: 'Are you sure you want to revoke this nickname?' } + = link_to _('Remove'), profile_chat_name_path(chat_name), method: :delete, class: 'btn btn-danger float-right', data: { confirm: _('Are you sure you want to revoke this nickname?') } diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml index 4b6e419af50..0c8098a97d5 100644 --- a/app/views/profiles/chat_names/index.html.haml +++ b/app/views/profiles/chat_names/index.html.haml @@ -1,4 +1,4 @@ -- page_title 'Chat' +- page_title _('Chat') - @content_class = "limit-container-width" unless fluid_layout .row.prepend-top-default @@ -6,7 +6,7 @@ %h4.prepend-top-0 = page_title %p - You can see your Chat accounts. + = _('You can see your chat accounts.') .col-lg-8 %h5 Active chat names (#{@chat_names.size}) @@ -16,15 +16,15 @@ %table.table.chat-names %thead %tr - %th Project - %th Service - %th Team domain - %th Nickname - %th Last used + %th= _('Project') + %th= _('Service') + %th= _('Team domain') + %th= _('Nickname') + %th= _('Last used') %th %tbody = render @chat_names - else .settings-message.text-center - You don't have any active chat names. + = _("You don't have any active chat names.") diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index 1823f191fb3..c90a0b3e329 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -26,7 +26,9 @@ %li Your Commit Email will be used for web based operations, such as edits and merges. %li - Your Notification Email will be used for account notifications. + Your Default Notification Email will be used for account notifications if a + = link_to 'group-specific email address', profile_notifications_path + is not set. %li Your Public Email will be displayed on your public profile. %li @@ -41,7 +43,7 @@ - if @primary_email === current_user.public_email %span.badge.badge-info Public email - if @primary_email === current_user.notification_email - %span.badge.badge-info Notification email + %span.badge.badge-info Default notification email - @emails.each do |email| %li = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? } diff --git a/app/views/profiles/gpg_keys/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml index 6c4cb614a2b..225487b2638 100644 --- a/app/views/profiles/gpg_keys/_form.html.haml +++ b/app/views/profiles/gpg_keys/_form.html.haml @@ -3,8 +3,8 @@ = form_errors(@gpg_key) .form-group - = f.label :key, class: 'label-bold' - = f.text_area :key, class: "form-control", rows: 8, required: true, placeholder: "Don't paste the private part of the GPG key. Paste the public part which begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'." + = f.label :key, s_('Profiles|Key'), class: 'label-bold' + = f.text_area :key, class: "form-control", rows: 8, required: true, placeholder: _("Don't paste the private part of the GPG key. Paste the public part which begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'.") .prepend-top-default - = f.submit 'Add key', class: "btn btn-success" + = f.submit s_('Profiles|Add key'), class: "btn btn-success" diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml index d1fd7bc8e71..f8351644df5 100644 --- a/app/views/profiles/gpg_keys/_key.html.haml +++ b/app/views/profiles/gpg_keys/_key.html.haml @@ -9,17 +9,19 @@ %code= key.fingerprint - if key.subkeys.present? .subkeys - %span.bold Subkeys: + %span.bold + = _('Subkeys') + = ':' %ul.subkeys-list - key.subkeys.each do |subkey| %li %code= subkey.fingerprint .float-right %span.key-created-at - created #{time_ago_with_tooltip(key.created_at)} - = link_to profile_gpg_key_path(key), data: { confirm: 'Are you sure? Removing this GPG key does not affect already signed commits.' }, method: :delete, class: "btn btn-danger prepend-left-10" do - %span.sr-only Remove + = s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)} + = link_to profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: "btn btn-danger prepend-left-10" do + %span.sr-only= _('Remove') = icon('trash') - = link_to revoke_profile_gpg_key_path(key), data: { confirm: 'Are you sure? All commits that were signed with this GPG key will be unverified.' }, method: :put, class: "btn btn-danger prepend-left-10" do - %span.sr-only Revoke - Revoke + = link_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: "btn btn-danger prepend-left-10" do + %span.sr-only= _('Revoke') + = _('Revoke') diff --git a/app/views/profiles/gpg_keys/_key_table.html.haml b/app/views/profiles/gpg_keys/_key_table.html.haml index b9b60c218fd..ebbd1c8f672 100644 --- a/app/views/profiles/gpg_keys/_key_table.html.haml +++ b/app/views/profiles/gpg_keys/_key_table.html.haml @@ -6,6 +6,6 @@ - else %p.settings-message.text-center - if is_admin - There are no GPG keys associated with this account. + = _('There are no GPG keys associated with this account.') - else - There are no GPG keys with access to your account. + = _('There are no GPG keys with access to your account.') diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml index 1d2e41cb437..f9f898a9225 100644 --- a/app/views/profiles/gpg_keys/index.html.haml +++ b/app/views/profiles/gpg_keys/index.html.haml @@ -1,4 +1,4 @@ -- page_title "GPG Keys" +- page_title _('GPG Keys') - @content_class = "limit-container-width" unless fluid_layout .row.prepend-top-default @@ -6,16 +6,16 @@ %h4.prepend-top-0 = page_title %p - GPG keys allow you to verify signed commits. + = _('GPG keys allow you to verify signed commits.') .col-lg-8 %h5.prepend-top-0 - Add a GPG key + = _('Add a GPG key') %p.profile-settings-content - Before you can add a GPG key you need to - = link_to 'generate it.', help_page_path('user/project/repository/gpg_signed_commits/index.md') + - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/repository/gpg_signed_commits/index.md') } + = _('Before you can add a GPG key you need to %{help_link_start}Generate it.%{help_link_end}'.html_safe) % {help_link_start: help_link_start, help_link_end:'</a>'.html_safe } = render 'form' %hr %h5 - Your GPG keys (#{@gpg_keys.count}) + = _('Your GPG keys (%{count})') % { count:@gpg_keys.count} .append-bottom-default = render 'key_table' diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml index 21eef08983c..7846cdbcd52 100644 --- a/app/views/profiles/keys/_form.html.haml +++ b/app/views/profiles/keys/_form.html.haml @@ -3,11 +3,11 @@ = form_errors(@key) .form-group - = f.label :key, class: 'label-bold' + = f.label :key, s_('Profiles|Key'), class: 'label-bold' %p= _("Paste your public SSH key, which is usually contained in the file '~/.ssh/id_rsa.pub' and begins with 'ssh-rsa'. Don't use your private SSH key.") = f.text_area :key, class: "form-control js-add-ssh-key-validation-input qa-key-public-key-field", rows: 8, required: true, placeholder: s_('Profiles|Typically starts with "ssh-rsa …"') .form-group - = f.label :title, class: 'label-bold' + = f.label :title, _('Title'), class: 'label-bold' = f.text_field :title, class: "form-control input-lg qa-key-title-field", required: true, placeholder: s_('Profiles|e.g. My MacBook key') %p.form-text.text-muted= _('Name your individual key via a title') diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index ce20994b0f4..b9d73d89334 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -17,7 +17,8 @@ = key.last_used_at ? time_ago_with_tooltip(key.last_used_at) : 'n/a' .float-right %span.key-created-at - created #{time_ago_with_tooltip(key.created_at)} - = link_to path_to_key(key, is_admin), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-transparent prepend-left-10" do - %span.sr-only Remove - = icon('trash') + = s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)} + - if key.can_delete? + = link_to path_to_key(key, is_admin), data: { confirm: _('Are you sure?')}, method: :delete, class: "btn btn-transparent prepend-left-10" do + %span.sr-only= _('Remove') + = icon('trash') diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index 88473c7f72d..0ef01dec493 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -3,25 +3,26 @@ .col-md-4 .card .card-header - SSH Key + = _('SSH Key') %ul.content-list %li - %span.light Title: + %span.light= _('Title:') %strong= @key.title %li - %span.light Created on: + %span.light= _('Created on:') %strong= @key.created_at.to_s(:medium) %li - %span.light Last used on: + %span.light= _('Last used on:') %strong= @key.last_used_at.try(:to_s, :medium) || 'N/A' .col-md-8 = form_errors(@key, type: 'key') unless @key.valid? %p - %span.light Fingerprint: + %span.light= _('Fingerprint:') %code.key-fingerprint= @key.fingerprint %pre.well-pre = @key.key .col-md-12 .float-right - = link_to 'Remove', path_to_key(@key, is_admin), data: {confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove delete-key qa-delete-key-button" + - if @key.can_delete? + = link_to _('Remove'), path_to_key(@key, is_admin), data: {confirm: _('Are you sure?')}, method: :delete, class: "btn btn-remove delete-key qa-delete-key-button" diff --git a/app/views/profiles/keys/_key_table.html.haml b/app/views/profiles/keys/_key_table.html.haml index e088140fdd2..4a6d8a1870d 100644 --- a/app/views/profiles/keys/_key_table.html.haml +++ b/app/views/profiles/keys/_key_table.html.haml @@ -6,6 +6,6 @@ - else %p.settings-message.text-center - if is_admin - There are no SSH keys associated with this account. + = _('There are no SSH keys associated with this account.') - else - There are no SSH keys with access to your account. + = _('There are no SSH keys with access to your account.') diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml index 55ca8d0ebd4..da6aa0fce3a 100644 --- a/app/views/profiles/keys/index.html.haml +++ b/app/views/profiles/keys/index.html.haml @@ -1,4 +1,4 @@ -- page_title "SSH Keys" +- page_title _('SSH Keys') - @content_class = "limit-container-width" unless fluid_layout .row.prepend-top-default @@ -6,10 +6,10 @@ %h4.prepend-top-0 = page_title %p - SSH keys allow you to establish a secure connection between your computer and GitLab. + = _('SSH keys allow you to establish a secure connection between your computer and GitLab.') .col-lg-8 %h5.prepend-top-0 - Add an SSH key + = _('Add an SSH key') %p.profile-settings-content - generate_link_url = help_page_path("ssh/README", anchor: 'generating-a-new-ssh-key-pair') - existing_link_url = help_page_path("ssh/README", anchor: 'locating-an-existing-ssh-key-pair') @@ -19,6 +19,6 @@ = render 'form' %hr %h5 - Your SSH keys (#{@keys.count}) + = _('Your SSH keys (%{count})') % { count:@keys.count } .append-bottom-default = render 'key_table' diff --git a/app/views/profiles/keys/show.html.haml b/app/views/profiles/keys/show.html.haml index 28be6172219..360de7a0c11 100644 --- a/app/views/profiles/keys/show.html.haml +++ b/app/views/profiles/keys/show.html.haml @@ -1,5 +1,5 @@ - add_to_breadcrumbs "SSH Keys", profile_keys_path - breadcrumb_title @key.title -- page_title @key.title, "SSH Keys" +- page_title @key.title, _('SSH Keys') - @content_class = "limit-container-width" unless fluid_layout = render "key_details" diff --git a/app/views/profiles/notifications/_email_settings.html.haml b/app/views/profiles/notifications/_email_settings.html.haml new file mode 100644 index 00000000000..34dcf8f5402 --- /dev/null +++ b/app/views/profiles/notifications/_email_settings.html.haml @@ -0,0 +1,6 @@ +- form = local_assigns.fetch(:form) +.form-group + = form.label :notification_email, class: "label-bold" + = form.select :notification_email, @user.all_emails, { include_blank: false }, class: "select2", disabled: local_assigns.fetch(:email_change_disabled, nil) + .help-block + = local_assigns.fetch(:help_text, nil) diff --git a/app/views/profiles/notifications/_group_settings.html.haml b/app/views/profiles/notifications/_group_settings.html.haml index a12246bcdcc..cf17ee44145 100644 --- a/app/views/profiles/notifications/_group_settings.html.haml +++ b/app/views/profiles/notifications/_group_settings.html.haml @@ -1,12 +1,17 @@ -%li.notification-list-item - %span.notification.fa.fa-holder.append-right-5 - - if setting.global? - = notification_icon(current_user.global_notification_setting.level) - - else - = notification_icon(setting.level) +.gl-responsive-table-row.notification-list-item + .table-section.section-40 + %span.notification.fa.fa-holder.append-right-5 + - if setting.global? + = notification_icon(current_user.global_notification_setting.level) + - else + = notification_icon(setting.level) - %span.str-truncated - = link_to group.name, group_path(group) + %span.str-truncated + = link_to group.name, group_path(group) - .float-right + .table-section.section-30.text-right = render 'shared/notifications/button', notification_setting: setting + + .table-section.section-30 + = form_for @user.notification_settings.find { |ns| ns.source == group }, url: profile_notifications_group_path(group), method: :put, html: { class: 'update-notifications' } do |f| + = f.select :notification_email, @user.all_emails, { include_blank: 'Global notification email' }, class: 'select2 js-group-notification-email' diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index 712eb2a4573..1f311e9a4a4 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -1,4 +1,4 @@ -- page_title "Notifications" +- page_title _('Notifications') - @content_class = "limit-container-width" unless fluid_layout %div @@ -14,17 +14,15 @@ %h4.prepend-top-0 = page_title %p - You can specify notification level per group or per project. + = _('You can specify notification level per group or per project.') %p - By default, all projects and groups will use the global notifications setting. + = _('By default, all projects and groups will use the global notifications setting.') .col-lg-8 %h5.prepend-top-0 - Global notification settings + = _('Global notification settings') = form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications prepend-top-default' } do |f| - .form-group - = f.label :notification_email, class: "label-bold" - = f.select :notification_email, @user.all_emails, { include_blank: false }, class: "select2" + = render_if_exists 'profiles/notifications/email_settings', form: f = label_tag :global_notification_level, "Global notification level", class: "label-bold" %br @@ -37,19 +35,18 @@ = form_for @user, url: profile_notifications_path, method: :put do |f| %label{ for: 'user_notified_of_own_activity' } = f.check_box :notified_of_own_activity - %span Receive notifications about your own activity + %span= _('Receive notifications about your own activity') %hr %h5 - Groups (#{@group_notifications.count}) + = _('Groups (%{count})') % { count: @group_notifications.count } %div - %ul.bordered-list - - @group_notifications.each do |setting| - = render 'group_settings', setting: setting, group: setting.source + - @group_notifications.each do |setting| + = render 'group_settings', setting: setting, group: setting.source %h5 - Projects (#{@project_notifications.count}) + = _('Projects (%{count})') % { count: @project_notifications.count } %p.account-well - To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there. + = _('To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there.') .append-bottom-default %ul.bordered-list - @project_notifications.each do |setting| diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml index 0b4b9841ea1..ac8c31189d0 100644 --- a/app/views/profiles/passwords/edit.html.haml +++ b/app/views/profiles/passwords/edit.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title "Edit Password" -- page_title "Password" +- breadcrumb_title _('Edit Password') +- page_title _('Password') - @content_class = "limit-container-width" unless fluid_layout .row.prepend-top-default @@ -7,28 +7,29 @@ %h4.prepend-top-0 = page_title %p - After a successful password update, you will be redirected to the login page where you can log in with your new password. + = _('After a successful password update, you will be redirected to the login page where you can log in with your new password.') .col-lg-8 %h5.prepend-top-0 - Change your password - - unless @user.password_automatically_set? - or recover your current one + - if @user.password_automatically_set + = _('Change your password') + - else + = _('Change your password or recover your current one') = form_for @user, url: profile_password_path, method: :put, html: {class: "update-password"} do |f| = form_errors(@user) - unless @user.password_automatically_set? .form-group - = f.label :current_password, class: 'label-bold' + = f.label :current_password, _('Current password'), class: 'label-bold' = f.password_field :current_password, required: true, class: 'form-control' %p.form-text.text-muted - You must provide your current password in order to change it. + = _('You must provide your current password in order to change it.') .form-group - = f.label :password, 'New password', class: 'label-bold' + = f.label :password, _('New password'), class: 'label-bold' = f.password_field :password, required: true, class: 'form-control' .form-group - = f.label :password_confirmation, class: 'label-bold' + = f.label :password_confirmation, _('Password confirmation'), class: 'label-bold' = f.password_field :password_confirmation, required: true, class: 'form-control' .prepend-top-default.append-bottom-default - = f.submit 'Save password', class: "btn btn-success append-right-10" + = f.submit _('Save password'), class: "btn btn-success append-right-10" - unless @user.password_automatically_set? - = link_to "I forgot my password", reset_profile_password_path, method: :put, class: "account-btn-link" + = link_to _('I forgot my password'), reset_profile_password_path, method: :put, class: "account-btn-link" diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/profiles/passwords/new.html.haml index 4b84835429c..ce60455ab89 100644 --- a/app/views/profiles/passwords/new.html.haml +++ b/app/views/profiles/passwords/new.html.haml @@ -13,13 +13,18 @@ - unless @user.password_automatically_set? .form-group.row - = f.label :current_password, class: 'col-form-label col-sm-2' - .col-sm-10= f.password_field :current_password, required: true, class: 'form-control' + .col-sm-2.col-form-label + = f.label :current_password, _('Current password') + .col-sm-10 + = f.password_field :current_password, required: true, class: 'form-control' .form-group.row - = f.label :password, class: 'col-form-label col-sm-2' - .col-sm-10= f.password_field :password, required: true, class: 'form-control' + .col-sm-2.col-form-label + = f.label :password, _('New password') + .col-sm-10 + = f.password_field :password, required: true, class: 'form-control' .form-group.row - = f.label :password_confirmation, class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :password_confirmation, _('Password confirmation') .col-sm-10 = f.password_field :password_confirmation, required: true, class: 'form-control' .form-actions diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index bfe1c3ddf33..4ebfaff0860 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -1,11 +1,12 @@ -- page_title 'Preferences' +- page_title _('Preferences') - @content_class = "limit-container-width" unless fluid_layout = form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f| .col-lg-4.application-theme %h4.prepend-top-0 = s_('Preferences|Navigation theme') - %p Customize the appearance of the application header and navigation sidebar. + %p + = s_('Preferences|Customize the appearance of the application header and navigation sidebar.') .col-lg-8.application-theme - Gitlab::Themes.each do |theme| = label_tag do @@ -18,11 +19,11 @@ .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 - Syntax highlighting theme + = s_('Preferences|Syntax highlighting theme') %p - This setting allows you to customize the appearance of the syntax. + = s_('Preferences|This setting allows you to customize the appearance of the syntax.') = succeed '.' do - = link_to 'Learn more', help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank' + = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank' .col-lg-8.syntax-theme - Gitlab::ColorSchemes.each do |scheme| = label_tag do @@ -35,31 +36,31 @@ .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 - Behavior + = s_('Preferences|Behavior') %p - This setting allows you to customize the behavior of the system layout and default views. + = s_('Preferences|This setting allows you to customize the behavior of the system layout and default views.') = succeed '.' do - = link_to 'Learn more', help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank' + = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank' .col-lg-8 .form-group = f.label :layout, class: 'label-bold' do - Layout width + = s_('Preferences|Layout width') = f.select :layout, layout_choices, {}, class: 'form-control' .form-text.text-muted - Choose between fixed (max. 1280px) and fluid (100%) application layout. + = s_('Preferences|Choose between fixed (max. 1280px) and fluid (100%%) application layout.') .form-group = f.label :dashboard, class: 'label-bold' do - Default dashboard + = s_('Preferences|Default dashboard') = f.select :dashboard, dashboard_choices, {}, class: 'form-control' = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific .form-group = f.label :project_view, class: 'label-bold' do - Project overview content + = s_('Preferences|Project overview content') = f.select :project_view, project_view_choices, {}, class: 'form-control' .form-text.text-muted - Choose what content you want to see on a project’s overview page. + = s_('Preferences|Choose what content you want to see on a project’s overview page.') .col-sm-12 %hr @@ -82,5 +83,31 @@ = f.label :first_day_of_week, class: 'label-bold' do = _('First day of the week') = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'form-control' + - if Feature.enabled?(:user_time_settings) + .col-sm-12 + %hr + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0= s_('Preferences|Time preferences') + %p= s_('Preferences|These settings will update how dates and times are displayed for you.') + .col-lg-8 + .form-group + %h5= s_('Preferences|Time format') + .checkbox-icon-inline-wrapper.form-check + - time_format_label = capture do + = s_('Preferences|Display time in 24-hour format') + = f.check_box :time_format_in_24h, class: 'form-check-input' + = f.label :time_format_in_24h do + = time_format_label + %h5= s_('Preferences|Time display') + .checkbox-icon-inline-wrapper.form-check + - time_display_label = capture do + = s_('Preferences|Use relative times') + = f.check_box :time_display_relative, class: 'form-check-input' + = f.label :time_display_relative do + = time_display_label + .text-muted + = s_('Preferences|For example: 30 mins ago.') + .col-lg-4.profile-settings-sidebar + .col-lg-8 .form-group = f.submit _('Save changes'), class: 'btn btn-success' diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 4d3d92d09c0..e36d5192a29 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -47,9 +47,9 @@ - if @user.status = emoji_icon @user.status.emoji %span#js-no-emoji-placeholder.no-emoji-placeholder{ class: ('hidden' if @user.status) } - = sprite_icon('emoji_slightly_smiling_face', css_class: 'award-control-icon-neutral') - = sprite_icon('emoji_smiley', css_class: 'award-control-icon-positive') - = sprite_icon('emoji_smile', css_class: 'award-control-icon-super-positive') + = sprite_icon('slight-smile', css_class: 'award-control-icon-neutral') + = sprite_icon('smiley', css_class: 'award-control-icon-positive') + = sprite_icon('smile', css_class: 'award-control-icon-super-positive') - reset_message_button = button_tag type: :button, id: 'js-clear-user-status-button', class: 'clear-user-status btn has-tooltip', @@ -64,6 +64,18 @@ prepend: emoji_button, append: reset_message_button, placeholder: s_("Profiles|What's your status?") + - if Feature.enabled?(:user_time_settings) + %hr + .row.user-time-preferences + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0= s_("Profiles|Time settings") + %p= s_("Profiles|You can set your current timezone here") + .col-lg-8 + -# TODO: might need an entry in user/profile.md to describe some of these settings + -# https://gitlab.com/gitlab-org/gitlab-ce/issues/60070 + %h5= ("Time zone") + = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'btn js-timezone-dropdown input-lg', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } ) + %input.hidden{ :type => 'hidden', :id => 'user_timezone', :name => 'user[timezone]', value: @user.timezone } %hr .row @@ -80,21 +92,10 @@ = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9 qa-full-name' }, help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) } - else - = f.text_field :name, label: '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' }, help: s_("Profiles|Enter your name, so people you know can recognize you") - = f.text_field :id, readonly: true, label: 'User ID', wrapper: { class: 'col-md-3' } + = 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' }, 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' } - - if @user.read_only_attribute?(:email) - = f.text_field :email, required: true, class: 'input-lg', readonly: true, help: s_("Profiles|Your email address was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:email) } - - else - = f.text_field :email, required: true, class: 'input-lg', value: (@user.email unless @user.temp_oauth_email?), - help: user_email_help_text(@user) - = f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email), - { help: s_("Profiles|This email will be displayed on your public profile"), include_blank: s_("Profiles|Do not show on profile") }, - control_class: 'select2 input-lg' - - commit_email_docs_link = link_to s_('Profiles|Learn more'), help_page_path('user/profile/index', anchor: 'commit-email', target: '_blank') - = f.select :commit_email, options_for_select(commit_email_select_options(@user), selected: selected_commit_email(@user)), - { help: s_("Profiles|This email will be used for web based operations, such as edits and merges. %{learn_more}").html_safe % { learn_more: commit_email_docs_link } }, - control_class: 'select2 input-lg' + = render_if_exists 'profiles/email_settings', form: f = f.text_field :skype, class: 'input-md', placeholder: s_("Profiles|username") = f.text_field :linkedin, class: 'input-md', help: s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename") = f.text_field :twitter, class: 'input-md', placeholder: s_("Profiles|@username") @@ -102,18 +103,18 @@ - if @user.read_only_attribute?(:location) = f.text_field :location, readonly: true, help: s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) } - else - = f.text_field :location, class: 'input-lg', placeholder: s_("Profiles|City, country") - = f.text_field :organization, class: 'input-md', help: s_("Profiles|Who you represent or work for") - = f.text_area :bio, rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters") + = f.text_field :location, label: s_('Profiles|Location'), class: 'input-lg', placeholder: s_("Profiles|City, country") + = f.text_field :organization, label: s_('Profiles|Organization'), class: 'input-md', help: s_("Profiles|Who you represent or work for") + = f.text_area :bio, label: s_('Profiles|Bio'), rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters") %hr - %h5= ("Private profile") + %h5= s_("Private profile") .checkbox-icon-inline-wrapper - private_profile_label = capture do = s_("Profiles|Don't display activity-related personal information on your profiles") - = f.check_box :private_profile, label: private_profile_label + = f.check_box :private_profile, label: private_profile_label, inline: true, wrapper_class: 'mr-0' = link_to icon('question-circle'), help_page_path('user/profile/index.md', anchor: 'private-profile') %h5= s_("Profiles|Private contributions") - = f.check_box :include_private_contributions, label: 'Include private contributions on my profile' + = f.check_box :include_private_contributions, label: s_('Profiles|Include private contributions on my profile'), wrapper_class: 'mb-2', inline: true .help-block = s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information") .prepend-top-default.append-bottom-default diff --git a/app/views/profiles/two_factor_auths/_codes.html.haml b/app/views/profiles/two_factor_auths/_codes.html.haml index 759d39cf5f5..be0af977011 100644 --- a/app/views/profiles/two_factor_auths/_codes.html.haml +++ b/app/views/profiles/two_factor_auths/_codes.html.haml @@ -1,8 +1,6 @@ %p.slead - Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one - time each to regain access to your account. Please save them in a safe place, or you - %b will - lose access to your account. + - lose_2fa_message = _('Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{b_start}will%{b_end} lose access to your account.') % { b_start:'<b>', b_end:'</b>' } + = lose_2fa_message.html_safe .codes.card %ul @@ -11,5 +9,5 @@ %span.monospace= code .d-flex - = link_to 'Proceed', profile_account_path, class: 'btn btn-success append-right-10' - = link_to 'Download codes', "data:text/plain;charset=utf-8,#{CGI.escape(@codes.join("\n"))}", download: "gitlab-recovery-codes.txt", class: 'btn btn-default' + = link_to _('Proceed'), profile_account_path, class: 'btn btn-success append-right-10' + = link_to _('Download codes'), "data:text/plain;charset=utf-8,#{CGI.escape(@codes.join("\n"))}", download: "gitlab-recovery-codes.txt", class: 'btn btn-default' diff --git a/app/views/profiles/two_factor_auths/codes.html.haml b/app/views/profiles/two_factor_auths/codes.html.haml index addf356697a..53907ebffab 100644 --- a/app/views/profiles/two_factor_auths/codes.html.haml +++ b/app/views/profiles/two_factor_auths/codes.html.haml @@ -1,5 +1,6 @@ -- page_title 'Recovery Codes', 'Two-factor Authentication' +- page_title _('Recovery Codes'), _('Two-factor Authentication') -%h3.page-title Two-factor Authentication Recovery codes +%h3.page-title + = _('Two-factor Authentication Recovery codes') %hr = render 'codes' diff --git a/app/views/profiles/two_factor_auths/create.html.haml b/app/views/profiles/two_factor_auths/create.html.haml index e330aadac13..973eb8136c4 100644 --- a/app/views/profiles/two_factor_auths/create.html.haml +++ b/app/views/profiles/two_factor_auths/create.html.haml @@ -1,6 +1,6 @@ -- page_title 'Two-factor Authentication', 'Account' +- page_title _('Two-factor Authentication'), _('Account') .alert.alert-success - Congratulations! You have enabled Two-factor Authentication! + = _('Congratulations! You have enabled Two-factor Authentication!') = render 'codes' diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index d986c566928..5501e63e027 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -1,72 +1,68 @@ -- page_title 'Two-Factor Authentication', 'Account' -- add_to_breadcrumbs("Two-Factor Authentication", profile_account_path) +- page_title _('Two-Factor Authentication'), _('Account') +- add_to_breadcrumbs(_('Two-Factor Authentication'), profile_account_path) - @content_class = "limit-container-width" unless fluid_layout .js-two-factor-auth{ 'data-two-factor-skippable' => "#{two_factor_skippable?}", 'data-two_factor_skip_url' => skip_profile_two_factor_auth_path } .row.prepend-top-default .col-lg-4 %h4.prepend-top-0 - Register Two-Factor Authenticator + = _('Register Two-Factor Authenticator') %p - Use an one time password authenticator on your mobile device or computer to enable two-factor authentication (2FA). + = _('Use an one time password authenticator on your mobile device or computer to enable two-factor authentication (2FA).') .col-lg-8 - if current_user.two_factor_otp_enabled? %p - You've already enabled two-factor authentication using one time password authenticators. In order to register a different device, you must first disable two-factor authentication. + = _("You've already enabled two-factor authentication using one time password authenticators. In order to register a different device, you must first disable two-factor authentication.") %p - If you lose your recovery codes you can generate new ones, invalidating all previous codes. + = _('If you lose your recovery codes you can generate new ones, invalidating all previous codes.') %div - = link_to 'Disable two-factor authentication', profile_two_factor_auth_path, + = link_to _('Disable two-factor authentication'), profile_two_factor_auth_path, method: :delete, - data: { confirm: "Are you sure? This will invalidate your registered applications and U2F devices." }, + data: { confirm: _('Are you sure? This will invalidate your registered applications and U2F devices.') }, class: 'btn btn-danger append-right-10' = form_tag codes_profile_two_factor_auth_path, {style: 'display: inline-block', method: :post} do |f| - = submit_tag 'Regenerate recovery codes', class: 'btn' + = submit_tag _('Regenerate recovery codes'), class: 'btn' - else %p - Install a soft token authenticator like <a href="https://freeotp.github.io/">FreeOTP</a> - or Google Authenticator from your application repository and scan this QR code. - More information is available in the #{link_to('documentation', help_page_path('user/profile/account/two_factor_authentication'))}. + - help_link_start = '<a href="%{url}" target="_blank">' % { url: help_page_path('user/profile/account/two_factor_authentication') } + - register_2fa_token = _('Install a soft token authenticator like %{free_otp_link} or Google Authenticator from your application repository and scan this QR code. More information is available in the %{help_link_start}documentation%{help_link_end}.') % { free_otp_link:'<a href="https://freeotp.github.io/">FreeOTP</a>', help_link_start:help_link_start, help_link_end:'</a>' } + = register_2fa_token.html_safe .row.append-bottom-10 .col-md-4 = raw @qr_code .col-md-8 .account-well %p.prepend-top-0.append-bottom-0 - Can't scan the code? + = _("Can't scan the code?") %p.prepend-top-0.append-bottom-0 - To add the entry manually, provide the following details to the application on your phone. + = _('To add the entry manually, provide the following details to the application on your phone.') %p.prepend-top-0.append-bottom-0 - Account: - = @account_string + = _('Account: %{account}') % { account: @account_string } %p.prepend-top-0.append-bottom-0 - Key: - = current_user.otp_secret.scan(/.{4}/).join(' ') + = _('Key: %{key}') %{ key: current_user.otp_secret.scan(/.{4}/).join(' ') } %p.two-factor-new-manual-content - Time based: Yes + = _('Time based: Yes') = form_tag profile_two_factor_auth_path, method: :post do |f| - if @error .alert.alert-danger = @error .form-group - = label_tag :pin_code, nil, class: "label-bold" + = label_tag :pin_code, _('Pin code'), class: "label-bold" = text_field_tag :pin_code, nil, class: "form-control", required: true .prepend-top-default - = submit_tag 'Register with two-factor app', class: 'btn btn-success' + = submit_tag _('Register with two-factor app'), class: 'btn btn-success' %hr .row.prepend-top-default .col-lg-4 %h4.prepend-top-0 - Register Universal Two-Factor (U2F) Device + = _('Register Universal Two-Factor (U2F) Device') %p - Use a hardware device to add the second factor of authentication. + = _('Use a hardware device to add the second factor of authentication.') %p - As U2F devices are only supported by a few browsers, we require that you set up a - two-factor authentication app before a U2F device. That way you'll always be able to - log in - even when you're using an unsupported browser. + = _("As U2F devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser.") .col-lg-8 - if @u2f_registration.errors.present? = form_errors(@u2f_registration) @@ -74,7 +70,8 @@ %hr - %h5 U2F Devices (#{@u2f_registrations.length}) + %h5 + = _('U2F Devices (%{length})') % { length: @u2f_registrations.length } - if @u2f_registrations.present? .table-responsive @@ -85,16 +82,16 @@ %col{ width: "20%" } %thead %tr - %th Name - %th Registered On + %th= _('Name') + %th= s_('2FADevice|Registered On') %th %tbody - @u2f_registrations.each do |registration| %tr - %td= registration.name.presence || "<no name set>" + %td= registration.name.presence || _("<no name set>") %td= registration.created_at.to_date.to_s(:medium) - %td= link_to "Delete", profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger float-right", data: { confirm: "Are you sure you want to delete this device? This action cannot be undone." } + %td= link_to _('Delete'), profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger float-right", data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.') } - else .settings-message.text-center - You don't have any U2F devices registered yet. + = _("You don't have any U2F devices registered yet.") diff --git a/app/views/projects/_classification_policy_settings.html.haml b/app/views/projects/_classification_policy_settings.html.haml new file mode 100644 index 00000000000..5a766ab024f --- /dev/null +++ b/app/views/projects/_classification_policy_settings.html.haml @@ -0,0 +1,6 @@ +- if ::Gitlab::ExternalAuthorization.enabled? + .form-group.col-md-9 + = f.label :external_authorization_classification_label, _('Classification Label (optional)'), class: 'label-bold' + = f.text_field :external_authorization_classification_label, class: "form-control" + %span.form-text.text-muted + = external_classification_label_help_message diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index 409b6dba9ca..1056977886a 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -1,42 +1,33 @@ - return unless Gitlab::CurrentSettings.project_export_enabled? - project = local_assigns.fetch(:project) -- expanded = Rails.env.test? -%section.settings.no-animate#js-export-project{ class: ('expanded' if expanded) } - .settings-header - %h4 - Export project - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' - %p - Export this project with all its related data in order to move your project to a new GitLab instance. Once the export is finished, you can import the file from the "New Project" page. - .settings-content - .bs-callout.bs-callout-info - %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 - %p - The following items will NOT be exported: - %ul - %li Job traces and artifacts - %li Container registry images - %li CI variables - %li Webhooks - %li Any encrypted tokens - %p - Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page. - - if project.export_status == :finished - = link_to 'Download export', download_export_project_path(project), - rel: 'nofollow', download: '', method: :get, class: "btn btn-default" - = link_to 'Generate new export', generate_new_export_project_path(project), - method: :post, class: "btn btn-default" - - else - = link_to 'Export project', export_project_path(project), - method: :post, class: "btn btn-default" +.sub-section + %h4= _('Export project') + %p= _('Export this project with all its related data in order to move your project to a new GitLab instance. Once the export is finished, you can import the file from the "New Project" page.') + + .bs-callout.bs-callout-info + %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') + %p= _('The following items will NOT be exported:') + %ul + %li= _('Job traces and artifacts') + %li= _('Container registry images') + %li= _('CI variables') + %li= _('Webhooks') + %li= _('Any encrypted tokens') + %p= _('Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page.') + - if project.export_status == :finished + = link_to _('Download export'), download_export_project_path(project), + rel: 'nofollow', download: '', method: :get, class: "btn btn-default" + = link_to _('Generate new export'), generate_new_export_project_path(project), + method: :post, class: "btn btn-default" + - else + = link_to _('Export project'), export_project_path(project), + method: :post, class: "btn btn-default" diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 22a721ee9ad..2b0c3985755 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -13,7 +13,12 @@ = render 'shared/commit_well', commit: commit, ref: ref, project: project - if is_project_overview - .project-buttons.append-bottom-default + .project-buttons.append-bottom-default{ class: ("js-keep-hidden-on-navigation" if vue_file_list_enabled?) } = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout) - = render 'projects/tree/tree_content', tree: @tree, content_url: content_url + - if vue_file_list_enabled? + #js-tree-list{ data: { project_path: @project.full_path, project_short_path: @project.path, ref: ref, full_name: @project.name_with_namespace } } + - if @tree.readme + = render "projects/tree/readme", readme: @tree.readme + - else + = render 'projects/tree/tree_content', tree: @tree, content_url: content_url diff --git a/app/views/projects/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml index 7a5fff96676..b2dab0b5348 100644 --- a/app/views/projects/_flash_messages.html.haml +++ b/app/views/projects/_flash_messages.html.haml @@ -5,4 +5,6 @@ - if current_user && can?(current_user, :download_code, project) = render 'shared/no_ssh' = render 'shared/no_password' - = render 'shared/auto_devops_implicitly_enabled_banner', project: project + - unless project.empty_repo? + = render 'shared/auto_devops_implicitly_enabled_banner', project: project + = render_if_exists 'projects/above_size_limit_warning', project: project diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index bba303c906c..9f5241344a7 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -1,6 +1,7 @@ - empty_repo = @project.empty_repo? - show_auto_devops_callout = show_auto_devops_callout?(@project) -.project-home-panel{ class: ("empty-project" if empty_repo) } +- max_project_topic_length = 15 +.project-home-panel{ class: [("empty-project" if empty_repo), ("js-keep-hidden-on-navigation" if vue_file_list_enabled?)] } .row.append-bottom-8 .home-panel-title-row.col-md-12.col-lg-6.d-flex .avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none @@ -11,7 +12,7 @@ = @project.name %span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } = visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'}) - .home-panel-metadata.d-flex.align-items-center.text-secondary + .home-panel-metadata.d-flex.flex-wrap.text-secondary - if can?(current_user, :read_project, @project) %span.text-secondary = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id } @@ -19,15 +20,21 @@ %span.access-request-links.prepend-left-8 = render 'shared/members/access_request_links', source: @project - if @project.tag_list.present? - %span.home-panel-topic-list.d-inline-flex.prepend-left-8.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_topics? ? @project.tag_list.join(', ') : nil } + %span.home-panel-topic-list.mt-2.w-100.d-inline-flex = sprite_icon('tag', size: 16, css_class: 'icon append-right-4') - @project.topics_to_show.each do |topic| - %a{ class: 'badge badge-pill badge-secondary append-right-5 str-truncated-30', href: explore_projects_path(tag: topic) } - = topic.titleize + - project_topics_classes = "badge badge-pill badge-secondary append-right-5" + - explore_project_topic_path = explore_projects_path(tag: topic) + - if topic.length > max_project_topic_length + %a{ class: "#{ project_topics_classes } str-truncated-30 has-tooltip", data: { container: "body" }, title: topic, href: explore_project_topic_path } + = topic.titleize + - else + %a{ class: project_topics_classes, href: explore_project_topic_path } + = topic.titleize - if @project.has_extra_topics? - .text-nowrap + .text-nowrap.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_topics? ? @project.topics_not_shown.join(', ') : nil } = _("+ %{count} more") % { count: @project.count_of_extra_topics_not_shown } @@ -50,7 +57,10 @@ - if can?(current_user, :download_code, @project) %nav.project-stats .nav-links.quick-links - = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout) + - if @project.empty_repo? + = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors + - else + = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout) .home-panel-home-desc.mt-1 - if @project.description.present? @@ -70,6 +80,8 @@ - deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)') = deleted_message % { project_name: fork_source_name(@project) } + = render_if_exists "projects/home_mirror" + - if @project.badges.present? .project-badges.mb-2 - @project.badges.each do |badge| diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml index 2b425f18389..28d4f8eb201 100644 --- a/app/views/projects/_import_project_pane.html.haml +++ b/app/views/projects/_import_project_pane.html.haml @@ -8,61 +8,67 @@ .import-buttons - if gitlab_project_import_enabled? .import_gitlab_project.has-tooltip{ data: { container: 'body' } } - = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "gitlab_export" } do + = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit', **tracking_attrs(track_label, 'click_button', 'gitlab_export') do = icon('gitlab', text: 'GitLab export') - if github_import_enabled? %div - = link_to new_import_github_path, class: 'btn js-import-github', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "github" } do + = link_to new_import_github_path, class: 'btn js-import-github', **tracking_attrs(track_label, 'click_button', 'github') do = icon('github', text: 'GitHub') - if bitbucket_import_enabled? %div = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}", - data: { track_label: "#{track_label}", track_event: "click_button", track_property: "bitbucket_cloud" } do + **tracking_attrs(track_label, 'click_button', 'bitbucket_cloud') do = icon('bitbucket', text: 'Bitbucket Cloud') - unless bitbucket_import_configured? = render 'bitbucket_import_modal' - if bitbucket_server_import_enabled? %div - = link_to status_import_bitbucket_server_path, class: "btn import_bitbucket", - data: { track_label: "#{track_label}", track_event: "click_button", track_property: "bitbucket_server" } do + = link_to status_import_bitbucket_server_path, class: "btn import_bitbucket", **tracking_attrs(track_label, 'click_button', 'bitbucket_server') do = icon('bitbucket-square', text: 'Bitbucket Server') %div - if gitlab_import_enabled? %div = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}", - data: { track_label: "#{track_label}", track_event: "click_button", track_property: "gitlab_com" } do + **tracking_attrs(track_label, 'click_button', 'gitlab_com') do = icon('gitlab', text: 'GitLab.com') - unless gitlab_import_configured? = render 'gitlab_import_modal' - if google_code_import_enabled? %div - = link_to new_import_google_code_path, class: 'btn import_google_code', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "google_code" } do + = link_to new_import_google_code_path, class: 'btn import_google_code', **tracking_attrs(track_label, 'click_button', 'google_code') do = icon('google', text: 'Google Code') - if fogbugz_import_enabled? %div - = link_to new_import_fogbugz_path, class: 'btn import_fogbugz', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "fogbugz" } do + = link_to new_import_fogbugz_path, class: 'btn import_fogbugz', **tracking_attrs(track_label, 'click_button', 'fogbugz') do = icon('bug', text: 'Fogbugz') - if gitea_import_enabled? %div - = link_to new_import_gitea_path, class: 'btn import_gitea', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "gitea" } do - = custom_icon('go_logo') + = link_to new_import_gitea_path, class: 'btn import_gitea', **tracking_attrs(track_label, 'click_button', 'gitea') do + = custom_icon('gitea_logo') Gitea - if git_import_enabled? %div - %button.btn.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active', data: { toggle_open_class: 'active', track_label: "#{track_label}" , track_event: "click_button", track_property: "repo_url" } } } + %button.btn.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' }, **tracking_attrs(track_label, 'click_button', 'repo_url') } = icon('git', text: 'Repo by URL') - if manifest_import_enabled? %div - = link_to new_import_manifest_path, class: 'btn import_manifest', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "manifest_file" } do + = link_to new_import_manifest_path, class: 'btn import_manifest', **tracking_attrs(track_label, 'click_button', 'manifest_file') do = icon('file-text-o', text: 'Manifest file') + - if phabricator_import_enabled? + %div + = link_to new_import_phabricator_path, class: 'btn import_phabricator', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "phabricator" } do + = custom_icon('issues') + = _("Phabricator Tasks") + + .js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') } = form_for @project, html: { class: 'new_project' } do |f| %hr diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml index 03ba1104507..10575aa68b1 100644 --- a/app/views/projects/_md_preview.html.haml +++ b/app/views/projects/_md_preview.html.haml @@ -7,22 +7,22 @@ = _('This merge request is locked.') = _('Only project members can comment.') -.md-area +.md-area.position-relative .md-header %ul.nav.nav-tabs.nav-links.clearfix %li.md-header-tab.active %button.js-md-write-button{ tabindex: -1 } - Write + = _("Write") %li.md-header-tab %button.js-md-preview-button{ tabindex: -1 } - Preview + = _("Preview") %li.md-header-toolbar.active = render 'projects/blob/markdown_buttons', show_fullscreen_button: true .md-write-holder = yield - .md.md-preview-holder.js-md-preview.hide.md-preview{ data: { url: url } } + .md.md-preview-holder.js-md-preview.hide{ data: { url: url } } .referenced-commands.hide - if referenced_users diff --git a/app/views/projects/_merge_request_merge_checks_settings.html.haml b/app/views/projects/_merge_request_merge_checks_settings.html.haml new file mode 100644 index 00000000000..c21d333f21a --- /dev/null +++ b/app/views/projects/_merge_request_merge_checks_settings.html.haml @@ -0,0 +1,19 @@ +- form = local_assigns.fetch(:form) + +.form-group + %b= s_('ProjectSettings|Merge checks') + %p.text-secondary= s_('ProjectSettings|These checks must pass before merge requests can be merged') + .form-check.mb-2.builds-feature + = form.check_box :only_allow_merge_if_pipeline_succeeds, class: 'form-check-input' + = form.label :only_allow_merge_if_pipeline_succeeds, class: 'form-check-label' do + = s_('ProjectSettings|Pipelines must succeed') + .descr.text-secondary + = s_('ProjectSettings|Pipelines need to be configured to enable this feature.') + = link_to icon('question-circle'), + help_page_path('ci/merge_request_pipelines/index.md', + anchor: 'pipelines-for-merge-requests'), + target: '_blank' + .form-check.mb-2 + = form.check_box :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-input' + = form.label :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-label' do + = s_('ProjectSettings|All discussions must be resolved') diff --git a/app/views/projects/_merge_request_merge_method_settings.html.haml b/app/views/projects/_merge_request_merge_method_settings.html.haml index 935581643cd..47c311f42d0 100644 --- a/app/views/projects/_merge_request_merge_method_settings.html.haml +++ b/app/views/projects/_merge_request_merge_method_settings.html.haml @@ -1,35 +1,33 @@ - form = local_assigns.fetch(:form) .form-group - = label_tag :merge_method_merge, class: 'label-bold' do - Merge method - .form-check + %b= s_('ProjectSettings|Merge method') + %p.text-secondary= s_('ProjectSettings|This will dictate the commit history when you merge a merge request') + .form-check.mb-2 = form.radio_button :merge_method, :merge, class: "js-merge-method-radio form-check-input" = label_tag :project_merge_method_merge, class: 'form-check-label' do - %strong Merge commit - %br - %span.descr - A merge commit is created for every merge, and merging is allowed as long as there are no conflicts. + = s_('ProjectSettings|Merge commit') + .descr.text-secondary + = s_('ProjectSettings|Every merge creates a merge commit') - .form-check + .form-check.mb-2 = form.radio_button :merge_method, :rebase_merge, class: "js-merge-method-radio form-check-input" = label_tag :project_merge_method_rebase_merge, class: 'form-check-label' do - %strong Merge commit with semi-linear history - %br - %span.descr - A merge commit is created for every merge, but merging is only allowed if fast-forward merge is possible. - This way you could make sure that if this merge request would build, after merging to target branch it would also build. - %br - %span.descr - When fast-forward merge is not possible, the user is given the option to rebase. + = s_('ProjectSettings|Merge commit with semi-linear history') + .descr.text-secondary + = s_('ProjectSettings|Every merge creates a merge commit') + %br + = s_('ProjectSettings|Fast-forward merges only') + %br + = s_('ProjectSettings|When conflicts arise the user is given the option to rebase') - .form-check + .form-check.mb-2 = form.radio_button :merge_method, :ff, class: "js-merge-method-radio qa-radio-button-merge-ff form-check-input" = label_tag :project_merge_method_ff, class: 'form-check-label' do - %strong Fast-forward merge - %br - %span.descr - No merge commits are created and all merges are fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded. - %br - %span.descr - When fast-forward merge is not possible, the user is given the option to rebase. + = s_('ProjectSettings|Fast-forward merge') + .descr.text-secondary + = s_('ProjectSettings|No merge commits are created') + %br + = s_('ProjectSettings|Fast-forward merges only') + %br + = s_('ProjectSettings|When conflicts arise the user is given the option to rebase') diff --git a/app/views/projects/_merge_request_merge_options_settings.html.haml b/app/views/projects/_merge_request_merge_options_settings.html.haml new file mode 100644 index 00000000000..5ab475822de --- /dev/null +++ b/app/views/projects/_merge_request_merge_options_settings.html.haml @@ -0,0 +1,14 @@ +- form = local_assigns.fetch(:form) + +.form-group + %b= s_('ProjectSettings|Merge options') + %p.text-secondary= s_('ProjectSettings|Additional merge request capabilities that influence how and when merges will be performed') + = render_if_exists 'projects/merge_pipelines_settings', form: form + .form-check.mb-2 + = form.check_box :resolve_outdated_diff_discussions, class: 'form-check-input' + = form.label :resolve_outdated_diff_discussions, class: 'form-check-label' do + = s_('ProjectSettings|Automatically resolve merge request diff discussions when they become outdated') + .form-check.mb-2 + = form.check_box :printing_merge_request_link_enabled, class: 'form-check-input' + = form.label :printing_merge_request_link_enabled, class: 'form-check-label' do + = s_('ProjectSettings|Show link to create/view merge request when pushing from the command line') diff --git a/app/views/projects/_merge_request_merge_settings.html.haml b/app/views/projects/_merge_request_merge_settings.html.haml deleted file mode 100644 index f178c94e008..00000000000 --- a/app/views/projects/_merge_request_merge_settings.html.haml +++ /dev/null @@ -1,23 +0,0 @@ -- form = local_assigns.fetch(:form) - -.form-group - .form-check.builds-feature{ class: ("hidden" if @project && @project.project_feature.send(:builds_access_level) == 0) } - = form.check_box :only_allow_merge_if_pipeline_succeeds, class: 'form-check-input' - = form.label :only_allow_merge_if_pipeline_succeeds, class: 'form-check-label' do - %strong Only allow merge requests to be merged if the pipeline succeeds - %br - %span.descr - Pipelines need to be configured to enable this feature. - = link_to icon('question-circle'), help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds'), target: '_blank' - .form-check - = form.check_box :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-input' - = form.label :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-label' do - %strong Only allow merge requests to be merged if all discussions are resolved - .form-check - = form.check_box :resolve_outdated_diff_discussions, class: 'form-check-input' - = form.label :resolve_outdated_diff_discussions, class: 'form-check-label' do - %strong Automatically resolve merge request diff discussions when they become outdated - .form-check - = form.check_box :printing_merge_request_link_enabled, class: 'form-check-input' - = form.label :printing_merge_request_link_enabled, class: 'form-check-label' do - %strong Show link to create/view merge request when pushing from the command line diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml index c80e831dd33..f2ba38387a3 100644 --- a/app/views/projects/_merge_request_settings.html.haml +++ b/app/views/projects/_merge_request_settings.html.haml @@ -2,4 +2,6 @@ = render 'projects/merge_request_merge_method_settings', project: @project, form: form -= render 'projects/merge_request_merge_settings', form: form += render 'projects/merge_request_merge_options_settings', project: @project, form: form + += render 'projects/merge_request_merge_checks_settings', project: @project, form: form diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index 276363df7da..e423631ec99 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -1,4 +1,4 @@ -- visibility_level = params.dig(:project, :visibility_level) || default_project_visibility +- visibility_level = selected_visibility_level(@project, params.dig(:project, :visibility_level)) - ci_cd_only = local_assigns.fetch(:ci_cd_only, false) - hide_init_with_readme = local_assigns.fetch(:hide_init_with_readme, false) - track_label = local_assigns.fetch(:track_label, 'blank_project') @@ -12,21 +12,21 @@ .form-group.project-path.col-sm-6 = f.label :namespace_id, class: 'label-bold' do %span= s_("Project URL") - .input-group + .input-group.flex-nowrap - if current_user.can_select_namespace? - .input-group-prepend.has-tooltip{ title: root_url } + .input-group-prepend.flex-shrink-0.has-tooltip{ title: root_url } .input-group-text = root_url - namespace_id = namespace_id_from(params) = f.select(:namespace_id, - namespaces_options(namespace_id || :current_user, - display_path: true, - extra_group: namespace_id), + namespaces_options_with_developer_maintainer_access(selected: namespace_id, + display_path: true, + extra_group: namespace_id), {}, - { class: 'select2 js-select-namespace qa-project-namespace-select', tabindex: 1, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_path", track_value: "" }}) + { class: 'select2 js-select-namespace qa-project-namespace-select block-truncated', tabindex: 1, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_path", track_value: "" }}) - else - .input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' } + .input-group-prepend.static-namespace.flex-shrink-0.has-tooltip{ title: user_url(current_user.username) + '/' } .input-group-text.border-0 #{user_url(current_user.username)}/ = f.hidden_field :namespace_id, value: current_user.namespace_id @@ -54,7 +54,7 @@ .form-group.row.initialize-with-readme-setting %div{ :class => "col-sm-12" } .form-check - = check_box_tag 'project[initialize_with_readme]', '1', false, class: 'form-check-input', data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "init_with_readme" } + = check_box_tag 'project[initialize_with_readme]', '1', false, class: 'form-check-input qa-initialize-with-readme-checkbox', data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "init_with_readme" } = label_tag 'project[initialize_with_readme]', class: 'form-check-label' do .option-title %strong Initialize repository with a README diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml index de4653dad2c..6103d86bf5a 100644 --- a/app/views/projects/_wiki.html.haml +++ b/app/views/projects/_wiki.html.haml @@ -1,8 +1,7 @@ - if @wiki_home.present? %div{ class: container_class } - .prepend-top-default.append-bottom-default - .wiki - = render_wiki_content(@wiki_home) + .md.md-file.prepend-top-default.append-bottom-default + = render_wiki_content(@wiki_home) - else - can_create_wiki = can?(current_user, :create_wiki, @project) .landing{ class: [('row-content-block row p-0 align-items-center' if can_create_wiki), ('content-block' unless can_create_wiki)] } diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml index afc40ca4eab..c502b392384 100644 --- a/app/views/projects/_zen.html.haml +++ b/app/views/projects/_zen.html.haml @@ -8,6 +8,7 @@ = f.text_area attr, class: classes, placeholder: placeholder, + dir: 'auto', data: { supports_quick_actions: supports_quick_actions, supports_autocomplete: supports_autocomplete } - else diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml index 09295940529..6a7cb1499c5 100644 --- a/app/views/projects/artifacts/browse.html.haml +++ b/app/views/projects/artifacts/browse.html.haml @@ -4,7 +4,7 @@ = render "projects/jobs/header" - add_to_breadcrumbs(s_('CICD|Jobs'), project_jobs_path(@project)) -- add_to_breadcrumbs("##{@build.id}", project_jobs_path(@project)) +- add_to_breadcrumbs("##{@build.id}", project_job_path(@project, @build)) .tree-holder .nav-block diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml index 4bef45932d0..7ed71a7d43c 100644 --- a/app/views/projects/blob/_header_content.html.haml +++ b/app/views/projects/blob/_header_content.html.haml @@ -1,12 +1,12 @@ .file-header-content = blob_icon blob.mode, blob.name - %strong.file-title-name + %strong.file-title-name.qa-file-title-name = blob.name = copy_file_path_button(blob.path) - %small + %small.mr-1 = number_to_human_size(blob.raw_size) - if blob.stored_externally? && blob.external_storage == :lfs diff --git a/app/views/projects/blob/_markdown_buttons.html.haml b/app/views/projects/blob/_markdown_buttons.html.haml index 1d6acd86108..28d1ff97825 100644 --- a/app/views/projects/blob/_markdown_buttons.html.haml +++ b/app/views/projects/blob/_markdown_buttons.html.haml @@ -1,13 +1,13 @@ .md-header-toolbar.active - = markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: s_("MarkdownToolbar|Add bold text") }) - = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: s_("MarkdownToolbar|Add italic text") }) - = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: s_("MarkdownToolbar|Insert a quote") }) - = markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: s_("MarkdownToolbar|Insert code") }) - = markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: s_("MarkdownToolbar|Add a link") }) - = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a bullet list") }) - = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a numbered list") }) - = markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a task list") }) - = markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a table") }) + = markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: _("Add bold text") }) + = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: _("Add italic text") }) + = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: _("Insert a quote") }) + = markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: _("Insert code") }) + = markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: _("Add a link") }) + = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: _("Add a bullet list") }) + = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") }) + = markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: _("Add a task list") }) + = markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: _("Add a table") }) - if show_fullscreen_button - %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: s_("MarkdownToolbar|Go full screen"), data: { container: "body" } } + %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: _("Go full screen"), data: { container: "body" } } = sprite_icon("screen-full") diff --git a/app/views/projects/blob/diff.html.haml b/app/views/projects/blob/diff.html.haml index ea7a71792a3..4f3db61f688 100644 --- a/app/views/projects/blob/diff.html.haml +++ b/app/views/projects/blob/diff.html.haml @@ -15,14 +15,14 @@ %a{ href: "#", data: { linenumber: line_number_old }, disabled: true } %td.new_line.diff-line-num{ data: { linenumber: line_number_new } } %a{ href: "#", data: { linenumber: line_number_new }, disabled: true } - %td.line_content.noteable_line{ class: line_class }= line + %td.line_content{ class: line_class }= line - when :parallel %td.old_line.diff-line-num{ data: { linenumber: line_number_old } } %a{ href: "##{line_number_old}", data: { linenumber: line_number_old }, disabled: true } - %td.line_content.noteable_line.left-side{ class: line_class }= line + %td.line_content.left-side{ class: line_class }= line %td.new_line.diff-line-num{ data: { linenumber: line_number_new } } %a{ href: "##{line_number_new}", data: { linenumber: line_number_new }, disabled: true } - %td.line_content.noteable_line.right-side{ class: line_class }= line + %td.line_content.right-side{ class: line_class }= line - if @form.unfold? && @form.bottom? && @form.to < @blob.lines.size %tr.line_holder{ id: @form.to, class: line_class } diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml index 66687f087ff..3e893343165 100644 --- a/app/views/projects/blob/preview.html.haml +++ b/app/views/projects/blob/preview.html.haml @@ -1,21 +1,20 @@ -.diff-file.file-holder - .diff-content - - if markup?(@blob.name) - .file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } - = markup(@blob.name, @content) - - else - .file-content.code.js-syntax-highlight - - unless @diff_lines.empty? - %table.text-file - - @diff_lines.each do |line| - %tr.line_holder{ class: "#{line.type}" } - - if line.type == "match" - %td.old_line.diff-line-num= "..." - %td.new_line.diff-line-num= "..." - %td.line_content.match= line.text - - else - %td.old_line.diff-line-num - %td.new_line.diff-line-num - %td.line_content{ class: "#{line.type}" }= diff_line_content(line.text) - - else - .nothing-here-block No changes. +- if markup?(@blob.name) + .file-content.md.md-file{ class: ('use-csslab' if Feature.enabled?(:csslab)) } + = markup(@blob.name, @content) +- else + .diff-file + .diff-content + - unless @diff_lines.empty? + %table.text-file.code.js-syntax-highlight + - @diff_lines.each do |line| + %tr.line_holder{ class: line.type } + - if line.type == "match" + %td.old_line.diff-line-num.match= "..." + %td.new_line.diff-line-num.match= "..." + %td.line_content.match= line.text + - else + %td.old_line.diff-line-num{ class: line.type } + %td.new_line.diff-line-num{ class: line.type } + %td.line_content{ class: line.type }= diff_line_content(line.text) + - else + .nothing-here-block No changes. diff --git a/app/views/projects/blob/viewers/_dependency_manager.html.haml b/app/views/projects/blob/viewers/_dependency_manager.html.haml index 87aa7c1dbf8..5970d41fdab 100644 --- a/app/views/projects/blob/viewers/_dependency_manager.html.haml +++ b/app/views/projects/blob/viewers/_dependency_manager.html.haml @@ -3,9 +3,4 @@ This project manages its dependencies using %strong= viewer.manager_name - - if viewer.package_name - and defines a #{viewer.package_type} named - %strong< - = link_to_if viewer.package_url.present?, viewer.package_name, viewer.package_url, target: '_blank', rel: 'noopener noreferrer' - = link_to 'Learn more', viewer.manager_url, target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml index 1a77eb078be..abc74b66e90 100644 --- a/app/views/projects/blob/viewers/_markup.html.haml +++ b/app/views/projects/blob/viewers/_markup.html.haml @@ -1,4 +1,4 @@ - blob = viewer.blob - context = blob.respond_to?(:rendered_markup) ? { rendered: blob.rendered_markup } : {} -.file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } +.file-content.md.md-file{ class: ('use-csslab' if Feature.enabled?(:csslab)) } = markup(blob.name, blob.data, context) diff --git a/app/views/projects/blob/viewers/_route_map.html.haml b/app/views/projects/blob/viewers/_route_map.html.haml index 6d6bd79bc3c..07b9378ba97 100644 --- a/app/views/projects/blob/viewers/_route_map.html.haml +++ b/app/views/projects/blob/viewers/_route_map.html.haml @@ -6,4 +6,4 @@ This Route Map is invalid: = viewer.validation_message -= link_to 'Learn more', help_page_path('ci/environments', anchor: 'go-directly-from-source-files-to-public-pages-on-the-environment') += link_to 'Learn more', help_page_path('ci/environments', anchor: 'going-from-source-files-to-public-pages') diff --git a/app/views/projects/blob/viewers/_route_map_loading.html.haml b/app/views/projects/blob/viewers/_route_map_loading.html.haml index a5f73fb0197..f11c047e85a 100644 --- a/app/views/projects/blob/viewers/_route_map_loading.html.haml +++ b/app/views/projects/blob/viewers/_route_map_loading.html.haml @@ -1,4 +1,4 @@ = icon('spinner spin fw') Validating Route Map… -= link_to 'Learn more', help_page_path('ci/environments', anchor: 'go-directly-from-source-files-to-public-pages-on-the-environment') += link_to 'Learn more', help_page_path('ci/environments', anchor: 'going-from-source-files-to-public-pages') diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 91c51d5e091..1074cd6bf4e 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -10,7 +10,7 @@ .branch-info .branch-title = sprite_icon('fork', size: 12) - = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name prepend-left-8' do + = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name prepend-left-8 qa-branch-name' do = branch.name - if branch.name == @repository.root_ref %span.badge.badge-primary.prepend-left-5 default @@ -22,6 +22,8 @@ %span.badge.badge-success.prepend-left-5 = s_('Branches|protected') + = render_if_exists 'projects/branches/diverged_from_upstream' + .block-truncated - if commit = render 'projects/branches/commit', commit: commit, project: @project diff --git a/app/views/projects/branches/_commit.html.haml b/app/views/projects/branches/_commit.html.haml index 7892019bb15..e33e9509e3a 100644 --- a/app/views/projects/branches/_commit.html.haml +++ b/app/views/projects/branches/_commit.html.haml @@ -1,9 +1,9 @@ -.branch-commit +.branch-commit.cgray .icon-container.commit-icon = custom_icon("icon_commit") = link_to commit.short_id, project_commit_path(project, commit.id), class: "commit-sha" · %span.str-truncated - = link_to_markdown commit.title, project_commit_path(project, commit.id), class: "commit-row-message" + = link_to_markdown commit.title, project_commit_path(project, commit.id), class: "commit-row-message cgray" · #{time_ago_with_tooltip(commit.committed_date)} diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index 43f1cd01b67..d270e461ac8 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -1,5 +1,6 @@ - @no_container = true - page_title _('Branches') +- add_to_breadcrumbs(_('Repository'), project_tree_path(@project)) %div{ class: container_class } .top-area.adjust @@ -44,6 +45,8 @@ = link_to new_project_branch_path(@project), class: 'btn btn-success' do = s_('Branches|New branch') + = render_if_exists 'projects/commits/mirror_status' + - if can?(current_user, :admin_project, @project) - project_settings_link = link_to s_('Branches|project settings'), project_protected_branches_path(@project) .row-content-block diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index 159d9e44e17..09f05b30433 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -7,7 +7,7 @@ = sprite_icon("arrow-down", css_class: "icon") %ul.p-3.dropdown-menu.dropdown-menu-right.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options - if ssh_enabled? - %li.pb-2 + %li %label.label-bold = _('Clone with SSH') .input-group @@ -16,7 +16,7 @@ = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard") = render_if_exists 'projects/buttons/geo' - if http_enabled? - %li + %li.pt-2 %label.label-bold = _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase } .input-group @@ -24,5 +24,6 @@ .input-group-append = clipboard_button(target: '#http_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard") = render_if_exists 'projects/buttons/geo' + = render_if_exists 'projects/buttons/kerberos_clone_field' = render_if_exists 'shared/geo_info_modal', project: project diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index 4eb53faa6ff..4762045ee96 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -7,31 +7,22 @@ = sprite_icon('download') %span.sr-only= _('Select Archive Format') = sprite_icon("arrow-down") - %ul.dropdown-menu.dropdown-menu-right{ role: 'menu' } - %li.dropdown-header - #{ _('Source code') } - %li - = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'zip'), rel: 'nofollow', download: '' do - %span= _('Download zip') - %li - = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.gz'), rel: 'nofollow', download: '' do - %span= _('Download tar.gz') - %li - = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.bz2'), rel: 'nofollow', download: '' do - %span= _('Download tar.bz2') - %li - = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar'), rel: 'nofollow', download: '' do - %span= _('Download tar') - + .dropdown-menu.dropdown-menu-right{ role: 'menu' } + %section + %h5.m-0.dropdown-bold-header= _('Download source code') + .dropdown-menu-content + = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil + - if directory? && Feature.enabled?(:git_archive_path, default_enabled: true) + %section.border-top.pt-1.mt-1 + %h5.m-0.dropdown-bold-header= _('Download this directory') + .dropdown-menu-content + = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path - if pipeline && pipeline.latest_builds_with_artifacts.any? - %li.dropdown-header Artifacts - - unless pipeline.latest? - - latest_pipeline = project.pipeline_for(ref) - %li - .unclickable= ci_status_for_statuseable(latest_pipeline) - %li.dropdown-header Previous Artifacts - - pipeline.latest_builds_with_artifacts.each do |job| - %li - = link_to latest_succeeded_project_artifacts_path(project, "#{ref}/download", job: job.name), rel: 'nofollow', download: '' do - %span - #{s_('DownloadArtifacts|Download')} '#{job.name}' + %section.border-top.pt-1.mt-1 + %h5.m-0.dropdown-bold-header= _('Download artifacts') + - unless pipeline.latest? + %span.unclickable= ci_status_for_statuseable(project.pipeline_for(ref)) + %h6.m-0.dropdown-header= _('Previous Artifacts') + %ul + - pipeline.latest_builds_with_artifacts.each do |job| + %li= link_to job.name, latest_succeeded_project_artifacts_path(project, "#{ref}/download", job: job.name), rel: 'nofollow', download: '' diff --git a/app/views/projects/buttons/_download_links.html.haml b/app/views/projects/buttons/_download_links.html.haml new file mode 100644 index 00000000000..d344167a6c5 --- /dev/null +++ b/app/views/projects/buttons/_download_links.html.haml @@ -0,0 +1,5 @@ +- formats = [['zip', 'btn-primary'], ['tar.gz'], ['tar.bz2'], ['tar']] + +.btn-group.ml-0.w-100 + - formats.each do |(fmt, extra_class)| + = link_to fmt, project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: fmt), rel: 'nofollow', download: '', class: "btn btn-xs #{extra_class}" diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 9d069c025ba..bdf7b933ab8 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -12,7 +12,7 @@ %td.status = render "ci/status/badge", status: job.detailed_status(current_user), title: job.status_title - %td.branch-commit + %td.branch-commit.cgray - if can?(current_user, :read_build, job) = link_to project_job_path(job.project, job) do %span.build-link ##{job.id} @@ -30,7 +30,7 @@ = custom_icon("icon_commit") - if commit_sha - = link_to job.short_sha, project_commit_path(job.project, job.sha), class: "commit-sha" + = link_to job.short_sha, project_commit_path(job.project, job.sha), class: "commit-sha mr-0" - if job.stuck? = icon('warning', class: 'text-warning has-tooltip', title: _('Job is stuck. Check runners.')) @@ -53,9 +53,10 @@ %span.badge.badge-info= _('manual') - if pipeline_link - %td - = link_to pipeline_path(pipeline) do + %td.pipeline-link + = link_to pipeline_path(pipeline), class: 'has-tooltip', title: _('Pipeline ID (IID)') do %span.pipeline-id ##{pipeline.id} + %span.pipeline-iid (##{pipeline.iid}) %span by - if pipeline.user = user_avatar(user: pipeline.user, size: 20) diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml index 888be4ee282..ed3c9890efd 100644 --- a/app/views/projects/cleanup/_show.html.haml +++ b/app/views/projects/cleanup/_show.html.haml @@ -1,4 +1,4 @@ -- expanded = Rails.env.test? +- expanded = expanded_by_default? %section.settings.no-animate#cleanup{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index a0db48bf8ff..ef2777e6601 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -81,7 +81,7 @@ = link_to project_pipeline_path(@project, last_pipeline.id), class: "ci-status-icon-#{last_pipeline.status}" do = ci_icon_for_status(last_pipeline.status) #{ _('Pipeline') } - = link_to "##{last_pipeline.id}", project_pipeline_path(@project, last_pipeline.id) + = link_to "##{last_pipeline.id} (##{last_pipeline.iid})", project_pipeline_path(@project, last_pipeline.id), class: "has-tooltip", title: _('Pipeline ID (IID)') = ci_label_for_status(last_pipeline.status) - if last_pipeline.stages_count.nonzero? #{ n_(s_('Pipeline|with stage'), s_('Pipeline|with stages'), last_pipeline.stages_count) } diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 0d3c6e7027c..87b9920e8b4 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -9,10 +9,13 @@ - commit_status = commit.present(current_user: current_user).status_for(ref) - link = commit_path(project, commit, merge_request: merge_request) + +- show_project_name = local_assigns.fetch(:show_project_name, false) + %li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" } .avatar-cell.d-none.d-sm-block - = author_avatar(commit, size: 36, has_tooltip: false) + = author_avatar(commit, size: 40, has_tooltip: false) .commit-detail.flex-list .commit-content.qa-commit-content @@ -20,12 +23,9 @@ = link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: "commit-row-message item-title" - else = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title") - %span.commit-row-message.d-block.d-sm-none + %span.commit-row-message.d-inline.d-sm-none · = commit.short_id - - if commit_status - .d-block.d-sm-none - = render_commit_status(commit, ref: ref) - if commit.description? %button.text-expander.js-toggle-button = sprite_icon('ellipsis_h', size: 12) @@ -35,12 +35,13 @@ - commit_timeago = time_ago_with_tooltip(commit.authored_date, placement: 'bottom') - commit_text = _('%{commit_author_link} authored %{commit_timeago}') % { commit_author_link: commit_author_link, commit_timeago: commit_timeago } #{ commit_text.html_safe } + = render_if_exists 'projects/commits/project_namespace', show_project_name: show_project_name, project: project - if commit.description? %pre.commit-row-description.js-toggle-content.append-bottom-8 = preserve(markdown_field(commit, :description)) - .commit-actions.flex-row.d-none.d-sm-flex + .commit-actions.flex-row - if request.xhr? = render partial: 'projects/commit/signature', object: commit.signature - else @@ -51,8 +52,8 @@ .js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } } - .commit-sha-group - .label.label-monospace + .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") = link_to_browse_code(project, commit) diff --git a/app/views/projects/commits/_inline_commit.html.haml b/app/views/projects/commits/_inline_commit.html.haml index caaff082cc3..56bebeca581 100644 --- a/app/views/projects/commits/_inline_commit.html.haml +++ b/app/views/projects/commits/_inline_commit.html.haml @@ -3,6 +3,6 @@ = link_to commit.short_id, project_commit_path(project, commit), class: "commit-sha" %span.str-truncated - = link_to_markdown_field(commit, :title, project_commit_path(project, commit.id), class: "commit-row-message") + = link_to_markdown_field(commit, :title, project_commit_path(project, commit.id), class: "commit-row-message cgray") .float-right #{time_ago_with_tooltip(commit.committed_date)} diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index 9d254463fb6..2db1efdd52f 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -30,6 +30,8 @@ = link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn' do = icon("rss") + = render_if_exists 'projects/commits/mirror_status' + %div{ id: dom_id(@project) } %ol#commits-list.list-unstyled.content_list = render 'commits', project: @project, ref: @ref diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index bdf021fd87f..59f0afd59e6 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -10,7 +10,7 @@ .wrapper{ "v-show" => "!isLoading && !hasError" } .card .card-header - {{ __('Pipeline Health') }} + {{ __('Recent Project Activity') }} .content-block .container-fluid .row diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml index ff6a9d49a61..59efcde5825 100644 --- a/app/views/projects/default_branch/_show.html.haml +++ b/app/views/projects/default_branch/_show.html.haml @@ -1,4 +1,4 @@ -- expanded = Rails.env.test? +- expanded = expanded_by_default? %section.settings.no-animate#default-branch-settings{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index 24d665761cc..fcf27351a21 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -1,4 +1,4 @@ -- expanded = Rails.env.test? +- expanded = expanded_by_default? %section.qa-deploy-keys-settings.settings.no-animate#js-deploy-keys-settings{ class: ('expanded' if expanded) } .settings-header %h4 diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml index f4c91377ecb..c84c376d57b 100644 --- a/app/views/projects/deployments/_actions.haml +++ b/app/views/projects/deployments/_actions.haml @@ -10,5 +10,5 @@ - actions.each do |action| - next unless can?(current_user, :update_build, action) %li - = link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow', class: 'btn' do - %span= action.name.humanize + = link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow' do + %span= action.name diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml index 282566eeadc..743aa60b3ba 100644 --- a/app/views/projects/deployments/_commit.html.haml +++ b/app/views/projects/deployments/_commit.html.haml @@ -1,17 +1,17 @@ .table-mobile-content - .branch-commit + .branch-commit.cgray - if deployment.ref %span.icon-container = deployment.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite') = link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name" .icon-container.commit-icon = custom_icon("icon_commit") - = link_to deployment.short_sha, project_commit_path(@project, deployment.sha), class: "commit-sha" + = link_to deployment.short_sha, project_commit_path(@project, deployment.sha), class: "commit-sha mr-0" %p.commit-title.flex-truncate-parent %span.flex-truncate-child - if commit_title = deployment.commit_title = author_avatar(deployment.commit, size: 20) - = link_to_markdown commit_title, project_commit_path(@project, deployment.sha), class: "commit-row-message" + = link_to_markdown commit_title, project_commit_path(@project, deployment.sha), class: "commit-row-message cgray" - else = _("Can't find HEAD commit for this branch") diff --git a/app/views/projects/deployments/_confirm_rollback_modal.html.haml b/app/views/projects/deployments/_confirm_rollback_modal.html.haml new file mode 100644 index 00000000000..ff40e404e5f --- /dev/null +++ b/app/views/projects/deployments/_confirm_rollback_modal.html.haml @@ -0,0 +1,23 @@ +- commit_sha = link_to deployment.short_sha, project_commit_path(@project, deployment.sha), class: "commit-sha has-tooltip", title: h(deployment.commit_title) +.modal.ws-normal.fade{ tabindex: -1, id: "confirm-rollback-modal-#{deployment.id}" } + .modal-dialog + .modal-content + .modal-header + %h4.modal-title.d-flex.mw-100 + - if deployment.last? + = s_("Environments|Re-deploy environment %{environment_name}?") % {environment_name: @environment.name} + - else + = s_("Environments|Rollback environment %{environment_name}?") % {environment_name: @environment.name} + .modal-body + - if deployment.last? + %p= s_('Environments|This action will relaunch the job for commit %{commit_id}, putting the environment in a previous version. Are you sure you want to continue?').html_safe % {commit_id: commit_sha} + - else + %p + = s_('Environments|This action will run the job defined by staging for commit %{commit_id}, putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?').html_safe % {commit_id: commit_sha} + .modal-footer + = button_tag _('Cancel'), type: 'button', class: 'btn btn-cancel', data: { dismiss: 'modal' } + = link_to [:retry, @project.namespace.becomes(Namespace), @project, deployment.deployable], method: :post, class: 'btn btn-danger' do + - if deployment.last? + = s_('Environments|Re-deploy') + - else + = s_('Environments|Rollback') diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml index 85bc8ec07e3..a11e23b6daa 100644 --- a/app/views/projects/deployments/_deployment.html.haml +++ b/app/views/projects/deployments/_deployment.html.haml @@ -18,7 +18,7 @@ - if deployment.user %div by - = user_avatar(user: deployment.user, size: 20) + = user_avatar(user: deployment.user, size: 20, css_class: "mr-0 float-none") .table-section.section-15{ role: 'gridcell' } .table-mobile-header{ role: 'rowheader' }= _("Created") diff --git a/app/views/projects/deployments/_rollback.haml b/app/views/projects/deployments/_rollback.haml index 1bd538a08ff..d6bf8d564de 100644 --- a/app/views/projects/deployments/_rollback.haml +++ b/app/views/projects/deployments/_rollback.haml @@ -1,7 +1,8 @@ - if can?(current_user, :create_deployment, deployment) - tooltip = deployment.last? ? s_('Environments|Re-deploy to environment') : s_('Environments|Rollback environment') - = link_to [:retry, @project.namespace.becomes(Namespace), @project, deployment.deployable], method: :post, class: 'btn btn-build has-tooltip', title: tooltip do + = 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? = sprite_icon('repeat') - else = sprite_icon('redo') + = render 'projects/deployments/confirm_rollback_modal', deployment: deployment diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml index 68f74f702ea..590fcdb0234 100644 --- a/app/views/projects/diffs/_content.html.haml +++ b/app/views/projects/diffs/_content.html.haml @@ -1,2 +1,2 @@ .diff-content - = render 'projects/diffs/viewer', viewer: diff_file.rich_viewer || diff_file.simple_viewer + = render 'projects/diffs/viewer', viewer: diff_file.viewer diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml index ffdca500abe..d35443cca1e 100644 --- a/app/views/projects/diffs/_line.html.haml +++ b/app/views/projects/diffs/_line.html.haml @@ -30,7 +30,7 @@ = link_text - else %a{ href: "##{line_code}", data: { linenumber: link_text } } - %td.line_content.noteable_line{ class: type }< + %td.line_content{ class: type }< - if email %pre= line.rich_text - else diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml index 4b1d4b3ea17..9587ea4696b 100644 --- a/app/views/projects/diffs/_parallel_view.html.haml +++ b/app/views/projects/diffs/_parallel_view.html.haml @@ -1,7 +1,7 @@ / Side-by-side diff view -.text-file.diff-wrap-lines.code.js-syntax-highlight{ data: diff_view_data } - %table +.text-file{ data: diff_view_data } + %table.diff-wrap-lines.code.code-commit.js-syntax-highlight - diff_file.parallel_diff_lines.each do |line| - left = line[:left] - right = line[:right] @@ -23,7 +23,7 @@ - discussion_left = discussions_left.try(:first) - if discussion_left && discussion_left.resolvable? %diff-note-avatars{ "discussion-id" => discussion_left.id } - %td.line_content.parallel.noteable_line.left-side{ id: left_line_code, class: left.type }= diff_line_content(left.rich_text) + %td.line_content.parallel.left-side{ id: left_line_code, class: left.type }= diff_line_content(left.rich_text) - else %td.old_line.diff-line-num.empty-cell %td.line_content.parallel.left-side @@ -44,7 +44,7 @@ - discussion_right = discussions_right.try(:first) - if discussion_right && discussion_right.resolvable? %diff-note-avatars{ "discussion-id" => discussion_right.id } - %td.line_content.parallel.noteable_line.right-side{ id: right_line_code, class: right.type }= diff_line_content(right.rich_text) + %td.line_content.parallel.right-side{ id: right_line_code, class: right.type }= diff_line_content(right.rich_text) - else %td.old_line.diff-line-num.empty-cell %td.line_content.parallel.right-side diff --git a/app/views/projects/diffs/_replaced_image_diff.html.haml b/app/views/projects/diffs/_replaced_image_diff.html.haml index 6dffc7c4390..566dfe798c6 100644 --- a/app/views/projects/diffs/_replaced_image_diff.html.haml +++ b/app/views/projects/diffs/_replaced_image_diff.html.haml @@ -35,10 +35,10 @@ .swipe.view.hide .swipe-frame - .frame.deleted + .frame.deleted.old-diff = image_tag(old_blob_raw_url, alt: diff_file.old_path, lazy: false) - .swipe-wrap - = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.new_path } + .swipe-wrap.left-oriented + = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added old-diff js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.new_path } %span.swipe-bar %span.top-handle %span.bottom-handle diff --git a/app/views/projects/diffs/_single_image_diff.html.haml b/app/views/projects/diffs/_single_image_diff.html.haml index 454f814795a..daac543b939 100644 --- a/app/views/projects/diffs/_single_image_diff.html.haml +++ b/app/views/projects/diffs/_single_image_diff.html.haml @@ -10,5 +10,5 @@ .image.js-single-image{ data: diff_view_data } .wrap - single_class_name = diff_file.deleted_file? ? 'deleted' : 'added' - = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "#{single_class_name} #{class_name} js-image-frame", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.file_path } + = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "#{single_class_name} #{class_name} old-diff js-image-frame", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.file_path } %p.image-info= number_to_human_size(blob.size) diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml index 56427a74d56..641a0689c26 100644 --- a/app/views/projects/diffs/_text_file.html.haml +++ b/app/views/projects/diffs/_text_file.html.haml @@ -1,9 +1,9 @@ - too_big = diff_file.diff_lines.count > Commit::DIFF_SAFE_LINES - if too_big .suppressed-container - %a.show-suppressed-diff.js-show-suppressed-diff= _("Changes suppressed. Click to show.") + %a.show-suppressed-diff.cursor-pointer.js-show-suppressed-diff= _("Changes suppressed. Click to show.") -%table.text-file.diff-wrap-lines.code.js-syntax-highlight.commit-diff{ data: diff_view_data, class: too_big ? 'hide' : '' } +%table.text-file.diff-wrap-lines.code.code-commit.js-syntax-highlight.commit-diff{ data: diff_view_data, class: too_big ? 'hide' : '' } = render partial: "projects/diffs/line", collection: diff_file.highlighted_diff_lines, as: :line, diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 1a489bfa275..c15b84d0aac 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -1,227 +1,157 @@ -- breadcrumb_title "General Settings" -- page_title "General" +- breadcrumb_title _("General Settings") +- page_title _("General") - @content_class = "limit-container-width" unless fluid_layout -- expanded = Rails.env.test? - -.project-edit-container - %section.settings.general-settings.no-animate#js-general-project-settings{ class: ('expanded' if expanded) } - .settings-header - %h4 - General project - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' - %p - Update your project name, description, avatar, and other general settings. - .settings-content - .project-edit-errors - = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "edit-project" }, authenticity_token: true do |f| - %input{ name: 'update_section', type: 'hidden', value: 'js-general-project-settings' } - %fieldset - .row - .form-group.col-md-9 - = f.label :name, class: 'label-bold', for: 'project_name_edit' do - Project name - = f.text_field :name, class: "form-control", id: "project_name_edit" - - .form-group.col-md-3 - = f.label :id, class: 'label-bold' do - Project ID - = f.text_field :id, class: 'form-control', readonly: true - - .form-group - = f.label :description, class: 'label-bold' do - Project description - %span.light (optional) - = f.text_area :description, class: "form-control", rows: 3, maxlength: 250 - - = render_if_exists 'projects/classification_policy_settings', f: f - - = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project - - .form-group - = f.label :tag_list, "Topics", class: 'label-bold' - = f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control" - %p.form-text.text-muted Separate topics with commas. - %fieldset.features - %h5.prepend-top-0= _("Project avatar") - .form-group - - if @project.avatar? - .avatar-container.rect-avatar.s160.append-bottom-15 - = project_icon(@project, alt: '', class: 'avatar project-avatar s160', width: 160, height: 160) - - if @project.avatar_in_git - %p.light - = _("Project avatar in repository: %{link}").html_safe % { link: @project.avatar_in_git } - .prepend-top-5.append-bottom-10 - %button.btn.js-choose-project-avatar-button{ type: 'button' }= _("Choose file...") - %span.file_name.prepend-left-default.js-filename= _("No file chosen") - = f.file_field :avatar, class: "js-project-avatar-input hidden" - .form-text.text-muted= _("The maximum file size allowed is 200KB.") - - if @project.avatar? - %hr - = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _("Avatar will be removed. Are you sure?") }, method: :delete, class: "btn btn-danger btn-inverted" - = f.submit 'Save changes', class: "btn btn-success js-btn-success-general-project-settings" - - %section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded) } - .settings-header - %h4 - Permissions - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' - %p - Enable or disable certain project features and choose access levels. - .settings-content - = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f| - %input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' } - -# haml-lint:disable InlineJavaScript - %script.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data_json(@project) - .js-project-permissions-form - = f.submit 'Save changes', class: "btn btn-success" - - = render_if_exists 'projects/issues_settings' - - %section.qa-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] } - .settings-header - %h4 - Merge request - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' - %p - Customize your merge request restrictions. - .settings-content - = render_if_exists 'shared/promotions/promote_mr_features' - - = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "merge-request-settings-form" }, authenticity_token: true do |f| - %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' } - = render 'projects/merge_request_settings', form: f - = f.submit 'Save changes', class: "btn btn-success qa-save-merge-request-changes" - - = render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded - - = render_if_exists 'projects/service_desk_settings' - - %section.settings.no-animate{ class: ('expanded' if expanded) } - .settings-header - %h4 - = s_('ProjectSettings|Badges') - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' - %p - = s_('ProjectSettings|Customize your project badges.') - = link_to s_('ProjectSettings|Learn more about badges.'), help_page_path('user/project/badges') - .settings-content - = render 'shared/badges/badge_settings' - - = render 'export', project: @project - - %section.qa-advanced-settings.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded) } - .settings-header - %h4 - Advanced - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' - %p - Perform advanced options such as housekeeping, archiving, renaming, transferring, or removing your project. - .settings-content +- expanded = expanded_by_default? + +%section.settings.general-settings.no-animate.expanded#js-general-settings + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, topics, avatar') + %button.btn.btn-default.js-settings-toggle{ type: 'button' }= _('Collapse') + %p= _('Update your project name, topics, description and avatar.') + .settings-content= render 'projects/settings/general' + +%section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded) } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Visibility, project features, permissions') + %button.btn.btn-default.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand') + %p= _('Choose visibility level, enable/disable project features (issues, repository, wiki, snippets) and set permissions.') + + .settings-content + = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f| + %input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' } + %template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data_json(@project) + .js-project-permissions-form + = f.submit _('Save changes'), class: "btn btn-success" + +%section.qa-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Merge requests') + %button.btn.btn-default.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand') + %p= _('Choose your merge method, options, checks, and set up a default merge request description template.') + + .settings-content + = render_if_exists 'shared/promotions/promote_mr_features' + + = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f| + %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' } + = render 'projects/merge_request_settings', form: f + = f.submit _('Save changes'), class: "btn btn-success qa-save-merge-request-changes" + += render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded + + +%section.settings.no-animate{ class: ('expanded' if expanded) } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only + = s_('ProjectSettings|Badges') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = s_('ProjectSettings|Customize your project badges.') + = link_to s_('ProjectSettings|Learn more about badges.'), help_page_path('user/project/badges') + .settings-content + = render 'shared/badges/badge_settings' + += render_if_exists 'projects/settings/default_issue_template' + += render_if_exists 'projects/service_desk_settings' + +%section.qa-advanced-settings.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded) } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Advanced') + %button.btn.btn-default.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand') + %p= _('Housekeeping, export, path, transfer, remove, archive.') + + .settings-content + .sub-section + %h4= _('Housekeeping') + %p= _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.') + = link_to _('Run housekeeping'), housekeeping_project_path(@project), + method: :post, class: "btn btn-default" + + = render 'export', project: @project + + - if can? current_user, :archive_project, @project .sub-section - %h4 Housekeeping - %p - Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects. - = link_to 'Run housekeeping', housekeeping_project_path(@project), - method: :post, class: "btn btn-default" - - if can? current_user, :archive_project, @project - .sub-section - %h4.warning-title - - if @project.archived? - Unarchive project - - else - Archive project + %h4.warning-title - if @project.archived? - %p - Unarchiving the project will restore people's ability to make changes to it. - The repository can be committed to, and issues, comments and other entities can be created. - %strong Once active this project shows up in the search and on the dashboard. - = link_to 'Unarchive project', unarchive_project_path(@project), - data: { confirm: "Are you sure that you want to unarchive this project?" }, - method: :post, class: "btn btn-success" + = _('Unarchive project') - else - %p - Archiving the project will make it entirely read-only. It is hidden from the dashboard and doesn't show up in searches. - %strong The repository cannot be committed to, and no issues, comments or other entities can be created. - = link_to 'Archive project', archive_project_path(@project), - data: { confirm: "Are you sure that you want to archive this project?" }, - method: :post, class: "btn btn-warning" - .sub-section.rename-repository - %h4.warning-title - Rename repository - = render 'projects/errors' - = form_for([@project.namespace.becomes(Namespace), @project]) do |f| - .form-group.project_name_holder - = f.label :name, class: 'label-bold' do - Project name - .form-group - = f.text_field :name, class: "form-control" + = _('Archive project') + - if @project.archived? + %p= _("Unarchiving the project will restore people's ability to make changes to it. The repository can be committed to, and issues, comments and other entities can be created. <strong>Once active this project shows up in the search and on the dashboard.</strong>").html_safe + = link_to _('Unarchive project'), unarchive_project_path(@project), + data: { confirm: _("Are you sure that you want to unarchive this project?") }, + method: :post, class: "btn btn-success" + - else + %p= _("Archiving the project will make it entirely read-only. It is hidden from the dashboard and doesn't show up in searches. <strong>The repository cannot be committed to, and no issues, comments or other entities can be created.</strong>").html_safe + = link_to _('Archive project'), archive_project_path(@project), + data: { confirm: _("Are you sure that you want to archive this project?") }, + method: :post, class: "btn btn-warning" + .sub-section.rename-repository + %h4.warning-title= _('Change path') + = render 'projects/errors' + = form_for([@project.namespace.becomes(Namespace), @project]) do |f| + .form-group + = f.label :path, _('Path'), class: 'label-bold' + .form-group + .input-group + .input-group-prepend + .input-group-text + #{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/ + = f.text_field :path, class: 'form-control qa-project-path-field h-auto' + %ul + %li= _("Be careful. Renaming a project's repository can have unintended side effects.") + %li= _('You will need to update your local repositories to point to the new location.') + - if @project.deployment_platform.present? + %li= _('Your deployment services will be broken, you will need to manually fix the services after renaming.') + = f.submit _('Change path'), class: "btn btn-warning qa-change-path-button" + + - if can?(current_user, :change_namespace, @project) + .sub-section + %h4.danger-title= _('Transfer project') + = form_for([@project.namespace.becomes(Namespace), @project], url: transfer_project_path(@project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } ) do |f| .form-group - = f.label :path, class: 'label-bold' do - %span Path + = label_tag :new_namespace_id, nil, class: 'label-bold' do + %span= _('Select a new namespace') .form-group - .input-group - .input-group-prepend - .input-group-text - #{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/ - = f.text_field :path, class: 'form-control' + = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2' %ul - %li Be careful. Renaming a project's repository can have unintended side effects. - %li You will need to update your local repositories to point to the new location. - - if @project.deployment_platform.present? - %li Your deployment services will be broken, you will need to manually fix the services after renaming. - = f.submit 'Rename project', class: "btn btn-warning" - - if can?(current_user, :change_namespace, @project) - .sub-section - %h4.danger-title - Transfer project - = form_for([@project.namespace.becomes(Namespace), @project], url: transfer_project_path(@project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } ) do |f| - .form-group - = label_tag :new_namespace_id, nil, class: 'label-bold' do - %span Select a new namespace - .form-group - = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2' - %ul - %li Be careful. Changing the project's namespace can have unintended side effects. - %li You can only transfer the project to namespaces you manage. - %li You will need to update your local repositories to point to the new location. - %li Project visibility level will be changed to match namespace rules when transferring to a group. - = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => transfer_project_message(@project) } - - if @project.forked? && can?(current_user, :remove_fork_project, @project) - .sub-section - %h4.danger-title - Remove fork relationship + %li= _("Be careful. Changing the project's namespace can have unintended side effects.") + %li= _('You can only transfer the project to namespaces you manage.') + %li= _('You will need to update your local repositories to point to the new location.') + %li= _('Project visibility level will be changed to match namespace rules when transferring to a group.') + = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => transfer_project_message(@project) } + + - if @project.forked? && can?(current_user, :remove_fork_project, @project) + .sub-section + %h4.danger-title= _('Remove fork relationship') + %p + = _('This will remove the fork relationship to source project') + = succeed "." do + - if @project.fork_source + = link_to(fork_source_name(@project), project_path(@project.fork_source)) + - else + = fork_source_name(@project) + = form_for([@project.namespace.becomes(Namespace), @project], url: remove_fork_project_path(@project), method: :delete, remote: true, html: { class: 'transfer-project' }) do |f| %p - This will remove the fork relationship to source project - = succeed "." do - - if @project.fork_source - = link_to(fork_source_name(@project), project_path(@project.fork_source)) - - else - = fork_source_name(@project) - = form_for([@project.namespace.becomes(Namespace), @project], url: remove_fork_project_path(@project), method: :delete, remote: true, html: { class: 'transfer-project' }) do |f| - %p - %strong Once removed, the fork relationship cannot be restored and you will no longer be able to send merge requests to the source. - = button_to 'Remove fork relationship', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_message(@project) } - - if can?(current_user, :remove_project, @project) - .sub-section - %h4.danger-title - Remove project + %strong= _('Once removed, the fork relationship cannot be restored and you will no longer be able to send merge requests to the source.') + = button_to _('Remove fork relationship'), '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_message(@project) } + + - if can?(current_user, :remove_project, @project) + .sub-section + %h4.danger-title= _('Remove project') + %p= _('Removing the project will delete its repository and all related resources including issues, merge requests etc.') + = form_tag(project_path(@project), method: :delete) do %p - Removing the project will delete its repository and all related resources including issues, merge requests etc. - = form_tag(project_path(@project), method: :delete) do - %p - %strong Removed projects cannot be restored! - = button_to 'Remove project', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_project_message(@project) } + %strong= _('Removed projects cannot be restored!') + = button_to _('Remove project'), '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_project_message(@project) } .save-project-loader.hide .center %h2 %i.fa.fa-spinner.fa-spin - Saving project. - %p Please wait a moment, this page will automatically refresh when ready. + = _('Saving project.') + %p= _('Please wait a moment, this page will automatically refresh when ready.') = render 'shared/confirm_modal', phrase: @project.path diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 081990ac9b7..9fa31c147eb 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -7,89 +7,64 @@ %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } = render "home_panel" - .project-empty-note-panel - %h4.append-bottom-20 - = _('The repository for this project is empty') + %h4.prepend-top-0.append-bottom-8 + = _('The repository for this project is empty') - - if @project.can_current_user_push_code? - %p - - link_to_cli = link_to _('command line instructions'), '#repo-command-line-instructions' - = _('If you already have files you can push them using the %{link_to_cli} below.').html_safe % { link_to_cli: link_to_cli } - %p - %em - - link_to_protected_branches = link_to _('Learn more about protected branches'), help_page_path('user/project/protected_branches') - = _('Note that the master branch is automatically protected. %{link_to_protected_branches}').html_safe % { link_to_protected_branches: link_to_protected_branches } - - %hr - %p - - link_to_auto_devops_settings = link_to(s_('AutoDevOps|enable Auto DevOps'), project_settings_ci_cd_path(@project, anchor: 'autodevops-settings')) - - link_to_add_kubernetes_cluster = link_to(s_('AutoDevOps|add a Kubernetes cluster'), new_project_cluster_path(@project)) - = s_('AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}.').html_safe % { link_to_auto_devops_settings: link_to_auto_devops_settings, link_to_add_kubernetes_cluster: link_to_add_kubernetes_cluster } + - if @project.can_current_user_push_code? + %p.append-bottom-0 + = _('You can create files directly in GitLab using one of the following options.') - %hr - %p - = _('Otherwise it is recommended you start with one of the options below.') - .prepend-top-20 - - %nav.project-buttons - .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller.qa-quick-actions - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') - .nav-links.scrolling-tabs.quick-links - = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons + .project-buttons.qa-quick-actions + = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons - if can?(current_user, :push_code, @project) - %div - .prepend-top-20 - .empty_wrapper - %h3#repo-command-line-instructions.page-title-empty - = _('Command line instructions') - .git-empty.js-git-empty - %fieldset - %h5= _('Git global setup') - %pre.bg-light - :preserve - git config --global user.name "#{h git_user_name}" - git config --global user.email "#{h git_user_email}" - - %fieldset - %h5= _('Create a new repository') - %pre.bg-light - :preserve - git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} - cd #{h @project.path} - touch README.md - git add README.md - git commit -m "add README" - - if @project.can_current_user_push_to_default_branch? - %span>< - git push -u origin master + .empty-wrapper.prepend-top-32 + %h3#repo-command-line-instructions.page-title-empty + = _('Command line instructions') + %p + = _('You can also upload existing files from your computer using the instructions below.') + .git-empty.js-git-empty + %fieldset + %h5= _('Git global setup') + %pre.bg-light + :preserve + git config --global user.name "#{h git_user_name}" + git config --global user.email "#{h git_user_email}" - %fieldset - %h5= _('Existing folder') - %pre.bg-light - :preserve - cd existing_folder - git init - git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} - git add . - git commit -m "Initial commit" - - if @project.can_current_user_push_to_default_branch? - %span>< - git push -u origin master + %fieldset + %h5= _('Create a new repository') + %pre.bg-light + :preserve + git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} + cd #{h @project.path} + touch README.md + git add README.md + git commit -m "add README" + - if @project.can_current_user_push_to_default_branch? + %span>< + git push -u origin master - %fieldset - %h5= _('Existing Git repository') - %pre.bg-light - :preserve - cd existing_repo - git remote rename origin old-origin - git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} - - if @project.can_current_user_push_to_default_branch? - %span>< - git push -u origin --all - git push -u origin --tags + %fieldset + %h5= _('Push an existing folder') + %pre.bg-light + :preserve + cd existing_folder + git init + git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} + git add . + git commit -m "Initial commit" + - if @project.can_current_user_push_to_default_branch? + %span>< + git push -u origin master - - if can? current_user, :remove_project, @project - .prepend-top-20 - = link_to _('Remove project'), [@project.namespace.becomes(Namespace), @project], data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-inverted btn-remove float-right" + %fieldset + %h5= _('Push an existing Git repository') + %pre.bg-light + :preserve + cd existing_repo + git remote rename origin old-origin + git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} + - if @project.can_current_user_push_to_default_branch? + %span>< + git push -u origin --all + git push -u origin --tags diff --git a/app/views/projects/environments/_form.html.haml b/app/views/projects/environments/_form.html.haml index cbd5c54cecc..1fbe34cfff3 100644 --- a/app/views/projects/environments/_form.html.haml +++ b/app/views/projects/environments/_form.html.haml @@ -17,5 +17,5 @@ = f.url_field :external_url, class: 'form-control' .form-actions - = f.submit _('Save'), class: 'btn btn-save' + = f.submit _('Save'), class: 'btn btn-success' = link_to _('Cancel'), project_environments_path(@project), class: 'btn btn-cancel' diff --git a/app/views/projects/forks/error.html.haml b/app/views/projects/forks/error.html.haml index e8a89b8c6fc..b37dba8b35d 100644 --- a/app/views/projects/forks/error.html.haml +++ b/app/views/projects/forks/error.html.haml @@ -1,24 +1,20 @@ -- page_title "Fork project" +- page_title _("Fork project") - if @forked_project && !@forked_project.saved? .alert.alert-danger.alert-block %h4 = sprite_icon('fork', size: 16) - Fork Error! + = _("Fork Error!") %p - You tried to fork - = link_to_project @project - but it failed for the following reason: - + = _("You tried to fork %{link_to_the_project} but it failed for the following reason:").html_safe % { link_to_the_project: link_to_project(@project) } - if @forked_project && @forked_project.errors.any? %p – - error = @forked_project.errors.full_messages.first - if error.include?("already been taken") - Name has already been taken + = _("Name has already been taken") - else = error %p - = link_to new_project_fork_path(@project), title: "Fork", class: "btn" do - Try to fork again + = link_to _("Try to fork again"), new_project_fork_path(@project), title: _("Fork"), class: "btn" diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml index c63c34c4ebb..0397a7034c7 100644 --- a/app/views/projects/forks/index.html.haml +++ b/app/views/projects/forks/index.html.haml @@ -5,12 +5,12 @@ .nav-controls = form_tag request.original_url, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| - = search_field_tag :filter_projects, nil, placeholder: 'Search forks', class: 'projects-list-filter project-filter-form-field form-control input-short', + = search_field_tag :filter_projects, nil, placeholder: _('Search forks'), class: 'projects-list-filter project-filter-form-field form-control input-short', spellcheck: false, data: { 'filter-selector' => 'span.namespace-name' } .dropdown %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } - %span.light sort: + %span.light= _("sort:") - if @sort.present? = sort_options_hash[@sort] - else @@ -30,13 +30,12 @@ - if current_user && can?(current_user, :fork_project, @project) - if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2 - = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: 'Go to your fork', class: 'btn btn-success' do + = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn btn-success' do = sprite_icon('fork', size: 12) - %span Fork + %span= _('Fork') - else - = link_to new_project_fork_path(@project), title: "Fork project", class: 'btn btn-success' do + = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn btn-success' do = sprite_icon('fork', size: 12) - %span Fork - + %span= _('Fork') = render 'projects', projects: @forks diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml index a603b1024eb..bf03353a565 100644 --- a/app/views/projects/forks/new.html.haml +++ b/app/views/projects/forks/new.html.haml @@ -1,13 +1,11 @@ -- page_title "Fork project" +- page_title _("Fork project") .row.prepend-top-default .col-lg-3 %h4.prepend-top-0 - Fork project + = _("Fork project") %p - A fork is a copy of a project. - %br - Forking a repository allows you to make changes without affecting the original project. + = _("A fork is a copy of a project.<br />Forking a repository allows you to make changes without affecting the original project.").html_safe .col-lg-9 - if @namespaces.present? .fork-thumbnail-container.js-fork-content @@ -17,13 +15,13 @@ = render 'fork_button', namespace: namespace - else %strong - No available namespaces to fork the project. + = _("No available namespaces to fork the project.") %p.prepend-top-default - You must have permission to create a project in a namespace before forking. + = _("You must have permission to create a project in a namespace before forking.") .save-project-loader.hide.js-fork-content %h2.text-center = icon('spinner spin') - Forking repository + = _("Forking repository") %p.text-center - Please wait a moment, this page will automatically refresh when ready. + = _("Please wait a moment, this page will automatically refresh when ready.") diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml index 7614d40ba1f..1118b44d7a2 100644 --- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml @@ -5,11 +5,11 @@ - pipeline_link = local_assigns.fetch(:pipeline_link, false) - stage = local_assigns.fetch(:stage, false) -%tr.generic_commit_status{ class: ('retried' if retried) } +%tr.generic-commit-status{ class: ('retried' if retried) } %td.status = render 'ci/status/badge', status: generic_commit_status.detailed_status(current_user) - %td.generic_commit_status-link + %td.generic-commit-status-link - if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url = link_to generic_commit_status.target_url do %span.build-link ##{generic_commit_status.id} diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml index f1b14d4c4d1..4b2417ff43b 100644 --- a/app/views/projects/graphs/show.html.haml +++ b/app/views/projects/graphs/show.html.haml @@ -22,6 +22,6 @@ = s_('ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits.') % { branch_name: @ref } %input#brush_change{ :type => "hidden" } .graphs.row - #contributors-master + #contributors-master.svg-w-100 #contributors.clearfix - %ol.contributors-list.row + %ol.contributors-list.svg-w-100.row diff --git a/app/views/projects/issues/_closed_by_box.html.haml b/app/views/projects/issues/_closed_by_box.html.haml deleted file mode 100644 index 38469ed4774..00000000000 --- a/app/views/projects/issues/_closed_by_box.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -.issue-closed-by-widget.second-block - - pluralized_mr_this = merge_request_count > 1 ? "these" : "this" - - pluralized_mr_is = merge_request_count > 1 ? "are" : "is" - When #{pluralized_mr_this} merge #{"request".pluralize(merge_request_count)} #{pluralized_mr_is} accepted, this issue will be closed automatically. diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index ce7c7091c93..9293aa1b309 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -6,7 +6,7 @@ .issuable-info-container .issuable-main-info .issue-title.title - %span.issue-title-text + %span.issue-title-text{ dir: "auto" } - if issue.confidential? %span.has-tooltip{ title: _('Confidential') } = confidential_icon(issue) @@ -36,8 +36,10 @@ = issue.due_date.to_s(:medium) - if issue.labels.any? - - labels_sorted_by_title(issue.labels).each do |label| - = link_to_label(label, subject: issue.project, css_class: 'label-link') + - presented_labels_sorted_by_title(issue.labels, issue.project).each do |label| + = link_to_label(label, css_class: 'label-link') + + = render_if_exists "projects/issues/issue_weight", issue: issue .issuable-meta %ul.controls @@ -46,7 +48,7 @@ CLOSED - if issue.assignees.any? %li - = render 'shared/issuable/assignees', project: @project, issue: issue + = render 'shared/issuable/assignees', project: @project, issuable: issue = render 'shared/issuable_meta_data', issuable: issue diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml deleted file mode 100644 index 310e339ac8d..00000000000 --- a/app/views/projects/issues/_merge_requests.html.haml +++ /dev/null @@ -1,36 +0,0 @@ -- if @merge_requests.any? - .card-slim.mt-3 - .card-header - %h2.card-title.mt-0.mb-0.h5.merge-requests-title - %span.mr-1.bold - = _('Related merge requests') - .d-inline-flex.lh-100.align-middle - .mr-count-badge - .mr-count-badge-count - = sprite_icon('merge-request', size: 16, css_class: 'mr-1 text-secondary') - = @merge_requests.count - %ul.content-list.related-items-list - - has_any_head_pipeline = @merge_requests.any?(&:head_pipeline_id) - - @merge_requests.each do |merge_request| - - merge_request = merge_request.present(current_user: current_user) - %li.list-item.py-0.px-0 - .item-body.issuable-info-container.py-lg-3.px-lg-3.pl-md-3 - .item-contents - .item-title.d-flex.align-items-center.mr-title - = render partial: 'projects/issues/merge_requests_status', locals: { merge_request: merge_request, css_class: 'd-none d-xl-block append-right-8' } - = link_to merge_request.title, merge_request_path(merge_request), { class: 'mr-title-link'} - .item-meta - = render partial: 'projects/issues/merge_requests_status', locals: { merge_request: merge_request, css_class: 'd-xl-none d-lg-block append-right-5' } - %span.d-flex.align-items-center.append-right-8.mr-item-path.item-path-id.mt-0 - %span.path-id-text.bold.text-truncate{ data: { toggle: 'tooltip'}, title: merge_request.target_project.full_path } - = merge_request.target_project.full_path - = merge_request.to_reference - %span.mr-ci-status.flex-md-grow-1.justify-content-end.d-flex.ml-md-2 - - if merge_request.can_read_pipeline? - = render_pipeline_status(merge_request.head_pipeline, tooltip_placement: 'bottom') - - elsif has_any_head_pipeline - = icon('blank fw') - - - if @closed_by_merge_requests.present? - %p - = render partial: 'projects/issues/closed_by_box', locals: {merge_request_count: @merge_requests.count} diff --git a/app/views/projects/issues/_merge_requests_status.html.haml b/app/views/projects/issues/_merge_requests_status.html.haml deleted file mode 100644 index 90838a75214..00000000000 --- a/app/views/projects/issues/_merge_requests_status.html.haml +++ /dev/null @@ -1,25 +0,0 @@ -- time_format = '%b %e, %Y %l:%M%P %Z%z' - -- if merge_request.merged? - - mr_status_date = merge_request.merged_at - - mr_status_title = _('Merged') - - mr_status_icon = 'merge' - - mr_status_class = 'merged' -- elsif merge_request.closed? - - mr_status_date = merge_request.closed_event&.created_at - - mr_status_title = _('Closed') - - mr_status_icon = 'issue-close' - - mr_status_class = 'closed' -- else - - mr_status_date = merge_request.created_at - - mr_status_title = mr_status_date ? _('Opened') : _('Open') - - mr_status_icon = 'issue-open-m' - - mr_status_class = 'open' - -- if mr_status_date - - mr_status_tooltip = "<div><span class=\"bold\">#{mr_status_title}</span> #{time_ago_in_words(mr_status_date)} ago</div><span class=\"text-tertiary\">#{l(mr_status_date.to_time, format: time_format)}</span>" -- else - - mr_status_tooltip = "<div><span class=\"bold\">#{mr_status_title}</span></div>" - -%span.mr-status-wrapper.suggestion-help-hover{ class: css_class, data: { toggle: 'tooltip', placement: 'bottom', html: 'true', title: mr_status_tooltip } } - = sprite_icon(mr_status_icon, size: 16, css_class: "merge-request-status #{mr_status_class}") diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index fbd70cd1906..457b2936278 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -8,18 +8,18 @@ - create_branch_path = project_branches_path(@project, branch_name: @issue.to_branch_name, ref: @project.default_branch, issue_iid: @issue.iid) - refs_path = refs_namespace_project_path(@project.namespace, @project, search: '') - .create-mr-dropdown-wrap.d-inline-block{ data: { can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path } } - .btn-group.unavailable + .create-mr-dropdown-wrap.d-inline-block.full-width-mobile{ data: { can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path } } + .btn-group.btn-group-sm.unavailable %button.btn.btn-grouped{ type: 'button', disabled: 'disabled' } = icon('spinner', class: 'fa-spin') %span.text Checking branch availability… - .btn-group.available.hidden + .btn-group.btn-group-sm.available.hidden %button.btn.js-create-merge-request.btn-success.btn-inverted{ type: 'button', data: { action: data_action } } = value - %button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-success.btn-inverted.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' }, display: 'static' } } + %button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-success.btn-inverted.js-dropdown-toggle.flex-grow-0{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' }, display: 'static' } } = icon('caret-down') .droplab-dropdown diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index ffdd96870ef..6da4956a036 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -8,7 +8,7 @@ - pipeline = @project.pipeline_for(branch, target.sha) if target - if can?(current_user, :read_pipeline, pipeline) %span.related-branch-ci-status - = render_pipeline_status(pipeline) + = render 'ci/status/icon', status: pipeline.detailed_status(current_user) %span.related-branch-info %strong = link_to branch, project_compare_path(@project, from: @project.default_branch, to: branch), class: "ref-name" diff --git a/app/views/projects/issues/new.html.haml b/app/views/projects/issues/new.html.haml index 9a081a42b6f..d1601d7fd10 100644 --- a/app/views/projects/issues/new.html.haml +++ b/app/views/projects/issues/new.html.haml @@ -1,9 +1,8 @@ -- add_to_breadcrumbs "Issues", project_issues_path(@project) -- breadcrumb_title "New" -- page_title "New Issue" +- add_to_breadcrumbs _("Issues"), project_issues_path(@project) +- breadcrumb_title _("New") +- page_title _("New Issue") -%h3.page-title - New Issue +%h3.page-title= _("New Issue") %hr = render "form" diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 3a674da6e87..d55afee4523 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -1,7 +1,7 @@ - @content_class = "limit-container-width" unless fluid_layout -- add_to_breadcrumbs "Issues", project_issues_path(@project) +- add_to_breadcrumbs _("Issues"), project_issues_path(@project) - breadcrumb_title @issue.to_reference -- page_title "#{@issue.title} (#{@issue.to_reference})", "Issues" +- page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues") - page_description @issue.description - page_card_attributes @issue.card_attributes @@ -15,7 +15,7 @@ .issuable-status-box.status-box.status-box-issue-closed{ class: issue_button_visibility(@issue, false) } = sprite_icon('mobile-issue-close', size: 16, css_class: 'd-block d-sm-none') .d-none.d-sm-block - - if @issue.moved? + - if @issue.moved? && can?(current_user, :read_issue, @issue.moved_to) - moved_link_start = "<a href=\"#{issue_path(@issue.moved_to)}\" class=\"text-white text-underline\">".html_safe - moved_link_end = '</a>'.html_safe = s_('IssuableStatus|Closed (%{moved_link_start}moved%{moved_link_end})').html_safe % {moved_link_start: moved_link_start, @@ -72,16 +72,18 @@ %h2.title= markdown_field(@issue, :title) - if @issue.description.present? .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' } - .wiki= markdown_field(@issue, :description) + .md= markdown_field(@issue, :description) %textarea.hidden.js-task-list-field= @issue.description = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago') - #merge-requests{ data: { url: referenced_merge_requests_project_issue_path(@project, @issue) } } - // This element is filled in using JavaScript. + = render_if_exists 'projects/issues/related_issues' - #related-branches{ data: { url: related_branches_project_issue_path(@project, @issue) } } - // This element is filled in using JavaScript. + #js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: @issue.iid)), project_namespace: @project.namespace.path, project_path: @project.path } } + + - if can?(current_user, :download_code, @project) + #related-branches{ data: { url: related_branches_project_issue_path(@project, @issue) } } + -# This element is filled in using JavaScript. .content-block.emoji-block.emoji-block-sticky .row diff --git a/app/views/projects/jobs/_table.html.haml b/app/views/projects/jobs/_table.html.haml index d124d3ebfc1..b08223546f7 100644 --- a/app/views/projects/jobs/_table.html.haml +++ b/app/views/projects/jobs/_table.html.haml @@ -16,7 +16,7 @@ %th Runner %th Stage %th Name - %th + %th Timing %th Coverage %th diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml index 475bae887ec..81a53f22f67 100644 --- a/app/views/projects/jobs/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -8,6 +8,7 @@ %div{ class: container_class } #js-job-vue-app{ data: { endpoint: project_job_path(@project, @build, format: :json), + deployment_help_url: help_page_path('user/project/clusters/index.html', anchor: 'troubleshooting-failed-deployment-jobs'), runner_help_url: help_page_path('ci/runners/README.html', anchor: 'setting-maximum-job-timeout-for-a-runner'), runner_settings_url: project_runners_path(@build.project, anchor: 'js-runners-settings'), build_options: javascript_build_options } } diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index bb7c297ba1f..511d7a82d1b 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -11,31 +11,30 @@ = render 'shared/labels/nav', labels_or_filters: labels_or_filters, can_admin_label: can_admin_label .labels-container.prepend-top-10 - - if can_admin_label - - if search.blank? - %p.text-muted - = _('Labels can be applied to issues and merge requests.') - %br - = _('Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging.') - -# Only show it in the first page - - hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1') - .prioritized-labels{ class: ('hide' if hide) } - %h5.prepend-top-10= _('Prioritized Labels') - .content-list.manage-labels-list.js-prioritized-labels{ "data-url" => set_priorities_project_labels_path(@project) } - #js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" } - = render 'shared/empty_states/priority_labels' - - if @prioritized_labels.present? - = render partial: 'shared/label', subject: @project, collection: @prioritized_labels, as: :label, locals: { force_priority: true } - - elsif search.present? - .nothing-here-block - = _('No prioritised labels with such name or description') + - if can_admin_label && search.blank? + %p.text-muted + = _('Labels can be applied to issues and merge requests.') + %br + = _('Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging.') + + -# Only show it in the first page + - hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1') + .prioritized-labels{ class: [('hide' if hide), ('is-not-draggable' unless can_admin_label)] } + %h5.prepend-top-10= _('Prioritized Labels') + .content-list.manage-labels-list.js-prioritized-labels{ data: { url: set_priorities_project_labels_path(@project), sortable: can_admin_label } } + #js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" } + = render 'shared/empty_states/priority_labels' + - if @prioritized_labels.present? + = render partial: 'shared/label', collection: @prioritized_labels, as: :label, locals: { force_priority: true, subject: @project } + - elsif search.present? + .nothing-here-block + = _('No prioritised labels with such name or description') - if @labels.present? .other-labels - - if can_admin_label - %h5{ class: ('hide' if hide) }= _('Other Labels') + %h5{ class: ('hide' if hide) }= _('Other Labels') .content-list.manage-labels-list.js-other-labels - = render partial: 'shared/label', subject: @project, collection: @labels, as: :label + = render partial: 'shared/label', collection: @labels, as: :label, locals: { subject: @project } = paginate @labels, theme: 'gitlab' - elsif search.present? .other-labels diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 90916191d97..67e5e4ca62d 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -34,8 +34,8 @@ = merge_request.target_branch - if merge_request.labels.any? - - labels_sorted_by_title(merge_request.labels).each do |label| - = link_to_label(label, subject: merge_request.project, type: :merge_request, css_class: 'label-link') + - presented_labels_sorted_by_title(merge_request.labels, merge_request.project).each do |label| + = link_to_label(label, type: :merge_request, css_class: 'label-link') .issuable-meta %ul.controls @@ -48,14 +48,14 @@ CLOSED - if can?(current_user, :read_pipeline, merge_request.head_pipeline) %li.issuable-pipeline-status.d-none.d-sm-inline-block - = render_pipeline_status(merge_request.head_pipeline) + = render 'ci/status/icon', status: merge_request.head_pipeline.detailed_status(current_user) - if merge_request.open? && merge_request.broken? %li.issuable-pipeline-broken.d-none.d-sm-inline-block = link_to merge_request_path(merge_request), class: "has-tooltip", title: _('Cannot be merged automatically') do = icon('exclamation-triangle') - - if merge_request.assignee + - if merge_request.assignees.any? %li - = link_to_member(merge_request.source_project, merge_request.assignee, name: false, title: _('Assigned to :name')) + = render 'shared/issuable/assignees', project: merge_request.project, issuable: merge_request = render_if_exists 'projects/merge_requests/approvals_count', merge_request: merge_request = render 'shared/issuable_meta_data', issuable: merge_request diff --git a/app/views/projects/merge_requests/_merge_requests.html.haml b/app/views/projects/merge_requests/_merge_requests.html.haml index bd6f1c05949..57fbd360d46 100644 --- a/app/views/projects/merge_requests/_merge_requests.html.haml +++ b/app/views/projects/merge_requests/_merge_requests.html.haml @@ -1,5 +1,5 @@ %ul.content-list.mr-list.issuable-list - - if @merge_requests.exists? + - if @merge_requests.present? = render @merge_requests - else = render 'shared/empty_states/merge_requests' diff --git a/app/views/projects/merge_requests/_mr_box.html.haml b/app/views/projects/merge_requests/_mr_box.html.haml index 1a9ab288683..7f2c9dcacfd 100644 --- a/app/views/projects/merge_requests/_mr_box.html.haml +++ b/app/views/projects/merge_requests/_mr_box.html.haml @@ -5,7 +5,7 @@ %div - if @merge_request.description.present? .description{ class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : '' } - .wiki + .md = markdown_field(@merge_request, :description) %textarea.hidden.js-task-list-field = @merge_request.description diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index 3cd83feb842..92e34b3ceda 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -1,8 +1,9 @@ - can_update_merge_request = can?(current_user, :update_merge_request, @merge_request) +- can_reopen_merge_request = can?(current_user, :reopen_merge_request, @merge_request) - if @merge_request.closed_without_fork? .alert.alert-danger - %p The source project of this merge request has been removed. + The source project of this merge request has been removed. .detail-page-header .detail-page-header-body @@ -33,10 +34,11 @@ - if can_update_merge_request %li{ class: [merge_request_button_visibility(@merge_request, true), 'js-close-item'] } = link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request' + - if can_reopen_merge_request %li{ class: merge_request_button_visibility(@merge_request, false) } = link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request' - if can_update_merge_request = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-sm-none d-md-block btn btn-grouped js-issuable-edit qa-edit-button" - = render 'shared/issuable/close_reopen_button', issuable: @merge_request, can_update: can_update_merge_request, can_reopen: can_update_merge_request + = render 'shared/issuable/close_reopen_button', issuable: @merge_request, can_update: can_update_merge_request, can_reopen: can_reopen_merge_request diff --git a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml index 8181267184a..55c89f137c5 100644 --- a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml +++ b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml @@ -6,7 +6,7 @@ .form-group.row .col-md-4 %h4= _('Resolve conflicts on source branch') - .resolve-info + .resolve-info{ "v-pre": true } = translation.html_safe .col-md-8 %label.label-bold{ "for" => "commit-message" } diff --git a/app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml b/app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml index d828cb6cf9e..7bd5c437942 100644 --- a/app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml +++ b/app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml @@ -1,5 +1,5 @@ %inline-conflict-lines{ "inline-template" => "true", ":file" => "file" } - %table + %table.diff-wrap-lines.code.code-commit.js-syntax-highlight %tr.line_holder.diff-inline{ "v-for" => "line in file.inlineLines" } %td.diff-line-num.new_line{ ":class" => "lineCssClass(line)", "v-if" => "!line.isHeader" } %a {{line.new_line}} diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml index 09aeb81671a..f48390aa046 100644 --- a/app/views/projects/merge_requests/conflicts/show.html.haml +++ b/app/views/projects/merge_requests/conflicts/show.html.haml @@ -26,9 +26,9 @@ %strong {{file.filePath}} = render partial: 'projects/merge_requests/conflicts/file_actions' .diff-content.diff-wrap-lines - .diff-wrap-lines.code.file-content.js-syntax-highlight{ "v-show" => "!isParallel && file.resolveMode === 'interactive' && file.type === 'text'" } + .file-content{ "v-show" => "!isParallel && file.resolveMode === 'interactive' && file.type === 'text'" } = render partial: "projects/merge_requests/conflicts/components/inline_conflict_lines" - .diff-wrap-lines.code.file-content.js-syntax-highlight{ "v-show" => "isParallel && file.resolveMode === 'interactive' && file.type === 'text'" } + .file-content{ "v-show" => "isParallel && file.resolveMode === 'interactive' && file.type === 'text'" } %parallel-conflict-lines{ ":file" => "file" } %div{ "v-show" => "file.resolveMode === 'edit' || file.type === 'text-editor'" } = render partial: "projects/merge_requests/conflicts/components/diff_file_editor" diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 5111c9fab8d..a201fafb949 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -31,29 +31,26 @@ .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } .merge-request-tabs-container - .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') - %ul.merge-request-tabs.nav-tabs.nav.nav-links.scrolling-tabs - %li.notes-tab.qa-notes-tab - = tab_link_for @merge_request, :show, force_link: @commit.present? do - Discussion - %span.badge.badge-pill= @merge_request.related_notes.user.count - - if @merge_request.source_project - %li.commits-tab - = tab_link_for @merge_request, :commits do - Commits - %span.badge.badge-pill= @commits_count - - if @pipelines.any? - %li.pipelines-tab - = tab_link_for @merge_request, :pipelines do - Pipelines - %span.badge.badge-pill.js-pipelines-mr-count= @pipelines.size - %li.diffs-tab.qa-diffs-tab - = tab_link_for @merge_request, :diffs do - Changes - %span.badge.badge-pill= @merge_request.diff_size - .d-inline-flex.flex-wrap + %ul.merge-request-tabs.nav-tabs.nav.nav-links + %li.notes-tab.qa-notes-tab + = tab_link_for @merge_request, :show, force_link: @commit.present? do + = _("Discussion") + %span.badge.badge-pill= @merge_request.related_notes.user.count + - if @merge_request.source_project + %li.commits-tab + = tab_link_for @merge_request, :commits do + = _("Commits") + %span.badge.badge-pill= @commits_count + - if @pipelines.any? + %li.pipelines-tab + = tab_link_for @merge_request, :pipelines do + = _("Pipelines") + %span.badge.badge-pill.js-pipelines-mr-count= @pipelines.size + %li.diffs-tab.qa-diffs-tab + = tab_link_for @merge_request, :diffs do + = _("Changes") + %span.badge.badge-pill= @merge_request.diff_size + .d-flex.flex-wrap.align-items-center.justify-content-lg-end #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@merge_request), notes_filters: UserPreference.notes_filters.to_json } } #js-vue-discussion-counter @@ -82,7 +79,8 @@ help_page_path: suggest_changes_help_path, current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json, project_path: project_path(@merge_request.project), - changes_empty_state_illustration: image_path('illustrations/merge_request_changes_empty.svg') } } + changes_empty_state_illustration: image_path('illustrations/merge_request_changes_empty.svg'), + is_fluid_layout: fluid_layout.to_s } } .mr-loading-status = spinner diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index 0542b349e44..1cee8be604a 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -54,13 +54,12 @@ %div - if @milestone.description.present? - .description - .wiki - = markdown_field(@milestone, :description) + .description.md + = markdown_field(@milestone, :description) = render_if_exists 'shared/milestones/burndown', milestone: @milestone, project: @project - - if can?(current_user, :read_issue, @project) && @milestone.total_items_count(current_user).zero? + - if can?(current_user, :read_issue, @project) && @milestone.total_issues_count(current_user).zero? .alert.alert-success.prepend-top-default %span= _('Assign some issues to this milestone.') - elsif @milestone.complete?(current_user) && @milestone.active? diff --git a/app/views/projects/mirrors/_authentication_method.html.haml b/app/views/projects/mirrors/_authentication_method.html.haml index 293a2e3ebfe..ee82d68d398 100644 --- a/app/views/projects/mirrors/_authentication_method.html.haml +++ b/app/views/projects/mirrors/_authentication_method.html.haml @@ -1,14 +1,12 @@ - mirror = f.object -- is_push = local_assigns.fetch(:is_push, false) - auth_options = [[_('Password'), 'password'], [_('SSH public key'), 'ssh_public_key']] -- regen_data = { auth_method: 'ssh_public_key', regenerate_ssh_private_key: true } -- ssh_public_key_present = mirror.ssh_public_key.present? .form-group = f.label :auth_method, _('Authentication method'), class: 'label-bold' = f.select :auth_method, options_for_select(auth_options, mirror.auth_method), {}, { class: "form-control js-mirror-auth-type qa-authentication-method" } + = f.hidden_field :auth_method, value: "password", class: "js-hidden-mirror-auth-type" .form-group .collapse.js-well-changing-auth @@ -16,21 +14,3 @@ .well-password-auth.collapse.js-well-password-auth = f.label :password, _("Password"), class: "label-bold" = f.password_field :password, value: mirror.password, class: 'form-control qa-password', autocomplete: 'new-password' - - unless is_push - .well-ssh-auth.collapse.js-well-ssh-auth - %p.js-ssh-public-key-present{ class: ('collapse' unless ssh_public_key_present) } - = _('Here is the public SSH key that needs to be added to the remote server. For more information, please refer to the documentation.') - %p.js-ssh-public-key-pending{ class: ('collapse' if ssh_public_key_present) } - = _('An SSH key will be automatically generated when the form is submitted. For more information, please refer to the documentation.') - - .clearfix.js-ssh-public-key-wrap{ class: ('collapse' unless ssh_public_key_present) } - %code.prepend-top-10.ssh-public-key - = mirror.ssh_public_key - = clipboard_button(text: mirror.ssh_public_key, title: _("Copy SSH public key to clipboard"), class: 'prepend-top-10 btn-copy-ssh-public-key') - - = button_tag type: 'button', - data: { endpoint: project_mirror_path(@project), project_data: { import_data_attributes: regen_data } }, - class: "btn btn-inverted btn-warning prepend-top-10 js-btn-regenerate-ssh-key#{ ' collapse' unless ssh_public_key_present }" do - = icon('spinner spin', class: 'js-spinner d-none') - = _('Regenerate key') - = render 'projects/mirrors/regenerate_public_ssh_key_confirm_modal' diff --git a/app/views/projects/mirrors/_disabled_mirror_badge.html.haml b/app/views/projects/mirrors/_disabled_mirror_badge.html.haml new file mode 100644 index 00000000000..356cb43f07f --- /dev/null +++ b/app/views/projects/mirrors/_disabled_mirror_badge.html.haml @@ -0,0 +1 @@ +.badge.badge-warning.qa-disabled-mirror-badge{ data: { toggle: 'tooltip', html: 'true' }, title: _('Disabled mirrors can only be enabled by instance owners. It is recommended that you delete them.') }= _('Disabled') diff --git a/app/views/projects/mirrors/_instructions.html.haml b/app/views/projects/mirrors/_instructions.html.haml index 35a6885318a..33e5a6e67c3 100644 --- a/app/views/projects/mirrors/_instructions.html.haml +++ b/app/views/projects/mirrors/_instructions.html.haml @@ -7,7 +7,7 @@ %li - minutes = Gitlab.config.gitlab_shell.git_timeout / 60 = _("The update action will time out after %{number_of_minutes} minutes. For big repositories, use a clone/push combination.") % { number_of_minutes: minutes } - %li= _('The Git LFS objects will <strong>not</strong> be synced.').html_safe + %li= mirror_lfs_sync_message %li = _('This user will be the author of all events in the activity feed that are the result of an update, like new branches being created or new commits being pushed to existing branches.') diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index 21b105e6f80..e68fa5d08c7 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -1,4 +1,4 @@ -- expanded = Rails.env.test? +- 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) } @@ -11,7 +11,7 @@ = link_to _('Read more'), help_page_path('workflow/repository_mirroring'), target: '_blank' .settings-content - = form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'false', data: mirrors_form_data_attributes } do |f| + = form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'new-password', data: mirrors_form_data_attributes } do |f| .panel.panel-default .panel-heading %h3.panel-title= _('Mirror a repository') @@ -20,7 +20,7 @@ .form-group.has-feedback = label_tag :url, _('Git repository URL'), class: 'label-light' - = text_field_tag :url, nil, class: 'form-control js-mirror-url js-repo-url qa-mirror-repository-url-input', placeholder: _('Input your repository URL'), required: true, pattern: "(#{protocols}):\/\/.+" + = text_field_tag :url, nil, class: 'form-control js-mirror-url js-repo-url qa-mirror-repository-url-input', placeholder: _('Input your repository URL'), required: true, pattern: "(#{protocols}):\/\/.+", autocomplete: 'new-password' = render 'projects/mirrors/instructions' @@ -29,7 +29,7 @@ .form-check.append-bottom-10 = check_box_tag :only_protected_branches, '1', false, class: 'js-mirror-protected form-check-input' = label_tag :only_protected_branches, _('Only mirror protected branches'), class: 'form-check-label' - = link_to icon('question-circle'), help_page_path('user/project/protected_branches') + = link_to icon('question-circle'), help_page_path('user/project/protected_branches'), target: '_blank' .panel-footer = f.submit _('Mirror repository'), class: 'btn btn-success js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror @@ -49,17 +49,19 @@ %tbody.js-mirrors-table-body = render_if_exists 'projects/mirrors/table_pull_row' - @project.remote_mirrors.each_with_index do |mirror, index| - - if mirror.enabled - %tr.qa-mirrored-repository-row - %td.qa-mirror-repository-url= mirror.safe_url - %td= _('Push') - %td.qa-mirror-last-update-at= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never') - %td - - if mirror.last_error.present? - .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error') - %td.mirror-action-buttons - .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')) - = render 'shared/remote_mirror_update_button', remote_mirror: mirror - %button.js-delete-mirror.btn.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= icon('trash-o') + - next if mirror.new_record? + %tr.qa-mirrored-repository-row{ class: ('bg-secondary' if mirror.disabled?) } + %td.qa-mirror-repository-url= mirror.safe_url + %td= _('Push') + %td.qa-mirror-last-update-at= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never') + %td + - 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') + %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')) + = render 'shared/remote_mirror_update_button', remote_mirror: mirror + %button.js-delete-mirror.qa-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/_mirror_repos_push.html.haml b/app/views/projects/mirrors/_mirror_repos_push.html.haml index 1d9c83653fe..b7c885b4a63 100644 --- a/app/views/projects/mirrors/_mirror_repos_push.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_push.html.haml @@ -5,4 +5,4 @@ = rm_f.hidden_field :url, class: 'js-mirror-url-hidden', required: true, pattern: "(#{protocols}):\/\/.+" = rm_f.hidden_field :only_protected_branches, class: 'js-mirror-protected-hidden' = render partial: 'projects/mirrors/ssh_host_keys', locals: { f: rm_f } - = render partial: 'projects/mirrors/authentication_method', locals: { f: rm_f, is_push: true } + = render partial: 'projects/mirrors/authentication_method', locals: { f: rm_f } diff --git a/app/views/projects/mirrors/_ssh_host_keys.html.haml b/app/views/projects/mirrors/_ssh_host_keys.html.haml index f61aa6ecd11..7762fb4b844 100644 --- a/app/views/projects/mirrors/_ssh_host_keys.html.haml +++ b/app/views/projects/mirrors/_ssh_host_keys.html.haml @@ -3,7 +3,7 @@ - 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-success.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' } = 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?) } diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index ff7c36c2d5b..d7e16dbd40c 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -16,6 +16,7 @@ = _('A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}.').html_safe % { among_other_things_link: among_other_things_link } %p = _('All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings.') + = render_if_exists 'projects/new_ci_cd_banner_external_repo' %p - pages_getting_started_guide = link_to _('Pages getting started guide'), help_page_path("user/project/pages/getting_started_part_two", anchor: "fork-a-project-to-get-started-from"), target: '_blank' = _('Information about additional Pages templates and how to install them can be found in our %{pages_getting_started_guide}.').html_safe % { pages_getting_started_guide: pages_getting_started_guide } @@ -42,13 +43,19 @@ %a.nav-link{ href: '#import-project-pane', id: 'import-project-tab', data: { toggle: 'tab', track_label: 'import_project', track_event: "click_tab" }, role: 'tab' } %span.d-none.d-sm-block Import project %span.d-block.d-sm-none Import + = render_if_exists 'projects/new_ci_cd_only_project_tab', active_tab: active_tab .tab-content.gitlab-tab-content .tab-pane{ id: 'blank-project-pane', class: active_when(active_tab == 'blank'), role: 'tabpanel' } = form_for @project, html: { class: 'new_project' } do |f| = render 'new_project_fields', f: f, project_name_id: "blank-project-name" - .tab-pane.no-padding{ id: 'create-from-template-pane', class: active_when(active_tab == 'template'), role: 'tabpanel' } + #create-from-template-pane.tab-pane.px-0.pb-0{ class: active_when(active_tab == 'template'), role: 'tabpanel' } + .card-slim.m-4.p-4 + %div + - contributing_templates_url = 'https://gitlab.com/gitlab-org/project-templates/contributing' + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: contributing_templates_url } + = _('Learn how to %{link_start}contribute to the built-in templates%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } = form_for @project, html: { class: 'new_project' } do |f| .project-template .form-group @@ -63,6 +70,8 @@ %h4 No import options available %p Contact an administrator to enable options for importing your project. + = render_if_exists 'projects/new_ci_cd_only_project_pane', active_tab: active_tab + .save-project-loader.d-none .center %h2 diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index eb6838cec8d..044adb75bea 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -41,9 +41,9 @@ .note-actions-item = button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji} has-tooltip btn btn-transparent", data: { position: 'right', container: 'body' } do = icon('spinner spin') - %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') - %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') - %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') + %span{ class: 'link-highlight award-control-icon-neutral' }= sprite_icon('slight-smile') + %span{ class: 'link-highlight award-control-icon-positive' }= sprite_icon('smiley') + %span{ class: 'link-highlight award-control-icon-super-positive' }= sprite_icon('smile') - if note_editable .note-actions-item diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml index 8de84f82e9f..8a6e5fde99b 100644 --- a/app/views/projects/notes/_more_actions_dropdown.html.haml +++ b/app/views/projects/notes/_more_actions_dropdown.html.haml @@ -11,7 +11,7 @@ - unless is_current_user %li = link_to new_abuse_report_path(user_id: note.author.id, ref_url: noteable_note_url(note)) do - = _('Report abuse to GitLab') + = _('Report abuse to admin') - if note_editable %li = link_to note_url(note), method: :delete, data: { confirm: 'Are you sure you want to delete this comment?' }, remote: true, class: 'js-note-delete' do diff --git a/app/views/projects/pages/_https_only.html.haml b/app/views/projects/pages/_https_only.html.haml index ce3ef29c32e..74478ee011c 100644 --- a/app/views/projects/pages/_https_only.html.haml +++ b/app/views/projects/pages/_https_only.html.haml @@ -3,7 +3,7 @@ .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 domains with SSL certificates to use HTTPS + %strong Force HTTPS (requires valid certificates) - unless pages_https_only_disabled? .prepend-top-10 diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml index b7b46c56c37..33f2166480b 100644 --- a/app/views/projects/pages_domains/_form.html.haml +++ b/app/views/projects/pages_domains/_form.html.haml @@ -1,29 +1,80 @@ - if @domain.errors.any? - #error_explanation - .alert.alert-danger - - @domain.errors.full_messages.each do |msg| - %p= msg + .alert.alert-danger + - @domain.errors.full_messages.each do |msg| + = msg .form-group.row - = f.label :domain, class: 'col-form-label col-sm-2' do - = _("Domain") + .col-sm-2.col-form-label + = f.label :domain, _("Domain") .col-sm-10 - = f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control', disabled: @domain.persisted? + = f.text_field :domain, required: true, autocomplete: "off", class: "form-control", disabled: @domain.persisted? - if Gitlab.config.pages.external_https - .form-group.row - = f.label :certificate, class: 'col-form-label col-sm-2' do - = _("Certificate (PEM)") - .col-sm-10 - = f.text_area :certificate, rows: 5, class: 'form-control' - %span.help-inline= _("Upload a certificate for your domain with all intermediates") - - .form-group.row - = f.label :key, class: 'col-form-label col-sm-2' do - = _("Key (PEM)") - .col-sm-10 - = f.text_area :key, rows: 5, class: 'form-control' - %span.help-inline= _("Upload a private key for your certificate") + + - auto_ssl_available = Feature.enabled?(:pages_auto_ssl) + - auto_ssl_enabled = @domain.auto_ssl_enabled? + - auto_ssl_available_and_enabled = auto_ssl_available && auto_ssl_enabled + + - if auto_ssl_available + .form-group.row + .col-sm-2.col-form-label + %label{ for: "pages_domain_auto_ssl_enabled_button" } + - lets_encrypt_link_url = "https://letsencrypt.org/" + - lets_encrypt_link_start = "<a href=\"%{lets_encrypt_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { lets_encrypt_link_url: lets_encrypt_link_url } + - lets_encrypt_link_end = "</a>".html_safe + = _("Automatic certificate management using %{lets_encrypt_link_start}Let's Encrypt%{lets_encrypt_link_end}").html_safe % { lets_encrypt_link_start: lets_encrypt_link_start, lets_encrypt_link_end: lets_encrypt_link_end } + + .col-sm-10.js-auto-ssl-toggle-container + %button{ type: "button", id: "pages_domain_auto_ssl_enabled_button", + class: "js-project-feature-toggle project-feature-toggle mt-2 #{"is-checked" if auto_ssl_available_and_enabled}", + "aria-label": _("Automatic certificate management using Let's Encrypt") } + = f.hidden_field :auto_ssl_enabled?, class: "js-project-feature-toggle-input" + %span.toggle-icon + = sprite_icon("status_success_borderless", size: 16, css_class: "toggle-icon-svg toggle-status-checked") + = sprite_icon("status_failed_borderless", size: 16, css_class: "toggle-icon-svg toggle-status-unchecked") + %p.text-secondary.mt-3 + - docs_link_url = help_page_path("user/project/pages/lets_encrypt_for_gitlab_pages.md", anchor: "lets-encrypt-for-gitlab-pages") + - docs_link_start = "<a href=\"%{docs_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { docs_link_url: docs_link_url } + - docs_link_end = "</a>".html_safe + = _("Let's Encrypt is a free, automated, and open certificate authority (CA) that gives digital certificates in order to enable HTTPS (SSL/TLS) for websites. Learn more about Let's Encrypt configuration by following the %{docs_link_start}documentation on GitLab Pages%{docs_link_end}.").html_safe % { docs_link_url: docs_link_url, docs_link_start: docs_link_start, docs_link_end: docs_link_end } + + .js-shown-if-auto-ssl{ class: ("d-none" unless auto_ssl_available_and_enabled) } + .form-group.row + .col-sm-2.col-form-label + = f.label :certificate, _("Certificate (PEM)") + .col-sm-10 + - if auto_ssl_available_and_enabled && !@domain.certificate.empty? + = f.text_area :certificate, + rows: 5, + class: "form-control", + disabled: true + %span.help-inline.text-muted= _("This certificate is automatically managed by Let's Encrypt") + - else + %p.text-secondary.form-control-plaintext= _("The certificate will be shown here once it has been obtained from Let's Encrypt. This process may take up to an hour to complete.") + + .js-shown-unless-auto-ssl{ class: ("d-none" if auto_ssl_available_and_enabled) } + .form-group.row + .col-sm-2.col-form-label + = f.label :certificate, _("Certificate (PEM)") + .col-sm-10 + = f.text_area :certificate, + rows: 5, + class: "form-control js-enabled-unless-auto-ssl", + value: (@domain.certificate unless auto_ssl_available_and_enabled), + disabled: auto_ssl_available_and_enabled + %span.help-inline.text-muted= _("Upload a certificate for your domain with all intermediates") + + .form-group.row + .col-sm-2.col-form-label + = f.label :key, _("Key (PEM)") + .col-sm-10 + = f.text_area :key, + rows: 5, + class: "form-control js-enabled-unless-auto-ssl", + value: (@domain.key unless auto_ssl_available_and_enabled), + disabled: auto_ssl_available_and_enabled + %span.help-inline.text-muted= _("Upload a private key for your certificate") + - else .nothing-here-block = _("Support for custom certificates is disabled. Ask your system's administrator to enable it.") diff --git a/app/views/projects/pages_domains/_helper_text.html.haml b/app/views/projects/pages_domains/_helper_text.html.haml new file mode 100644 index 00000000000..5a79fefabfc --- /dev/null +++ b/app/views/projects/pages_domains/_helper_text.html.haml @@ -0,0 +1,9 @@ +- docs_link_url = help_page_path("user/project/pages/getting_started_part_three.md", anchor: "adding-certificates-to-your-project") +- docs_link_start = "<a href=\"%{docs_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { docs_link_url: docs_link_url } +- docs_link_end = "</a>".html_safe + +-# Hiding behind a feature flag to avoid any changes to this feature's implemention +-# when the :pages_auto_ssl feature flag is disabled. This check should be removed +-# once the :pages_auto_ssl feature flag is removed. +- if Feature.enabled?(:pages_auto_ssl) + %p= _("Learn more about adding certificates to your project by following the %{docs_link_start}documentation on GitLab Pages%{docs_link_end}.").html_safe % { docs_link_url: docs_link_url, docs_link_start: docs_link_start, docs_link_end: docs_link_end } diff --git a/app/views/projects/pages_domains/edit.html.haml b/app/views/projects/pages_domains/edit.html.haml index e11387ae742..7c0777e5496 100644 --- a/app/views/projects/pages_domains/edit.html.haml +++ b/app/views/projects/pages_domains/edit.html.haml @@ -3,6 +3,7 @@ - page_title @domain.domain %h3.page-title = @domain.domain += render 'projects/pages_domains/helper_text' %hr.clearfix %div = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f| diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml index c7cefa87c76..e23ccb5d4c6 100644 --- a/app/views/projects/pages_domains/new.html.haml +++ b/app/views/projects/pages_domains/new.html.haml @@ -2,6 +2,7 @@ - page_title _('New Pages Domain') %h3.page-title = _("New Pages Domain") += render 'projects/pages_domains/helper_text' %hr.clearfix %div = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f| diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml index 1121cf06b5c..396e5da87bc 100644 --- a/app/views/projects/pipeline_schedules/_form.html.haml +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -1,4 +1,4 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "js-pipeline-schedule-form" } do |f| += form_for [@project.namespace.becomes(Namespace), @project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "js-pipeline-schedule-form pipeline-schedule-form" } do |f| = form_errors(@schedule) .form-group.row .col-md-9 @@ -11,12 +11,12 @@ .form-group.row .col-md-9 = f.label :cron_timezone, _('Cron Timezone'), class: 'label-bold' - = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'btn js-timezone-dropdown', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } ) + = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'btn js-timezone-dropdown w-100', dropdown_class: 'w-100', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } ) = f.text_field :cron_timezone, value: @schedule.cron_timezone, id: 'schedule_cron_timezone', class: 'hidden', name: 'schedule[cron_timezone]', required: true .form-group.row .col-md-9 = f.label :ref, _('Target Branch'), class: 'label-bold' - = dropdown_tag(_("Select target branch"), options: { toggle_class: 'btn js-target-branch-dropdown', dropdown_class: 'git-revision-dropdown', title: _("Select target branch"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } ) + = dropdown_tag(_("Select target branch"), options: { toggle_class: 'btn js-target-branch-dropdown w-100', dropdown_class: 'git-revision-dropdown w-100', title: _("Select target branch"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } ) = f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true .form-group.row.js-ci-variable-list-section .col-md-9 diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 9c2efd6aa35..5d307d6a70d 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -10,13 +10,7 @@ .icon-container = icon('clock-o') = pluralize @pipeline.total_size, "job" - - if @pipeline.ref - from - - if @pipeline.ref_exists? - = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name" - - else - %span.ref-name - = @pipeline.ref + = @pipeline.ref_text - if @pipeline.duration in = time_interval_in_words(@pipeline.duration) @@ -48,9 +42,9 @@ content: "<a class='autodevops-link' href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>", } } Auto DevOps - - if @pipeline.merge_request? - %span.js-pipeline-url-mergerequest.badge.badge-info.has-tooltip{ title: "This pipeline is run in a merge request context" } - merge request + - if @pipeline.detached_merge_request_pipeline? + %span.js-pipeline-url-mergerequest.badge.badge-info.has-tooltip{ title: "This pipeline is run on the source branch" } + detached - if @pipeline.stuck? %span.js-pipeline-url-stuck.badge.badge-warning stuck diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 66e202103a9..c04f076a3ab 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -2,15 +2,15 @@ %ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator.nav.nav-tabs %li.js-pipeline-tab-link = link_to project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do - = _("Pipeline") + = _('Pipeline') %li.js-builds-tab-link = link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do - = _("Jobs") + = _('Jobs') %span.badge.badge-pill.js-builds-counter= pipeline.total_size - if @pipeline.failed_builds.present? %li.js-failures-tab-link = link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do - = _("Failed Jobs") + = _('Failed Jobs') %span.badge.badge-pill.js-failures-counter= @pipeline.failed_builds.count = render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project @@ -24,41 +24,41 @@ %table.table.ci-table.pipeline %thead %tr - %th Status - %th Job ID - %th Name + %th= _('Status') + %th= _('Job ID') + %th= _('Name') %th - %th Coverage + %th= _('Coverage') %th = render partial: "projects/stage/stage", collection: pipeline.legacy_stages, as: :stage - elsif pipeline.project.builds_enabled? && !pipeline.ci_yaml_file .bs-callout.bs-callout-warning - \.gitlab-ci.yml not found in this commit + = _("%{gitlab_ci_yml} not found in this commit") % { gitlab_ci_yml: ".gitlab-ci.yml" } - if @pipeline.failed_builds.present? #js-tab-failures.build-failures.tab-pane.build-page %table.table.responsive-table.ci-table.responsive-table-sm-rounded %thead %th.table-th-transparent - %th.table-th-transparent= _("Name") - %th.table-th-transparent= _("Stage") - %th.table-th-transparent= _("Failure") + %th.table-th-transparent= _('Name') + %th.table-th-transparent= _('Stage') + %th.table-th-transparent= _('Failure') %tbody - @pipeline.failed_builds.each_with_index do |build, index| - job = build.present(current_user: current_user) %tr.build-state.responsive-table-border-start - %td.responsive-table-cell.ci-status-icon-failed{ data: { column: "Status"} } + %td.responsive-table-cell.ci-status-icon-failed{ data: { column: _('Status')} } .d-none.d-md-block.build-icon = custom_icon("icon_status_#{build.status}") .d-md-none.build-badge = render "ci/status/badge", link: false, status: job.detailed_status(current_user) - %td.responsive-table-cell.build-name{ data: { column: _("Name")} } + %td.responsive-table-cell.build-name{ data: { column: _('Name')} } = link_to build.name, pipeline_job_url(pipeline, build) - %td.responsive-table-cell.build-stage{ data: { column: _("Stage")} } + %td.responsive-table-cell.build-stage{ data: { column: _('Stage')} } = build.stage.titleize - %td.responsive-table-cell.build-failure{ data: { column: _("Failure")} } + %td.responsive-table-cell.build-failure{ data: { column: _('Failure')} } = build.present.callout_failure_message %td.responsive-table-cell.build-actions - if can?(current_user, :update_build, job) diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml index ec17eddba79..4d1d078661d 100644 --- a/app/views/projects/pipelines/charts.html.haml +++ b/app/views/projects/pipelines/charts.html.haml @@ -1,11 +1,7 @@ - @no_container = true -- page_title _("CI / CD Charts") +- page_title _('CI / CD Charts') %div{ class: container_class } - .sub-header-block - .oneline - = _("A collection of graphs regarding Continuous Integration") - #charts.ci-charts .row .col-md-6 diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index c0ee81fe28d..4e4638085fd 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -1,5 +1,7 @@ - @no_container = true -- page_title "Pipelines" +- page_title _('Pipelines') + += render_if_exists "shared/shared_runners_minutes_limit_flash_message" %div{ 'class' => container_class } #pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json), diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index f1cdc0a70dd..bfcaa09ae8c 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -1,16 +1,16 @@ -- breadcrumb_title "Pipelines" -- page_title s_("Pipeline|Run Pipeline") +- breadcrumb_title _('Pipelines') +- page_title s_('Pipeline|Run Pipeline') - settings_link = link_to _('CI/CD settings'), project_settings_ci_cd_path(@project) %h3.page-title - = s_("Pipeline|Run Pipeline") + = s_('Pipeline|Run Pipeline') %hr = form_for @pipeline, as: :pipeline, url: project_pipelines_path(@project), html: { id: "new-pipeline-form", class: "js-new-pipeline-form js-requires-input" } do |f| = form_errors(@pipeline) .form-group.row .col-sm-12 - = f.label :ref, s_('Pipeline|Create for'), class: 'col-form-label' + = f.label :ref, s_('Pipeline|Run for'), class: 'col-form-label' = hidden_field_tag 'pipeline[ref]', params[:ref] || @project.default_branch = dropdown_tag(params[:ref] || @project.default_branch, options: { toggle_class: 'js-branch-select wide monospace', @@ -28,8 +28,8 @@ = (s_("Pipeline|Specify variable values to be used in this run. The values specified in %{settings_link} will be used by default.") % {settings_link: settings_link}).html_safe .form-actions - = f.submit s_('Pipeline|Create pipeline'), class: 'btn btn-success js-variables-save-button', tabindex: 3 - = link_to 'Cancel', project_pipelines_path(@project), class: 'btn btn-default float-right' + = f.submit s_('Pipeline|Run Pipeline'), class: 'btn btn-success js-variables-save-button', tabindex: 3 + = link_to _('Cancel'), project_pipelines_path(@project), class: 'btn btn-default float-right' -# haml-lint:disable InlineJavaScript %script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 193d437dad1..8a6d7b082e3 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -1,7 +1,7 @@ - @no_container = true -- add_to_breadcrumbs "Pipelines", project_pipelines_path(@project) +- add_to_breadcrumbs _('Pipelines'), project_pipelines_path(@project) - breadcrumb_title "##{@pipeline.id}" -- page_title "Pipeline" +- page_title _('Pipeline') .js-pipeline-container{ class: container_class, data: { controller_action: "#{controller.action_name}" } } #js-pipeline-header-vue.pipeline-header-container @@ -11,11 +11,13 @@ - if @pipeline.builds.empty? && @pipeline.yaml_errors.present? .bs-callout.bs-callout-danger - %h4 Found errors in your .gitlab-ci.yml: + %h4= _('Found errors in your %{gitlab_ci_yml}:') % { gitlab_ci_yml: '.gitlab-ci.yml' } %ul - @pipeline.yaml_errors.split(",").each do |error| %li= error - You can test your .gitlab-ci.yml in #{link_to "CI Lint", project_ci_lint_path(@project)}. + - lint_link_url = project_ci_lint_path(@project) + - lint_link_start = '<a href="%{url}">'.html_safe % { url: lint_link_url } + = s_('You can also test your %{gitlab_ci_yml} in %{lint_link_start}CI Lint%{lint_link_end}').html_safe % { gitlab_ci_yml: '.gitlab-ci.yml', lint_link_start: lint_link_start, lint_link_end: '</a>'.html_safe } - else = render "projects/pipelines/with_tabs", pipeline: @pipeline diff --git a/app/views/projects/project_members/_groups.html.haml b/app/views/projects/project_members/_groups.html.haml index b5d397e3065..00321014f91 100644 --- a/app/views/projects/project_members/_groups.html.haml +++ b/app/views/projects/project_members/_groups.html.haml @@ -1,6 +1,6 @@ .card.project-members-groups .card-header - = _("Groups with access to <strong>%{project_name}</strong>").html_safe % { project_name: sanitize_project_name(@project.name) } + = _("Groups with access to <strong>%{project_name}</strong>").html_safe % { project_name: sanitize(@project.name, tags: []) } %span.badge.badge-pill= group_links.size %ul.content-list.members-list = render partial: 'shared/members/group', collection: group_links, as: :group_link diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/projects/project_members/_new_project_member.html.haml index 0590578c3fe..efabb7f7b19 100644 --- a/app/views/projects/project_members/_new_project_member.html.haml +++ b/app/views/projects/project_members/_new_project_member.html.haml @@ -19,4 +19,5 @@ = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date' %i.clear-icon.js-clear-input = f.submit _("Add to project"), class: "btn btn-success qa-add-member-button" - = link_to _("Import"), import_project_project_members_path(@project), class: "btn btn-default", title: _("Import members from another project") + - if can_import_members? + = link_to _("Import"), import_project_project_members_path(@project), class: "btn btn-default", title: _("Import members from another project") diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml index e0dd386fc5d..f220299ec30 100644 --- a/app/views/projects/project_members/_team.html.haml +++ b/app/views/projects/project_members/_team.html.haml @@ -4,7 +4,7 @@ .card .card-header.flex-project-members-panel %span.flex-project-title - = _("Members of <strong>%{project_name}</strong>").html_safe % { project_name: sanitize_project_name(project.name) } + = _("Members of <strong>%{project_name}</strong>").html_safe % { project_name: sanitize(project.name, tags: []) } %span.badge.badge-pill= members.total_count = form_tag project_project_members_path(project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do .form-group diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 242ff91f539..cc98ba64f08 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -1,29 +1,35 @@ - page_title _("Members") +- can_admin_project_members = can?(current_user, :admin_project_member, @project) .row.prepend-top-default .col-lg-12 - %h4 - = _("Project members") - - if can?(current_user, :admin_project_member, @project) - %p - = _("You can invite a new member to <strong>%{project_name}</strong> or invite another group.").html_safe % { project_name: sanitize_project_name(@project.name) } - - else - %p - = _("Members can be added by project <i>Maintainers</i> or <i>Owners</i>").html_safe + - if project_can_be_shared? + %h4 + = _("Project members") + - if can_admin_project_members + %p= share_project_description(@project) + - else + %p + = _("Members can be added by project <i>Maintainers</i> or <i>Owners</i>").html_safe + .light - - if can?(current_user, :admin_project_member, @project) - %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } - %li.nav-tab{ role: 'presentation' } - %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member") - - if @project.allowed_to_share_with_group? + - if can_admin_project_members && project_can_be_shared? + - if !membership_locked? && @project.allowed_to_share_with_group? + %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } %li.nav-tab{ role: 'presentation' } + %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member") + %li.nav-tab{ role: 'presentation', class: ('active' if membership_locked?) } %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite group") - .tab-content.gitlab-tab-content - .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } - = render 'projects/project_members/new_project_member', tab_title: _('Invite member') - .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' } - = render 'projects/project_members/new_project_group', tab_title: _('Invite group') + .tab-content.gitlab-tab-content + .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } + = render 'projects/project_members/new_project_member', tab_title: _('Invite member') + .tab-pane{ id: 'invite-group-pane', role: 'tabpanel', class: ('active' if membership_locked?) } + = render 'projects/project_members/new_project_group', tab_title: _('Invite group') + - elsif !membership_locked? + .invite-member= render 'projects/project_members/new_project_member', tab_title: _('Invite member') + - elsif @project.allowed_to_share_with_group? + .invite-group= render 'projects/project_members/new_project_group', tab_title: _('Invite group') = render 'shared/members/requests', membership_source: @project, requesters: @requesters .clearfix diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml index b12ae995ece..366d7a7a2eb 100644 --- a/app/views/projects/protected_branches/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_protected_branch.html.haml @@ -1,2 +1,2 @@ = render layout: 'projects/protected_branches/shared/protected_branch', locals: { protected_branch: protected_branch } do - = render partial: 'projects/protected_branches/update_protected_branch', locals: { protected_branch: protected_branch } + = render_if_exists 'projects/protected_branches/update_protected_branch', protected_branch: protected_branch diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml index d617d85afc2..3644a623d2c 100644 --- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml @@ -6,8 +6,8 @@ .card-body = form_errors(@protected_branch) .form-group.row - = f.label :name, class: 'col-md-2 text-right' do - Branch: + .col-md-2.text-right + = f.label :name, 'Branch:' .col-md-10 = render partial: "projects/protected_branches/shared/dropdown", locals: { f: f } .form-text.text-muted diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml index 539b184e5c2..63748d8d85f 100644 --- a/app/views/projects/protected_branches/shared/_index.html.haml +++ b/app/views/projects/protected_branches/shared/_index.html.haml @@ -1,4 +1,4 @@ -- expanded = Rails.env.test? +- expanded = expanded_by_default? %section.qa-protected-branches-settings.settings.no-animate#js-protected-branches-settings{ class: ('expanded' if expanded) } .settings-header 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 bb7998f739d..81dcab1d1ab 100644 --- a/app/views/projects/protected_branches/shared/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_protected_branch.html.haml @@ -1,6 +1,6 @@ - can_admin_project = can?(current_user, :admin_project, @project) -%tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } } +%tr.qa-protected-branch.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } } %td %span.ref-name= protected_branch.name diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml index cbf1938664c..020e6e187a6 100644 --- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml @@ -6,8 +6,8 @@ .card-body = form_errors(@protected_tag) .form-group.row - = f.label :name, class: 'col-md-2 text-right' do - Tag: + .col-md-2.text-right + = f.label :name, 'Tag:' .col-md-10.protected-tags-dropdown = render partial: "projects/protected_tags/shared/dropdown", locals: { f: f } .form-text.text-muted diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml index 9a50a51e4be..b0c87ac8c17 100644 --- a/app/views/projects/protected_tags/shared/_index.html.haml +++ b/app/views/projects/protected_tags/shared/_index.html.haml @@ -1,4 +1,4 @@ -- expanded = Rails.env.test? +- expanded = expanded_by_default? %section.settings.no-animate#js-protected-tags-settings{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/projects/registry/repositories/_tag.html.haml b/app/views/projects/registry/repositories/_tag.html.haml index a4cde53e8c6..9594c9184a2 100644 --- a/app/views/projects/registry/repositories/_tag.html.haml +++ b/app/views/projects/registry/repositories/_tag.html.haml @@ -1,7 +1,7 @@ %tr.tag %td = escape_once(tag.name) - = clipboard_button(text: "docker pull #{tag.location}") + = clipboard_button(text: "#{tag.location}") %td - if tag.revision %span.has-tooltip{ title: "#{tag.revision}" } diff --git a/app/views/projects/serverless/functions/index.html.haml b/app/views/projects/serverless/functions/index.html.haml index 635580eac5c..9c69aedfbfc 100644 --- a/app/views/projects/serverless/functions/index.html.haml +++ b/app/views/projects/serverless/functions/index.html.haml @@ -5,7 +5,10 @@ - status_path = project_serverless_functions_path(@project, format: :json) - clusters_path = project_clusters_path(@project) -.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path, installed: @installed, clusters_path: clusters_path, help_path: help_page_path('user/project/clusters/serverless/index') } } +.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path, + installed: @installed, + clusters_path: clusters_path, + help_path: help_page_path('user/project/clusters/serverless/index') } } %div{ class: [container_class, ('limit-container-width' unless fluid_layout)] } .js-serverless-functions-notice diff --git a/app/views/projects/serverless/functions/show.html.haml b/app/views/projects/serverless/functions/show.html.haml index 29737b7014a..d1fe208ce60 100644 --- a/app/views/projects/serverless/functions/show.html.haml +++ b/app/views/projects/serverless/functions/show.html.haml @@ -1,14 +1,19 @@ - @no_container = true - @content_class = "limit-container-width" unless fluid_layout +- clusters_path = project_clusters_path(@project) +- help_path = help_page_path('user/project/clusters/serverless/index') - add_to_breadcrumbs('Serverless', project_serverless_functions_path(@project)) - page_title @service[:name] -.serverless-function-details-page.js-serverless-function-details-page{ data: { service: @service.as_json } } +.serverless-function-details-page.js-serverless-function-details-page{ data: { service: @service.as_json, + prometheus: @prometheus, + clusters_path: clusters_path, + help_path: help_path } } + %div{ class: [container_class, ('limit-container-width' unless fluid_layout)] } - .top-area.adjust - .serverless-function-details#js-serverless-function-details + .serverless-function-details#js-serverless-function-details .js-serverless-function-notice .flash-container diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml index 9409418bbcc..82c1d57c97e 100644 --- a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml @@ -18,22 +18,22 @@ .help-form .form-group - = label_tag :display_name, 'Display name', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :display_name, 'Display name', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :display_name, "GitLab / #{@project.full_name}", class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#display_name', class: 'input-group-text') .form-group - = label_tag :description, 'Description', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :description, 'Description', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#description', class: 'input-group-text') .form-group - = label_tag nil, 'Command trigger word', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block + = label_tag nil, 'Command trigger word', class: 'col-12 col-form-label label-bold' + .col-12 %p Fill in the word that works best for your team. %p Suggestions: @@ -42,44 +42,44 @@ %code= @project.full_path .form-group - = label_tag :request_url, 'Request URL', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :request_url, 'Request URL', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :request_url, service_trigger_url(subject), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#request_url', class: 'input-group-text') .form-group - = label_tag nil, 'Request method', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block POST + = label_tag nil, 'Request method', class: 'col-12 col-form-label label-bold' + .col-12 POST .form-group - = label_tag :response_username, 'Response username', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :response_username, 'Response username', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :response_username, 'GitLab', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#response_username', class: 'input-group-text') .form-group - = label_tag :response_icon, 'Response icon', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :response_icon, 'Response icon', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :response_icon, asset_url('gitlab_logo.png'), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#response_icon', class: 'input-group-text') .form-group - = label_tag nil, 'Autocomplete', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block Yes + = label_tag nil, 'Autocomplete', class: 'col-12 col-form-label label-bold' + .col-12 Yes .form-group - = label_tag :autocomplete_hint, 'Autocomplete hint', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :autocomplete_hint, 'Autocomplete hint', class: 'col-12 col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :autocomplete_hint, '[help]', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#autocomplete_hint', class: 'input-group-text') .form-group - = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#autocomplete_description', class: 'input-group-text') diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml index 9a7004f89c0..9b7732abc62 100644 --- a/app/views/projects/services/slack_slash_commands/_help.html.haml +++ b/app/views/projects/services/slack_slash_commands/_help.html.haml @@ -27,8 +27,8 @@ .help-form .form-group - = label_tag nil, 'Command', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block + = label_tag nil, 'Command', class: 'col-12 col-form-label label-bold' + .col-12 %p Fill in the word that works best for your team. %p Suggestions: @@ -37,50 +37,50 @@ %code= @project.full_path .form-group - = label_tag :url, 'URL', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :url, 'URL', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :url, service_trigger_url(subject), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#url', class: 'input-group-text') .form-group - = label_tag nil, 'Method', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block POST + = label_tag nil, 'Method', class: 'col-12 col-form-label label-bold' + .col-12 POST .form-group - = label_tag :customize_name, 'Customize name', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :customize_name, 'Customize name', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :customize_name, 'GitLab', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#customize_name', class: 'input-group-text') .form-group - = label_tag nil, 'Customize icon', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block - = image_tag(asset_url('slash-command-logo.png'), width: 36, height: 36) + = label_tag nil, 'Customize icon', class: 'col-12 col-form-label label-bold' + .col-12 + = image_tag(asset_url('slash-command-logo.png'), width: 36, height: 36, class: 'mr-3') = link_to('Download image', asset_url('gitlab_logo.png'), class: 'btn btn-sm', target: '_blank', rel: 'noopener noreferrer') .form-group - = label_tag nil, 'Autocomplete', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block Show this command in the autocomplete list + = label_tag nil, 'Autocomplete', class: 'col-12 col-form-label label-bold' + .col-12 Show this command in the autocomplete list .form-group - = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#autocomplete_description', class: 'input-group-text') .form-group - = label_tag :autocomplete_usage_hint, 'Autocomplete usage hint', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :autocomplete_usage_hint, 'Autocomplete usage hint', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :autocomplete_usage_hint, '[help]', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#autocomplete_usage_hint', class: 'input-group-text') .form-group - = label_tag :descriptive_label, 'Descriptive label', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group + = label_tag :descriptive_label, 'Descriptive label', class: 'col-12 col-form-label label-bold' + .col-12.input-group = text_field_tag :descriptive_label, 'Perform common operations on GitLab project', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#descriptive_label', class: 'input-group-text') diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml new file mode 100644 index 00000000000..520f342f567 --- /dev/null +++ b/app/views/projects/settings/_general.html.haml @@ -0,0 +1,42 @@ += form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "edit-project js-general-settings-form" }, authenticity_token: true do |f| + %input{ name: 'update_section', type: 'hidden', value: 'js-general-settings' } + = form_errors(@project) + + %fieldset + .row + .form-group.col-md-5 + = f.label :name, class: 'label-bold', for: 'project_name_edit' do + = _('Project name') + = f.text_field :name, class: 'form-control qa-project-name-field', id: "project_name_edit" + + .form-group.col-md-7 + = f.label :id, class: 'label-bold' do + = _('Project ID') + = f.text_field :id, class: 'form-control w-auto', readonly: true + + .row + .form-group.col-md-9 + = f.label :tag_list, _('Topics'), class: 'label-bold' + = f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control" + %p.form-text.text-muted= _('Separate topics with commas.') + + .row + .form-group.col-md-9 + = f.label :description, _('Project description (optional)'), class: 'label-bold' + = f.text_area :description, class: 'form-control', rows: 3, maxlength: 250 + + .row= render_if_exists 'projects/classification_policy_settings', f: f + + = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project + + .form-group.prepend-top-default.append-bottom-20 + .avatar-container.s90 + = project_icon(@project, alt: _('Project avatar'), class: 'avatar project-avatar s90') + = f.label :avatar, _('Project avatar'), class: 'label-bold d-block' + = render 'shared/choose_avatar_button', f: f + - if @project.avatar? + %hr + = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link' + + + = f.submit _('Save changes'), class: "btn btn-success mt-4 qa-save-naming-topics-avatar-button" 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 8c4d1c32ebe..fe74dc122c3 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -8,19 +8,20 @@ .card.auto-devops-card .card-body .form-check - = form.check_box :enabled, class: 'form-check-input js-toggle-extra-settings', checked: @project.auto_devops_enabled? + = form.check_box :enabled, class: 'form-check-input js-toggle-extra-settings', checked: auto_devops_enabled = form.label :enabled, class: 'form-check-label' do %strong= s_('CICD|Default to Auto DevOps pipeline') - - if @project.has_auto_devops_implicitly_enabled? - %span.badge.badge-info.js-instance-default-badge= s_('CICD|instance enabled') + - if auto_devops_enabled + %span.badge.badge-info.js-instance-default-badge= badge_for_auto_devops_scope(@project) .form-text.text-muted = s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.') = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank' - .card-footer.js-extra-settings{ class: @project.auto_devops_enabled? || 'hidden' } - %p.settings-message.text-center - - kubernetes_cluster_link = help_page_path('user/project/clusters/index') - - kubernetes_cluster_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: kubernetes_cluster_link } - = s_('CICD|You must add a %{kubernetes_cluster_start}Kubernetes cluster integration%{kubernetes_cluster_end} to this project with a domain in order for your deployment strategy to work correctly.').html_safe % { kubernetes_cluster_start: kubernetes_cluster_start, kubernetes_cluster_end: '</a>'.html_safe } + .card-footer.js-extra-settings{ class: auto_devops_enabled || 'hidden' } + - if @project.all_clusters.empty? + %p.settings-message.text-center + - kubernetes_cluster_link = help_page_path('user/project/clusters/index') + - kubernetes_cluster_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: kubernetes_cluster_link } + = s_('CICD|You must add a %{kubernetes_cluster_start}Kubernetes cluster integration%{kubernetes_cluster_end} to this project with a domain in order for your deployment strategy to work correctly.').html_safe % { kubernetes_cluster_start: kubernetes_cluster_start, kubernetes_cluster_end: '</a>'.html_safe } %label.prepend-top-10 %strong= s_('CICD|Deployment strategy') .form-check diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index bfb275b9ef5..2d108a1cba5 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -26,6 +26,14 @@ %hr .form-group + = f.fields_for :ci_cd_settings_attributes, @project.ci_cd_settings do |form| + = form.label :default_git_depth, _('Git shallow clone'), class: 'label-bold' + = form.number_field :default_git_depth, { class: 'form-control', min: 0, max: 1000 } + %p.form-text.text-muted + = _('The number of changes to be fetched from GitLab when cloning a repository. This can speed up Pipelines execution. Keep empty or set to 0 to disable shallow clone by default and make GitLab CI fetch all branches and tags each time.') + + %hr + .form-group = f.label :build_timeout_human_readable, _('Timeout'), class: 'label-bold' = f.text_field :build_timeout_human_readable, class: 'form-control' %p.form-text.text-muted @@ -102,17 +110,20 @@ tap --coverage-report=text-summary (NodeJS) - %code ^Statements\s*:\s*([^%]+) %li + nyc npm test (NodeJS) - + %code All files[^|]*\|[^|]*\s+([\d\.]+) + %li excoveralls (Elixir) - %code \[TOTAL\]\s+(\d+\.\d+)% %li + mix test --cover (Elixir) - + %code \d+.\d+\%\s+\|\s+Total + %li JaCoCo (Java/Kotlin) %code Total.*?([0-9]{1,3})% %li go test -cover (Go) %code coverage: \d+.\d+% of statements - %li - nyc npm test (NodeJS) - - %code All files[^|]*\|[^|]*\s+([\d\.]+) = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 6966bf96724..5e3e1076c2c 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -2,7 +2,7 @@ - page_title _("CI / CD Settings") - page_title _("CI / CD") -- expanded = Rails.env.test? +- expanded = expanded_by_default? - general_expanded = @project.errors.empty? ? expanded : true %section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded) } @@ -26,7 +26,7 @@ = s_('CICD|Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration.') = link_to s_('CICD|Learn more about Auto DevOps'), help_page_path('topics/autodevops/index.md') .settings-content - = render 'autodevops_form' + = render 'autodevops_form', auto_devops_enabled: @project.auto_devops_enabled? = render_if_exists 'projects/settings/ci_cd/protected_environments', expanded: expanded diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml index 4911e8d3770..583fc08f375 100644 --- a/app/views/projects/settings/operations/_error_tracking.html.haml +++ b/app/views/projects/settings/operations/_error_tracking.html.haml @@ -2,29 +2,19 @@ - setting = error_tracking_setting -%section.settings.expanded.border-0.no-animate +%section.settings.no-animate.js-error-tracking-settings .settings-header %h4 = _('Error Tracking') + %button.btn.js-settings-toggle{ type: 'button' } + = _('Expand') %p = _('To link Sentry to GitLab, enter your Sentry URL and Auth Token.') + = link_to _('More information'), help_page_path('user/project/operations/error_tracking'), target: '_blank', rel: 'noopener noreferrer' .settings-content - = form_for @project, url: project_settings_operations_path(@project), method: :patch do |f| - = form_errors(@project) - .form-group - = f.fields_for :error_tracking_setting_attributes, setting do |form| - .form-check.form-group - = form.check_box :enabled, class: 'form-check-input' - = form.label :enabled, _('Active'), class: 'form-check-label' - .form-group - = form.label :api_url, _('Sentry API URL'), class: 'label-bold' - = form.url_field :api_url, class: 'form-control', placeholder: _('http://<sentry-host>/api/0/projects/{organization_slug}/{project_slug}/') - %p.form-text.text-muted - = _('Enter your Sentry API URL') - .form-group - = form.label :token, _('Auth Token'), class: 'label-bold' - = form.text_field :token, class: 'form-control' - %p.form-text.text-muted - = _('Find and manage Auth Tokens in your Sentry account settings page.') - - = f.submit _('Save changes'), class: 'btn btn-success' + .js-error-tracking-form{ data: { list_projects_endpoint: list_projects_project_error_tracking_index_path(@project, format: :json), + operations_settings_endpoint: project_settings_operations_path(@project), + project: error_tracking_setting_project_json, + api_host: setting.api_host, + enabled: setting.enabled.to_json, + token: setting.token } } diff --git a/app/views/projects/settings/operations/_external_dashboard.html.haml b/app/views/projects/settings/operations/_external_dashboard.html.haml new file mode 100644 index 00000000000..a124283921d --- /dev/null +++ b/app/views/projects/settings/operations/_external_dashboard.html.haml @@ -0,0 +1,3 @@ +.js-operation-settings{ data: { operations_settings_endpoint: project_settings_operations_path(@project), + external_dashboard: { url: metrics_external_dashboard_url, + help_page_path: help_page_path('user/project/operations/link_to_external_dashboard') } } } diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml index b36fa9a5f51..0a7a155bc12 100644 --- a/app/views/projects/settings/operations/show.html.haml +++ b/app/views/projects/settings/operations/show.html.haml @@ -1,5 +1,8 @@ - @content_class = 'limit-container-width' unless fluid_layout -- page_title _('Operations') +- page_title _('Operations Settings') +- breadcrumb_title _('Operations Settings') -= render 'projects/settings/operations/error_tracking', expanded: true += render_if_exists 'projects/settings/operations/incidents' += render 'projects/settings/operations/error_tracking' += render 'projects/settings/operations/external_dashboard' = render_if_exists 'projects/settings/operations/tracing' diff --git a/app/views/projects/settings/repository/_protected_branches.html.haml b/app/views/projects/settings/repository/_protected_branches.html.haml new file mode 100644 index 00000000000..31630828571 --- /dev/null +++ b/app/views/projects/settings/repository/_protected_branches.html.haml @@ -0,0 +1,2 @@ += render "projects/protected_branches/index" += render "projects/protected_tags/index" diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index cb3a035c49e..ff30cc4f6db 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -3,14 +3,17 @@ - @content_class = "limit-container-width" unless fluid_layout = render "projects/default_branch/show" += render_if_exists "projects/push_rules/index" = render "projects/mirrors/mirror_repos" -# Protected branches & tags use a lot of nested partials. -# The shared parts of the views can be found in the `shared` directory. -# Those are used throughout the actual views. These `shared` views are then -# reused in EE. -= render "projects/protected_branches/index" -= render "projects/protected_tags/index" += render "projects/settings/repository/protected_branches" + = render @deploy_keys = render "projects/deploy_tokens/index" = render "projects/cleanup/show" + += render_if_exists 'shared/promotions/promote_repository_features' diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index cc203cfad86..8bfface3f5a 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -20,9 +20,8 @@ %p = s_("TagsPage|Can't find HEAD commit for this tag") - if release && release.description.present? - .description.prepend-top-default - .wiki - = markdown_field(release, :description) + .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/index.html.haml b/app/views/projects/tags/index.html.haml index 458096f9dd6..2e78b0bff3e 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -9,7 +9,7 @@ .nav-text.row-main-content = s_('TagsPage|Tags give the ability to mark specific points in history as being important') - .nav-controls.row-fixed-content + .nav-controls = form_tag(filter_tags_path, method: :get) do = search_field_tag :search, params[:search], { placeholder: s_('TagsPage|Filter by tag name'), id: 'tag-search', class: 'form-control search-text-input input-short', spellcheck: false } diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index feeaf799f51..59232372150 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -18,12 +18,12 @@ - else = s_("TagsPage|Can't find HEAD commit for this tag") - .nav-controls.controls-flex + .nav-controls - if can?(current_user, :push_code, @project) = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn btn-edit controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do = icon("pencil") = link_to project_tree_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Browse files') do - = icon('files-o') + = sprite_icon('folder-open') = link_to project_commits_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Browse commits') do = icon('history') .btn-container.controls-item @@ -39,8 +39,7 @@ .append-bottom-default.prepend-top-default - if @release.description.present? - .description - .wiki - = markdown_field(@release, :description) + .description.md + = markdown_field(@release, :description) - else = s_('TagsPage|This tag has no release notes.') diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml index 4daacbe157c..4f6c7e1f9a6 100644 --- a/app/views/projects/tree/_readme.html.haml +++ b/app/views/projects/tree/_readme.html.haml @@ -1,5 +1,5 @@ - if readme.rich_viewer - %article.file-holder.readme-holder{ id: 'readme', class: ("limited-width-container" unless fluid_layout) } + %article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout), ("js-hide-on-navigation" if vue_file_list_enabled?)] } .js-file-title.file-title = blob_icon readme.mode, readme.name = link_to project_blob_path(@project, tree_join(@ref, readme.path)) do diff --git a/app/views/projects/tree/_tree_commit_column.html.haml b/app/views/projects/tree/_tree_commit_column.html.haml index e37fd7624be..065fef606d5 100644 --- a/app/views/projects/tree/_tree_commit_column.html.haml +++ b/app/views/projects/tree/_tree_commit_column.html.haml @@ -1,2 +1,3 @@ +- full_title = markdown_field(commit, :full_title) %span.str-truncated - = link_to_html commit.redacted_full_title_html, project_commit_path(@project, commit.id), title: commit.redacted_full_title_html, class: 'tree-commit-link' + = link_to_html full_title, project_commit_path(@project, commit.id), title: full_title, class: 'tree-commit-link' diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index ec8e5234bd4..ea6349f2f57 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -6,71 +6,74 @@ = render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true - if on_top_of_branch? - - addtotree_toggle_attributes = { href: '#', 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown', 'data-boundary': 'window' } + - addtotree_toggle_attributes = { 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown', 'data-boundary': 'window' } - else - addtotree_toggle_attributes = { title: _("You can only add files when you are on a branch"), data: { container: 'body' }, class: 'disabled has-tooltip' } - %ul.breadcrumb.repo-breadcrumb - %li.breadcrumb-item - = link_to project_tree_path(@project, @ref) do - = @project.path - - path_breadcrumbs do |title, path| + - if vue_file_list_enabled? + #js-repo-breadcrumb + - else + %ul.breadcrumb.repo-breadcrumb %li.breadcrumb-item - = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path)) + = link_to project_tree_path(@project, @ref) do + = @project.path + - path_breadcrumbs do |title, path| + %li.breadcrumb-item + = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path)) - - if can_collaborate || can_create_mr_from_fork - %li.breadcrumb-item - %a.btn.add-to-tree.qa-add-to-tree{ addtotree_toggle_attributes } - = sprite_icon('plus', size: 16, css_class: 'float-left') - = sprite_icon('arrow-down', size: 16, css_class: 'float-left') - - if on_top_of_branch? - .add-to-tree-dropdown - %ul.dropdown-menu - - if can_edit_tree? - %li.dropdown-header - #{ _('This directory') } - %li - = link_to project_new_blob_path(@project, @id), class: 'qa-new-file-option' do - #{ _('New file') } - %li - = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do - #{ _('Upload file') } - %li - = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do - #{ _('New directory') } - - elsif can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project) - %li - - continue_params = { to: project_new_blob_path(@project, @id), - notice: edit_in_new_fork_notice, - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) - = link_to fork_path, method: :post do - #{ _('New file') } - %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to upload a file again.", - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) - = link_to fork_path, method: :post do - #{ _('Upload file') } - %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to create a new directory again.", - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) - = link_to fork_path, method: :post do - #{ _('New directory') } + - if can_collaborate || can_create_mr_from_fork + %li.breadcrumb-item + %button.btn.add-to-tree.qa-add-to-tree{ addtotree_toggle_attributes, type: 'button' } + = sprite_icon('plus', size: 16, css_class: 'float-left') + = sprite_icon('arrow-down', size: 16, css_class: 'float-left') + - if on_top_of_branch? + .add-to-tree-dropdown + %ul.dropdown-menu + - if can_edit_tree? + %li.dropdown-header + #{ _('This directory') } + %li + = link_to project_new_blob_path(@project, @id), class: 'qa-new-file-option' do + #{ _('New file') } + %li + = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do + #{ _('Upload file') } + %li + = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do + #{ _('New directory') } + - elsif can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project) + %li + - continue_params = { to: project_new_blob_path(@project, @id), + notice: edit_in_new_fork_notice, + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) + = link_to fork_path, method: :post do + #{ _('New file') } + %li + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to upload a file again.", + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) + = link_to fork_path, method: :post do + #{ _('Upload file') } + %li + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to create a new directory again.", + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) + = link_to fork_path, method: :post do + #{ _('New directory') } - - if can?(current_user, :push_code, @project) - %li.divider - %li.dropdown-header - #{ _('This repository') } - %li - = link_to new_project_branch_path(@project) do - #{ _('New branch') } - %li - = link_to new_project_tag_path(@project) do - #{ _('New tag') } + - if can?(current_user, :push_code, @project) + %li.divider + %li.dropdown-header + #{ _('This repository') } + %li + = link_to new_project_branch_path(@project) do + #{ _('New branch') } + %li + = link_to new_project_tag_path(@project) do + #{ _('New tag') } .tree-controls = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' diff --git a/app/views/projects/wikis/pages.html.haml b/app/views/projects/wikis/pages.html.haml index 94267b6e0cf..77fdf7f001c 100644 --- a/app/views/projects/wikis/pages.html.haml +++ b/app/views/projects/wikis/pages.html.haml @@ -2,6 +2,7 @@ - add_to_breadcrumbs "Wiki", project_wiki_path(@project, :home) - breadcrumb_title s_("Wiki|Pages") - page_title s_("Wiki|Pages"), _("Wiki") +- sort_title = wiki_sort_title(params[:sort]) %div{ class: container_class } .wiki-page-header @@ -15,6 +16,18 @@ = icon('cloud-download') = _("Clone repository") + .dropdown.inline.wiki-sort-dropdown + .btn-group{ role: 'group' } + .btn-group{ role: 'group' } + %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' } + = sort_title + = icon('chevron-down') + %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort + %li + = sortable_item(s_("Wiki|Title"), project_wikis_pages_path(@project, sort: ProjectWiki::TITLE_ORDER), sort_title) + = sortable_item(s_("Wiki|Created date"), project_wikis_pages_path(@project, sort: ProjectWiki::CREATED_AT_ORDER), sort_title) + = wiki_sort_controls(@project, params[:sort], params[:direction]) + %ul.wiki-pages-list.content-list = render @wiki_entries, context: 'pages' diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml index 8e1c054b50c..40d674f3fec 100644 --- a/app/views/projects/wikis/show.html.haml +++ b/app/views/projects/wikis/show.html.haml @@ -26,7 +26,7 @@ = (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe .prepend-top-default.append-bottom-default - .wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } + .md.md-file{ class: ('use-csslab' if Feature.enabled?(:csslab)) } = render_wiki_content(@page) = render 'sidebar' diff --git a/app/views/repository_check_mailer/notify.html.haml b/app/views/repository_check_mailer/notify.html.haml index d5327a2b4cc..dfcd1c6b19f 100644 --- a/app/views/repository_check_mailer/notify.html.haml +++ b/app/views/repository_check_mailer/notify.html.haml @@ -6,3 +6,5 @@ %p = _("You are receiving this message because you are a GitLab administrator for %{url}.") % { url: Gitlab.config.gitlab.url } + += render_if_exists 'repository_check_mailer/email_additional_text' diff --git a/app/views/repository_check_mailer/notify.text.haml b/app/views/repository_check_mailer/notify.text.haml index 6b64b337b0e..a2e04fa710f 100644 --- a/app/views/repository_check_mailer/notify.text.haml +++ b/app/views/repository_check_mailer/notify.text.haml @@ -3,3 +3,5 @@ = _("View details: %{details_url}") % { details_url: admin_projects_url(last_repository_check_failed: 1) } = _("You are receiving this message because you are a GitLab administrator for %{url}.") % { url: Gitlab.config.gitlab.url } + += render_if_exists 'repository_check_mailer/email_additional_text' diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index aaf9b973cda..df408e5fb60 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -1,3 +1,11 @@ +- users = capture_haml do + - if search_tabs?(:members) + %li{ class: active_when(@scope == 'users') } + = link_to search_filter_path(scope: 'users') do + Users + %span.badge.badge-pill + = limited_count(@search_results.limited_users_count) + .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller .fade-left= icon('angle-left') .fade-right= icon('angle-right') @@ -45,6 +53,7 @@ = _("Commits") %span.badge.badge-pill = @search_results.commits_count + = users - elsif @show_snippets %li{ class: active_when(@scope == 'snippet_blobs') } @@ -78,3 +87,4 @@ = _("Milestones") %span.badge.badge-pill = limited_count(@search_results.limited_milestones_count) + = users diff --git a/app/views/search/_form.html.haml b/app/views/search/_form.html.haml index 4af0c6bf84a..db0dcc8adfb 100644 --- a/app/views/search/_form.html.haml +++ b/app/views/search/_form.html.haml @@ -13,3 +13,4 @@ - unless params[:snippets].eql? 'true' = render 'filter' = button_tag _("Search"), class: "btn btn-success btn-search" + = render_if_exists 'search/form_elasticsearch' diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index be7a2436d16..12eb8d7fa81 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -1,5 +1,6 @@ - if @search_objects.to_a.empty? = render partial: "search/results/empty" + = render_if_exists 'shared/promotions/promote_advanced_search' - else .row-content-block - unless @search_objects.is_a?(Kaminari::PaginatableWithoutCount) @@ -11,7 +12,7 @@ - elsif @group - link_to_group = link_to(@group.name, @group) = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } - + = render_if_exists 'shared/promotions/promote_advanced_search' .results.prepend-top-10 - if @scope == 'commits' %ul.content-list.commit-list @@ -20,9 +21,10 @@ .search-results - if @scope == 'projects' .term - = render 'shared/projects/list', projects: @search_objects + = render 'shared/projects/list', { projects: @search_objects, pipeline_status: false }.merge(@display_options) - else - = render partial: "search/results/#{@scope.singularize}", collection: @search_objects + - locals = { projects: blob_projects(@search_objects) } if %w[blobs wiki_blobs].include?(@scope) + = render partial: "search/results/#{@scope.singularize}", collection: @search_objects, locals: locals - if @scope != 'projects' = paginate_collection(@search_objects) diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml index 2a602095845..bdad07f36d1 100644 --- a/app/views/search/results/_blob.html.haml +++ b/app/views/search/results/_blob.html.haml @@ -1,4 +1,4 @@ -- project = find_project_for_result_blob(blob) +- project = find_project_for_result_blob(projects, blob) - return unless project - blob = parse_search_result(blob) diff --git a/app/views/search/results/_issue.html.haml b/app/views/search/results/_issue.html.haml index 796782035f2..1f055cdfa31 100644 --- a/app/views/search/results/_issue.html.haml +++ b/app/views/search/results/_issue.html.haml @@ -1,7 +1,7 @@ .search-result-row %h4 = confidential_icon(issue) - = link_to [issue.project.namespace.becomes(Namespace), issue.project, issue] do + = link_to namespace_project_issue_path(issue.project.namespace.becomes(Namespace), issue.project, issue) do %span.term.str-truncated= issue.title - if issue.closed? %span.badge.badge-danger.prepend-left-5= _("Closed") diff --git a/app/views/search/results/_merge_request.html.haml b/app/views/search/results/_merge_request.html.haml index f0e0af11f27..074bb9bce8d 100644 --- a/app/views/search/results/_merge_request.html.haml +++ b/app/views/search/results/_merge_request.html.haml @@ -1,6 +1,6 @@ .search-result-row %h4 - = link_to [merge_request.target_project.namespace.becomes(Namespace), merge_request.target_project, merge_request] do + = link_to namespace_project_merge_request_path(merge_request.target_project.namespace.becomes(Namespace), merge_request.target_project, merge_request) do %span.term.str-truncated= merge_request.title - if merge_request.merged? %span.badge.badge-primary.prepend-left-5= _("Merged") diff --git a/app/views/search/results/_milestone.html.haml b/app/views/search/results/_milestone.html.haml index 2daa96e34d1..3201f1a7815 100644 --- a/app/views/search/results/_milestone.html.haml +++ b/app/views/search/results/_milestone.html.haml @@ -1,6 +1,6 @@ .search-result-row %h4 - = link_to [milestone.project.namespace.becomes(Namespace), milestone.project, milestone] do + = link_to namespace_project_milestone_path(milestone.project.namespace.becomes(Namespace), milestone.project, milestone) do %span.term.str-truncated= milestone.title - if milestone.description.present? diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml index a60a4501557..f17dae0a94c 100644 --- a/app/views/search/results/_snippet_blob.html.haml +++ b/app/views/search/results/_snippet_blob.html.haml @@ -18,7 +18,7 @@ %i.fa.fa-file %strong= snippet.file_name - if markup?(snippet.file_name) - .file-content.wiki + .file-content.md.md-file - snippet_chunks.each do |chunk| - unless chunk[:data].empty? = markup(snippet.file_name, chunk[:data]) diff --git a/app/views/search/results/_user.html.haml b/app/views/search/results/_user.html.haml new file mode 100644 index 00000000000..8060a1577e4 --- /dev/null +++ b/app/views/search/results/_user.html.haml @@ -0,0 +1,10 @@ +%ul.content-list + %li + .avatar-cell.d-none.d-sm-block + = user_avatar(user: user, user_name: user.name, css_class: 'd-none d-sm-inline avatar s40') + .user-info + = link_to user_path(user), class: 'd-none d-sm-inline' do + .item-title + = user.name + = user_status(user) + .cgray= user.to_reference diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml index 389e4cc75b9..5847751b268 100644 --- a/app/views/search/results/_wiki_blob.html.haml +++ b/app/views/search/results/_wiki_blob.html.haml @@ -1,5 +1,5 @@ -- project = find_project_for_result_blob(wiki_blob) +- project = find_project_for_result_blob(projects, wiki_blob) - wiki_blob = parse_search_result(wiki_blob) -- wiki_blob_link = project_wiki_path(project, wiki_blob.basename) +- wiki_blob_link = project_wiki_path(project, Pathname.new(wiki_blob.filename).sub_ext('')) = render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, file_name: wiki_blob.filename, blob_link: wiki_blob_link } diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml index ca392e1adfc..22fcfcda297 100644 --- a/app/views/sent_notifications/unsubscribe.html.haml +++ b/app/views/sent_notifications/unsubscribe.html.haml @@ -1,6 +1,6 @@ - noteable = @sent_notification.noteable - noteable_type = @sent_notification.noteable_type.titleize.downcase -- noteable_text = %(#{noteable.title} (#{noteable.to_reference})) +- noteable_text = show_unsubscribe_title?(noteable) ? %(#{noteable.title} (#{noteable.to_reference})) : %(#{noteable.to_reference}) - page_title _("Unsubscribe"), noteable_text, noteable_type.pluralize, @sent_notification.project.full_name %h3.page-title diff --git a/app/views/shared/_choose_avatar_button.html.haml b/app/views/shared/_choose_avatar_button.html.haml new file mode 100644 index 00000000000..0d46d047134 --- /dev/null +++ b/app/views/shared/_choose_avatar_button.html.haml @@ -0,0 +1,4 @@ +%button.btn.js-choose-avatar-button{ type: 'button' }= _("Choose file…") +%span.file_name.js-avatar-filename= _("No file chosen") += f.file_field :avatar, class: "js-avatar-input hidden" +.form-text.text-muted= _("The maximum file size allowed is 200KB.") diff --git a/app/views/shared/_choose_group_avatar_button.html.haml b/app/views/shared/_choose_group_avatar_button.html.haml deleted file mode 100644 index 0552fe62090..00000000000 --- a/app/views/shared/_choose_group_avatar_button.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -%button.btn.js-choose-group-avatar-button{ type: 'button' }= _("Choose File ...") -%span.file_name.js-avatar-filename= _("No file chosen") -= f.file_field :avatar, class: "js-group-avatar-input hidden" -.form-text.text-muted= _("The maximum file size allowed is 200KB.") diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index a2df0347fd6..1e509ea0d1f 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -16,7 +16,12 @@ = ssh_clone_button(project) %li = http_clone_button(project) + = render_if_exists 'shared/kerberos_clone_button', project: project = 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") + + = render_if_exists 'shared/geo_modal_button' + += render_if_exists 'shared/geo_modal', project: project diff --git a/app/views/shared/_confirm_modal.html.haml b/app/views/shared/_confirm_modal.html.haml index 1dcf4369253..3967c8148d2 100644 --- a/app/views/shared/_confirm_modal.html.haml +++ b/app/views/shared/_confirm_modal.html.haml @@ -2,8 +2,7 @@ .modal-dialog .modal-content .modal-header - %h3.page-title - Confirmation required + %h3.page-title= _('Confirmation required') %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } %span{ "aria-hidden": true } × @@ -11,8 +10,7 @@ %p.text-danger.js-confirm-text %p - This action can lead to data loss. - To prevent accidental actions we ask you to confirm your intention. + %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 @@ -21,4 +19,4 @@ .form-group = text_field_tag 'confirm_name_input', '', class: 'form-control js-confirm-danger-input' .form-actions - = submit_tag 'Confirm', class: "btn btn-danger js-confirm-danger-submit" + = submit_tag _('Confirm'), class: "btn btn-danger js-confirm-danger-submit" diff --git a/app/views/shared/_delete_label_modal.html.haml b/app/views/shared/_delete_label_modal.html.haml index b96380923ac..f37dd2cdf02 100644 --- a/app/views/shared/_delete_label_modal.html.haml +++ b/app/views/shared/_delete_label_modal.html.haml @@ -2,20 +2,20 @@ .modal-dialog .modal-content .modal-header - %h3.page-title Delete #{render_colored_label(label, tooltip: false)} ? + %h3.page-title Delete #{render_label(label, tooltip: false)} ? %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } %span{ "aria-hidden": true } × .modal-body %p %strong= label.name - %span will be permanently deleted from #{label.is_a?(ProjectLabel)? label.project.name : label.group.name}. This cannot be undone. + %span will be permanently deleted from #{label.subject_name}. This cannot be undone. .modal-footer %a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' } Cancel = link_to 'Delete label', - destroy_label_path(label), + label.destroy_path, title: 'Delete', method: :delete, class: 'btn btn-remove' diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml index 5073e6ad48f..d7e57fc0d01 100644 --- a/app/views/shared/_file_highlight.html.haml +++ b/app/views/shared/_file_highlight.html.haml @@ -1,4 +1,4 @@ -.file-content.code.js-syntax-highlight +.file-content.code.js-syntax-highlight.qa-file-content .line-numbers - if blob.data.present? - link_icon = icon('link') diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml index 7b593ca4f76..d0f9374e832 100644 --- a/app/views/shared/_import_form.html.haml +++ b/app/views/shared/_import_form.html.haml @@ -1,11 +1,26 @@ - ci_cd_only = local_assigns.fetch(:ci_cd_only, false) +- import_url = Gitlab::UrlSanitizer.new(f.object.import_url) -.form-group.import-url-data - = f.label :import_url, class: 'label-bold' do - %span - = _('Git repository URL') +.import-url-data + .form-group + = f.label :import_url, class: 'label-bold' do + %span + = _('Git repository URL') + = f.text_field :import_url, value: import_url.sanitized_url, + autocomplete: 'off', class: 'form-control', placeholder: 'https://gitlab.company.com/group/project.git', required: true - = f.text_field :import_url, autocomplete: 'off', class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git', required: true + .row + .form-group.col-md-6 + = f.label :import_url_user, class: 'label-bold' do + %span + = _('Username (optional)') + = f.text_field :import_url_user, value: import_url.user, class: 'form-control', required: false, autocomplete: 'new-password' + + .form-group.col-md-6 + = f.label :import_url_password, class: 'label-bold' do + %span + = _('Password (optional)') + = f.password_field :import_url_password, class: 'form-control', required: false, autocomplete: 'new-password' .info-well.prepend-top-20 .well-segment @@ -13,8 +28,11 @@ %li = _('The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>.').html_safe %li - = _('If your HTTP repository is not publicly accessible, add authentication information to the URL: <code>https://username:password@gitlab.company.com/group/project.git</code>.').html_safe + = _('If your HTTP repository is not publicly accessible, add your credentials.') %li = import_will_timeout_message(ci_cd_only) %li = import_svn_message(ci_cd_only) + = render_if_exists 'shared/ci_cd_only_link', ci_cd_only: ci_cd_only + += render_if_exists 'shared/ee/import_form', f: f, ci_cd_only: ci_cd_only diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml index 6cc8c485666..31a5370a5f8 100644 --- a/app/views/shared/_issuable_meta_data.html.haml +++ b/app/views/shared/_issuable_meta_data.html.haml @@ -1,4 +1,4 @@ -- note_count = @issuable_meta_data[issuable.id].notes_count +- note_count = @issuable_meta_data[issuable.id].user_notes_count - issue_votes = @issuable_meta_data[issuable.id] - upvotes, downvotes = issue_votes.upvotes, issue_votes.downvotes - issuable_url = @collection_type == "Issue" ? issue_path(issuable, anchor: 'notes') : merge_request_path(issuable, anchor: 'notes') diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index 21ea188d7b3..c4b7ef481fd 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -1,13 +1,13 @@ +- label = label.present(issuable_subject: local_assigns[:subject]) - label_css_id = dom_id(label) - status = label_subscription_status(label, @project).inquiry if current_user -- subject = local_assigns[:subject] - use_label_priority = local_assigns.fetch(:use_label_priority, false) - force_priority = local_assigns.fetch(:force_priority, use_label_priority ? label.priority.present? : false) - toggle_subscription_path = toggle_subscription_label_path(label, @project) if current_user - tooltip_title = label_status_tooltip(label, status) if status %li.label-list-item{ id: label_css_id, data: { id: label.id } } - = render "shared/label_row", label: label, subject: subject, force_priority: force_priority + = render "shared/label_row", label: label, force_priority: force_priority %ul.label-actions-list - if @project %li.inline @@ -21,7 +21,7 @@ = sprite_icon('star') - if can?(current_user, :admin_label, label) %li.inline - = link_to edit_label_path(label), class: 'btn btn-transparent label-action edit has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do + = link_to label.edit_path, class: 'btn btn-transparent label-action edit has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do = sprite_icon('pencil') - if can?(current_user, :admin_label, label) %li.inline @@ -30,7 +30,7 @@ = sprite_icon('ellipsis_v') .dropdown-menu.dropdown-open-left %ul - - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group) + - if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group) %li %button.js-promote-project-label-button.btn.btn-transparent.btn-action{ disabled: true, type: 'button', data: { url: promote_project_label_path(label.project, label), @@ -48,7 +48,7 @@ %button.text-danger.remove-row{ type: 'button' }= _('Delete') - if current_user %li.inline.label-subscription - - if can_subscribe_to_label_in_different_levels?(label) + - if label.can_subscribe_to_label_in_different_levels? %button.js-unsubscribe-button.label-subscribe-button.btn.btn-default{ class: ('hidden' if status.unsubscribed?), data: { url: toggle_subscription_path, toggle: 'tooltip' }, title: tooltip_title } %span= _('Unsubscribe') .dropdown.dropdown-group-label{ class: ('hidden' unless status.unsubscribed?) } diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml index c5ea15a7f63..af11ce94ec5 100644 --- a/app/views/shared/_label_row.html.haml +++ b/app/views/shared/_label_row.html.haml @@ -1,13 +1,10 @@ -- subject = local_assigns[:subject] - force_priority = local_assigns.fetch(:force_priority, false) -- show_label_issues_link = defined?(@project) && show_label_issuables_link?(label, :issues, project: @project) -- show_label_merge_requests_link = defined?(@project) && show_label_issuables_link?(label, :merge_requests, project: @project) +- subject_or_group_defined = defined?(@project) || defined?(@group) +- show_label_issues_link = subject_or_group_defined && show_label_issuables_link?(label, :issues, project: @project) +- show_label_merge_requests_link = subject_or_group_defined && show_label_issuables_link?(label, :merge_requests, project: @project) .label-name - - if defined?(@project) - = link_to_label(label, subject: @project, tooltip: false) - - else - = render_colored_label(label, tooltip: false) + = render_label(label, tooltip: false) .label-description .append-right-default.prepend-left-default - if label.description.present? @@ -16,11 +13,13 @@ %ul.label-links - if show_label_issues_link %li.label-link-item.inline - = link_to_label(label, subject: subject) { 'Issues' } + = link_to_label(label) { 'Issues' } - if show_label_merge_requests_link · %li.label-link-item.inline - = link_to_label(label, subject: subject, type: :merge_request) { _('Merge requests') } + = link_to_label(label, type: :merge_request) { _('Merge requests') } - if force_priority + · %li.label-link-item.priority-badge.js-priority-badge.inline.prepend-left-10 .label-badge.label-badge-blue= _('Prioritized label') + = render_if_exists 'shared/label_row_epics_link', label: label diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml index 8607f87ce0b..a1f21c2a83e 100644 --- a/app/views/shared/_mini_pipeline_graph.html.haml +++ b/app/views/shared/_mini_pipeline_graph.html.haml @@ -4,7 +4,7 @@ - detailed_status = stage.detailed_status(current_user) - icon_status = "#{detailed_status.icon}_borderless" - .stage-container.dropdown{ class: klass } + .stage-container.mt-0.ml-1.dropdown{ class: klass } %button.mini-pipeline-graph-dropdown-toggle.has-tooltip.js-builds-dropdown-button{ class: "ci-status-icon-#{detailed_status.group}", type: 'button', data: { toggle: 'dropdown', title: "#{stage.name}: #{detailed_status.label}", placement: 'top', "stage-endpoint" => stage_ajax_project_pipeline_path(pipeline.project, pipeline, stage: stage.name) } } = sprite_icon(icon_status) @@ -13,5 +13,5 @@ %ul %li.js-builds-dropdown-loading.hidden - .text-center - %i.fa.fa-spinner.fa-spin{ 'aria-hidden': 'true', 'aria-label': 'Loading' } + .loading-container.text-center + %span.spinner{ 'aria-label': 'Loading' } diff --git a/app/views/shared/_mobile_clone_panel.html.haml b/app/views/shared/_mobile_clone_panel.html.haml index 6e2527bd1a1..1e6b6f7c79b 100644 --- a/app/views/shared/_mobile_clone_panel.html.haml +++ b/app/views/shared/_mobile_clone_panel.html.haml @@ -13,3 +13,4 @@ - if http_enabled? %li = dropdown_item_with_description(http_copy_label, project.http_url_to_repo, href: project.http_url_to_repo, data: { clone_type: 'http' }) + = render_if_exists 'shared/mobile_kerberos_clone' diff --git a/app/views/shared/_old_visibility_level.html.haml b/app/views/shared/_old_visibility_level.html.haml index fd576e4fbea..e8f3d888cce 100644 --- a/app/views/shared/_old_visibility_level.html.haml +++ b/app/views/shared/_old_visibility_level.html.haml @@ -1,6 +1,6 @@ .form-group.row .col-sm-2.col-form-label = _('Visibility level') - = link_to icon('question-circle'), help_page_path("public_access/public_access") + = link_to icon('question-circle'), help_page_path("public_access/public_access"), target: '_blank' .col-sm-10 = render 'shared/visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_visibility_level, form_model: form_model, with_label: with_label diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml index 721a2af8069..8da2ae5111a 100644 --- a/app/views/shared/_remote_mirror_update_button.html.haml +++ b/app/views/shared/_remote_mirror_update_button.html.haml @@ -1,6 +1,6 @@ - if remote_mirror.update_in_progress? %button.btn.disabled{ type: 'button', data: { toggle: 'tooltip', container: 'body' }, title: _('Updating') } = icon("refresh spin") -- else +- elsif remote_mirror.enabled? = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn qa-update-now-button", data: { toggle: 'tooltip', container: 'body' }, title: _('Update now') do = icon("refresh") diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml index 2530db986e0..d90a6d43761 100644 --- a/app/views/shared/_sidebar_toggle_button.html.haml +++ b/app/views/shared/_sidebar_toggle_button.html.haml @@ -1,8 +1,8 @@ %a.toggle-sidebar-button.js-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" } = sprite_icon('angle-double-left', css_class: 'icon-angle-double-left') = sprite_icon('angle-double-right', css_class: 'icon-angle-double-right') - %span.collapse-text Collapse sidebar + %span.collapse-text= _("Collapse sidebar") = button_tag class: 'close-nav-button', type: 'button' do = sprite_icon('close', size: 16) - %span.collapse-text Close sidebar + %span.collapse-text= _("Close sidebar") diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index f0d1dd162df..813fccd217b 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -13,14 +13,14 @@ %script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal, show_sorting_dropdown: false %script#js-board-promotion{ type: "text/x-template" }= render_if_exists "shared/promotions/promote_issue_board" -#board-app.boards-app{ "v-cloak" => true, data: board_data, ":class" => "{ 'is-compact': detailIssueVisible }" } +#board-app.boards-app.position-relative{ "v-cloak" => "true", data: board_data, ":class" => "{ 'is-compact': detailIssueVisible }" } .d-none.d-sm-none.d-md-block = render 'shared/issuable/search_bar', type: :boards, board: board - .boards-list - .boards-app-loading.text-center{ "v-if" => "loading" } - = icon("spinner spin") - %board{ "v-cloak" => true, + .boards-list.w-100.py-3.px-2.text-nowrap + .boards-app-loading.w-100.text-center{ "v-if" => "loading" } + = icon("spinner spin 2x") + %board{ "v-cloak" => "true", "v-for" => "list in state.lists", "ref" => "board", ":list" => "list", diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index 307a0919a4c..f9cfcabc015 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -1,8 +1,8 @@ -.board{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }', +.board.d-inline-block.h-100.px-2.align-top.ws-normal{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }', ":data-id" => "list.id" } - .board-inner.d-flex.flex-column - %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" } - %h3.board-title.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset) }' } + .board-inner.d-flex.flex-column.position-relative.h-100.rounded + %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color, "position-relative": list.isExpanded, "position-absolute position-top-0 position-left-0 w-100 h-100": !list.isExpanded }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" } + %h3.board-title.m-0.d-flex.align-items-center.py-2.px-3.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset), "p-0 border-bottom-0 justify-content-center": !list.isExpanded }' } %i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable", ":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }", "aria-hidden": "true" } @@ -31,9 +31,9 @@ %board-delete{ "inline-template" => true, ":list" => "list", "v-if" => "!list.preset && list.id" } - %button.board-delete.has-tooltip.float-right{ type: "button", title: _("Delete list"), "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" } + %button.board-delete.p-0.border-0.has-tooltip.float-right{ type: "button", title: _("Delete list"), ":class": "{ 'd-none': !list.isExpanded }", "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" } = icon("trash") - .issue-count-badge.text-secondary{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":title": "counterTooltip", "v-tooltip": true, data: { placement: "top" } } + .issue-count-badge.text-secondary{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":title": "counterTooltip", ":class": "{ 'd-none': !list.isExpanded }", "v-tooltip": true, data: { placement: "top" } } %span.issue-count-badge-count %icon.mr-1{ name: "issues" } {{ list.issuesSize }} @@ -42,6 +42,7 @@ %button.issue-count-badge-add-button.btn.btn-sm.btn-default.ml-1.has-tooltip.js-no-trigger-collapse{ type: "button", "@click" => "showNewIssueForm", "v-if" => "isNewIssueShown", + ":class": "{ 'd-none': !list.isExpanded }", "aria-label" => _("New issue"), "title" => _("New issue"), data: { placement: "top", container: "body" } } diff --git a/app/views/shared/boards/components/_sidebar.html.haml b/app/views/shared/boards/components/_sidebar.html.haml index c9ff63f8c45..b4f75967a67 100644 --- a/app/views/shared/boards/components/_sidebar.html.haml +++ b/app/views/shared/boards/components/_sidebar.html.haml @@ -2,16 +2,16 @@ %transition{ name: "boards-sidebar-slide" } %aside.right-sidebar.right-sidebar-expanded.issue-boards-sidebar{ "v-show" => "showSidebar" } .issuable-sidebar - .block.issuable-sidebar-header + .block.issuable-sidebar-header.position-relative %span.issuable-header-text.hide-collapsed.float-left - %strong + %strong.bold {{ issue.title }} %br/ %span = render_if_exists "shared/boards/components/sidebar/issue_project_path" = precede "#" do {{ issue.iid }} - %a.gutter-toggle.float-right{ role: "button", + %a.gutter-toggle.position-absolute.position-top-0.position-right-0{ role: "button", href: "#", "@click.prevent" => "closeSidebar", "aria-label" => "Toggle sidebar" } @@ -20,6 +20,7 @@ = render "shared/boards/components/sidebar/assignee" = render_if_exists "shared/boards/components/sidebar/epic" = render "shared/boards/components/sidebar/milestone" + = render "shared/boards/components/sidebar/time_tracker" = render "shared/boards/components/sidebar/due_date" = render "shared/boards/components/sidebar/labels" = render_if_exists "shared/boards/components/sidebar/weight" diff --git a/app/views/shared/boards/components/sidebar/_assignee.html.haml b/app/views/shared/boards/components/sidebar/_assignee.html.haml index 1374da9d82c..af6a519a967 100644 --- a/app/views/shared/boards/components/sidebar/_assignee.html.haml +++ b/app/views/shared/boards/components/sidebar/_assignee.html.haml @@ -19,7 +19,7 @@ ":data-name" => "assignee.name", ":data-username" => "assignee.username" } .dropdown - - dropdown_options = issue_assignees_dropdown_options + - dropdown_options = assignees_dropdown_options('issue') %button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: 'button', ref: 'assigneeDropdown', data: board_sidebar_user_data, ":data-issuable-id" => "issue.iid" } = dropdown_options[:title] diff --git a/app/views/shared/boards/components/sidebar/_due_date.html.haml b/app/views/shared/boards/components/sidebar/_due_date.html.haml index 5630375f428..117d56b30f5 100644 --- a/app/views/shared/boards/components/sidebar/_due_date.html.haml +++ b/app/views/shared/boards/components/sidebar/_due_date.html.haml @@ -7,7 +7,7 @@ .value .value-content %span.no-value{ "v-if" => "!issue.dueDate" } - = _("No due date") + = _("None") %span.bold{ "v-if" => "issue.dueDate" } {{ issue.dueDate | due-date }} - if can_admin_issue? diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml index 19159684420..c50826a7cda 100644 --- a/app/views/shared/boards/components/sidebar/_labels.html.haml +++ b/app/views/shared/boards/components/sidebar/_labels.html.haml @@ -7,10 +7,17 @@ .value.issuable-show-labels.dont-hide %span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" } = _("None") - %a{ href: "#", - "v-for" => "label in issue.labels" } - .badge.color-label.has-tooltip{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" } - {{ label.title }} + %span{ "v-for" => "label in issue.labels" } + %span.d-inline-block.position-relative.scoped-label-wrapper{ "v-if" => "showScopedLabels(label)" } + %a{ href: '#' } + %span.badge.color-label.label{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" } + {{ label.title }} + %a.label.scoped-label{ ":href" => "helpLink()" } + %i.fa.fa-question-circle{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" } + %a{ href: "#", "v-else" => true } + .badge.color-label.has-tooltip{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" } + {{ label.title }} + - if can_admin_issue? .selectbox %input{ type: "hidden", @@ -21,17 +28,11 @@ %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button", ":data-selected" => "selectedLabels", ":data-labels" => "issue.assignableLabelsEndpoint", - data: { toggle: "dropdown", - field_name: "issue[label_names][]", - show_no: "true", - show_any: "true", - project_id: @project&.try(:id), - namespace_path: @namespace_path, - project_path: @project.try(:path) } } + data: label_dropdown_data(@project, namespace_path: @namespace_path, field_name: "issue[label_names][]") } %span.dropdown-toggle-text {{ labelDropdownTitle }} = icon('chevron-down') - .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable + .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height = render partial: "shared/issuable/label_page_default" - if can?(current_user, :admin_label, current_board_parent) - = render partial: "shared/issuable/label_page_create" + = render partial: "shared/issuable/label_page_create", locals: { show_add_list: true } diff --git a/app/views/shared/boards/components/sidebar/_time_tracker.html.haml b/app/views/shared/boards/components/sidebar/_time_tracker.html.haml new file mode 100644 index 00000000000..b76d44c5907 --- /dev/null +++ b/app/views/shared/boards/components/sidebar/_time_tracker.html.haml @@ -0,0 +1,6 @@ +.block.time-tracking + %time-tracker{ ":time-estimate" => "issue.timeEstimate || 0", + ":time-spent" => "issue.timeSpent || 0", + ":human-time-estimate" => "issue.humanTimeEstimate", + ":human-time-spent" => "issue.humanTimeSpent", + "root-path" => "#{root_url}" } diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml index 913c065e188..bc0dc7f9631 100644 --- a/app/views/shared/deploy_keys/_form.html.haml +++ b/app/views/shared/deploy_keys/_form.html.haml @@ -13,8 +13,9 @@ = form.label :key, class: 'col-form-label col-sm-2' .col-sm-10 %p.light - Paste a machine public key here. Read more about how to generate it - = link_to 'here', help_page_path('ssh/README') + - link_start = "<a href='#{help_page_path('ssh/README')}' target='_blank' rel='noreferrer noopener'>".html_safe + - link_end = '</a>' + = _('Paste a machine public key here. Read more about how to generate it %{link_start}here%{link_end}').html_safe % { link_start: link_start, link_end: link_end.html_safe } = form.text_area :key, class: 'form-control thin_area', rows: 5 - else = form.label :fingerprint, class: 'col-form-label col-sm-2' @@ -28,6 +29,6 @@ .col-sm-10 = deploy_keys_project_form.label :can_push do = deploy_keys_project_form.check_box :can_push - %strong Write access allowed + %strong= _('Write access allowed') %p.light.append-bottom-0 - Allow this key to push to repository as well? (Default only allows pull access.) + = _('Allow this key to push to repository as well? (Default only allows pull access.)') diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml index 25df2fe5cd6..b11cb8a3076 100644 --- a/app/views/shared/form_elements/_description.html.haml +++ b/app/views/shared/form_elements/_description.html.haml @@ -5,7 +5,7 @@ - supports_quick_actions = model.new_record? - if supports_quick_actions - - preview_url = preview_markdown_path(project, quick_actions_target_type: model.class.name) + - preview_url = preview_markdown_path(project, target_type: model.class.name) - else - preview_url = preview_markdown_path(project) diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml index 1ae6d1f5ee3..f4915440cb2 100644 --- a/app/views/shared/groups/_dropdown.html.haml +++ b/app/views/shared/groups/_dropdown.html.haml @@ -24,10 +24,10 @@ %li.divider %li.js-filter-archived-projects = link_to filter_groups_path(archived: nil), class: ("is-active" unless params[:archived].present?) do - Hide archived projects + = _("Hide archived projects") %li.js-filter-archived-projects = link_to filter_groups_path(archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do - Show archived projects + = _("Show archived projects") %li.js-filter-archived-projects = link_to filter_groups_path(archived: 'only'), class: ("is-active" if params[:archived] == 'only') do - Show archived projects only + = _("Show archived projects only") diff --git a/app/views/shared/icons/_emoji_slightly_smiling_face.svg b/app/views/shared/icons/_emoji_slightly_smiling_face.svg deleted file mode 100644 index 56dbad91554..00000000000 --- a/app/views/shared/icons/_emoji_slightly_smiling_face.svg +++ /dev/null @@ -1 +0,0 @@ -<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369.721.721 0 0 1 .568.047.715.715 0 0 1 .37.445c.195.625.556 1.131 1.084 1.518A2.93 2.93 0 0 0 9 12.75a2.93 2.93 0 0 0 1.775-.58 2.913 2.913 0 0 0 1.084-1.518.711.711 0 0 1 .375-.445.737.737 0 0 1 .575-.047c.195.063.34.186.433.37.094.183.11.372.047.568zM7.5 6c0 .414-.146.768-.44 1.06-.292.294-.646.44-1.06.44-.414 0-.768-.146-1.06-.44A1.445 1.445 0 0 1 4.5 6c0-.414.146-.768.44-1.06.292-.294.646-.44 1.06-.44.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm6 0c0 .414-.146.768-.44 1.06-.292.294-.646.44-1.06.44-.414 0-.768-.146-1.06-.44A1.445 1.445 0 0 1 10.5 6c0-.414.146-.768.44-1.06.292-.294.646-.44 1.06-.44.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm3 3a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></svg> diff --git a/app/views/shared/icons/_emoji_smile.svg b/app/views/shared/icons/_emoji_smile.svg deleted file mode 100644 index ce645fee46f..00000000000 --- a/app/views/shared/icons/_emoji_smile.svg +++ /dev/null @@ -1 +0,0 @@ -<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369c.195-.062 7.41-.062 7.606 0 .195.063.34.186.433.37.094.183.11.372.047.568zM14 6.37c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm-6.5 0c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm9 2.63a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></svg> diff --git a/app/views/shared/icons/_emoji_smiley.svg b/app/views/shared/icons/_emoji_smiley.svg deleted file mode 100644 index ddfae50e566..00000000000 --- a/app/views/shared/icons/_emoji_smiley.svg +++ /dev/null @@ -1 +0,0 @@ -<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369c.195-.062 7.41-.062 7.606 0 .195.063.34.186.433.37.094.183.11.372.047.568h.001zM7.5 6c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 6 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 4.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 6 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm6 0c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 12 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 10.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 12 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm3 3a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6c.92.397 1.91.6 2.912.598a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39c.397-.92.6-1.91.598-2.912zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="nonzero"/></svg> diff --git a/app/views/shared/icons/_gitea_logo.svg.erb b/app/views/shared/icons/_gitea_logo.svg.erb new file mode 100644 index 00000000000..c8ddbc5535e --- /dev/null +++ b/app/views/shared/icons/_gitea_logo.svg.erb @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?><!-- Created with Inkscape (http://www.inkscape.org/) --><svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="<%= size %>" height="<%= size %>" viewBox="0 0 135.46667 135.46667" version="1.1" id="svg8" sodipodi:docname="logo.svg" inkscape:version="0.92.1 r15371" inkscape:export-filename="" inkscape:export-xdpi="48.000004" inkscape:export-ydpi="48.000004" style="zoom: 1;"><defs id="defs2"></defs><sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:zoom="0.70710678" inkscape:cx="418.13805" inkscape:cy="177.57445" inkscape:document-units="mm" inkscape:current-layer="layer2" showgrid="false" units="px" width="256px" showguides="false" inkscape:window-width="1920" inkscape:window-height="1137" inkscape:window-x="1912" inkscape:window-y="-8" inkscape:window-maximized="1" inkscape:pagecheckerboard="false" inkscape:measure-start="283.373,243.952" inkscape:measure-end="290.267,236.527"><sodipodi:guide position="0,0" orientation="0,512" id="guide3699" inkscape:locked="false"></sodipodi:guide><sodipodi:guide position="135.46667,0" orientation="-512,0" id="guide3701" inkscape:locked="false"></sodipodi:guide><sodipodi:guide position="135.46667,135.46667" orientation="0,-512" id="guide3703" inkscape:locked="false"></sodipodi:guide><sodipodi:guide position="0,135.46667" orientation="512,0" id="guide3705" inkscape:locked="false"></sodipodi:guide></sodipodi:namedview><metadata id="metadata5"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"></dc:type><dc:title></dc:title></cc:Work></rdf:RDF></metadata><g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(0,-161.53334)" style="display:inline"><path d="M27.709937,195.15095 c-9.546573,-0.0272 -22.3392732,6.79805 -21.6317552,23.90397 c1.105534,26.72889 25.4565952,29.20839 35.1916502,29.42301 c1.068023,5.01357 12.521798,22.30563 21.001818,23.21667 h37.15277 c22.27763,-1.66785 38.9607,-75.75671 26.59321,-76.03825 c-46.781583,2.47691 -49.995146,2.13838 -88.599758,0 c-2.495053,-0.0266 -5.972321,-0.49474 -9.707935,-0.5054 z m2.491319,9.45886 c1.351378,13.69267 3.555849,21.70359 8.018216,33.94345 c-11.382872,-1.50473 -21.069822,-5.22443 -22.851515,-19.10984 c-0.950962,-7.4112 2.390428,-15.16769 14.833299,-14.83361 z " id="path3722" sodipodi:nodetypes="sscccccsccsc" inkscape:connector-curvature="0" style="fill:#000;fill-opacity:1;stroke:#000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"></path></g><g inkscape:groupmode="layer" id="layer2" inkscape:label="Layer 2" style="display:inline"><rect style="display:inline;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.24757317;stroke-opacity:1" id="rect4599" width="34.762054" height="34.762054" x="87.508659" y="18.291576" transform="rotate(25.914715)" ry="5.4825778"></rect><path style="display:inline;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.26644793px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" d="m 79.804947,57.359056 3.241146,1.609954 V 35.255731 h -3.262698 z" id="path4525" inkscape:connector-curvature="0" sodipodi:nodetypes="ccccc"></path></g><g inkscape:groupmode="layer" id="layer3" inkscape:label="Layer 3" style="display:inline"><g style="display:inline" id="g4539"><circle style="fill:#000;fill-opacity:1;stroke:none;stroke-width:0.26458332;stroke-opacity:1" id="path4606" cy="90.077766" r="3.4745038" cx="49.064713" transform="rotate(-19.796137)"></circle><circle style="fill:#000;fill-opacity:1;stroke:none;stroke-width:0.26458332;stroke-opacity:1" id="path4606-3" cy="102.1049" r="3.4745038" cx="36.810425" transform="rotate(-19.796137)"></circle><circle style="fill:#000;fill-opacity:1;stroke:none;stroke-width:0.26458332;stroke-opacity:1" id="path4606-1" cy="111.43928" r="3.4745038" cx="46.484283" transform="rotate(-19.796137)"></circle><rect height="27.261492" style="fill:#000;fill-opacity:1;stroke:none;stroke-width:0.27444693;stroke-opacity:1" x="97.333458" y="18.061695" id="rect4629-8" width="2.6726954" transform="rotate(26.024158)"></rect><path d="M76.558096,68.116343 c12.97589,6.395378 13.012989,4.101862 4.890858,20.907244 " id="path4514" sodipodi:nodetypes="cc" inkscape:connector-curvature="0" style="fill:none;stroke:#000;stroke-width:2.68000007;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"></path></g></g></svg>
\ No newline at end of file diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml index ef3d44a9241..24734ed66cf 100644 --- a/app/views/shared/issuable/_assignees.html.haml +++ b/app/views/shared/issuable/_assignees.html.haml @@ -1,9 +1,9 @@ - max_render = 4 -- assignees_rendering_overflow = issue.assignees.size > max_render +- assignees_rendering_overflow = issuable.assignees.size > max_render - render_count = assignees_rendering_overflow ? max_render - 1 : max_render -- more_assignees_count = issue.assignees.size - render_count +- more_assignees_count = issuable.assignees.size - render_count -- issue.assignees.take(render_count).each do |assignee| # rubocop: disable CodeReuse/ActiveRecord +- issuable.assignees.take(render_count).each do |assignee| # rubocop: disable CodeReuse/ActiveRecord = link_to_member(@project, assignee, name: false, title: "Assigned to :name") - if more_assignees_count.positive? 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 fd413bd68c8..416b4a34651 100644 --- a/app/views/shared/issuable/_board_create_list_dropdown.html.haml +++ b/app/views/shared/issuable/_board_create_list_dropdown.html.haml @@ -4,5 +4,5 @@ .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) - = render partial: "shared/issuable/label_page_create" + = 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/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml index 909eb738f95..a05a13814ac 100644 --- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml +++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml @@ -21,10 +21,7 @@ .title Assignee .filter-item - - if type == :issues - - field_name = "update[assignee_ids][]" - - else - - field_name = "update[assignee_id]" + - field_name = "update[assignee_ids][]" = dropdown_tag("Select assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } }) .block diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml index d5fb85ba0f3..483652852b6 100644 --- a/app/views/shared/issuable/_label_dropdown.html.haml +++ b/app/views/shared/issuable/_label_dropdown.html.haml @@ -8,7 +8,7 @@ - classes = local_assigns.fetch(:classes, []) - selected = local_assigns.fetch(:selected, nil) - dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by label") -- dropdown_data = {toggle: 'dropdown', field_name: "label_name[]", show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), labels: labels_filter_path_with_defaults, default_label: "Labels"} +- dropdown_data = label_dropdown_data(@project, labels: labels_filter_path_with_defaults, default_label: "Labels") - dropdown_data.merge!(data_options) - label_name = local_assigns.fetch(:label_name, "Labels") - no_default_styles = local_assigns.fetch(:no_default_styles, false) @@ -25,7 +25,7 @@ %span.dropdown-toggle-text{ class: ("is-default" if apply_is_default_styles) } = multi_label_name(selected, label_name) = icon('chevron-down') - .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable + .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height = render partial: "shared/issuable/label_page_default", locals: { title: dropdown_title, show_footer: show_footer, show_create: show_create } - if show_create && project && can?(current_user, :admin_label, project) = render partial: "shared/issuable/label_page_create" diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml index 55edaa7eda4..a0d3bc64f1f 100644 --- a/app/views/shared/issuable/_label_page_create.html.haml +++ b/app/views/shared/issuable/_label_page_create.html.haml @@ -1,4 +1,7 @@ - show_close = local_assigns.fetch(:show_close, true) +- show_add_list = local_assigns.fetch(:show_add_list, false) +- add_list = local_assigns.fetch(:add_list, false) +- add_list_class = local_assigns.fetch(:add_list_class, '') - subject = @project || @group .dropdown-page-two.dropdown-new-label = dropdown_title(create_label_title(subject), options: { back: true, close: show_close }) @@ -6,12 +9,15 @@ .dropdown-labels-error.js-label-error %input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') } .suggest-colors.suggest-colors-dropdown - - suggested_colors.each do |color| - = link_to '#', style: "background-color: #{color}", data: { color: color } do -   + = render_suggested_colors .dropdown-label-color-input .dropdown-label-color-preview.js-dropdown-label-color-preview %input#new_label_color.default-dropdown-input{ type: "text", placeholder: _('Assign custom color like #FF0000') } + - if show_add_list + .dropdown-label-input{ class: add_list_class } + %label + %input.js-add-list{ type: "checkbox", name: "add_list", checked: add_list } + %span= _('Add list') .clearfix %button.btn.btn-primary.float-left.js-new-label-btn{ type: "button" } = _('Create') diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml index aa4a5f0e0d3..a0fb5229fc3 100644 --- a/app/views/shared/issuable/_label_page_default.html.haml +++ b/app/views/shared/issuable/_label_page_default.html.haml @@ -11,7 +11,7 @@ = dropdown_title(title) - if show_boards_content .issue-board-dropdown-content - %p + %p.m-0 = content_title = dropdown_filter(filter_placeholder) = dropdown_content diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index bdba47ed14d..3d6c5d29d44 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -3,11 +3,11 @@ - block_css_class = type != :boards_modal ? 'row-content-block second-block' : '' - user_can_admin_list = board && can?(current_user, :admin_list, board.parent) -.issues-filters - .issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal } +.issues-filters{ class: ("w-100" if type == :boards_modal) } + .issues-details-filters.filtered-search-block.d-flex{ class: block_css_class, "v-pre" => type == :boards_modal } - if type == :boards = render_if_exists "shared/boards/switcher", board: board - = form_tag page_filter_path, method: :get, class: 'filter-form js-filter-form' do + = form_tag page_filter_path, method: :get, class: 'filter-form js-filter-form w-100' do - if params[:search].present? = hidden_field_tag :search, params[:search] - if @can_bulk_update @@ -71,6 +71,7 @@ = render 'shared/issuable/user_dropdown_item', user: User.new(username: '{{username}}', name: '{{name}}'), avatar: { lazy: true, url: '{{avatar_url}}' } + = render_if_exists 'shared/issuable/approver_dropdown' #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'None' } } @@ -136,6 +137,11 @@ %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } } %button.btn.btn-link{ type: 'button' } = _('No') + #js-dropdown-target-branch.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.js-data-value.monospace + {{title}} = render_if_exists 'shared/issuable/filter_weight', type: type diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 9596c1df20e..3a5adb34ad1 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -73,7 +73,7 @@ %span.bold= issuable_sidebar[:due_date].to_s(:medium) - else %span.no-value - = _('No due date') + = _('None') - if can_edit_issuable %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable_sidebar[:due_date].nil?) } \- @@ -105,10 +105,8 @@ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right' .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| - = link_to sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label[:title]) do - %span.badge.color-label.has-tooltip{ style: "background-color: #{label[:color]}; color: #{label[:text_color]}", title: label[:description], data: { container: "body" } } - = label[:title] + - 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') @@ -116,11 +114,11 @@ - 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: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable_type}[label_names][]", ability_name: issuable_type, show_no: "true", show_any: "true", namespace_path: issuable_sidebar[:namespace_path], project_path: issuable_sidebar[:project_path], issue_update: issuable_sidebar[:issuable_json_path], labels: issuable_sidebar[:project_labels_path], display: 'static' } } + %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.dropdown-menu-labels.dropdown-menu-selectable + .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height = render partial: "shared/issuable/label_page_default" - if issuable_sidebar.dig(:current_user, :can_admin_label) = render partial: "shared/issuable/label_page_create" @@ -160,13 +158,13 @@ %button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } = _('Move issue') - .dropdown-menu.dropdown-menu-selectable + .dropdown-menu.dropdown-menu-selectable.dropdown-extended-height = dropdown_title(_('Move issue')) = dropdown_filter(_('Search project'), search_id: 'sidebar-move-issue-dropdown-search') = dropdown_content = dropdown_loading = dropdown_footer add_content_class: true do - %button.btn.btn-success.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ disabled: true } + %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') diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index 1a59055f652..ab01094ed6e 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -1,42 +1,10 @@ - issuable_type = issuable_sidebar[:type] - signed_in = !!issuable_sidebar.dig(:current_user, :id) -- can_edit_issuable = issuable_sidebar.dig(:current_user, :can_edit) -- if issuable_type == "issue" - #js-vue-sidebar-assignees{ data: { field: "#{issuable_type}[assignee_ids]", signed_in: signed_in } } - .title.hide-collapsed - = _('Assignee') - = icon('spinner spin') -- else - - assignee = assignees.first - .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body", boundary: 'viewport' }, title: (issuable_sidebar.dig(:assignee, :name) || _('Assignee')) } - - if issuable_sidebar[:assignee] - = link_to_member(@project, assignee, size: 24) - - else - = icon('user', 'aria-hidden': 'true') +#js-vue-sidebar-assignees{ data: { field: "#{issuable_type}[assignee_ids]", signed_in: signed_in } } .title.hide-collapsed = _('Assignee') - = 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' - - if !signed_in - %a.gutter-toggle.float-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => _('Toggle sidebar') } - = sidebar_gutter_toggle_icon - .value.hide-collapsed - - if issuable_sidebar[:assignee] - = link_to_member(@project, assignee, size: 32, extra_class: 'bold') do - - unless issuable_sidebar[:assignee][:can_merge] - %span.float-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: _('Not allowed to merge') } - = icon('exclamation-triangle', 'aria-hidden': 'true') - %span.username - @#{issuable_sidebar[:assignee][:username]} - - else - %span.assign-yourself.no-value - = _('No assignee') - - if can_edit_issuable - \- - %a.js-assign-yourself{ href: '#' } - = _('assign yourself') + = icon('spinner spin') .selectbox.hide-collapsed - if assignees.none? @@ -59,17 +27,15 @@ ability_name: issuable_type, null_user: true, display: 'static' } } - - title = _('Select assignee') - - if issuable_type == "issue" - - dropdown_options = issue_assignees_dropdown_options - - title = dropdown_options[:title] - - options[:toggle_class] += ' js-multiselect js-save-user-data' - - data = { field_name: "#{issuable_type}[assignee_ids][]" } - - data[:multi_select] = true - - data['dropdown-title'] = title - - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header'] - - data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select'] - - options[:data].merge!(data) + - dropdown_options = assignees_dropdown_options(issuable_type) + - title = dropdown_options[:title] + - options[:toggle_class] += ' js-multiselect js-save-user-data' + - data = { field_name: "#{issuable_type}[assignee_ids][]" } + - data[:multi_select] = true + - data['dropdown-title'] = title + - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header'] + - data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select'] + - options[:data].merge!(data) = dropdown_tag(title, options: options) diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml index b6ea9185b10..1dd97bc4ed1 100644 --- a/app/views/shared/issuable/_sort_dropdown.html.haml +++ b/app/views/shared/issuable/_sort_dropdown.html.haml @@ -5,17 +5,18 @@ .dropdown.inline.prepend-left-10.issue-sort-dropdown .btn-group{ role: 'group' } .btn-group{ role: 'group' } - %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' } + %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' } = sort_title = icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort %li - = sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority), sort_title) - = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date), sort_title) - = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated), sort_title) - = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone), sort_title) - = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date), sort_title) if viewing_issues - = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity), sort_title) - = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority), sort_title) + = sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority), sort_title) + = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date), sort_title) + = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated), sort_title) + = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone), sort_title) + = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date), sort_title) if viewing_issues + = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity), sort_title) + = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority), sort_title) + = sortable_item(sort_title_relative_position, page_filter_path(sort: sort_value_relative_position), sort_title) if viewing_issues && Feature.enabled?(:manual_sorting) = render_if_exists('shared/ee/issuable/sort_dropdown', viewing_issues: viewing_issues, sort_title: sort_title) = issuable_sort_direction_button(sort_value) diff --git a/app/views/shared/issuable/form/_contribution.html.haml b/app/views/shared/issuable/form/_contribution.html.haml index bc9a1edc39c..a78231b37ce 100644 --- a/app/views/shared/issuable/form/_contribution.html.haml +++ b/app/views/shared/issuable/form/_contribution.html.haml @@ -15,6 +15,6 @@ = form.check_box :allow_collaboration, disabled: !issuable.can_allow_collaboration?(current_user), class: 'form-check-input' = form.label :allow_collaboration, class: 'form-check-label' do = _('Allow commits from members who can merge to the target branch.') - = link_to 'About this feature', help_page_path('user/project/merge_requests/allow_collaboration') + = link_to 'About this feature', help_page_path('user/project/merge_requests/allow_collaboration'), target: '_blank', rel: 'noopener noreferrer nofollow' .form-text.text-muted = allow_collaboration_unavailable_reason(issuable) diff --git a/app/views/shared/issuable/form/_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_merge_request_assignee.html.haml deleted file mode 100644 index 05c03dedd91..00000000000 --- a/app/views/shared/issuable/form/_merge_request_assignee.html.haml +++ /dev/null @@ -1,31 +0,0 @@ -- merge_request = issuable -.block.assignee - .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: sidebar_assignee_tooltip_label(issuable) } - - if merge_request.assignee - = link_to_member(@project, merge_request.assignee, size: 24) - - else - = icon('user', 'aria-hidden': 'true') - .title.hide-collapsed - Assignee - = 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' - .value.hide-collapsed - - if merge_request.assignee - = link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do - - unless merge_request.can_be_merged_by?(merge_request.assignee) - %span.float-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } - = icon('exclamation-triangle', 'aria-hidden': 'true') - %span.username - = merge_request.assignee.to_reference - - else - %span.assign-yourself.no-value - No assignee - - if can_edit_issuable - \- - %a.js-assign-yourself{ href: '#' } - assign yourself - - .selectbox.hide-collapsed - = f.hidden_field 'assignee_id', value: merge_request.assignee_id, id: 'issue_assignee_id' - = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: @project&.id, author_id: merge_request.author_id, field_name: 'merge_request[assignee_id]', issue_update: issuable_json_path(merge_request), ability_name: 'merge_request', null_user: true } }) diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index e370dff9526..1e03440a5dc 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -8,11 +8,8 @@ %hr .row %div{ class: (has_due_date ? "col-lg-6" : "col-12") } - .form-group.row.issue-assignee - - if issuable.is_a?(Issue) - = render "shared/issuable/form/metadata_issue_assignee", issuable: issuable, form: form, has_due_date: has_due_date - - else - = render "shared/issuable/form/metadata_merge_request_assignee", issuable: issuable, form: form, has_due_date: has_due_date + .form-group.row.merge-request-assignee + = render "shared/issuable/form/metadata_issuable_assignee", issuable: issuable, form: form, has_due_date: has_due_date .form-group.row.issue-milestone = form.label :milestone_id, "Milestone", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}" .col-sm-10{ class: ("col-md-8" if has_due_date) } diff --git a/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml index 6d4f9ccd66f..5336159e762 100644 --- a/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml +++ b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml @@ -1,4 +1,4 @@ -= form.label :assignee_ids, "Assignee", class: "col-form-label #{"col-md-2 col-lg-4" if has_due_date}" += form.label :assignee_id, "Assignee", class: "col-form-label #{has_due_date ? "col-lg-4" : "col-sm-2"}" .col-sm-10{ class: ("col-md-8" if has_due_date) } .issuable-form-select-holder.selectbox - issuable.assignees.each do |assignee| @@ -7,5 +7,5 @@ - if issuable.assignees.length === 0 = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' } - = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_assignees_dropdown_options) - = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}" + = dropdown_tag(users_dropdown_label(issuable.assignees), options: assignees_dropdown_options(issuable.to_ability_name)) + = link_to 'Assign to me', '#', class: "assign-to-me-link qa-assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}" diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml index 56c4b021eab..75e9ab547ce 100644 --- a/app/views/shared/issuable/form/_title.html.haml +++ b/app/views/shared/issuable/form/_title.html.haml @@ -6,7 +6,7 @@ %div{ class: div_class } = form.text_field :title, required: true, maxlength: 255, autofocus: true, - autocomplete: 'off', class: 'form-control pad qa-issuable-form-title', placeholder: _('Title') + autocomplete: 'off', class: 'form-control pad qa-issuable-form-title', placeholder: _('Title'), dir: 'auto' - if issuable.respond_to?(:work_in_progress?) .form-text.text-muted diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml index 7619d0a2e9c..78ff225daad 100644 --- a/app/views/shared/labels/_form.html.haml +++ b/app/views/shared/labels/_form.html.haml @@ -2,15 +2,20 @@ = form_errors(@label) .form-group.row - = f.label :title, class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :title .col-sm-10 - = f.text_field :title, class: "form-control qa-label-title", required: true, autofocus: true + = f.text_field :title, class: "form-control js-label-title qa-label-title", required: true, autofocus: true + = render_if_exists 'shared/labels/create_label_help_text' + .form-group.row - = f.label :description, class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :description .col-sm-10 = f.text_field :description, class: "form-control js-quick-submit qa-label-description" .form-group.row - = f.label :color, "Background color", class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :color, "Background color" .col-sm-10 .input-group .input-group-prepend @@ -20,12 +25,7 @@ Choose any color. %br Or you can choose one of the suggested colors below - - .suggest-colors - - suggested_colors.each do |color| - = link_to '#', style: "background-color: #{color}", data: { color: color } do - - + = render_suggested_colors .form-actions - if @label.persisted? = f.submit 'Save changes', class: 'btn btn-success js-save-button' diff --git a/app/views/shared/members/_access_request_links.html.haml b/app/views/shared/members/_access_request_links.html.haml index f7227b9101e..eac743b5206 100644 --- a/app/views/shared/members/_access_request_links.html.haml +++ b/app/views/shared/members/_access_request_links.html.haml @@ -5,7 +5,7 @@ = link_to link_text, polymorphic_path([:leave, source, :members]), method: :delete, data: { confirm: leave_confirmation_message(source) }, - class: 'access-request-link' + class: 'access-request-link js-leave-link' - elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]), method: :delete, diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml index 9ec76d82d18..e83ca5eaab8 100644 --- a/app/views/shared/members/_group.html.haml +++ b/app/views/shared/members/_group.html.haml @@ -2,9 +2,12 @@ - group = group_link.group - can_admin_member = can?(current_user, :admin_project_member, @project) - dom_id = "group_member_#{group_link.id}" -%li.member.group_member{ id: dom_id } - %span.list-item-name - = group_icon(group, class: "avatar s40", alt: '') + +-# Note this is just for groups. For individual members please see shared/members/_member + +%li.member.group_member.py-2.px-3.d-flex.flex-column.flex-md-row{ id: dom_id } + %span.list-item-name.mb-2.m-md-0 + = group_icon(group, class: "avatar s40 flex-shrink-0 flex-grow-0", alt: '') .user-info = link_to group.full_name, group_path(group), class: 'member' .cgray @@ -13,10 +16,10 @@ · %span{ class: ('text-warning' if group_link.expires_soon?) } = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(group_link.expires_at) } - .controls.member-controls - = form_tag project_group_link_path(@project, group_link), method: :put, remote: true, class: 'js-edit-member-form form-group row append-right-5' do + .controls.member-controls.align-items-center + = form_tag project_group_link_path(@project, group_link), method: :put, remote: true, class: 'js-edit-member-form form-group d-sm-flex' do = hidden_field_tag "group_link[group_access]", group_link.group_access - .member-form-control.dropdown.append-right-5 + .member-form-control.dropdown.mr-sm-2.d-sm-inline-block %button.dropdown-menu-toggle.js-member-permissions-dropdown{ type: "button", disabled: !can_admin_member, data: { toggle: "dropdown", field_name: "group_link[group_access]" } } @@ -32,14 +35,14 @@ = link_to role, "javascript:void(0)", class: ("is-active" if group_link.group_access == role_id), data: { id: role_id, el_id: dom_id } - .prepend-left-5.clearable-input.member-form-control + .clearable-input.member-form-control.d-sm-inline-block = text_field_tag 'group_link[expires_at]', group_link.expires_at, class: 'form-control js-access-expiration-date js-member-update-control', placeholder: _('Expiration date'), id: "member_expires_at_#{group.id}", disabled: !can_admin_member %i.clear-icon.js-clear-input - if can_admin_member = link_to project_group_link_path(@project, group_link), method: :delete, data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name } }, - class: 'btn btn-remove prepend-left-10' do + class: 'btn btn-remove m-0 ml-sm-2 align-self-center' do %span.d-block.d-sm-none = _("Delete") = icon('trash', class: 'd-none d-sm-block') diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 2db1f67a793..331283f7eec 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -4,11 +4,14 @@ - member = local_assigns.fetch(:member) - user = local_assigns.fetch(:user, member.user) - source = member.source +- override = member.try(:override) -%li.member{ class: dom_class(member), id: dom_id(member) } - %span.list-item-name +-# Note this is just for individual members. For groups please see shared/members/_group + +%li.member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("is-overridden" if override), ("flex-md-row" unless force_mobile_view)], id: dom_id(member) } + %span.list-item-name.mb-2.m-md-0 - if user - = image_tag avatar_icon_for_user(user, 40), class: "avatar s40", alt: '' + = image_tag avatar_icon_for_user(user, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: '' .user-info = link_to user.name, user_path(user), class: 'member js-user-link', data: { user_id: user.id } = user_status(user) @@ -42,7 +45,7 @@ = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(member.expires_at) } - else - = image_tag avatar_icon_for_email(member.invite_email, 40), class: "avatar s40", alt: '' + = image_tag avatar_icon_for_email(member.invite_email, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: '' .user-info .member= member.invite_email .cgray @@ -53,20 +56,22 @@ = time_ago_with_tooltip(member.created_at) - if show_roles - current_resource = @project || @group - .controls.member-controls + .controls.member-controls.align-items-center + = render_if_exists 'shared/members/ee/ldap_tag', can_override: member.can_override? - if show_controls && member.source == current_resource - if member.can_resend_invite? = link_to icon('paper-plane'), polymorphic_path([:resend_invite, member]), method: :post, - class: 'btn btn-default prepend-left-10 d-none d-sm-block', + class: 'btn btn-default align-self-center mr-sm-2', title: _('Resend invite') - if user != current_user && member.can_update? - = form_for member, remote: true, html: { class: 'js-edit-member-form form-group row append-right-5' } do |f| + = form_for member, remote: true, html: { class: "js-edit-member-form form-group #{'d-sm-flex' unless force_mobile_view}" } do |f| = f.hidden_field :access_level - .member-form-control.dropdown.append-right-5 + .member-form-control.dropdown{ class: [("mr-sm-2 d-sm-inline-block" unless force_mobile_view)] } %button.dropdown-menu-toggle.js-member-permissions-dropdown{ type: "button", + disabled: member.can_override? && !override, data: { toggle: "dropdown", field_name: "#{f.object_name}[access_level]" } } %span.dropdown-toggle-text = member.human_access @@ -80,20 +85,25 @@ = link_to role, "javascript:void(0)", class: ("is-active" if member.access_level == role_id), data: { id: role_id, el_id: dom_id(member) } - .prepend-left-5.clearable-input.member-form-control + = render_if_exists 'shared/members/ee/revert_ldap_group_sync_option', + group: @group, + member: member, + can_override: member.can_override? + .clearable-input.member-form-control{ class: [("d-sm-inline-block" unless force_mobile_view)] } = f.text_field :expires_at, + disabled: member.can_override? && !override, class: 'form-control js-access-expiration-date js-member-update-control', placeholder: _('Expiration date'), id: "member_expires_at_#{member.id}", data: { el_id: dom_id(member) } %i.clear-icon.js-clear-input - else - %span.member-access-text= member.human_access + %span.member-access-text.user-access-role= member.human_access - if member.can_approve? = link_to polymorphic_path([:approve_access_request, member]), method: :post, - class: 'btn btn-success prepend-left-10', + class: "btn btn-success align-self-center m-0 mb-2 #{'mb-sm-0 ml-sm-2' unless force_mobile_view}", title: _('Grant access') do %span{ class: ('d-block d-sm-none' unless force_mobile_view) } = _('Grant access') @@ -105,16 +115,19 @@ = link_to icon('sign-out', text: _('Leave')), polymorphic_path([:leave, member.source, :members]), method: :delete, data: { confirm: leave_confirmation_message(member.source) }, - class: 'btn btn-remove prepend-left-10' + class: "btn btn-remove align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}" - else = link_to member, method: :delete, data: { confirm: remove_member_message(member) }, - class: 'btn btn-remove prepend-left-10', + class: "btn btn-remove align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}", title: remove_member_title(member) do %span{ class: ('d-block d-sm-none' unless force_mobile_view) } = _("Delete") - unless force_mobile_view = icon('trash', class: 'd-none d-sm-block') + = render_if_exists 'shared/members/ee/override_member_buttons', group: @group, member: member, user: user, action: :edit, can_override: member.can_override? - else - %span.member-access-text= member.human_access + %span.member-access-text.user-access-role= member.human_access + += render_if_exists 'shared/members/ee/override_member_buttons', group: @group, member: member, user: user, action: :confirm, can_override: member.can_override? diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml index eba64daaadc..ae3ab2adfd0 100644 --- a/app/views/shared/milestones/_issuable.html.haml +++ b/app/views/shared/milestones/_issuable.html.haml @@ -21,8 +21,7 @@ %span.issuable-number= issuable.to_reference - labels.each do |label| - = link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }) do - - render_colored_label(label) + = render_label(label.present(issuable_subject: project), link: polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' })) %span.assignee-icon - assignees.each do |assignee| diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml index 6797520650d..ecab037e378 100644 --- a/app/views/shared/milestones/_labels_tab.html.haml +++ b/app/views/shared/milestones/_labels_tab.html.haml @@ -2,11 +2,10 @@ - labels.each do |label| - options = { milestone_title: @milestone.title, label_name: label.title } - %li.is-not-draggable + %li.no-border %span.label-row %span.label-name - = link_to milestones_label_path(options) do - - render_colored_label(label, tooltip: false) + = render_label(label, tooltip: false, link: milestones_label_path(options)) %span.prepend-description-left = markdown_field(label, :description) diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 40b8374848e..e99aa3f1ee4 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -32,7 +32,7 @@ = milestone_progress_bar(milestone) = link_to pluralize(milestone.total_issues_count(current_user), 'Issue'), issues_path · - = link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path + = link_to pluralize(milestone.merge_requests_visible_to_user(current_user).size, 'Merge Request'), merge_requests_path .float-lg-right.light #{milestone.percent_complete(current_user)}% complete .col-sm-2 .milestone-actions.d-flex.justify-content-sm-start.justify-content-md-end @@ -52,7 +52,7 @@ = link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-sm btn-close btn-grouped" - unless milestone.active? - = link_to 'Reopen Milestone', project_milestone_path(@project, milestone, {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen" + = link_to 'Reopen Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen" - if @group - if can?(current_user, :admin_milestone, @group) - if milestone.closed? diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml index 55460acab8f..b877f66c71e 100644 --- a/app/views/shared/milestones/_tabs.html.haml +++ b/app/views/shared/milestones/_tabs.html.haml @@ -12,7 +12,7 @@ %li.nav-item = link_to '#tab-merge-requests', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do Merge Requests - %span.badge.badge-pill= milestone.merge_requests.size + %span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size - else %li.nav-item = link_to '#tab-merge-requests', class: 'nav-link active', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do @@ -21,11 +21,11 @@ %li.nav-item = link_to '#tab-participants', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_participants_tab_path(milestone) do Participants - %span.badge.badge-pill= milestone.participants.count + %span.badge.badge-pill= milestone.issue_participants_visible_by_user(current_user).count %li.nav-item = link_to '#tab-labels', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_labels_tab_path(milestone) do Labels - %span.badge.badge-pill= milestone.labels.count + %span.badge.badge-pill= milestone.issue_labels_visible_by_user(current_user).count - issues = milestone.sorted_issues(current_user) - show_project_name = local_assigns.fetch(:show_project_name, false) diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index 55b1c14022f..43503e1d08a 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -4,10 +4,7 @@ - group = local_assigns[:group] - is_dynamic_milestone = milestone.legacy_group_milestone? || milestone.dashboard_milestone? -.detail-page-header - %a.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ href: "#" } - = icon('angle-double-left') - +.detail-page-header.milestone-page-header .status-box{ class: "status-box-#{milestone.closed? ? 'closed' : 'open'}" } - if milestone.closed? Closed @@ -15,14 +12,17 @@ Expired - else Open - %span.identifier - Milestone #{milestone.title} - - if milestone.due_date || milestone.start_date - %span.creator - · - = milestone_date_range(milestone) - - if group - .float-right + + .header-text-content + %span.identifier + Milestone #{milestone.title} + - if milestone.due_date || milestone.start_date + %span.creator + · + = milestone_date_range(milestone) + + .milestone-buttons + - if group - if can?(current_user, :admin_milestone, group) - if milestone.group_milestone? = link_to edit_group_milestone_path(group, milestone), class: "btn btn btn-grouped" do @@ -35,6 +35,9 @@ - unless is_dynamic_milestone = render 'shared/milestones/delete_button' + %a.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ href: "#" } + = icon('angle-double-left') + = render 'shared/milestones/deprecation_message' if is_dynamic_milestone .detail-page-description.milestone-detail @@ -42,9 +45,8 @@ = markdown_field(milestone, :title) - if milestone.group_milestone? && milestone.description.present? %div - .description - .wiki - = markdown_field(milestone, :description) + .description.md + = markdown_field(milestone, :description) - if milestone.complete?(current_user) && milestone.active? .alert.alert-success.prepend-top-default diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml index 6a1eea85fde..d91bc6e57c9 100644 --- a/app/views/shared/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -1,7 +1,7 @@ - supports_autocomplete = local_assigns.fetch(:supports_autocomplete, true) - supports_quick_actions = note_supports_quick_actions?(@note) - if supports_quick_actions - - preview_url = preview_markdown_path(@project, quick_actions_target_type: @note.noteable_type, quick_actions_target_id: @note.noteable_id) + - preview_url = preview_markdown_path(@project, target_type: @note.noteable_type, target_id: @note.noteable_id) - else - preview_url = preview_markdown_path(@project) diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml index 46f3f8428f1..fae7d6526e8 100644 --- a/app/views/shared/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml @@ -28,8 +28,9 @@ or %button.attach-new-file.markdown-selector{ type: 'button' }= _("attach a new file") - %button.markdown-selector.button-attach-file{ type: 'button', tabindex: '-1' } + %button.markdown-selector.button-attach-file.btn-link{ type: 'button', tabindex: '-1' } = icon('file-image-o', class: 'toolbar-button-icon') - = _("Attach a file") + %span.text-attach-file<> + = _("Attach a file") %button.btn.btn-default.btn-sm.hide.button-cancel-uploading-files{ type: 'button' }= _("Cancel") diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index 41d6ae79c81..5c9dd72418e 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -31,19 +31,18 @@ .note-header .note-header-info %a{ href: user_path(note.author) } - %span.note-header-author-name + %span.note-header-author-name.bold = sanitize(note.author.name) = user_status(note.author) %span.note-headline-light = note.author.to_reference - %span.note-headline-light - %span.note-headline-meta - - if note.system - %span.system-note-message - = markdown_field(note, :note) - %span.system-note-separator - · - %a.system-note-separator{ href: "##{dom_id(note)}" }= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') + %span.note-headline-light.note-headline-meta + - if note.system + %span.system-note-message + = markdown_field(note, :note) + %span.system-note-separator + · + %a.system-note-separator{ href: "##{dom_id(note)}" }= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') - unless note.system? .note-actions - if note.for_personal_snippet? diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml index 2ece7b7f701..749aa258af6 100644 --- a/app/views/shared/notifications/_button.html.haml +++ b/app/views/shared/notifications/_button.html.haml @@ -1,24 +1,26 @@ - btn_class = local_assigns.fetch(:btn_class, nil) - if notification_setting - .js-notification-dropdown.notification-dropdown.home-panel-action-button.dropdown.inline + .js-notification-dropdown.notification-dropdown.mr-md-2.home-panel-action-button.dropdown.inline = form_for notification_setting, remote: true, html: { class: "inline notification-form" } do |f| = hidden_setting_source_input(notification_setting) = f.hidden_field :level, class: "notification_setting_level" .js-notification-toggle-btns %div{ class: ("btn-group" if notification_setting.custom?) } - if notification_setting.custom? - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting"), class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: _("Notification setting"), class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } = icon("bell", class: "js-notification-loading") = notification_title(notification_setting.level) %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } = icon('caret-down') .sr-only Toggle dropdown - else - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } - = icon("bell", class: "js-notification-loading") - = notification_title(notification_setting.level) - = icon("caret-down") + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting"), class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + .float-left + = icon("bell", class: "js-notification-loading") + = notification_title(notification_setting.level) + .float-right + = icon("caret-down") = render "shared/notifications/notification_dropdown", notification_setting: notification_setting diff --git a/app/views/shared/notifications/_new_button.html.haml b/app/views/shared/notifications/_new_button.html.haml index 6d26dbebbc8..052e6da5bae 100644 --- a/app/views/shared/notifications/_new_button.html.haml +++ b/app/views/shared/notifications/_new_button.html.haml @@ -10,14 +10,14 @@ %div{ class: ("btn-group" if notification_setting.custom?) } - if notification_setting.custom? %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", placement: 'top', toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } - = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon") + = notification_setting_icon(notification_setting) %span.js-notification-loading.fa.hidden %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" }, class: "#{btn_class}" } = sprite_icon("arrow-down", css_class: "icon mr-0") .sr-only Toggle dropdown - else - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting - #{notification_title(notification_setting.level)}", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", placement: 'top', toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } - = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon") + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", placement: 'top', toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + = notification_setting_icon(notification_setting) %span.js-notification-loading.fa.hidden = sprite_icon("arrow-down", css_class: "icon") diff --git a/app/views/shared/notifications/_notification_dropdown.html.haml b/app/views/shared/notifications/_notification_dropdown.html.haml index 85ad74f9a39..a6ef2d51171 100644 --- a/app/views/shared/notifications/_notification_dropdown.html.haml +++ b/app/views/shared/notifications/_notification_dropdown.html.haml @@ -8,5 +8,5 @@ %li.divider %li %a.update-notification{ href: "#", role: "button", class: ("is-active" if notification_setting.custom?), data: { toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), notification_level: "custom", notification_title: "Custom" } } - %strong.dropdown-menu-inner-title Custom + %strong.dropdown-menu-inner-title= s_('NotificationSetting|Custom') %span.dropdown-menu-inner-content= notification_description("custom") diff --git a/app/views/shared/projects/_dropdown.html.haml b/app/views/shared/projects/_dropdown.html.haml index 98b258d9275..88ac03bf9e3 100644 --- a/app/views/shared/projects/_dropdown.html.haml +++ b/app/views/shared/projects/_dropdown.html.haml @@ -1,10 +1,9 @@ - @sort ||= sort_value_latest_activity .dropdown.js-project-filter-dropdown-wrap - - toggle_text = projects_sort_options_hash[@sort] - = dropdown_toggle(toggle_text, { toggle: 'dropdown', display: 'static' }, { id: 'sort-projects-dropdown' }) + = dropdown_toggle(projects_sort_options_hash[@sort], { toggle: 'dropdown', display: 'static' }, { id: 'sort-projects-dropdown' }) %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable %li.dropdown-header - Sort by + = _("Sort by") - projects_sort_options_hash.each do |value, title| %li = link_to filter_projects_path(sort: value), class: ("is-active" if @sort == value) do @@ -13,29 +12,29 @@ %li.divider %li = link_to filter_projects_path(archived: nil), class: ("is-active" unless params[:archived].present?) do - Hide archived projects + = _("Hide archived projects") %li = link_to filter_projects_path(archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do - Show archived projects + = _("Show archived projects") %li = link_to filter_projects_path(archived: 'only'), class: ("is-active" if params[:archived] == 'only') do - Show archived projects only + = _("Show archived projects only") - if current_user %li.divider %li = link_to filter_projects_path(personal: nil), class: ("is-active" unless params[:personal].present?) do - Owned by anyone + = _("Owned by anyone") %li = link_to filter_projects_path(personal: true), class: ("is-active" if params[:personal].present?) do - Owned by me + = _("Owned by me") - if @group && @group.shared_projects.present? %li.divider %li = link_to filter_projects_path(shared: nil), class: ("is-active" unless params[:shared].present?) do - All projects + = _("All projects") %li = link_to filter_projects_path(shared: 0), class: ("is-active" if params[:shared] == '0') do - Hide shared projects + = _("Hide shared projects") %li = link_to filter_projects_path(shared: 1), class: ("is-active" if params[:shared] == '1') do - Hide group projects + = _("Hide group projects") diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index f1a87faa7ac..90fb067e75d 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -10,7 +10,7 @@ - show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && can_show_last_commit_in_list?(project) - css_class = '' unless local_assigns[:css_class] - css_class += " no-description" if project.description.blank? && !show_last_commit_as_description -- cache_key = project_list_cache_key(project) +- 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" - avatar_container_class = project.creator && use_creator_avatar ? '' : 'rect-avatar' @@ -85,7 +85,8 @@ = sprite_icon('issues', size: 14, css_class: 'append-right-4') = number_with_delimiter(project.open_issues_count) - if pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project) + - 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_project_pipeline_status(project.pipeline_status, tooltip_placement: 'top') + = render 'ci/status/icon', status: project.commit.last_pipeline.detailed_status(current_user), type: 'commit', tooltip_placement: 'top', path: pipeline_path .updated-note %span Updated #{updated_tooltip} diff --git a/app/views/shared/projects/_search_bar.html.haml b/app/views/shared/projects/_search_bar.html.haml new file mode 100644 index 00000000000..c1f2eaba284 --- /dev/null +++ b/app/views/shared/projects/_search_bar.html.haml @@ -0,0 +1,28 @@ +- @sort ||= sort_value_latest_activity +- project_tab_filter = local_assigns.fetch(:project_tab_filter, "") +- flex_grow_and_shrink_xs = 'd-flex flex-xs-grow-1 flex-xs-shrink-1 flex-grow-0 flex-shrink-0' + +.filtered-search-block.row-content-block.bt-0 + .filtered-search-wrapper.d-flex.flex-nowrap.flex-column.flex-sm-wrap.flex-sm-row.flex-xl-nowrap + - unless project_tab_filter == :starred + .filtered-search-nav.mb-2.mb-lg-0{ class: flex_grow_and_shrink_xs } + = render 'dashboard/projects/nav', project_tab_filter: project_tab_filter + .filtered-search.d-flex.flex-grow-1.flex-shrink-1.w-100.mb-2.mb-lg-0.ml-0{ class: project_tab_filter == :starred ? "extended-filtered-search-box mb-2 mb-lg-0" : "ml-sm-3" } + .btn-group.w-100{ role: "group" } + .btn-group.w-100{ role: "group" } + .filtered-search-box.m-0 + .filtered-search-box-input-container.pl-2 + = render 'shared/projects/search_form', admin_view: false, search_form_placeholder: _("Search projects...") + %button.btn.btn-secondary{ type: 'submit', form: 'project-filter-form' } + = sprite_icon('search', size: 16, css_class: 'search-icon ') + .filtered-search-dropdown.flex-row.align-items-center.mb-2.m-sm-0#filtered-search-visibility-dropdown{ class: flex_grow_and_shrink_xs } + .filtered-search-dropdown-label.p-0.pl-sm-3.font-weight-bold + %span + = _("Visibility") + = render 'explore/projects/filter', has_label: true + .filtered-search-dropdown.flex-row.align-items-center.m-sm-0#filtered-search-sorting-dropdown{ class: flex_grow_and_shrink_xs } + .filtered-search-dropdown-label.p-0.pl-sm-3.font-weight-bold + %span + = _("Sort by") + = render 'shared/projects/sort_dropdown' + diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml index 3b5c13ed93a..7c7c0a363ac 100644 --- a/app/views/shared/projects/_search_form.html.haml +++ b/app/views/shared/projects/_search_form.html.haml @@ -1,7 +1,10 @@ +- form_field_classes = local_assigns[:admin_view] || !Feature.enabled?(:project_list_filter_bar) ? 'input-short js-projects-list-filter' : '' +- placeholder = local_assigns[:search_form_placeholder] ? search_form_placeholder : 'Filter by name...' + = form_tag filter_projects_path, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| = search_field_tag :name, params[:name], - placeholder: 'Filter by name...', - class: 'project-filter-form-field form-control input-short js-projects-list-filter', + placeholder: placeholder, + class: "project-filter-form-field form-control #{form_field_classes}", spellcheck: false, id: 'project-filter-form-field', tabindex: "2", diff --git a/app/views/shared/projects/_sort_dropdown.html.haml b/app/views/shared/projects/_sort_dropdown.html.haml new file mode 100644 index 00000000000..f5f940db189 --- /dev/null +++ b/app/views/shared/projects/_sort_dropdown.html.haml @@ -0,0 +1,39 @@ +- @sort ||= sort_value_latest_activity +- toggle_text = projects_sort_option_titles[@sort] + +.btn-group.w-100{ role: "group" } + .btn-group.w-100.dropdown.js-project-filter-dropdown-wrap{ role: "group" } + %button#sort-projects-dropdown.btn.btn-default.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } + = toggle_text + = icon('chevron-down') + %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable + %li.dropdown-header + = _("Sort by") + - projects_sort_options_hash.each do |value, title| + %li + = link_to title, filter_projects_path(sort: value), class: ("is-active" if toggle_text == title) + + %li.divider + %li + = link_to filter_projects_path(archived: nil), class: ("is-active" unless params[:archived].present?) do + = _("Hide archived projects") + %li + = link_to filter_projects_path(archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do + = _("Show archived projects") + %li + = link_to filter_projects_path(archived: 'only'), class: ("is-active" if params[:archived] == 'only') do + = _("Show archived projects only") + + - if current_user && @group && @group.shared_projects.present? + %li.divider + %li + = link_to filter_projects_path(shared: nil), class: ("is-active" unless params[:shared].present?) do + = _("All projects") + %li + = link_to filter_projects_path(shared: 0), class: ("is-active" if params[:shared] == '0') do + = _("Hide shared projects") + %li + = link_to filter_projects_path(shared: 1), class: ("is-active" if params[:shared] == '1') do + = _("Hide group projects") + + = project_sort_direction_button(@sort) diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index 3007da0c189..2d2382e469a 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -7,9 +7,10 @@ = form_errors(@snippet) .form-group.row - = f.label :title, class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :title .col-sm-10 - = f.text_field :title, class: 'form-control', required: true, autofocus: true + = f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true = render 'shared/form_elements/description', model: @snippet, project: @project, form: f @@ -17,11 +18,12 @@ .file-editor .form-group.row - = f.label :file_name, "File", class: 'col-form-label col-sm-2' + .col-sm-2.col-form-label + = f.label :file_name, "File" .col-sm-10 .file-holder.snippet .js-file-title.file-title - = f.text_field :file_name, placeholder: "Optionally name this file to add code highlighting, e.g. example.rb for Ruby.", class: 'form-control snippet-file-name' + = f.text_field :file_name, placeholder: "Optionally name this file to add code highlighting, e.g. example.rb for Ruby.", class: 'form-control snippet-file-name qa-snippet-file-name' .file-content.code %pre#editor= @snippet.content = f.hidden_field :content, class: 'snippet-file-content' @@ -31,7 +33,7 @@ .form-actions - if @snippet.new_record? - = f.submit 'Create snippet', class: "btn-success btn" + = f.submit 'Create snippet', class: "btn-success btn qa-create-snippet-button" - else = f.submit 'Save changes', class: "btn-success btn" diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index a43296aa806..ebb634fe75f 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -1,6 +1,6 @@ .detail-page-header .detail-page-header-body - .snippet-box.has-tooltip.inline.append-right-5{ title: snippet_visibility_level_description(@snippet.visibility_level, @snippet), data: { container: "body" } } + .snippet-box.qa-snippet-box.has-tooltip.inline.append-right-5{ title: snippet_visibility_level_description(@snippet.visibility_level, @snippet), data: { container: "body" } } %span.sr-only = visibility_level_label(@snippet.visibility_level) = visibility_level_icon(@snippet.visibility_level, fw: false) @@ -17,12 +17,12 @@ = render "snippets/actions" .snippet-header.limited-header-width - %h2.snippet-title.prepend-top-0.append-bottom-0 + %h2.snippet-title.prepend-top-0.append-bottom-0.qa-snippet-title = markdown_field(@snippet, :title) - if @snippet.description.present? - .description - .wiki + .description.qa-snippet-description + .md = markdown_field(@snippet, :description) %textarea.hidden.js-task-list-field = @snippet.description @@ -34,7 +34,7 @@ .embed-snippet .input-group .input-group-prepend - %button.btn.btn-svg.embed-toggle.input-group-text{ 'data-toggle': 'dropdown', type: 'button' } + %button.btn.btn-svg.embed-toggle.input-group-text.qa-embed-type{ 'data-toggle': 'dropdown', type: 'button' } %span.js-embed-action= _("Embed") = sprite_icon('angle-down', size: 12, css_class: 'caret-down') %ul.dropdown-menu.dropdown-menu-selectable.embed-toggle-list diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml index ef8664e6f47..9952f373156 100644 --- a/app/views/snippets/_actions.html.haml +++ b/app/views/snippets/_actions.html.haml @@ -7,7 +7,7 @@ - if can?(current_user, :admin_personal_snippet, @snippet) = link_to snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-inverted btn-remove", title: _('Delete Snippet') do = _("Delete") - = link_to new_snippet_path, class: "btn btn-grouped btn-inverted btn-create", title: _("New snippet") do + = link_to new_snippet_path, class: "btn btn-grouped btn-success btn-inverted", title: _("New snippet") do = _("New snippet") - if @snippet.submittable_as_spam_by?(current_user) = link_to _('Submit as spam'), mark_as_spam_snippet_path(@snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam') diff --git a/app/views/snippets/new.html.haml b/app/views/snippets/new.html.haml index 114c777bdc2..9d462865471 100644 --- a/app/views/snippets/new.html.haml +++ b/app/views/snippets/new.html.haml @@ -2,7 +2,7 @@ - @hide_breadcrumbs = true - page_title _("New Snippet") -.page-title-holder +.page-title-holder.d-flex.align-items-center %h1.page-title= _('New Snippet') .prepend-top-default diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml index 01b95145937..6e20890a47f 100644 --- a/app/views/snippets/notes/_actions.html.haml +++ b/app/views/snippets/notes/_actions.html.haml @@ -3,9 +3,9 @@ .note-actions-item = link_to '#', title: _('Add reaction'), class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip", data: { position: 'right' } do = icon('spinner spin') - %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') - %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') - %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') + %span{ class: 'link-highlight award-control-icon-neutral' }= sprite_icon('slight-smile') + %span{ class: 'link-highlight award-control-icon-positive' }= sprite_icon('smiley') + %span{ class: 'link-highlight award-control-icon-super-positive' }= sprite_icon('smile') - if note_editable .note-actions-item diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml index 01acbf8eadd..3191eaa1e2c 100644 --- a/app/views/users/calendar_activities.html.haml +++ b/app/views/users/calendar_activities.html.haml @@ -9,7 +9,7 @@ %i.fa.fa-clock-o = event.created_at.to_time.in_time_zone.strftime('%-I:%M%P') - if event.visible_to_user?(current_user) - - if event.push? + - if event.push_action? #{event.action_name} #{event.ref_type} %strong - commits_path = project_commits_path(event.project, event.ref_name) diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 211e3eafac6..a71bfd624e4 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -9,7 +9,7 @@ = auto_discovery_link_tag(:atom, user_url(@user, format: :atom), title: "#{@user.name} activity") .user-profile - .cover-block.user-cover-block.top-area + .cover-block.user-cover-block .cover-controls - if @user == current_user = link_to profile_path, class: 'btn btn-default has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile' do @@ -45,7 +45,7 @@ = emoji_icon(@user.status.emoji) = markdown_field(@user.status, :message) - .cover-desc.member-date + .cover-desc.member-date.cgray %p %span.middle-dot-divider @#{@user.username} @@ -53,7 +53,7 @@ %span.middle-dot-divider = s_('Member since %{date}') % { date: @user.created_at.to_date.to_s(:long) } - .cover-desc + .cover-desc.cgray - unless @user.public_email.blank? .profile-link-holder.middle-dot-divider = link_to @user.public_email, "mailto:#{@user.public_email}", class: 'text-link' @@ -82,7 +82,7 @@ = @user.organization - if @user.bio.present? - .cover-desc + .cover-desc.cgray %p.profile-user-bio = @user.bio diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index d0fc130b04f..fd0cc5fb24e 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1,11 +1,14 @@ --- - auto_devops:auto_devops_disable +- auto_merge:auto_merge_process + - cronjob:admin_email - cronjob:expire_build_artifacts - cronjob:gitlab_usage_ping - cronjob:import_export_project_cleanup - cronjob:pages_domain_verification_cron +- cronjob:pages_domain_removal_cron - cronjob:pipeline_schedule - cronjob:prune_old_events - cronjob:remove_expired_group_links @@ -21,8 +24,10 @@ - cronjob:trending_projects - cronjob:issue_due_scheduler - cronjob:prune_web_hook_logs +- cronjob:schedule_migrate_external_diffs - gcp_cluster:cluster_install_app +- gcp_cluster:cluster_patch_app - gcp_cluster:cluster_upgrade_app - gcp_cluster:cluster_provision - gcp_cluster:cluster_wait_for_app_installation @@ -30,6 +35,8 @@ - gcp_cluster:cluster_wait_for_ingress_ip_address - gcp_cluster:cluster_configure - gcp_cluster:cluster_project_configure +- gcp_cluster:clusters_applications_wait_for_uninstall_app +- gcp_cluster:clusters_applications_uninstall - github_import_advance_stage - github_importer:github_import_import_diff_note @@ -47,6 +54,9 @@ - github_importer:github_import_stage_import_repository - hashed_storage:hashed_storage_migrator +- hashed_storage:hashed_storage_rollbacker +- hashed_storage:hashed_storage_project_migrate +- hashed_storage:hashed_storage_project_rollback - mail_scheduler:mail_scheduler_issue_due - mail_scheduler:mail_scheduler_notification_service @@ -67,6 +77,7 @@ - pipeline_hooks:build_hooks - pipeline_hooks:pipeline_hooks - pipeline_processing:build_finished +- pipeline_processing:ci_build_prepare - pipeline_processing:build_queue - pipeline_processing:build_success - pipeline_processing:pipeline_process @@ -77,6 +88,7 @@ - pipeline_processing:ci_build_schedule - deployment:deployments_success +- deployment:deployments_finished - repository_check:repository_check_clear - repository_check:repository_check_batch @@ -114,6 +126,7 @@ - invalid_gpg_signature_update - irker - merge +- migrate_external_diffs - namespaceless_project_destroy - new_issue - new_merge_request @@ -126,7 +139,6 @@ - project_cache - project_destroy - project_export -- project_migrate_hashed_storage - project_service - propagate_service_template - reactive_caching @@ -137,6 +149,7 @@ - repository_remove_remote - system_hook_push - update_merge_requests +- update_project_statistics - upload_checksum - web_hook - repository_update_remote_mirror @@ -146,3 +159,4 @@ - repository_cleanup - delete_stored_files - import_issues_csv +- project_daily_statistics diff --git a/app/workers/auto_merge_process_worker.rb b/app/workers/auto_merge_process_worker.rb new file mode 100644 index 00000000000..cd81cdbc60c --- /dev/null +++ b/app/workers/auto_merge_process_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AutoMergeProcessWorker + include ApplicationWorker + + queue_namespace :auto_merge + + def perform(merge_request_id) + MergeRequest.find_by_id(merge_request_id).try do |merge_request| + AutoMergeService.new(merge_request.project, merge_request.merge_user) + .process(merge_request) + end + end +end diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb index adc38226405..8e2a18a8fd8 100644 --- a/app/workers/build_finished_worker.rb +++ b/app/workers/build_finished_worker.rb @@ -30,6 +30,7 @@ class BuildFinishedWorker # We execute these async as these are independent operations. BuildHooksWorker.perform_async(build.id) ArchiveTraceWorker.perform_async(build.id) + ExpirePipelineCacheWorker.perform_async(build.pipeline_id) ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat? end end diff --git a/app/workers/ci/build_prepare_worker.rb b/app/workers/ci/build_prepare_worker.rb new file mode 100644 index 00000000000..1a35a74ae53 --- /dev/null +++ b/app/workers/ci/build_prepare_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Ci + class BuildPrepareWorker + include ApplicationWorker + include PipelineQueue + + queue_namespace :pipeline_processing + + def perform(build_id) + Ci::Build.find_by_id(build_id).try do |build| + Ci::PrepareBuildService.new(build).execute + end + end + end +end diff --git a/app/workers/cluster_configure_worker.rb b/app/workers/cluster_configure_worker.rb index 63e6cc147be..6f64b7ea0ab 100644 --- a/app/workers/cluster_configure_worker.rb +++ b/app/workers/cluster_configure_worker.rb @@ -5,8 +5,10 @@ class ClusterConfigureWorker include ClusterQueue def perform(cluster_id) - Clusters::Cluster.find_by_id(cluster_id).try do |cluster| - Clusters::RefreshService.create_or_update_namespaces_for_cluster(cluster) + Clusters::Cluster.managed.find_by_id(cluster_id).try do |cluster| + if cluster.project_type? + Clusters::RefreshService.create_or_update_namespaces_for_cluster(cluster) + end end end end diff --git a/app/workers/cluster_patch_app_worker.rb b/app/workers/cluster_patch_app_worker.rb new file mode 100644 index 00000000000..0549e81ed05 --- /dev/null +++ b/app/workers/cluster_patch_app_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ClusterPatchAppWorker + include ApplicationWorker + include ClusterQueue + include ClusterApplications + + def perform(app_name, app_id) + find_application(app_name, app_id) do |app| + Clusters::Applications::PatchService.new(app).execute + end + end +end diff --git a/app/workers/clusters/applications/uninstall_worker.rb b/app/workers/clusters/applications/uninstall_worker.rb new file mode 100644 index 00000000000..85e8ecc4ad5 --- /dev/null +++ b/app/workers/clusters/applications/uninstall_worker.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class UninstallWorker + include ApplicationWorker + include ClusterQueue + include ClusterApplications + + def perform(app_name, app_id) + find_application(app_name, app_id) do |app| + Clusters::Applications::UninstallService.new(app).execute + end + end + end + end +end diff --git a/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb new file mode 100644 index 00000000000..163c99d3c3c --- /dev/null +++ b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class WaitForUninstallAppWorker + include ApplicationWorker + include ClusterQueue + include ClusterApplications + + INTERVAL = 10.seconds + TIMEOUT = 20.minutes + + def perform(app_name, app_id) + find_application(app_name, app_id) do |app| + Clusters::Applications::CheckUninstallProgressService.new(app).execute + end + end + end + end +end diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb index d64c2f82a09..25c3a945077 100644 --- a/app/workers/concerns/application_worker.rb +++ b/app/workers/concerns/application_worker.rb @@ -53,7 +53,7 @@ module ApplicationWorker schedule = now + delay.to_i if schedule <= now - raise ArgumentError, 'The schedule time must be in the future!' + raise ArgumentError, _('The schedule time must be in the future!') end Sidekiq::Client.push_bulk('class' => self, 'args' => args_list, 'at' => schedule) diff --git a/app/workers/concerns/waitable_worker.rb b/app/workers/concerns/waitable_worker.rb index 27b94a82444..17946bbc5ca 100644 --- a/app/workers/concerns/waitable_worker.rb +++ b/app/workers/concerns/waitable_worker.rb @@ -25,11 +25,9 @@ module WaitableWorker failed = [] args_list.each do |args| - begin - new.perform(*args) - rescue - failed << args - end + new.perform(*args) + rescue + failed << args end bulk_perform_async(failed) if failed.present? diff --git a/app/workers/create_gpg_signature_worker.rb b/app/workers/create_gpg_signature_worker.rb index 49c7a403838..7fac7822cf7 100644 --- a/app/workers/create_gpg_signature_worker.rb +++ b/app/workers/create_gpg_signature_worker.rb @@ -5,8 +5,8 @@ class CreateGpgSignatureWorker # rubocop: disable CodeReuse/ActiveRecord def perform(commit_shas, project_id) - # Older versions of GitPushService may push a single commit ID on the stack. - # We need this to be backwards compatible. + # Older versions of Git::BranchPushService may push a single commit ID on + # the stack. We need this to be backwards compatible. commit_shas = Array(commit_shas) return if commit_shas.empty? @@ -20,11 +20,9 @@ class CreateGpgSignatureWorker # This calculates and caches the signature in the database commits.each do |commit| - begin - Gitlab::Gpg::Commit.new(commit).signature - rescue => e - Rails.logger.error("Failed to create signature for commit #{commit.id}. Error: #{e.message}") - end + Gitlab::Gpg::Commit.new(commit).signature + rescue => e + Rails.logger.error("Failed to create signature for commit #{commit.id}. Error: #{e.message}") end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/workers/deployments/finished_worker.rb b/app/workers/deployments/finished_worker.rb new file mode 100644 index 00000000000..c9d448d5d18 --- /dev/null +++ b/app/workers/deployments/finished_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Deployments + class FinishedWorker + include ApplicationWorker + + queue_namespace :deployment + + def perform(deployment_id) + Deployment.find_by_id(deployment_id).try(:execute_hooks) + end + end +end diff --git a/app/workers/detect_repository_languages_worker.rb b/app/workers/detect_repository_languages_worker.rb index 64bc9776d48..838c3be78f0 100644 --- a/app/workers/detect_repository_languages_worker.rb +++ b/app/workers/detect_repository_languages_worker.rb @@ -12,13 +12,12 @@ class DetectRepositoryLanguagesWorker attr_reader :project # rubocop: disable CodeReuse/ActiveRecord - def perform(project_id, user_id) + def perform(project_id, user_id = nil) @project = Project.find_by(id: project_id) - user = User.find_by(id: user_id) - return unless project && user + return unless project try_obtain_lease do - ::Projects::DetectRepositoryLanguagesService.new(project, user).execute + ::Projects::DetectRepositoryLanguagesService.new(project).execute end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb index bf637f82df2..c4bcda2da16 100644 --- a/app/workers/email_receiver_worker.rb +++ b/app/workers/email_receiver_worker.rb @@ -24,22 +24,22 @@ class EmailReceiverWorker reason = case error when Gitlab::Email::UnknownIncomingEmail - "We couldn't figure out what the email is for. Please create your issue or comment through the web interface." + s_("EmailError|We couldn't figure out what the email is for. Please create your issue or comment through the web interface.") when Gitlab::Email::SentNotificationNotFoundError - "We couldn't figure out what the email is in reply to. Please create your comment through the web interface." + s_("EmailError|We couldn't figure out what the email is in reply to. Please create your comment through the web interface.") when Gitlab::Email::ProjectNotFound - "We couldn't find the project. Please check if there's any typo." + s_("EmailError|We couldn't find the project. Please check if there's any typo.") when Gitlab::Email::EmptyEmailError can_retry = true - "It appears that the email is blank. Make sure your reply is at the top of the email, we can't process inline replies." + s_("EmailError|It appears that the email is blank. Make sure your reply is at the top of the email, we can't process inline replies.") when Gitlab::Email::UserNotFoundError - "We couldn't figure out what user corresponds to the email. Please create your comment through the web interface." + s_("EmailError|We couldn't figure out what user corresponds to the email. Please create your comment through the web interface.") when Gitlab::Email::UserBlockedError - "Your account has been blocked. If you believe this is in error, contact a staff member." + s_("EmailError|Your account has been blocked. If you believe this is in error, contact a staff member.") when Gitlab::Email::UserNotAuthorizedError - "You are not allowed to perform this action. If you believe this is in error, contact a staff member." + s_("EmailError|You are not allowed to perform this action. If you believe this is in error, contact a staff member.") when Gitlab::Email::NoteableNotFoundError - "The thread you are replying to no longer exists, perhaps it was deleted? If you believe this is in error, contact a staff member." + s_("EmailError|The thread you are replying to no longer exists, perhaps it was deleted? If you believe this is in error, contact a staff member.") when Gitlab::Email::InvalidAttachment error.message when Gitlab::Email::InvalidRecordError diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb index 17ad1d5ab88..ed3e354e4c2 100644 --- a/app/workers/emails_on_push_worker.rb +++ b/app/workers/emails_on_push_worker.rb @@ -52,24 +52,22 @@ class EmailsOnPushWorker end valid_recipients(recipients).each do |recipient| - begin - send_email( - recipient, - project_id, - author_id: author_id, - ref: ref, - action: action, - compare: compare, - reverse_compare: reverse_compare, - diff_refs: diff_refs, - send_from_committer_email: send_from_committer_email, - disable_diffs: disable_diffs - ) - - # These are input errors and won't be corrected even if Sidekiq retries - rescue Net::SMTPFatalError, Net::SMTPSyntaxError => e - logger.info("Failed to send e-mail for project '#{project.full_name}' to #{recipient}: #{e}") - end + send_email( + recipient, + project_id, + author_id: author_id, + ref: ref, + action: action, + compare: compare, + reverse_compare: reverse_compare, + diff_refs: diff_refs, + send_from_committer_email: send_from_committer_email, + disable_diffs: disable_diffs + ) + + # These are input errors and won't be corrected even if Sidekiq retries + rescue Net::SMTPFatalError, Net::SMTPSyntaxError => e + logger.info("Failed to send e-mail for project '#{project.full_name}' to #{recipient}: #{e}") end ensure @email = nil diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb index 148384600b6..78e68d7bf46 100644 --- a/app/workers/expire_pipeline_cache_worker.rb +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -11,56 +11,7 @@ class ExpirePipelineCacheWorker pipeline = Ci::Pipeline.find_by(id: pipeline_id) return unless pipeline - store = Gitlab::EtagCaching::Store.new - - update_etag_cache(pipeline, store) - - Gitlab::Cache::Ci::ProjectPipelineStatus.update_for_pipeline(pipeline) + Ci::ExpirePipelineCacheService.new.execute(pipeline) end # rubocop: enable CodeReuse/ActiveRecord - - private - - def project_pipelines_path(project) - Gitlab::Routing.url_helpers.project_pipelines_path(project, format: :json) - end - - def project_pipeline_path(project, pipeline) - Gitlab::Routing.url_helpers.project_pipeline_path(project, pipeline, format: :json) - end - - def commit_pipelines_path(project, commit) - Gitlab::Routing.url_helpers.pipelines_project_commit_path(project, commit.id, format: :json) - end - - def new_merge_request_pipelines_path(project) - Gitlab::Routing.url_helpers.project_new_merge_request_path(project, format: :json) - end - - def each_pipelines_merge_request_path(project, pipeline) - pipeline.all_merge_requests.each do |merge_request| - path = Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(project, merge_request, format: :json) - - yield(path) - end - end - - # Updates ETag caches of a pipeline. - # - # This logic resides in a separate method so that EE can more easily extend - # it. - # - # @param [Ci::Pipeline] pipeline - # @param [Gitlab::EtagCaching::Store] store - def update_etag_cache(pipeline, store) - project = pipeline.project - - store.touch(project_pipelines_path(project)) - store.touch(project_pipeline_path(project, pipeline)) - store.touch(commit_pipelines_path(project, pipeline.commit)) unless pipeline.commit.nil? - store.touch(new_merge_request_pipelines_path(project)) - each_pipelines_merge_request_path(project, pipeline) do |path| - store.touch(path) - end - end end diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb index b33e9b1f718..489d6215774 100644 --- a/app/workers/git_garbage_collect_worker.rb +++ b/app/workers/git_garbage_collect_worker.rb @@ -23,13 +23,15 @@ class GitGarbageCollectWorker end task = task.to_sym - project.link_pool_repository + + ::Projects::GitDeduplicationService.new(project).execute + gitaly_call(task, project.repository.raw_repository) # Refresh the branch cache in case garbage collection caused a ref lookup to fail flush_ref_caches(project) if task == :gc - project.repository.expire_statistics_caches + project.repository.expire_statistics_caches if task != :pack_refs # In case pack files are deleted, release libgit2 cache and open file # descriptors ASAP instead of waiting for Ruby garbage collection @@ -58,7 +60,12 @@ class GitGarbageCollectWorker ## `repository` has to be a Gitlab::Git::Repository def gitaly_call(task, repository) - client = Gitlab::GitalyClient::RepositoryService.new(repository) + client = if task == :pack_refs + Gitlab::GitalyClient::RefService.new(repository) + else + Gitlab::GitalyClient::RepositoryService.new(repository) + end + case task when :gc client.garbage_collect(bitmaps_enabled?) @@ -66,6 +73,8 @@ class GitGarbageCollectWorker client.repack_full(bitmaps_enabled?) when :incremental_repack client.repack_incremental + when :pack_refs + client.pack_refs end rescue GRPC::NotFound => e Gitlab::GitLogger.error("#{__method__} failed:\nRepository not found") diff --git a/app/workers/hashed_storage/base_worker.rb b/app/workers/hashed_storage/base_worker.rb new file mode 100644 index 00000000000..816e0504db6 --- /dev/null +++ b/app/workers/hashed_storage/base_worker.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module HashedStorage + class BaseWorker + include ExclusiveLeaseGuard + + LEASE_TIMEOUT = 30.seconds.to_i + LEASE_KEY_SEGMENT = 'project_migrate_hashed_storage_worker'.freeze + + protected + + def lease_key + # we share the same lease key for both migration and rollback so they don't run simultaneously + "#{LEASE_KEY_SEGMENT}:#{project_id}" + end + + def lease_timeout + LEASE_TIMEOUT + end + end +end diff --git a/app/workers/hashed_storage/project_migrate_worker.rb b/app/workers/hashed_storage/project_migrate_worker.rb new file mode 100644 index 00000000000..f00a459a097 --- /dev/null +++ b/app/workers/hashed_storage/project_migrate_worker.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module HashedStorage + class ProjectMigrateWorker < BaseWorker + include ApplicationWorker + + queue_namespace :hashed_storage + + attr_reader :project_id + + # rubocop: disable CodeReuse/ActiveRecord + def perform(project_id, old_disk_path = nil) + @project_id = project_id # we need to set this in order to create the lease_key + + try_obtain_lease do + project = Project.without_deleted.find_by(id: project_id) + break unless project + + old_disk_path ||= project.disk_path + + ::Projects::HashedStorage::MigrationService.new(project, old_disk_path, logger: logger).execute + end + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/workers/hashed_storage/project_rollback_worker.rb b/app/workers/hashed_storage/project_rollback_worker.rb new file mode 100644 index 00000000000..55e1d7ab23e --- /dev/null +++ b/app/workers/hashed_storage/project_rollback_worker.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module HashedStorage + class ProjectRollbackWorker < BaseWorker + include ApplicationWorker + + queue_namespace :hashed_storage + + attr_reader :project_id + + # rubocop: disable CodeReuse/ActiveRecord + def perform(project_id, old_disk_path = nil) + @project_id = project_id # we need to set this in order to create the lease_key + + try_obtain_lease do + project = Project.without_deleted.find_by(id: project_id) + break unless project + + old_disk_path ||= project.disk_path + + ::Projects::HashedStorage::RollbackService.new(project, old_disk_path, logger: logger).execute + end + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/workers/hashed_storage/rollbacker_worker.rb b/app/workers/hashed_storage/rollbacker_worker.rb new file mode 100644 index 00000000000..a4da8443787 --- /dev/null +++ b/app/workers/hashed_storage/rollbacker_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module HashedStorage + class RollbackerWorker + include ApplicationWorker + + queue_namespace :hashed_storage + + # @param [Integer] start initial ID of the batch + # @param [Integer] finish last ID of the batch + def perform(start, finish) + migrator = Gitlab::HashedStorage::Migrator.new + migrator.bulk_rollback(start: start, finish: finish) + end + end +end diff --git a/app/workers/migrate_external_diffs_worker.rb b/app/workers/migrate_external_diffs_worker.rb new file mode 100644 index 00000000000..debac97af2c --- /dev/null +++ b/app/workers/migrate_external_diffs_worker.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: false + +class MigrateExternalDiffsWorker + include ApplicationWorker + + def perform(merge_request_diff_id) + diff = MergeRequestDiff.find_by_id(merge_request_diff_id) + return unless diff + + MergeRequests::MigrateExternalDiffsService.new(diff).execute + end +end diff --git a/app/workers/object_storage/migrate_uploads_worker.rb b/app/workers/object_storage/migrate_uploads_worker.rb index fe5d27b087d..12400d4e025 100644 --- a/app/workers/object_storage/migrate_uploads_worker.rb +++ b/app/workers/object_storage/migrate_uploads_worker.rb @@ -20,7 +20,7 @@ module ObjectStorage end def to_s - success? ? "Migration successful." : "Error while migrating #{upload.id}: #{error.message}" + success? ? _("Migration successful.") : _("Error while migrating %{upload_id}: %{error_message}") % { upload_id: upload.id, error_message: error.message } end end @@ -47,7 +47,7 @@ module ObjectStorage end def header(success, failures) - "Migrated #{success.count}/#{success.count + failures.count} files." + _("Migrated %{success_count}/%{total_count} files.") % { success_count: success.count, total_count: success.count + failures.count } end def failures(failures) @@ -75,9 +75,9 @@ module ObjectStorage model_types = uploads.map(&:model_type).uniq model_has_mount = mounted_as.nil? || model_class.uploaders[mounted_as] == uploader_class - raise(SanityCheckError, "Multiple uploaders found: #{uploader_types}") unless uploader_types.count == 1 - raise(SanityCheckError, "Multiple model types found: #{model_types}") unless model_types.count == 1 - raise(SanityCheckError, "Mount point #{mounted_as} not found in #{model_class}.") unless model_has_mount + raise(SanityCheckError, _("Multiple uploaders found: %{uploader_types}") % { uploader_types: uploader_types }) unless uploader_types.count == 1 + raise(SanityCheckError, _("Multiple model types found: %{model_types}") % { model_types: model_types }) unless model_types.count == 1 + raise(SanityCheckError, _("Mount point %{mounted_as} not found in %{model_class}.") % { mounted_as: mounted_as, model_class: model_class }) unless model_has_mount end # rubocop: disable CodeReuse/ActiveRecord @@ -110,9 +110,9 @@ module ObjectStorage return if args.count == 4 case args.count - when 3 then raise SanityCheckError, "Job is missing the `model_type` argument." + when 3 then raise SanityCheckError, _("Job is missing the `model_type` argument.") else - raise SanityCheckError, "Job has wrong arguments format." + raise SanityCheckError, _("Job has wrong arguments format.") end end @@ -126,11 +126,9 @@ module ObjectStorage def process_uploader(uploader) MigrationResult.new(uploader.upload).tap do |result| - begin - uploader.migrate!(@to_store) - rescue => e - result.error = e - end + uploader.migrate!(@to_store) + rescue => e + result.error = e end end end diff --git a/app/workers/pages_domain_removal_cron_worker.rb b/app/workers/pages_domain_removal_cron_worker.rb new file mode 100644 index 00000000000..79f38e1b89f --- /dev/null +++ b/app/workers/pages_domain_removal_cron_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class PagesDomainRemovalCronWorker + include ApplicationWorker + include CronjobQueue + + def perform + PagesDomain.for_removal.find_each do |domain| + domain.destroy! + rescue => e + Raven.capture_exception(e) + end + end +end diff --git a/app/workers/pages_domain_verification_cron_worker.rb b/app/workers/pages_domain_verification_cron_worker.rb index 92d62a15aee..60703c83e9e 100644 --- a/app/workers/pages_domain_verification_cron_worker.rb +++ b/app/workers/pages_domain_verification_cron_worker.rb @@ -5,6 +5,8 @@ class PagesDomainVerificationCronWorker include CronjobQueue def perform + return if Gitlab::Database.read_only? + PagesDomain.needs_verification.find_each do |domain| PagesDomainVerificationWorker.perform_async(domain.id) end diff --git a/app/workers/pages_domain_verification_worker.rb b/app/workers/pages_domain_verification_worker.rb index b3319ff5a13..7817b2ee5fc 100644 --- a/app/workers/pages_domain_verification_worker.rb +++ b/app/workers/pages_domain_verification_worker.rb @@ -5,6 +5,8 @@ class PagesDomainVerificationWorker # rubocop: disable CodeReuse/ActiveRecord def perform(domain_id) + return if Gitlab::Database.read_only? + domain = PagesDomain.find_by(id: domain_id) return unless domain diff --git a/app/workers/pipeline_metrics_worker.rb b/app/workers/pipeline_metrics_worker.rb index c2fbfd2b3a5..0ddad43b8d5 100644 --- a/app/workers/pipeline_metrics_worker.rb +++ b/app/workers/pipeline_metrics_worker.rb @@ -30,6 +30,6 @@ class PipelineMetricsWorker # rubocop: enable CodeReuse/ActiveRecord def merge_requests(pipeline) - pipeline.merge_requests.map(&:id) + pipeline.merge_requests_as_head_pipeline.map(&:id) end end diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb index ac4e9710f33..9410fd1a786 100644 --- a/app/workers/pipeline_schedule_worker.rb +++ b/app/workers/pipeline_schedule_worker.rb @@ -4,41 +4,11 @@ class PipelineScheduleWorker include ApplicationWorker include CronjobQueue - # rubocop: disable CodeReuse/ActiveRecord def perform - Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now) - .preload(:owner, :project).find_each do |schedule| - begin - Ci::CreatePipelineService.new(schedule.project, - schedule.owner, - ref: schedule.ref) - .execute!(:schedule, ignore_skip_ci: true, save_on_errors: true, schedule: schedule) - rescue => e - error(schedule, e) - ensure - schedule.schedule_next_run! + Ci::PipelineSchedule.runnable_schedules.preloaded.find_in_batches do |schedules| + schedules.each do |schedule| + Ci::PipelineScheduleService.new(schedule.project, schedule.owner).execute(schedule) end end end - # rubocop: enable CodeReuse/ActiveRecord - - private - - def error(schedule, error) - failed_creation_counter.increment - - Rails.logger.error "Failed to create a scheduled pipeline. " \ - "schedule_id: #{schedule.id} message: #{error.message}" - - Gitlab::Sentry - .track_exception(error, - issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/41231', - extra: { schedule_id: schedule.id }) - end - - def failed_creation_counter - @failed_creation_counter ||= - Gitlab::Metrics.counter(:pipeline_schedule_creation_failed_total, - "Counter of failed attempts of pipeline schedule creation") - end end diff --git a/app/workers/pipeline_success_worker.rb b/app/workers/pipeline_success_worker.rb index 4f349ed922c..666331e6cd4 100644 --- a/app/workers/pipeline_success_worker.rb +++ b/app/workers/pipeline_success_worker.rb @@ -6,13 +6,7 @@ class PipelineSuccessWorker queue_namespace :pipeline_processing - # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) - Ci::Pipeline.find_by(id: pipeline_id).try do |pipeline| - MergeRequests::MergeWhenPipelineSucceedsService - .new(pipeline.project, nil) - .trigger(pipeline) - end + # no-op end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index bbd4ab159e4..3f1639ec2ed 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -3,8 +3,10 @@ class PostReceive include ApplicationWorker - def perform(gl_repository, identifier, changes, push_options = []) - project, is_wiki = Gitlab::GlRepository.parse(gl_repository) + PIPELINE_PROCESS_LIMIT = 4 + + def perform(gl_repository, identifier, changes, push_options = {}) + project, repo_type = Gitlab::GlRepository.parse(gl_repository) if project.nil? log("Triggered hook for non-existing project with gl_repository \"#{gl_repository}\"") @@ -17,10 +19,12 @@ class PostReceive Sidekiq.logger.info "changes: #{changes.inspect}" if ENV['SIDEKIQ_LOG_ARGUMENTS'] post_received = Gitlab::GitPostReceive.new(project, identifier, changes, push_options) - if is_wiki + if repo_type.wiki? process_wiki_changes(post_received) - else + elsif repo_type.project? process_project_changes(post_received) + else + # Other repos don't have hooks for now end end @@ -36,23 +40,24 @@ class PostReceive return false end - post_received.changes_refs do |oldrev, newrev, ref| - if Gitlab::Git.tag_ref?(ref) - GitTagPushService.new( - post_received.project, - @user, - oldrev: oldrev, - newrev: newrev, - ref: ref, - push_options: post_received.push_options).execute - elsif Gitlab::Git.branch_ref?(ref) - GitPushService.new( + 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).execute + 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) @@ -69,6 +74,8 @@ class PostReceive def process_wiki_changes(post_received) post_received.project.touch(:last_activity_at, :last_repository_updated_at) + post_received.project.wiki.repository.expire_statistics_caches + ProjectCacheWorker.perform_async(post_received.project.id, [], [:wiki_size]) end def log(message) diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index 29a7f8e691a..3efb5343a96 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -48,7 +48,7 @@ class ProcessCommitWorker # Issues::CloseService#execute. IssueCollection.new(issues).updatable_by_user(user).each do |issue| Issues::CloseService.new(project, author) - .close_issue(issue, commit: commit) + .close_issue(issue, closed_via: commit) end end diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb index d27b5e62574..4e8ea903139 100644 --- a/app/workers/project_cache_worker.rb +++ b/app/workers/project_cache_worker.rb @@ -16,9 +16,11 @@ class ProjectCacheWorker def perform(project_id, files = [], statistics = []) project = Project.find_by(id: project_id) - return unless project && project.repository.exists? + return unless project - update_statistics(project, statistics.map(&:to_sym)) + update_statistics(project, statistics) + + return unless project.repository.exists? project.repository.refresh_method_caches(files.map(&:to_sym)) @@ -26,19 +28,28 @@ class ProjectCacheWorker end # rubocop: enable CodeReuse/ActiveRecord + # NOTE: triggering both an immediate update and one in 15 minutes if we + # successfully obtain the lease. That way, we only need to wait for the + # statistics to become accurate if they were already updated once in the + # last 15 minutes. def update_statistics(project, statistics = []) - return unless try_obtain_lease_for(project.id, :update_statistics) + return if Gitlab::Database.read_only? + return unless try_obtain_lease_for(project.id, statistics) - Rails.logger.info("Updating statistics for project #{project.id}") + Projects::UpdateStatisticsService.new(project, nil, statistics: statistics).execute - project.statistics.refresh!(only: statistics) + UpdateProjectStatisticsWorker.perform_in(LEASE_TIMEOUT, project.id, statistics) end private - def try_obtain_lease_for(project_id, section) + def try_obtain_lease_for(project_id, statistics) Gitlab::ExclusiveLease - .new("project_cache_worker:#{project_id}:#{section}", timeout: LEASE_TIMEOUT) + .new(project_cache_worker_key(project_id, statistics), timeout: LEASE_TIMEOUT) .try_obtain end + + def project_cache_worker_key(project_id, statistics) + ["project_cache_worker", project_id, *statistics.sort].join(":") + end end diff --git a/app/workers/project_daily_statistics_worker.rb b/app/workers/project_daily_statistics_worker.rb new file mode 100644 index 00000000000..101f5c28459 --- /dev/null +++ b/app/workers/project_daily_statistics_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ProjectDailyStatisticsWorker + include ApplicationWorker + + def perform(project_id) + project = Project.find_by_id(project_id) + + return unless project&.repository&.exists? + + Projects::FetchStatisticsIncrementService.new(project).execute + end +end diff --git a/app/workers/project_migrate_hashed_storage_worker.rb b/app/workers/project_migrate_hashed_storage_worker.rb deleted file mode 100644 index 1c8f313e6e9..00000000000 --- a/app/workers/project_migrate_hashed_storage_worker.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -class ProjectMigrateHashedStorageWorker - include ApplicationWorker - - LEASE_TIMEOUT = 30.seconds.to_i - LEASE_KEY_SEGMENT = 'project_migrate_hashed_storage_worker'.freeze - - # rubocop: disable CodeReuse/ActiveRecord - def perform(project_id, old_disk_path = nil) - uuid = lease_for(project_id).try_obtain - - if uuid - project = Project.find_by(id: project_id) - return if project.nil? || project.pending_delete? - - old_disk_path ||= project.disk_path - - ::Projects::HashedStorage::MigrationService.new(project, old_disk_path, logger: logger).execute - else - return false - end - - ensure - cancel_lease_for(project_id, uuid) if uuid - end - # rubocop: enable CodeReuse/ActiveRecord - - def lease_for(project_id) - Gitlab::ExclusiveLease.new(lease_key(project_id), timeout: LEASE_TIMEOUT) - end - - private - - def lease_key(project_id) - # we share the same lease key for both migration and rollback so they don't run simultaneously - "#{LEASE_KEY_SEGMENT}:#{project_id}" - end - - def cancel_lease_for(project_id, uuid) - Gitlab::ExclusiveLease.cancel(lease_key(project_id), uuid) - end -end diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb index 9ec8bcca4f3..b30864db802 100644 --- a/app/workers/reactive_caching_worker.rb +++ b/app/workers/reactive_caching_worker.rb @@ -3,7 +3,6 @@ class ReactiveCachingWorker include ApplicationWorker - # rubocop: disable CodeReuse/ActiveRecord def perform(class_name, id, *args) klass = begin class_name.constantize @@ -12,7 +11,9 @@ class ReactiveCachingWorker end return unless klass - klass.find_by(klass.primary_key => id).try(:exclusively_update_reactive_cache!, *args) + klass + .reactive_cache_worker_finder + .call(id, *args) + .try(:exclusively_update_reactive_cache!, *args) end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb index 41913900571..3497a1f9280 100644 --- a/app/workers/remove_expired_members_worker.rb +++ b/app/workers/remove_expired_members_worker.rb @@ -6,11 +6,9 @@ class RemoveExpiredMembersWorker def perform Member.expired.find_each do |member| - begin - Members::DestroyService.new.execute(member, skip_authorization: true) - rescue => ex - logger.error("Expired Member ID=#{member.id} cannot be removed - #{ex}") - end + Members::DestroyService.new.execute(member, skip_authorization: true) + rescue => ex + logger.error("Expired Member ID=#{member.id} cannot be removed - #{ex}") end end end diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index f72331c003a..43e0b9db22f 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -21,6 +21,30 @@ class RunPipelineScheduleWorker Ci::CreatePipelineService.new(schedule.project, user, ref: schedule.ref) - .execute(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule) + .execute!(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule) + rescue Ci::CreatePipelineService::CreateError + # no-op. This is a user operation error such as corrupted .gitlab-ci.yml. + rescue => e + error(schedule, e) + end + + private + + def error(schedule, error) + failed_creation_counter.increment + + Rails.logger.error "Failed to create a scheduled pipeline. " \ + "schedule_id: #{schedule.id} message: #{error.message}" + + Gitlab::Sentry + .track_exception(error, + issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/41231', + extra: { schedule_id: schedule.id }) + end + + def failed_creation_counter + @failed_creation_counter ||= + Gitlab::Metrics.counter(:pipeline_schedule_creation_failed_total, + "Counter of failed attempts of pipeline schedule creation") end end diff --git a/app/workers/schedule_migrate_external_diffs_worker.rb b/app/workers/schedule_migrate_external_diffs_worker.rb new file mode 100644 index 00000000000..70910f7ca04 --- /dev/null +++ b/app/workers/schedule_migrate_external_diffs_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: false + +class ScheduleMigrateExternalDiffsWorker + include ApplicationWorker + include CronjobQueue + include Gitlab::ExclusiveLeaseHelpers + + def perform + in_lock(self.class.name.underscore, ttl: 2.hours, retries: 0) do + MergeRequests::MigrateExternalDiffsService.enqueue! + end + rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError + end +end diff --git a/app/workers/todos_destroyer/confidential_issue_worker.rb b/app/workers/todos_destroyer/confidential_issue_worker.rb index 481fde8c83d..240a5f98ad5 100644 --- a/app/workers/todos_destroyer/confidential_issue_worker.rb +++ b/app/workers/todos_destroyer/confidential_issue_worker.rb @@ -5,8 +5,8 @@ module TodosDestroyer include ApplicationWorker include TodosDestroyerQueue - def perform(issue_id) - ::Todos::Destroy::ConfidentialIssueService.new(issue_id).execute + def perform(issue_id = nil, project_id = nil) + ::Todos::Destroy::ConfidentialIssueService.new(issue_id: issue_id, project_id: project_id).execute end end end diff --git a/app/workers/update_project_statistics_worker.rb b/app/workers/update_project_statistics_worker.rb new file mode 100644 index 00000000000..9a29cc12707 --- /dev/null +++ b/app/workers/update_project_statistics_worker.rb @@ -0,0 +1,18 @@ + +# frozen_string_literal: true + +# Worker for updating project statistics. +class UpdateProjectStatisticsWorker + include ApplicationWorker + + # 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 + # rubocop: disable CodeReuse/ActiveRecord + def perform(project_id, statistics = []) + project = Project.find_by(id: project_id) + + Projects::UpdateStatisticsService.new(project, nil, statistics: statistics).execute + end + # rubocop: enable CodeReuse/ActiveRecord +end |