diff options
Diffstat (limited to 'app/assets/javascripts')
598 files changed, 12366 insertions, 3734 deletions
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; |