diff options
author | Robert Speicher <rspeicher@gmail.com> | 2018-12-18 04:50:03 +0300 |
---|---|---|
committer | Robert Speicher <rspeicher@gmail.com> | 2018-12-18 04:50:03 +0300 |
commit | 5a013145c910abc7ad016da7bcf2cdbd65d650a2 (patch) | |
tree | 78990687304961b13e686a3bcbf8525f1c9091a7 /app | |
parent | 7d28248d1b3392246eac064b8ef305eb4ccd0741 (diff) | |
parent | b0cb0d7fd9a4483441866261dcba214a3c94d8c6 (diff) |
Merge branch '11-6-stable-prepare-rc8' into '11-6-stable'
Prepare 11.6 RC8 release
See merge request gitlab-org/gitlab-ce!23823
Diffstat (limited to 'app')
122 files changed, 1919 insertions, 282 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index de003e70e61..7607c4b3b79 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -21,8 +21,11 @@ const Api = { projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key', projectTemplatesPath: '/api/:version/projects/:id/templates/:type', usersPath: '/api/:version/users.json', - userStatusPath: '/api/:version/user/status', + userPath: '/api/:version/users/:id', + userStatusPath: '/api/:version/users/:id/status', + userPostStatusPath: '/api/:version/user/status', commitPath: '/api/:version/projects/:id/repository/commits', + applySuggestionPath: '/api/:version/suggestions/:id/apply', commitPipelinesPath: '/:project_id/commit/:sha/pipelines', branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', createBranchPath: '/api/:version/projects/:id/repository/branches', @@ -183,6 +186,12 @@ const Api = { }); }, + applySuggestion(id) { + const url = Api.buildUrl(Api.applySuggestionPath).replace(':id', encodeURIComponent(id)); + + return axios.put(url); + }, + commitPipelines(projectId, sha) { const encodedProjectId = projectId .split('/') @@ -254,6 +263,20 @@ const Api = { }); }, + user(id, options) { + const url = Api.buildUrl(this.userPath).replace(':id', encodeURIComponent(id)); + return axios.get(url, { + params: options, + }); + }, + + userStatus(id, options) { + const url = Api.buildUrl(this.userStatusPath).replace(':id', encodeURIComponent(id)); + return axios.get(url, { + params: options, + }); + }, + branches(id, query = '', options = {}) { const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id)); @@ -276,7 +299,7 @@ const Api = { }, postUserStatus({ emoji, message }) { - const url = Api.buildUrl(this.userStatusPath); + const url = Api.buildUrl(this.userPostStatusPath); return axios.put(url, { emoji, diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index a2d4331b6d1..fc9286d15e6 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -3,6 +3,7 @@ import syntaxHighlight from '~/syntax_highlight'; import renderMath from './render_math'; import renderMermaid from './render_mermaid'; import highlightCurrentUser from './highlight_current_user'; +import initUserPopovers from '../../user_popovers'; // Render GitLab flavoured Markdown // @@ -13,6 +14,7 @@ $.fn.renderGFM = function renderGFM() { renderMath(this.find('.js-render-math')); renderMermaid(this.find('.js-render-mermaid')); highlightCurrentUser(this.find('.gfm-project_member').get()); + initUserPopovers(this.find('.gfm-project_member').get()); return this; }; diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index 15937b1091a..e038198e6f0 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -15,6 +15,16 @@ export default { type: String, required: true, }, + cssClass: { + type: String, + required: false, + default: '', + }, + tooltipPlacement: { + type: String, + required: false, + default: 'bottom', + }, }, computed: { title() { @@ -66,15 +76,13 @@ export default { <template> <span> - <span ref="issueDueDate" class="board-card-info card-number"> - <icon - :class="{ 'text-danger': isPastDue, 'board-card-info-icon': true }" - name="calendar" - /><time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{ + <span ref="issueDueDate" :class="cssClass" class="board-card-info card-number"> + <icon :class="{ 'text-danger': isPastDue, 'board-card-info-icon': true }" name="calendar" /> + <time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{ body }}</time> </span> - <gl-tooltip :target="() => $refs.issueDueDate" placement="bottom"> + <gl-tooltip :target="() => $refs.issueDueDate" :placement="tooltipPlacement"> <span class="bold">{{ __('Due date') }}</span> <br /> <span :class="{ 'text-danger-muted': isPastDue }">{{ title }}</span> </gl-tooltip> diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index bf9244df7f7..7e5aca88984 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -42,6 +42,16 @@ export default { type: Object, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, + changesEmptyStateIllustration: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -196,6 +206,7 @@ export default { v-for="file in diffFiles" :key="file.newPath" :file="file" + :help-page-path="helpPagePath" :can-current-user-fork="canCurrentUserFork" /> </template> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 11cc4c09fed..ac963f2971e 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -23,6 +23,11 @@ export default { type: Object, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { ...mapState({ @@ -74,11 +79,13 @@ export default { v-if="isInlineView" :diff-file="diffFile" :diff-lines="diffFile.highlighted_diff_lines || []" + :help-page-path="helpPagePath" /> <parallel-diff-view v-if="isParallelView" :diff-file="diffFile" :diff-lines="diffFile.parallel_diff_lines || []" + :help-page-path="helpPagePath" /> </template> <diff-viewer diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index bee29b04e92..b2021cd6061 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -13,6 +13,11 @@ export default { type: Array, required: true, }, + line: { + type: Object, + required: false, + default: null, + }, shouldCollapseDiscussions: { type: Boolean, required: false, @@ -23,6 +28,11 @@ export default { required: false, default: false, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, methods: { ...mapActions(['toggleDiscussion']), @@ -72,6 +82,8 @@ export default { :render-diff-file="false" :always-expanded="true" :discussions-by-diff-order="true" + :line="line" + :help-page-path="helpPagePath" @noteDeleted="deleteNoteHandler" > <span v-if="renderAvatarBadge" slot="avatar-badge" class="badge badge-pill"> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 3b2a0d156ca..449f7007077 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -4,6 +4,7 @@ import _ from 'underscore'; import { __, sprintf } from '~/locale'; import createFlash from '~/flash'; import { GlLoadingIcon } from '@gitlab/ui'; +import eventHub from '../../notes/event_hub'; import DiffFileHeader from './diff_file_header.vue'; import DiffContent from './diff_content.vue'; @@ -22,6 +23,11 @@ export default { type: Boolean, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -75,6 +81,9 @@ export default { } }, }, + created() { + eventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.handleLoadCollapsedDiff); + }, methods: { ...mapActions('diffs', ['loadCollapsedDiff', 'assignDiscussionsToDiff']), handleToggle() { @@ -160,6 +169,7 @@ export default { v-if="!isCollapsed && file.renderIt" :class="{ hidden: isCollapsed || file.too_large }" :diff-file="file" + :help-page-path="helpPagePath" /> <gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" /> <div v-else-if="showExpandMessage" class="nothing-here-block diff-collapsed"> 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 9fd02acbd6e..e7569ba7b84 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -94,6 +94,7 @@ export default { ref="noteForm" :is-editing="true" :line-code="line.line_code" + :line="line" save-button-title="Comment" class="diff-comment-form" @cancelForm="handleCancelCommentForm" 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 aa40b24950a..814ee0b7c02 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue @@ -16,6 +16,11 @@ export default { type: String, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { className() { @@ -38,7 +43,12 @@ export default { <tr v-if="shouldRender" :class="className" class="notes_holder"> <td class="notes_content" colspan="3"> <div class="content"> - <diff-discussions v-if="line.discussions.length" :discussions="line.discussions" /> + <diff-discussions + v-if="line.discussions.length" + :line="line" + :discussions="line.discussions" + :help-page-path="helpPagePath" + /> <diff-line-note-form v-if="line.hasForm" :diff-file-hash="diffFileHash" diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index 6a0ce760e6d..9310e2b7ca9 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -17,6 +17,11 @@ export default { type: Array, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { ...mapGetters('diffs', ['commitId']), @@ -47,6 +52,7 @@ export default { :key="`icr-${index}`" :diff-file-hash="diffFile.file_hash" :line="line" + :help-page-path="helpPagePath" /> </template> </tbody> 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 b98463d3dd3..a65cf025cde 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue @@ -20,6 +20,11 @@ export default { type: Number, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { hasExpandedDiscussionOnLeft() { @@ -87,6 +92,8 @@ export default { <diff-discussions v-if="line.left.discussions.length" :discussions="line.left.discussions" + :line="line.left" + :help-page-path="helpPagePath" /> </div> <diff-line-note-form @@ -102,6 +109,8 @@ export default { <diff-discussions v-if="line.right.discussions.length" :discussions="line.right.discussions" + :line="line.right" + :help-page-path="helpPagePath" /> </div> <diff-line-note-form diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index 9a6e0e82529..e6bc0daebb3 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -17,6 +17,11 @@ export default { type: Array, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { ...mapGetters('diffs', ['commitId']), @@ -49,6 +54,7 @@ export default { :line="line" :diff-file-hash="diffFile.file_hash" :line-index="index" + :help-page-path="helpPagePath" /> </template> </tbody> diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 06ef4207d85..41e6bd81072 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -16,6 +16,7 @@ export default function initDiffsApp(store) { return { endpoint: dataset.endpoint, projectPath: dataset.projectPath, + helpPagePath: dataset.helpPagePath, currentUser: JSON.parse(dataset.currentUserData) || {}, }; }, @@ -30,6 +31,7 @@ export default function initDiffsApp(store) { endpoint: this.endpoint, currentUser: this.currentUser, projectPath: this.projectPath, + helpPagePath: this.helpPagePath, shouldShow: this.activeTab === 'diffs', }, }); diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 952963e0711..00a4bb6d3a3 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -3,8 +3,9 @@ import axios from '~/lib/utils/axios_utils'; import Cookies from 'js-cookie'; import createFlash from '~/flash'; import { s__ } from '~/locale'; -import { handleLocationHash, historyPushState } from '~/lib/utils/common_utils'; +import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils'; import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility'; +import eventHub from '../../notes/event_hub'; import { getDiffPositionByLineCode, getNoteFormData } from './utils'; import * as types from './mutation_types'; import { @@ -53,6 +54,10 @@ export const assignDiscussionsToDiff = ( diffPositionByLineCode, }); }); + + Vue.nextTick(() => { + eventHub.$emit('scrollToDiscussion'); + }); }; export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => { @@ -60,6 +65,27 @@ export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => { commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash: file_hash, lineCode: line_code, id }); }; +export const renderFileForDiscussionId = ({ commit, rootState, state }, discussionId) => { + const discussion = rootState.notes.discussions.find(d => d.id === discussionId); + + if (discussion) { + const file = state.diffFiles.find(f => f.file_hash === discussion.diff_file.file_hash); + + if (file) { + if (!file.renderIt) { + commit(types.RENDER_FILE, file); + } + + if (file.collapsed) { + eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`); + scrollToElement(document.getElementById(file.file_hash)); + } else { + eventHub.$emit('scrollToDiscussion'); + } + } + } +}; + export const startRenderDiffsQueue = ({ state, commit }) => { const checkItem = () => new Promise(resolve => { diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 331fb052371..2ea884d1293 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -123,22 +123,23 @@ export default { diffPosition: diffPositionByLineCode[line.line_code], latestDiff, }); + const mapDiscussions = (line, extraCheck = () => true) => ({ + ...line, + discussions: extraCheck() + ? line.discussions + .filter(() => !line.discussions.some(({ id }) => discussion.id === id)) + .concat(lineCheck(line) ? discussion : line.discussions) + : [], + }); state.diffFiles = state.diffFiles.map(diffFile => { if (diffFile.file_hash === fileHash) { const file = { ...diffFile }; if (file.highlighted_diff_lines) { - file.highlighted_diff_lines = file.highlighted_diff_lines.map(line => { - if (!line.discussions.some(({ id }) => discussion.id === id) && lineCheck(line)) { - return { - ...line, - discussions: line.discussions.concat(discussion), - }; - } - - return line; - }); + file.highlighted_diff_lines = file.highlighted_diff_lines.map(line => + mapDiscussions(line), + ); } if (file.parallel_diff_lines) { @@ -148,20 +149,8 @@ export default { if (left || right) { return { - left: { - ...line.left, - discussions: - left && !line.left.discussions.some(({ id }) => id === discussion.id) - ? line.left.discussions.concat(discussion) - : (line.left && line.left.discussions) || [], - }, - right: { - ...line.right, - discussions: - right && !left && !line.right.discussions.some(({ id }) => id === discussion.id) - ? line.right.discussions.concat(discussion) - : (line.right && line.right.discussions) || [], - }, + left: line.left ? mapDiscussions(line.left) : null, + right: line.right ? mapDiscussions(line.right, () => !left) : null, }; } @@ -170,7 +159,7 @@ export default { } if (!file.parallel_diff_lines || !file.highlighted_diff_lines) { - file.discussions = file.discussions.concat(discussion); + file.discussions = (file.discussions || []).concat(discussion); } return file; @@ -180,7 +169,7 @@ export default { }); }, - [types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode, id }) { + [types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode }) { const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash); if (selectedFile) { if (selectedFile.parallel_diff_lines) { @@ -193,7 +182,7 @@ export default { const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right'; Object.assign(targetLine[side], { - discussions: [], + discussions: targetLine[side].discussions.filter(discussion => discussion.notes.length), }); } } @@ -205,14 +194,14 @@ export default { if (targetInlineLine) { Object.assign(targetInlineLine, { - discussions: [], + discussions: targetInlineLine.discussions.filter(discussion => discussion.notes.length), }); } } if (selectedFile.discussions && selectedFile.discussions.length) { selectedFile.discussions = selectedFile.discussions.filter( - discussion => discussion.id !== id, + discussion => discussion.notes.length, ); } } diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js index eddaeda9578..000157efad0 100644 --- a/app/assets/javascripts/image_diff/helpers/badge_helper.js +++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js @@ -12,7 +12,7 @@ export function createImageBadge(noteId, { x, y }, classNames = []) { } export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) { - const buttonEl = createImageBadge(noteId, coordinate, ['badge']); + const buttonEl = createImageBadge(noteId, coordinate, ['badge', 'badge-pill']); buttonEl.innerText = badgeText; containerEl.appendChild(buttonEl); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 040d0bc659e..9e22cdc04e9 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -192,8 +192,12 @@ export const contentTop = () => { 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 diffFileTitleBar = + (diffFileLargeEnoughScreen && $('.diff-file .file-title-flex-parent:visible').height()) || 0; - return perfBar + mrTabsHeight + headerHeight + diffFilesChanged; + return perfBar + mrTabsHeight + headerHeight + diffFilesChanged + diffFileTitleBar; }; export const scrollToElement = element => { diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 3618c6af7e2..c095a017866 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -39,7 +39,14 @@ function blockTagText(text, textArea, blockTag, selected) { } } -function moveCursor({ textArea, tag, positionBetweenTags, removedLastNewLine, select }) { +function moveCursor({ + textArea, + tag, + cursorOffset, + positionBetweenTags, + removedLastNewLine, + select, +}) { var pos; if (!textArea.setSelectionRange) { return; @@ -61,11 +68,24 @@ function moveCursor({ textArea, tag, positionBetweenTags, removedLastNewLine, se pos -= 1; } + if (cursorOffset) { + pos -= cursorOffset; + } + return textArea.setSelectionRange(pos, pos); } } -export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select }) { +export function insertMarkdownText({ + textArea, + text, + tag, + cursorOffset, + blockTag, + selected, + wrap, + select, +}) { var textToInsert, selectedSplit, startChar, @@ -154,20 +174,30 @@ export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wr return moveCursor({ textArea, tag: tag.replace(textPlaceholder, selected), + cursorOffset, positionBetweenTags: wrap && selected.length === 0, removedLastNewLine, select, }); } -function updateText({ textArea, tag, blockTag, wrap, select }) { +function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) { var $textArea, selected, text; $textArea = $(textArea); textArea = $textArea.get(0); text = $textArea.val(); - selected = selectedText(text, textArea); + selected = selectedText(text, textArea) || tagContent; $textArea.focus(); - return insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select }); + return insertMarkdownText({ + textArea, + text, + tag, + cursorOffset, + blockTag, + selected, + wrap, + select, + }); } export function addMarkdownListeners(form) { @@ -178,9 +208,11 @@ export function addMarkdownListeners(form) { return updateText({ textArea: $this.closest('.md-area').find('textarea'), tag: $this.data('mdTag'), + cursorOffset: $this.data('mdCursorOffset'), blockTag: $this.data('mdBlock'), wrap: !$this.data('mdPrepend'), select: $this.data('mdSelect'), + tagContent: $this.data('mdTagContent').toString(), }); }); } diff --git a/app/assets/javascripts/lib/utils/users_cache.js b/app/assets/javascripts/lib/utils/users_cache.js index c0d45e017b4..9f980fd4899 100644 --- a/app/assets/javascripts/lib/utils/users_cache.js +++ b/app/assets/javascripts/lib/utils/users_cache.js @@ -22,6 +22,34 @@ class UsersCache extends Cache { }); // missing catch is intentional, error handling depends on use case } + + retrieveById(userId) { + if (this.hasData(userId) && this.get(userId).username) { + return Promise.resolve(this.get(userId)); + } + + return Api.user(userId).then(({ data }) => { + this.internalStorage[userId] = data; + return data; + }); + // missing catch is intentional, error handling depends on use case + } + + retrieveStatusById(userId) { + if (this.hasData(userId) && this.get(userId).status) { + return Promise.resolve(this.get(userId).status); + } + + return Api.userStatus(userId).then(({ data }) => { + if (!this.hasData(userId)) { + this.internalStorage[userId] = {}; + } + this.internalStorage[userId].status = data; + + return data; + }); + // missing catch is intentional, error handling depends on use case + } } export default new UsersCache(); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index a88b575ad99..c866e8d180a 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -30,6 +30,7 @@ import initUsagePingConsent from './usage_ping_consent'; import initPerformanceBar from './performance_bar'; import initSearchAutocomplete from './search_autocomplete'; import GlFieldErrors from './gl_field_errors'; +import initUserPopovers from './user_popovers'; // expose jQuery as global (TODO: remove these) window.jQuery = jQuery; @@ -78,6 +79,7 @@ document.addEventListener('DOMContentLoaded', () => { initTodoToggle(); initLogoAnimation(); initUsagePingConsent(); + initUserPopovers(); if (document.querySelector('.search')) initSearchAutocomplete(); if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' }); diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue new file mode 100644 index 00000000000..12224e36ba2 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -0,0 +1,97 @@ +<script> +import { GlAreaChart } from '@gitlab/ui'; +import dateFormat from 'dateformat'; + +export default { + components: { + GlAreaChart, + }, + props: { + graphData: { + type: Object, + required: true, + validator(data) { + return ( + 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 + ); + }, + }, + }, + computed: { + chartData() { + return this.graphData.queries.reduce((accumulator, query) => { + const xLabel = `${query.unit}`; + accumulator[xLabel] = {}; + query.result.forEach(res => + res.values.forEach(v => { + accumulator[xLabel][v.time.toISOString()] = v.value; + }), + ); + return accumulator; + }, {}); + }, + chartOptions() { + return { + xAxis: { + name: 'Time', + type: 'time', + axisLabel: { + formatter: date => dateFormat(date, 'h:MMtt'), + }, + nameTextStyle: { + padding: [18, 0, 0, 0], + }, + }, + yAxis: { + name: this.graphData.y_label, + axisLabel: { + formatter: value => value.toFixed(3), + }, + nameTextStyle: { + padding: [0, 0, 36, 0], + }, + }, + legend: { + formatter: this.xAxisLabel, + }, + }; + }, + xAxisLabel() { + return this.graphData.queries.map(query => query.label).join(', '); + }, + }, + methods: { + formatTooltipText(params) { + const [date, value] = params; + return [dateFormat(date, 'dd mmm yyyy, h:MMtt'), value.toFixed(3)]; + }, + onCreated(chart) { + this.$emit('created', chart); + }, + }, +}; +</script> + +<template> + <div class="prometheus-graph"> + <div class="prometheus-graph-header"> + <h5 class="prometheus-graph-title">{{ graphData.title }}</h5> + <div class="prometheus-graph-widgets"><slot></slot></div> + </div> + <gl-area-chart + :data="chartData" + :option="chartOptions" + :format-tooltip-text="formatTooltipText" + @created="onCreated" + /> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 218c508a608..2d9c5050c9b 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -4,6 +4,7 @@ import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import Flash from '../../flash'; import MonitoringService from '../services/monitoring_service'; +import MonitorAreaChart from './charts/area.vue'; import GraphGroup from './graph_group.vue'; import Graph from './graph.vue'; import EmptyState from './empty_state.vue'; @@ -12,6 +13,7 @@ import eventHub from '../event_hub'; export default { components: { + MonitorAreaChart, Graph, GraphGroup, EmptyState, @@ -102,6 +104,9 @@ export default { }; }, computed: { + graphComponent() { + return gon.features && gon.features.areaChart ? MonitorAreaChart : Graph; + }, forceRedraw() { return this.elWidth; }, @@ -207,7 +212,8 @@ export default { :name="groupData.group" :show-panels="showPanels" > - <graph + <component + :is="graphComponent" v-for="(graphData, graphIndex) in groupData.metrics" :key="graphIndex" :graph-data="graphData" @@ -220,7 +226,7 @@ export default { > <!-- EE content --> {{ null }} - </graph> + </component> </graph-group> </div> <empty-state diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index 1c98683c597..e4d72eb8318 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -33,6 +33,7 @@ export default function initMrNotes() { noteableData, currentUserData: JSON.parse(notesDataset.currentUserData), notesData: JSON.parse(notesDataset.notesData), + helpPagePath: notesDataset.helpPagePath, }; }, computed: { @@ -71,6 +72,7 @@ export default function initMrNotes() { notesData: this.notesData, userData: this.currentUserData, shouldShow: this.activeTab === 'show', + helpPagePath: this.helpPagePath, }, }); }, diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index c0bee600181..bcf5d334da4 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -1,10 +1,12 @@ <script> +import { mapActions } from 'vuex'; import $ from 'jquery'; import noteEditedText from './note_edited_text.vue'; import noteAwardsList from './note_awards_list.vue'; import noteAttachment from './note_attachment.vue'; import noteForm from './note_form.vue'; import autosave from '../mixins/autosave'; +import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; export default { components: { @@ -12,6 +14,7 @@ export default { noteAwardsList, noteAttachment, noteForm, + Suggestions, }, mixins: [autosave], props: { @@ -19,6 +22,11 @@ export default { type: Object, required: true, }, + line: { + type: Object, + required: false, + default: null, + }, canEdit: { type: Boolean, required: true, @@ -28,11 +36,22 @@ export default { required: false, default: false, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { noteBody() { return this.note.note; }, + hasSuggestion() { + return this.note.suggestions && this.note.suggestions.length; + }, + lineType() { + return this.line ? this.line.type : null; + }, }, mounted() { this.renderGFM(); @@ -53,6 +72,7 @@ export default { } }, methods: { + ...mapActions(['submitSuggestion']), renderGFM() { $(this.$refs['note-body']).renderGFM(); }, @@ -62,19 +82,35 @@ export default { formCancelHandler(shouldConfirm, isDirty) { this.$emit('cancelForm', shouldConfirm, isDirty); }, + applySuggestion({ suggestionId, flashContainer, callback }) { + const { discussion_id: discussionId, id: noteId } = this.note; + + this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer, callback }); + }, }, }; </script> <template> <div ref="note-body" :class="{ 'js-task-list-container': canEdit }" class="note-body"> - <div class="note-text md" v-html="note.note_html"></div> + <suggestions + v-if="hasSuggestion && !isEditing" + :suggestions="note.suggestions" + :note-html="note.note_html" + :line-type="lineType" + :help-page-path="helpPagePath" + @apply="applySuggestion" + /> + <div v-else class="note-text md" v-html="note.note_html"></div> <note-form v-if="isEditing" ref="noteForm" :is-editing="isEditing" :note-body="noteBody" :note-id="note.id" + :line="line" + :note="note" + :help-page-path="helpPagePath" :markdown-version="note.cached_markdown_version" @handleFormUpdate="handleFormUpdate" @cancelForm="formCancelHandler" diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue index 3d3dbbd7fe1..15ce49d7c31 100644 --- a/app/assets/javascripts/notes/components/note_edited_text.vue +++ b/app/assets/javascripts/notes/components/note_edited_text.vue @@ -39,7 +39,10 @@ export default { <div :class="className"> {{ actionText }} <template v-if="editedBy"> - by <a :href="editedBy.path" class="js-vue-author author-link"> {{ editedBy.name }} </a> + by + <a :href="editedBy.path" :data-user-id="editedBy.id" class="js-user-link author-link"> + {{ editedBy.name }} + </a> </template> {{ actionDetailText }} <time-ago-tooltip :time="editedAt" tooltip-placement="bottom" /> diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 95164183ccb..9b7f3d3588d 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -1,4 +1,5 @@ <script> +import { mergeUrlParams } from '~/lib/utils/url_utility'; import { mapGetters, mapActions } from 'vuex'; import eventHub from '../event_hub'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; @@ -53,6 +54,21 @@ export default { required: false, default: false, }, + line: { + type: Object, + required: false, + default: null, + }, + note: { + type: Object, + required: false, + default: null, + }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -79,7 +95,8 @@ export default { return '#'; }, markdownPreviewPath() { - return this.getNoteableDataByProp('preview_note_path'); + const notable = this.getNoteableDataByProp('preview_note_path'); + return mergeUrlParams({ preview_suggestions: true }, notable); }, markdownDocsPath() { return this.getNotesDataByProp('markdownDocsPath'); @@ -93,6 +110,18 @@ export default { isDisabled() { return !this.updatedNoteBody.length || this.isSubmitting; }, + discussionNote() { + const discussionNote = this.discussion.id + ? this.getDiscussionLastNote(this.discussion) + : this.note; + return discussionNote || {}; + }, + canSuggest() { + return ( + this.getNoteableData.can_receive_suggestion && + (this.line && this.line.can_receive_suggestion) + ); + }, }, watch: { noteBody() { @@ -171,7 +200,11 @@ export default { :markdown-docs-path="markdownDocsPath" :markdown-version="markdownVersion" :quick-actions-docs-path="quickActionsDocsPath" + :line="line" + :note="discussionNote" + :can-suggest="canSuggest" :add-spacing-classes="false" + :help-page-path="helpPagePath" > <textarea id="note_note" diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index e1a58e7cb26..7b39901024d 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -73,7 +73,14 @@ export default { {{ __('Toggle discussion') }} </button> </div> - <a v-if="hasAuthor" v-once :href="author.path"> + <a + v-if="hasAuthor" + v-once + :href="author.path" + class="js-user-link" + :data-user-id="author.id" + :data-username="author.username" + > <span class="note-header-author-name">{{ 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> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index f4991a41325..e540e7326fd 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -49,6 +49,11 @@ export default { type: Object, required: true, }, + line: { + type: Object, + required: false, + default: null, + }, renderDiffFile: { type: Boolean, required: false, @@ -64,6 +69,11 @@ export default { required: false, default: false, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { const { diff_discussion: isDiffDiscussion, resolved } = this.discussion; @@ -81,6 +91,7 @@ export default { 'nextUnresolvedDiscussionId', 'unresolvedDiscussionsCount', 'hasUnresolvedDiscussions', + 'showJumpToNextDiscussion', ]), author() { return this.initialDiscussion.author; @@ -121,6 +132,12 @@ export default { resolvedText() { return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved'); }, + shouldShowJumpToNextDiscussion() { + return this.showJumpToNextDiscussion( + this.discussion.id, + this.discussionsByDiffOrder ? 'diff' : 'discussion', + ); + }, shouldRenderDiffs() { return this.discussion.diff_discussion && this.renderDiffFile; }, @@ -183,6 +200,13 @@ export default { false, ); }, + diffLine() { + if (this.discussion.diff_discussion && this.discussion.truncated_diff_lines) { + return this.discussion.truncated_diff_lines.slice(-1)[0]; + } + + return this.line; + }, }, watch: { isReplying() { @@ -346,6 +370,8 @@ Please check your network connection and try again.`; <component :is="componentName(initialDiscussion)" :note="componentData(initialDiscussion)" + :line="line" + :help-page-path="helpPagePath" @handleDeleteNote="deleteNoteHandler" > <slot slot="avatar-badge" name="avatar-badge"></slot> @@ -362,6 +388,8 @@ Please check your network connection and try again.`; v-for="note in replies" :key="note.id" :note="componentData(note)" + :help-page-path="helpPagePath" + :line="line" @handleDeleteNote="deleteNoteHandler" /> </template> @@ -372,6 +400,8 @@ Please check your network connection and try again.`; 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> @@ -379,7 +409,7 @@ Please check your network connection and try again.`; </template> </ul> <div - v-if="!isRepliesCollapsed" + v-if="!isRepliesCollapsed || !hasReplies" :class="{ 'is-replying': isReplying }" class="discussion-reply-holder" > @@ -418,7 +448,7 @@ Please check your network connection and try again.`; <icon name="issue-new" /> </a> </div> - <div v-if="hasUnresolvedDiscussions" class="btn-group" role="group"> + <div v-if="shouldShowJumpToNextDiscussion" class="btn-group" role="group"> <button v-gl-tooltip class="btn btn-default discussion-next-btn" @@ -436,6 +466,7 @@ Please check your network connection and try again.`; ref="noteForm" :discussion="discussion" :is-editing="false" + :line="diffLine" save-button-title="Comment" @handleFormUpdate="saveReply" @cancelForm="cancelReplyForm" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index a17be51353e..57e9c40bd61 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -27,6 +27,16 @@ export default { type: Object, required: true, }, + line: { + type: Object, + required: false, + default: null, + }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -220,8 +230,10 @@ export default { <note-body ref="noteBody" :note="note" + :line="line" :can-edit="note.current_user.can_edit" :is-editing="isEditing" + :help-page-path="helpPagePath" @handleFormUpdate="formUpdateHandler" @cancelForm="formCancelHandler" /> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 0a87cd7ef1f..f3fcfdfda05 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -12,6 +12,7 @@ import placeholderNote from '../../vue_shared/components/notes/placeholder_note. import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; +import initUserPopovers from '../../user_popovers'; export default { name: 'NotesApp', @@ -48,6 +49,11 @@ export default { required: false, default: 0, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -106,7 +112,10 @@ export default { } }, updated() { - this.$nextTick(() => highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'))); + this.$nextTick(() => { + highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member')); + initUserPopovers(this.$el.querySelectorAll('.js-user-link')); + }); }, methods: { ...mapActions([ @@ -202,6 +211,7 @@ export default { :key="discussion.id" :discussion="discussion" :render-diff-file="true" + :help-page-path="helpPagePath" /> </template> </ul> diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index f7c4deee1f8..3d89d907777 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -1,29 +1,56 @@ import { scrollToElement } from '~/lib/utils/common_utils'; +import eventHub from '../../notes/event_hub'; export default { methods: { - jumpToDiscussion(id) { - if (id) { - const activeTab = window.mrTabs.currentAction; - const selector = - activeTab === 'diffs' - ? `ul.notes[data-discussion-id="${id}"]` - : `div.discussion[data-discussion-id="${id}"]`; - const el = document.querySelector(selector); + diffsJump(id) { + const selector = `ul.notes[data-discussion-id="${id}"]`; - if (activeTab === 'commits' || activeTab === 'pipelines') { - window.mrTabs.activateTab('show'); - } + eventHub.$once('scrollToDiscussion', () => { + const el = document.querySelector(selector); if (el) { - this.expandDiscussion({ discussionId: id }); - scrollToElement(el); + return true; } + + return false; + }); + + this.expandDiscussion({ discussionId: id }); + }, + discussionJump(id) { + const selector = `div.discussion[data-discussion-id="${id}"]`; + + const el = document.querySelector(selector); + + this.expandDiscussion({ discussionId: id }); + + if (el) { + scrollToElement(el); + + return true; } return false; }, + jumpToDiscussion(id) { + if (id) { + const activeTab = window.mrTabs.currentAction; + + if (activeTab === 'diffs') { + this.diffsJump(id); + } else if (activeTab === 'commits' || activeTab === 'pipelines') { + window.mrTabs.eventHub.$once('MergeRequestTabChange', () => { + setTimeout(() => this.discussionJump(id), 0); + }); + + window.mrTabs.tabShown('show'); + } else { + this.discussionJump(id); + } + } + }, }, }; diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js index 47a6f07cce2..237e70c0a4c 100644 --- a/app/assets/javascripts/notes/services/notes_service.js +++ b/app/assets/javascripts/notes/services/notes_service.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import Api from '~/api'; import VueResource from 'vue-resource'; import * as constants from '../constants'; @@ -44,4 +45,7 @@ export default { toggleIssueState(endpoint, data) { return Vue.http.put(endpoint, data); }, + applySuggestion(id) { + return Api.applySuggestion(id); + }, }; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index b4befdd6e4a..65f85314fa0 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -17,7 +17,13 @@ import { __ } from '~/locale'; let eTagPoll; -export const expandDiscussion = ({ commit }, data) => commit(types.EXPAND_DISCUSSION, data); +export const expandDiscussion = ({ commit, dispatch }, data) => { + if (data.discussionId) { + dispatch('diffs/renderFileForDiscussionId', data.discussionId, { root: true }); + } + + commit(types.EXPAND_DISCUSSION, data); +}; export const collapseDiscussion = ({ commit }, data) => commit(types.COLLAPSE_DISCUSSION, data); @@ -399,5 +405,25 @@ export const startTaskList = ({ dispatch }) => export const updateResolvableDiscussonsCounts = ({ commit }) => commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS); +export const submitSuggestion = ( + { commit }, + { discussionId, noteId, suggestionId, flashContainer, callback }, +) => { + service + .applySuggestion(suggestionId) + .then(() => { + commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }); + callback(); + }) + .catch(() => { + Flash( + __('Something went wrong while applying the suggestion. Please try again.'), + 'alert', + flashContainer, + ); + callback(); + }); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 2ed8aac059a..0ffc0cb2593 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -57,6 +57,17 @@ export const unresolvedDiscussionsCount = state => state.unresolvedDiscussionsCo export const resolvableDiscussionsCount = state => state.resolvableDiscussionsCount; export const hasUnresolvedDiscussions = state => state.hasUnresolvedDiscussions; +export const showJumpToNextDiscussion = (state, getters) => (discussionId, mode = 'discussion') => { + const orderedDiffs = + mode !== 'discussion' + ? getters.unresolvedDiscussionsIdsByDiff + : getters.unresolvedDiscussionsIdsByDate; + + const indexOf = orderedDiffs.indexOf(discussionId); + + return indexOf !== -1 && indexOf < orderedDiffs.length - 1; +}; + export const isDiscussionResolved = (state, getters) => discussionId => getters.resolvedDiscussionsById[discussionId] !== undefined; @@ -104,7 +115,7 @@ export const unresolvedDiscussionsIdsByDate = (state, getters) => // line numbers. export const unresolvedDiscussionsIdsByDiff = (state, getters) => getters.allResolvableDiscussions - .filter(d => !d.resolved) + .filter(d => !d.resolved && d.active) .sort((a, b) => { if (!a.diff_file || !b.diff_file) { return 0; diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index b5fe8bdb1d3..887e6d22b06 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -20,6 +20,7 @@ export default () => ({ userData: {}, noteableData: { current_user: {}, + preview_note_path: 'path/to/preview', }, commentsDisabled: false, resolvableDiscussionsCount: 0, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 9c68ab67a8c..df943c155f4 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -16,6 +16,7 @@ export const SET_DISCUSSION_DIFF_LINES = 'SET_DISCUSSION_DIFF_LINES'; export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE'; export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE'; export const DISABLE_COMMENTS = 'DISABLE_COMMENTS'; +export const APPLY_SUGGESTION = 'APPLY_SUGGESTION'; // DISCUSSION export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index dfce698e56f..8992454be2e 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -22,6 +22,7 @@ export default { if (isDiscussion && isInMRPage()) { noteData.resolvable = note.resolvable; noteData.resolved = false; + noteData.active = true; noteData.resolve_path = note.resolve_path; noteData.resolve_with_issue_path = note.resolve_with_issue_path; noteData.diff_discussion = false; @@ -196,6 +197,17 @@ export default { } }, + [types.APPLY_SUGGESTION](state, { noteId, discussionId, suggestionId }) { + const noteObj = utils.findNoteObjectById(state.discussions, discussionId); + const comment = utils.findNoteObjectById(noteObj.notes, noteId); + + comment.suggestions = comment.suggestions.map(suggestion => ({ + ...suggestion, + applied: suggestion.applied || suggestion.id === suggestionId, + appliable: false, + })); + }, + [types.UPDATE_DISCUSSION](state, noteData) { const note = noteData; const selectedDiscussion = state.discussions.find(disc => disc.id === note.id); diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue index 6233fb169e9..9af5660f764 100644 --- a/app/assets/javascripts/registry/components/app.vue +++ b/app/assets/javascripts/registry/components/app.vue @@ -1,15 +1,13 @@ <script> import { mapGetters, mapActions } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; -import Flash from '../../flash'; import store from '../stores'; -import collapsibleContainer from './collapsible_container.vue'; -import { errorMessages, errorMessagesTypes } from '../constants'; +import CollapsibleContainer from './collapsible_container.vue'; export default { name: 'RegistryListApp', components: { - collapsibleContainer, + CollapsibleContainer, GlLoadingIcon, }, props: { @@ -26,7 +24,7 @@ export default { this.setMainEndpoint(this.endpoint); }, mounted() { - this.fetchRepos().catch(() => Flash(errorMessages[errorMessagesTypes.FETCH_REPOS])); + this.fetchRepos(); }, methods: { ...mapActions(['setMainEndpoint', 'fetchRepos']), @@ -38,9 +36,9 @@ export default { <gl-loading-icon v-if="isLoading" :size="3" /> <collapsible-container - v-for="(item, index) in repos" + v-for="item in repos" v-else-if="!isLoading && repos.length" - :key="index" + :key="item.id" :repo="item" /> diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue index 6514c05a9c7..5451c61026c 100644 --- a/app/assets/javascripts/registry/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -1,22 +1,24 @@ <script> import { mapActions } from 'vuex'; -import { GlLoadingIcon } from '@gitlab/ui'; -import Flash from '../../flash'; -import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import tooltip from '../../vue_shared/directives/tooltip'; -import tableRegistry from './table_registry.vue'; +import { GlLoadingIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import createFlash from '../../flash'; +import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; +import Icon from '../../vue_shared/components/icon.vue'; +import TableRegistry from './table_registry.vue'; import { errorMessages, errorMessagesTypes } from '../constants'; import { __ } from '../../locale'; export default { name: 'CollapsibeContainerRegisty', components: { - clipboardButton, - tableRegistry, + ClipboardButton, + TableRegistry, GlLoadingIcon, + GlButton, + Icon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { repo: { @@ -29,30 +31,30 @@ export default { isOpen: false, }; }, + computed: { + iconName() { + return this.isOpen ? 'angle-up' : 'angle-right'; + }, + }, methods: { ...mapActions(['fetchRepos', 'fetchList', 'deleteRepo']), - toggleRepo() { this.isOpen = !this.isOpen; if (this.isOpen) { - this.fetchList({ repo: this.repo }).catch(() => - this.showError(errorMessagesTypes.FETCH_REGISTRY), - ); + this.fetchList({ repo: this.repo }); } }, - handleDeleteRepository() { this.deleteRepo(this.repo) .then(() => { - Flash(__('This container registry has been scheduled for deletion.'), 'notice'); + createFlash(__('This container registry has been scheduled for deletion.'), 'notice'); this.fetchRepos(); }) .catch(() => this.showError(errorMessagesTypes.DELETE_REPO)); }, - showError(message) { - Flash(errorMessages[message]); + createFlash(errorMessages[message]); }, }, }; @@ -61,18 +63,9 @@ export default { <template> <div class="container-image"> <div class="container-image-head"> - <button type="button" class="js-toggle-repo btn-link" @click="toggleRepo"> - <i - :class="{ - 'fa-chevron-right': !isOpen, - 'fa-chevron-up': isOpen, - }" - class="fa" - aria-hidden="true" - > - </i> - {{ repo.name }} - </button> + <gl-button class="js-toggle-repo btn-link align-baseline" @click="toggleRepo"> + <icon :name="iconName" /> {{ repo.name }} + </gl-button> <clipboard-button v-if="repo.location" @@ -82,17 +75,17 @@ export default { /> <div class="controls d-none d-sm-block float-right"> - <button + <gl-button v-if="repo.canDelete" - v-tooltip + v-gl-tooltip :title="s__('ContainerRegistry|Remove repository')" :aria-label="s__('ContainerRegistry|Remove repository')" - type="button" - class="js-remove-repo btn btn-danger" + class="js-remove-repo" + variant="danger" @click="handleDeleteRepository" > - <i class="fa fa-trash" aria-hidden="true"> </i> - </button> + <icon name="remove" /> + </gl-button> </div> </div> diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index 6735c3ff7cf..78c7671856a 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -1,21 +1,24 @@ <script> import { mapActions } from 'vuex'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { n__ } from '../../locale'; -import Flash from '../../flash'; -import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import tablePagination from '../../vue_shared/components/table_pagination.vue'; -import tooltip from '../../vue_shared/directives/tooltip'; +import createFlash from '../../flash'; +import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; +import TablePagination from '../../vue_shared/components/table_pagination.vue'; +import Icon from '../../vue_shared/components/icon.vue'; import timeagoMixin from '../../vue_shared/mixins/timeago'; import { errorMessages, errorMessagesTypes } from '../constants'; import { numberToHumanSize } from '../../lib/utils/number_utils'; export default { components: { - clipboardButton, - tablePagination, + ClipboardButton, + TablePagination, + GlButton, + Icon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, mixins: [timeagoMixin], props: { @@ -31,29 +34,24 @@ export default { }, methods: { ...mapActions(['fetchList', 'deleteRegistry']), - layers(item) { return item.layers ? n__('%d layer', '%d layers', item.layers) : ''; }, - formatSize(size) { return numberToHumanSize(size); }, - handleDeleteRegistry(registry) { this.deleteRegistry(registry) .then(() => this.fetchList({ repo: this.repo })) .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); }, - onPageChange(pageNumber) { this.fetchList({ repo: this.repo, page: pageNumber }).catch(() => this.showError(errorMessagesTypes.FETCH_REGISTRY), ); }, - showError(message) { - Flash(errorMessages[message]); + createFlash(errorMessages[message]); }, }, }; @@ -71,10 +69,9 @@ export default { </tr> </thead> <tbody> - <tr v-for="(item, i) in repo.list" :key="i"> + <tr v-for="item in repo.list" :key="item.tag"> <td> {{ item.tag }} - <clipboard-button v-if="item.location" :title="item.location" @@ -83,37 +80,34 @@ export default { /> </td> <td> - <span v-tooltip :title="item.revision" data-placement="bottom"> - {{ item.shortRevision }} - </span> + <span v-gl-tooltip.bottom :title="item.revision">{{ item.shortRevision }}</span> </td> <td> {{ formatSize(item.size) }} - <template v-if="item.size && item.layers"> - · - </template> + <template v-if="item.size && item.layers" + >·</template + > {{ layers(item) }} </td> <td> - <span v-tooltip :title="tooltipTitle(item.createdAt)" data-placement="bottom"> - {{ timeFormated(item.createdAt) }} - </span> + <span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">{{ + timeFormated(item.createdAt) + }}</span> </td> <td class="content"> - <button + <gl-button v-if="item.canDelete" - v-tooltip + v-gl-tooltip :title="s__('ContainerRegistry|Remove tag')" :aria-label="s__('ContainerRegistry|Remove tag')" - type="button" - class="js-delete-registry btn btn-danger d-none d-sm-block float-right" - data-container="body" + variant="danger" + class="js-delete-registry d-none d-sm-block float-right" @click="handleDeleteRegistry(item);" > - <i class="fa fa-trash" aria-hidden="true"> </i> - </button> + <icon name="remove" /> + </gl-button> </td> </tr> </tbody> diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js index a78aa90b7b5..51d057c62c1 100644 --- a/app/assets/javascripts/registry/stores/actions.js +++ b/app/assets/javascripts/registry/stores/actions.js @@ -1,39 +1,45 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; import * as types from './mutation_types'; - -Vue.use(VueResource); +import { errorMessages, errorMessagesTypes } from '../constants'; export const fetchRepos = ({ commit, state }) => { commit(types.TOGGLE_MAIN_LOADING); - return Vue.http + return axios .get(state.endpoint) - .then(res => res.json()) - .then(response => { + .then(({ data }) => { + commit(types.TOGGLE_MAIN_LOADING); + commit(types.SET_REPOS_LIST, data); + }) + .catch(() => { commit(types.TOGGLE_MAIN_LOADING); - commit(types.SET_REPOS_LIST, response); + createFlash(errorMessages[errorMessagesTypes.FETCH_REPOS]); }); }; export const fetchList = ({ commit }, { repo, page }) => { commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); - return Vue.http.get(repo.tagsPath, { params: { page } }).then(response => { - const { headers } = response; + return axios + .get(repo.tagsPath, { params: { page } }) + .then(response => { + const { headers, data } = response; - return response.json().then(resp => { commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); - commit(types.SET_REGISTRY_LIST, { repo, resp, headers }); + commit(types.SET_REGISTRY_LIST, { repo, resp: data, headers }); + }) + .catch(() => { + commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); + createFlash(errorMessages[errorMessagesTypes.FETCH_REGISTRY]); }); - }); }; // eslint-disable-next-line no-unused-vars -export const deleteRepo = ({ commit }, repo) => Vue.http.delete(repo.destroyPath); +export const deleteRepo = ({ commit }, repo) => axios.delete(repo.destroyPath); // eslint-disable-next-line no-unused-vars -export const deleteRegistry = ({ commit }, image) => Vue.http.delete(image.destroyPath); +export const deleteRegistry = ({ commit }, image) => axios.delete(image.destroyPath); export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data); export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING); diff --git a/app/assets/javascripts/registry/stores/index.js b/app/assets/javascripts/registry/stores/index.js index 78b67881210..1bb06bd6e81 100644 --- a/app/assets/javascripts/registry/stores/index.js +++ b/app/assets/javascripts/registry/stores/index.js @@ -3,36 +3,12 @@ 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 default new Vuex.Store({ - state: { - isLoading: false, - endpoint: '', // initial endpoint to fetch the repos list - /** - * Each object in `repos` has the following strucure: - * { - * name: String, - * isLoading: Boolean, - * tagsPath: String // endpoint to request the list - * destroyPath: String // endpoit to delete the repo - * list: Array // List of the registry images - * } - * - * Each registry image inside `list` has the following structure: - * { - * tag: String, - * revision: String - * shortRevision: String - * size: Number - * layers: Number - * createdAt: String - * destroyPath: String // endpoit to delete each image - * } - */ - repos: [], - }, + state: createState(), actions, getters, mutations, diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js index 69c051cd2d6..1ac699c538f 100644 --- a/app/assets/javascripts/registry/stores/mutations.js +++ b/app/assets/javascripts/registry/stores/mutations.js @@ -48,6 +48,7 @@ export default { [types.TOGGLE_REGISTRY_LIST_LOADING](state, list) { const listToUpdate = state.repos.find(el => el.id === list.id); + listToUpdate.isLoading = !listToUpdate.isLoading; }, }; diff --git a/app/assets/javascripts/registry/stores/state.js b/app/assets/javascripts/registry/stores/state.js new file mode 100644 index 00000000000..feeac10cbe1 --- /dev/null +++ b/app/assets/javascripts/registry/stores/state.js @@ -0,0 +1,26 @@ +export default () => ({ + isLoading: false, + endpoint: '', // initial endpoint to fetch the repos list + /** + * Each object in `repos` has the following strucure: + * { + * name: String, + * isLoading: Boolean, + * tagsPath: String // endpoint to request the list + * destroyPath: String // endpoit to delete the repo + * list: Array // List of the registry images + * } + * + * Each registry image inside `list` has the following structure: + * { + * tag: String, + * revision: String + * shortRevision: String + * size: Number + * layers: Number + * createdAt: String + * destroyPath: String // endpoit to delete each image + * } + */ + repos: [], +}); diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js new file mode 100644 index 00000000000..948f4d5e631 --- /dev/null +++ b/app/assets/javascripts/user_popovers.js @@ -0,0 +1,107 @@ +import Vue from 'vue'; + +import UsersCache from './lib/utils/users_cache'; +import UserPopover from './vue_shared/components/user_popover/user_popover.vue'; + +let renderedPopover; +let renderFn; + +const handleUserPopoverMouseOut = event => { + const { target } = event; + target.removeEventListener('mouseleave', handleUserPopoverMouseOut); + + if (renderFn) { + clearTimeout(renderFn); + } + if (renderedPopover) { + renderedPopover.$destroy(); + renderedPopover = null; + } +}; + +/** + * Adds a UserPopover component to the body, hands over as much data as the target element has in data attributes. + * loads based on data-user-id more data about a user from the API and sets it on the popover + */ +const handleUserPopoverMouseOver = event => { + const { target } = event; + // Add listener to actually remove it again + target.addEventListener('mouseleave', handleUserPopoverMouseOut); + + renderFn = setTimeout(() => { + // Helps us to use current markdown setup without maybe breaking or duplicating for now + if (target.dataset.user) { + target.dataset.userId = target.dataset.user; + // Removing titles so its not showing tooltips also + target.dataset.originalTitle = ''; + target.setAttribute('title', ''); + } + + const { userId, username, name, avatarUrl } = target.dataset; + const user = { + userId, + username, + name, + avatarUrl, + location: null, + bio: null, + organization: null, + status: null, + loaded: false, + }; + if (userId || username) { + const UserPopoverComponent = Vue.extend(UserPopover); + renderedPopover = new UserPopoverComponent({ + propsData: { + target, + user, + }, + }); + + renderedPopover.$mount(); + + UsersCache.retrieveById(userId) + .then(userData => { + if (!userData) { + return; + } + + Object.assign(user, { + avatarUrl: userData.avatar_url, + username: userData.username, + name: userData.name, + location: userData.location, + bio: userData.bio, + organization: userData.organization, + loaded: true, + }); + + UsersCache.retrieveStatusById(userId) + .then(status => { + if (!status) { + return; + } + + Object.assign(user, { + status, + }); + }) + .catch(() => { + throw new Error(`User status for "${userId}" could not be retrieved!`); + }); + }) + .catch(() => { + renderedPopover.$destroy(); + renderedPopover = null; + }); + } + }, 200); // 200ms delay so not every mouseover triggers Popover + API Call +}; + +export default elements => { + const userLinks = elements || [...document.querySelectorAll('.js-user-link')]; + + userLinks.forEach(el => { + el.addEventListener('mouseenter', handleUserPopoverMouseOver); + }); +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue index e3adc7f7af5..4b57693e8f1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue @@ -13,5 +13,7 @@ export default { </script> <template> - <div class="circle-icon-container append-right-default"><icon :name="name" /></div> + <div class="circle-icon-container append-right-default align-self-start align-self-lg-center"> + <icon :name="name" /> + </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue new file mode 100644 index 00000000000..7e79e63aa1e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue @@ -0,0 +1,94 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; + +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; + +export default { + components: { + UserAvatarLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + assignees: { + type: Array, + required: true, + }, + }, + data() { + return { + maxVisibleAssignees: 2, + maxAssigneeAvatars: 3, + maxAssignees: 99, + }; + }, + computed: { + countOverLimit() { + return this.assignees.length - this.maxVisibleAssignees; + }, + assigneesToShow() { + if (this.assignees.length > this.maxAssigneeAvatars) { + return this.assignees.slice(0, this.maxVisibleAssignees); + } + return this.assignees; + }, + assigneesCounterTooltip() { + const { countOverLimit, maxAssignees } = this; + const count = countOverLimit > maxAssignees ? maxAssignees : countOverLimit; + + return sprintf(__('%{count} more assignees'), { count }); + }, + shouldRenderAssigneesCounter() { + const assigneesCount = this.assignees.length; + if (assigneesCount <= this.maxAssigneeAvatars) { + return false; + } + + return assigneesCount > this.countOverLimit; + }, + assigneeCounterLabel() { + if (this.countOverLimit > this.maxAssignees) { + return `${this.maxAssignees}+`; + } + + return `+${this.countOverLimit}`; + }, + }, + methods: { + avatarUrlTitle(assignee) { + return sprintf(__('Avatar for %{assigneeName}'), { + assigneeName: assignee.name, + }); + }, + }, +}; +</script> +<template> + <div class="issue-assignees"> + <user-avatar-link + v-for="assignee in assigneesToShow" + :key="assignee.id" + :link-href="assignee.web_url" + :img-alt="avatarUrlTitle(assignee)" + :img-src="assignee.avatar_url" + :img-size="24" + class="js-no-trigger" + tooltip-placement="bottom" + > + <span class="js-assignee-tooltip"> + <span class="bold d-block">{{ __('Assignee') }}</span> {{ assignee.name }} + <span class="text-white-50">@{{ assignee.username }}</span> + </span> + </user-avatar-link> + <span + v-if="shouldRenderAssigneesCounter" + v-gl-tooltip + :title="assigneesCounterTooltip" + class="avatar-counter" + data-placement="bottom" + >{{ assigneeCounterLabel }}</span + > + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue new file mode 100644 index 00000000000..d5d967e25bf --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue @@ -0,0 +1,90 @@ +<script> +import { GlTooltip } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { timeFor, parsePikadayDate, dateInWords } from '~/lib/utils/datetime_utility'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + GlTooltip, + }, + mixins: [timeagoMixin], + props: { + milestone: { + type: Object, + 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: { + isMilestoneStarted() { + if (!this.milestoneStart) { + return false; + } + return Date.now() > this.milestoneStart; + }, + isMilestonePastDue() { + if (!this.milestoneDue) { + return false; + } + return Date.now() > this.milestoneDue; + }, + milestoneDatesAbsolute() { + if (this.milestoneDue) { + return `(${dateInWords(this.milestoneDue)})`; + } else if (this.milestoneStart) { + return `(${dateInWords(this.milestoneStart)})`; + } + return ''; + }, + milestoneDatesHuman() { + if (this.milestoneStart || this.milestoneDue) { + if (this.milestoneDue) { + return timeFor( + this.milestoneDue, + sprintf(__('Expired %{expiredOn}'), { + expiredOn: this.timeFormated(this.milestoneDue), + }), + ); + } + + return sprintf( + this.isMilestoneStarted ? __('Started %{startsIn}') : __('Starts %{startsIn}'), + { + startsIn: this.timeFormated(this.milestoneStart), + }, + ); + } + return ''; + }, + }, +}; +</script> +<template> + <div ref="milestoneDetails" class="issue-milestone-details"> + <icon :size="16" class="inline icon" name="clock" /> + <span class="milestone-title">{{ 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 /> + <span + v-if="milestoneStart || milestoneDue" + :class="{ + 'text-danger-muted': isMilestonePastDue, + 'text-tertiary': !isMilestonePastDue, + }" + ><span>{{ milestoneDatesHuman }}</span + ><br /><span>{{ milestoneDatesAbsolute }}</span> + </span> + </gl-tooltip> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 43def2673eb..2f7ed4a982c 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -1,17 +1,21 @@ <script> import $ from 'jquery'; +import _ from 'underscore'; import { __ } from '~/locale'; +import { stripHtml } from '~/lib/utils/text_utility'; import Flash from '../../../flash'; import GLForm from '../../../gl_form'; import markdownHeader from './header.vue'; import markdownToolbar from './toolbar.vue'; import icon from '../icon.vue'; +import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; export default { components: { markdownHeader, markdownToolbar, icon, + Suggestions, }, props: { markdownPreviewPath: { @@ -48,12 +52,33 @@ export default { required: false, default: true, }, + line: { + type: Object, + required: false, + default: null, + }, + note: { + type: Object, + required: false, + default: () => ({}), + }, + canSuggest: { + type: Boolean, + required: false, + default: false, + }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { return { markdownPreview: '', referencedCommands: '', referencedUsers: '', + hasSuggestion: false, markdownPreviewLoading: false, previewMarkdown: false, }; @@ -63,6 +88,39 @@ export default { const referencedUsersThreshold = 10; return this.referencedUsers.length >= referencedUsersThreshold; }, + lineContent() { + const FIRST_CHAR_REGEX = /^(\+|-)/; + const [firstSuggestion] = this.suggestions; + if (firstSuggestion) { + return firstSuggestion.from_content; + } + + if (this.line) { + const { rich_text: richText, text } = this.line; + + if (text) { + return text.replace(FIRST_CHAR_REGEX, ''); + } + + return _.unescape(stripHtml(richText).replace(/\n/g, '')); + } + + return ''; + }, + lineNumber() { + let lineNumber; + if (this.line) { + const { new_line: newLine, old_line: oldLine } = this.line; + lineNumber = newLine || oldLine; + } + return lineNumber; + }, + suggestions() { + return this.note.suggestions || []; + }, + lineType() { + return this.line ? this.line.type : ''; + }, }, mounted() { /* @@ -122,6 +180,7 @@ export default { if (data.references) { this.referencedCommands = data.references.commands; this.referencedUsers = data.references.users; + this.hasSuggestion = data.references.suggestions && data.references.suggestions.length; } this.$nextTick(() => { @@ -147,6 +206,8 @@ export default { > <markdown-header :preview-markdown="previewMarkdown" + :line-content="lineContent" + :can-suggest="canSuggest" @preview-markdown="showPreviewTab" @write-markdown="showWriteTab" /> @@ -163,19 +224,39 @@ export default { /> </div> </div> - <div - v-show="previewMarkdown" - ref="markdown-preview" - class="md-preview js-vue-md-preview md md-preview-holder" - v-html="markdownPreview" - ></div> + <template v-if="hasSuggestion"> + <div + v-show="previewMarkdown" + ref="markdown-preview" + class="md-preview js-vue-md-preview md md-preview-holder" + > + <suggestions + v-if="hasSuggestion" + :note-html="markdownPreview" + :from-line="lineNumber" + :from-content="lineContent" + :line-type="lineType" + :disabled="true" + :suggestions="suggestions" + :help-page-path="helpPagePath" + /> + </div> + </template> + <template v-else> + <div + v-show="previewMarkdown" + ref="markdown-preview" + class="md-preview js-vue-md-preview md md-preview-holder" + v-html="markdownPreview" + ></div> + </template> <template v-if="previewMarkdown && !markdownPreviewLoading"> <div v-if="referencedCommands" class="referenced-commands" v-html="referencedCommands"></div> <div v-if="shouldShowReferencedUsers" class="referenced-users"> <span> - <i class="fa fa-exclamation-triangle" aria-hidden="true"> </i> You are about to add + <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> You are about to add <strong> - <span class="js-referenced-users-count"> {{ referencedUsers.length }} </span> + <span class="js-referenced-users-count">{{ referencedUsers.length }}</span> </strong> people to the discussion. Proceed with caution. </span> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 4c4ba537065..bf4d42670ee 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -17,6 +17,16 @@ export default { type: Boolean, required: true, }, + lineContent: { + type: String, + required: false, + default: '', + }, + canSuggest: { + type: Boolean, + required: false, + default: true, + }, }, computed: { mdTable() { @@ -27,6 +37,9 @@ export default { '| cell | cell |', ].join('\n'); }, + mdSuggestion() { + return ['```suggestion', `{text}`, '```'].join('\n'); + }, }, mounted() { $(document).on('markdown-preview:show.vue', this.previewMarkdownTab); @@ -119,6 +132,16 @@ export default { :button-title="__('Add a table')" icon="table" /> + <toolbar-button + v-if="canSuggest" + :tag="mdSuggestion" + :prepend="true" + :button-title="__('Insert suggestion')" + :cursor-offset="4" + :tag-content="lineContent" + icon="doc-code" + class="qa-suggestion-btn" + /> <button v-gl-tooltip aria-label="Go full screen" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue new file mode 100644 index 00000000000..f98560f7336 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -0,0 +1,74 @@ +<script> +import SuggestionDiffHeader from './suggestion_diff_header.vue'; + +export default { + components: { + SuggestionDiffHeader, + }, + props: { + newLines: { + type: Array, + required: true, + }, + fromContent: { + type: String, + required: false, + default: '', + }, + fromLine: { + type: Number, + required: true, + }, + suggestion: { + type: Object, + required: true, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + helpPagePath: { + type: String, + required: true, + }, + }, + methods: { + applySuggestion(callback) { + this.$emit('apply', { suggestionId: this.suggestion.id, callback }); + }, + }, +}; +</script> + +<template> + <div> + <suggestion-diff-header + class="qa-suggestion-diff-header" + :can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled" + :is-applied="suggestion.applied" + :help-page-path="helpPagePath" + @apply="applySuggestion" + /> + <table class="mb-3 md-suggestion-diff"> + <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> + </tbody> + </table> + </div> +</template> 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 new file mode 100644 index 00000000000..563e2f94fcc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -0,0 +1,60 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { Icon }, + props: { + canApply: { + type: Boolean, + required: false, + default: false, + }, + isApplied: { + type: Boolean, + required: true, + default: false, + }, + helpPagePath: { + type: String, + required: true, + }, + }, + data() { + return { + isAppliedSuccessfully: false, + isApplying: false, + }; + }, + methods: { + applySuggestion() { + if (!this.canApply) return; + this.isApplying = true; + this.$emit('apply', this.applySuggestionCallback); + }, + applySuggestionCallback() { + this.isApplying = false; + }, + }, +}; +</script> + +<template> + <div class="md-suggestion-header border-bottom-0 mt-2"> + <div class="qa-suggestion-diff-header font-weight-bold"> + {{ __('Suggested change') }} + <a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')"> + <icon name="question-o" css-classes="link-highlight" /> + </a> + </div> + <span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span> + <button + v-if="canApply" + type="button" + class="btn qa-apply-btn" + :disabled="isApplying" + @click="applySuggestion" + > + {{ __('Apply suggestion') }} + </button> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue new file mode 100644 index 00000000000..7c6dbee3e19 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -0,0 +1,136 @@ +<script> +import Vue from 'vue'; +import SuggestionDiff from './suggestion_diff.vue'; +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, + default: '', + }, + suggestions: { + type: Array, + required: false, + default: () => [], + }, + noteHtml: { + type: String, + required: true, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + helpPagePath: { + type: String, + required: true, + }, + }, + data() { + return { + isRendered: false, + }; + }, + watch: { + suggestions() { + this.reset(); + }, + noteHtml() { + this.reset(); + }, + }, + mounted() { + this.renderSuggestions(); + }, + methods: { + renderSuggestions() { + // swaps out suggestion(s) markdown with rich diff components + // (while still keeping non-suggestion markdown in place) + + if (!this.noteHtml) return; + const { container } = this.$refs; + const suggestionElements = container.querySelectorAll('.js-render-suggestion'); + + if (this.lineType === 'old') { + Flash('Unable to apply suggestions to a deleted line.', 'alert', this.$el); + } + + suggestionElements.forEach((suggestionEl, i) => { + const suggestionParentEl = suggestionEl.parentElement; + const newLines = this.extractNewLines(suggestionParentEl); + const diffComponent = this.generateDiff(newLines, i); + diffComponent.$mount(suggestionParentEl); + }); + + this.isRendered = true; + }, + extractNewLines(suggestionEl) { + // extracts the suggested lines from the markdown + // calculates a line number for each line + + const FIRST_CHAR_REGEX = /^(\+|-)/; + 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.replace(FIRST_CHAR_REGEX, '')}\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 + + 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 }, + }); + + suggestionDiff.$on('apply', ({ suggestionId, callback }) => { + this.$emit('apply', { suggestionId, callback, flashContainer: this.$el }); + }); + + return suggestionDiff; + }, + reset() { + // resets the container HTML (replaces it with the updated noteHTML) + // calls `renderSuggestions` once the updated noteHTML is added to the DOM + + this.$refs.container.innerHTML = this.noteHtml; + this.isRendered = false; + this.renderSuggestions(); + this.$nextTick(() => this.renderSuggestions()); + }, + }, +}; +</script> + +<template> + <div> + <div class="flash-container mt-3"></div> + <div v-show="isRendered" ref="container" v-html="noteHtml"></div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue index a6d2cecdf7e..4572caa907b 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -37,6 +37,16 @@ export default { required: false, default: false, }, + tagContent: { + type: String, + required: false, + default: '', + }, + cursorOffset: { + type: Number, + required: false, + default: 0, + }, }, }; </script> @@ -45,8 +55,10 @@ export default { <button v-gl-tooltip :data-md-tag="tag" + :data-md-cursor-offset="cursorOffset" :data-md-select="tagSelect" :data-md-block="tagBlock" + :data-md-tag-content="tagContent" :data-md-prepend="prepend" :title="buttonTitle" :aria-label="buttonTitle" diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index 01b8b94f9e3..e833a8e0483 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -67,7 +67,7 @@ export default { // In both cases we should render the defaultAvatarUrl sanitizedSource() { let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; - if (baseSrc.indexOf('?') === -1) baseSrc += `?width=${this.size}`; + if (!baseSrc.startsWith('data:') && !baseSrc.includes('?')) baseSrc += `?width=${this.size}`; return baseSrc; }, resultantSrcAttribute() { @@ -97,6 +97,7 @@ export default { class="avatar" /> <gl-tooltip + v-if="tooltipText || $slots.default" :target="() => $refs.userAvatarImage" :placement="tooltipPlacement" boundary="window" 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 new file mode 100644 index 00000000000..7fbadcc0111 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -0,0 +1,104 @@ +<script> +import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; +import { glEmojiTag } from '../../../emoji'; + +export default { + name: 'UserPopover', + components: { + GlPopover, + GlSkeletonLoading, + UserAvatarImage, + }, + props: { + target: { + type: HTMLAnchorElement, + required: true, + }, + user: { + type: Object, + required: true, + default: null, + }, + loaded: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + jobLine() { + if (this.user.bio && this.user.organization) { + return sprintf(__('%{bio} at %{organization}'), { + bio: this.user.bio, + organization: this.user.organization, + }); + } else if (this.user.bio) { + return this.user.bio; + } else if (this.user.organization) { + return this.user.organization; + } + return null; + }, + statusHtml() { + if (this.user.status.emoji && this.user.status.message) { + return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message}`; + } else if (this.user.status.message) { + return this.user.status.message; + } + return ''; + }, + nameIsLoading() { + return !this.user.name; + }, + jobInfoIsLoading() { + return !this.user.loaded && this.user.organization === null; + }, + locationIsLoading() { + return !this.user.loaded && this.user.location === null; + }, + }, +}; +</script> + +<template> + <gl-popover :target="target" boundary="viewport" placement="top" show> + <div class="user-popover d-flex"> + <div class="p-1 flex-shrink-1"> + <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" /> + </div> + <div class="p-1 w-100"> + <h5 class="m-0"> + {{ user.name }} + <gl-skeleton-loading + v-if="nameIsLoading" + :lines="1" + class="animation-container-small mb-1" + /> + </h5> + <div class="text-secondary mb-2"> + <span v-if="user.username">@{{ user.username }}</span> + <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" /> + </div> + <div class="text-secondary"> + {{ jobLine }} + <gl-skeleton-loading + v-if="jobInfoIsLoading" + :lines="1" + class="animation-container-small mb-1" + /> + </div> + <div class="text-secondary"> + {{ user.location }} + <gl-skeleton-loading + v-if="locationIsLoading" + :lines="1" + class="animation-container-small mb-1" + /> + </div> + <div v-if="user.status" class="mt-2"><span v-html="statusHtml"></span></div> + </div> + </div> + </gl-popover> +</template> diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index bd1cca69c03..985fac11c87 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -35,6 +35,11 @@ @import "pages/**/*"; /* + * Component specific styles, will be moved to gitlab-ui + */ +@import "components/**/*"; + +/* * Code highlight */ @import "highlight/dark"; diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index 62024b8c555..f0671e36130 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -18,8 +18,10 @@ $input-border: $border-color; $padding-base-vertical: $gl-vert-padding; $padding-base-horizontal: $gl-padding; -html { - // Override default font size used in bs4 +body, +.form-control, +.search form { + // Override default font size used in non-csslab UI font-size: 14px; } diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss new file mode 100644 index 00000000000..2f4d30fe923 --- /dev/null +++ b/app/assets/stylesheets/components/popover.scss @@ -0,0 +1,9 @@ +.popover { + min-width: 300px; + + .popover-body .user-popover { + padding: $gl-padding-8; + font-size: $gl-font-size-small; + line-height: $gl-line-height; + } +} diff --git a/app/assets/stylesheets/csslab.scss b/app/assets/stylesheets/csslab.scss new file mode 100644 index 00000000000..acaa41e2677 --- /dev/null +++ b/app/assets/stylesheets/csslab.scss @@ -0,0 +1 @@ +@import "../../../node_modules/@gitlab/csslab/dist/css/csslab-slim"; diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index e36f99ac577..a4a9276c580 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -148,8 +148,8 @@ &.btn-xs { padding: 2px $gl-btn-padding; - font-size: $gl-btn-small-font-size; - line-height: $gl-btn-small-line-height; + font-size: $gl-btn-xs-font-size; + line-height: $gl-btn-xs-line-height; } &.btn-success, diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index f3c44f32d6f..f273eb9533d 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -176,9 +176,9 @@ display: block; font-weight: $gl-font-weight-normal; position: relative; - padding: 8px 16px; + padding: $dropdown-item-padding-y $dropdown-item-padding-x; color: $gl-text-color; - line-height: normal; + line-height: $gl-btn-line-height; white-space: normal; overflow: hidden; text-align: left; @@ -319,8 +319,8 @@ .dropdown-header { color: $gl-text-color-secondary; font-size: 13px; - line-height: 22px; - padding: 8px 16px; + line-height: $gl-line-height; + padding: $dropdown-item-padding-y $dropdown-item-padding-x; } &.capitalize-header .dropdown-header { @@ -329,13 +329,8 @@ .dropdown-bold-header { font-weight: $gl-font-weight-bold; - line-height: 22px; - padding: 0 16px; - } - - .separator + .dropdown-header, - .separator + .dropdown-bold-header { - padding-top: 10px; + line-height: $gl-line-height; + padding: $dropdown-item-padding-y $dropdown-item-padding-x; } .unclickable { diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 037a5adfb7e..3ac7b6b704b 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -24,7 +24,7 @@ } } - table { + &:not(.use-csslab) table { @extend .table; } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index c0cda29e239..45a52d99302 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -90,12 +90,6 @@ padding: 2px 8px; margin: 5px 2px 5px -8px; border-radius: $border-radius-default; - - .tanuki-logo { - @include media-breakpoint-up(sm) { - margin-right: 8px; - } - } } .project-item-select { @@ -127,12 +121,6 @@ } } - li.dropdown-bold-header { - color: $gl-text-color-secondary; - font-size: 12px; - padding: 0 16px; - } - .navbar-collapse { flex: 0 0 auto; border-top: 0; @@ -541,7 +529,7 @@ left: auto; li.current-user { - padding: 5px 18px; + padding: $dropdown-item-padding-y $dropdown-item-padding-x; .user-name { display: block; diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 0f6fb16774c..5609a2086e6 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -131,7 +131,7 @@ width: 100%; } -.md { +.md:not(.use-csslab) { &.md-preview-holder { // Reset ul style types since we're nested inside a ul already @include bulleted-list; @@ -277,6 +277,27 @@ } } +.md-suggestion-diff { + display: table !important; + border: 1px solid $border-color !important; +} + +.md-suggestion-header { + height: $suggestion-header-height; + display: flex; + align-items: center; + justify-content: space-between; + background-color: $gray-light; + border: 1px solid $border-color; + padding: $gl-padding; + border-radius: $border-radius-default $border-radius-default 0 0; + + svg { + vertical-align: middle; + margin-bottom: 3px; + } +} + @include media-breakpoint-down(xs) { .atwho-view-ul { width: 350px; diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index b3b99df5790..0c81dc2e156 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -368,11 +368,11 @@ code { * Apply Markdown typography * */ -.wiki { +.wiki:not(.use-csslab) { @include md-typography; } -.md { +.md:not(.use-csslab) { @include md-typography; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 134b3a4521b..4449193c104 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -172,6 +172,7 @@ $theme-light-red-700: #a62e21; $black: #000; $black-transparent: rgba(0, 0, 0, 0.3); +$shadow-color: rgba($black, 0.1); $almost-black: #242424; $border-white-light: darken($white-light, $darken-border-factor); @@ -250,6 +251,7 @@ $browserScrollbarSize: 10px; * Misc */ $header-height: 40px; +$suggestion-header-height: 46px; $ide-statusbar-height: 25px; $fixed-layout-width: 1280px; $limited-layout-width: 990px; @@ -368,7 +370,9 @@ $gl-btn-line-height: 16px; $gl-btn-vert-padding: 8px; $gl-btn-horz-padding: 12px; $gl-btn-small-font-size: 13px; -$gl-btn-small-line-height: 13px; +$gl-btn-small-line-height: 18px; +$gl-btn-xs-font-size: 13px; +$gl-btn-xs-line-height: 13px; /* * Badges @@ -399,7 +403,7 @@ $award-emoji-positive-add-lines: #bb9c13; * Search Box */ $search-input-border-color: rgba($blue-400, 0.8); -$search-input-width: 240px; +$search-input-width: 200px; $search-input-active-width: 320px; $location-icon-color: #e7e9ed; diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss index fab1b361f14..5ca76bb6c5a 100644 --- a/app/assets/stylesheets/framework/variables_overrides.scss +++ b/app/assets/stylesheets/framework/variables_overrides.scss @@ -21,3 +21,10 @@ $danger: $red-500; $zindex-modal-backdrop: 1040; $nav-divider-margin-y: ($grid-size / 2); $dropdown-divider-bg: $theme-gray-200; +$dropdown-item-padding-y: 8px; +$dropdown-item-padding-x: 12px; +$popover-max-width: 300px; +$popover-border-width: 1px; +$popover-border-color: $border-color; +$popover-box-shadow: 0 $border-radius-small $border-radius-default 0 $shadow-color; +$popover-arrow-outer-color: $shadow-color; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 5405f20a760..18c62cb4f1e 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -914,6 +914,7 @@ padding: 0; width: (2px * $image-comment-cursor-left-offset); height: (2px * $image-comment-cursor-top-offset); + color: $blue-400; // center the indicator to match the top left click region margin-top: (-1px * $image-comment-cursor-top-offset) + 2; margin-left: (-1px * $image-comment-cursor-left-offset) + 1; diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 132f3fea92b..a4831b64344 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -98,7 +98,6 @@ // Limits the width of the user bio for readability. max-width: 600px; margin: 10px auto; - padding: 0 16px; } .user-avatar-button { @@ -222,7 +221,11 @@ } .profile-header { - margin: 0 auto; + margin: 0 $gl-padding; + + &.with-no-profile-tabs { + margin-bottom: $gl-padding-24; + } .avatar-holder { width: 90px; diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 04151b1cd59..149c3254d84 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -101,8 +101,6 @@ input[type='checkbox']:hover { .dropdown-header { // Necessary because glDropdown doesn't support a second style of headers font-weight: $gl-font-weight-bold; - // .dropdown-menu li has 1px side padding - padding: $gl-padding-8 17px; color: $gl-text-color; font-size: $gl-font-size; line-height: 16px; diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss index 800f5c68e39..82e887aa62a 100644 --- a/app/assets/stylesheets/pages/wiki.scss +++ b/app/assets/stylesheets/pages/wiki.scss @@ -180,7 +180,7 @@ ul.wiki-pages-list.content-list { } } -.wiki { +.wiki:not(.use-csslab) { table { @include markdown-table; } diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index a597996a362..789e0dc736e 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -126,6 +126,8 @@ module IssuableCollections sort_param = params[:sort] sort_param ||= user_preference[issuable_sorting_field] + return sort_param if Gitlab::Database.read_only? + if user_preference[issuable_sorting_field] != sort_param user_preference.update_attribute(issuable_sorting_field, sort_param) end diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index c61b9fabe9e..4b0f0b8255c 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -12,7 +12,7 @@ module PreviewMarkdown when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] } when 'snippets' then { skip_project_check: true } when 'groups' then { group: group } - when 'projects' then { issuable_state_filter_enabled: true } + when 'projects' then projects_filter_params else {} end @@ -22,9 +22,17 @@ module PreviewMarkdown body: view_context.markdown(result[:text], markdown_params), references: { users: result[:users], + suggestions: result[:suggestions], commands: view_context.markdown(result[:commands]) } } end + + def projects_filter_params + { + issuable_state_filter_enabled: true, + suggestions_filter_enabled: params[:preview_suggestions].present? + } + end # rubocop:enable Gitlab/ModuleWithInstanceVariables end diff --git a/app/controllers/concerns/renders_commits.rb b/app/controllers/concerns/renders_commits.rb index f48e0586211..ed9b898a2a3 100644 --- a/app/controllers/concerns/renders_commits.rb +++ b/app/controllers/concerns/renders_commits.rb @@ -26,4 +26,10 @@ module RendersCommits commits end + + def valid_ref?(ref_name) + return true unless ref_name.present? + + Gitlab::GitRefValidator.validate(ref_name) + end end diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb index e40a1a1d744..2510a31c9b3 100644 --- a/app/controllers/projects/commits_controller.rb +++ b/app/controllers/projects/commits_controller.rb @@ -11,6 +11,7 @@ class Projects::CommitsController < Projects::ApplicationController before_action :require_non_empty_project before_action :assign_ref_vars, except: :commits_root before_action :authorize_download_code! + before_action :validate_ref!, except: :commits_root before_action :set_commits, except: :commits_root def commits_root @@ -54,6 +55,10 @@ class Projects::CommitsController < Projects::ApplicationController private + def validate_ref! + render_404 unless valid_ref?(@ref) + end + def set_commits render_404 unless @path.empty? || request.format == :atom || @repository.blob_at(@commit.id, @path) || @repository.tree(@commit.id, @path).entries.present? @limit, @offset = (params[:limit] || 40).to_i, (params[:offset] || 0).to_i diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index 2917925947f..5586c2fc631 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -65,12 +65,6 @@ class Projects::CompareController < Projects::ApplicationController private - def valid_ref?(ref_name) - return true unless ref_name.present? - - Gitlab::GitRefValidator.validate(ref_name) - end - def validate_refs! valid = [head_ref, start_ref].map { |ref| valid_ref?(ref) } diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index e940f382a19..a63eea0ca0e 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -11,6 +11,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :expire_etag_cache, only: [:index] + before_action do + push_frontend_feature_flag(:area_chart, project) + end + def index @environments = project.environments .with_state(params[:scope] || :available) diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index 4b6c5b215e8..8d8c62f1291 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -11,6 +11,10 @@ module DropdownsHelper dropdown_output = dropdown_toggle(toggle_text, data_attr, options) + if options.key?(:toggle_link) + dropdown_output = dropdown_toggle_link(toggle_text, data_attr, options) + end + dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}") do output = [] @@ -49,6 +53,11 @@ module DropdownsHelper end end + def dropdown_toggle_link(toggle_text, data_attr, options = {}) + output = content_tag(:a, toggle_text, class: "dropdown-toggle-text #{options[:toggle_class] if options.key?(:toggle_class)}", id: (options[:id] if options.key?(:id)), data: data_attr) + output.html_safe + end + def dropdown_title(title, options: {}) content_tag :div, class: "dropdown-title" do title_output = [] diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index dfa86f52e40..da991458ea7 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -179,7 +179,7 @@ module IssuablesHelper output << "Opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe output << content_tag(:strong) do - author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline", tooltip: true) + author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline") author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-block d-sm-none") if status = user_status(issuable.author) diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index a7fe8c3d59c..05da5ebdb22 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -47,8 +47,8 @@ module NavHelper class_names end - def show_separator? - Gitlab::Sherlock.enabled? || can?(current_user, :read_instance_statistics) + def has_extra_nav_icons? + Gitlab::Sherlock.enabled? || can?(current_user, :read_instance_statistics) || current_user.admin? end def page_has_markdown? diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 7ce6b04df7e..87aebe415c8 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -50,6 +50,12 @@ module ProjectsHelper default_opts = { avatar: true, name: true, title: ":name" } opts = default_opts.merge(opts) + data_attrs = { + user_id: author.id, + username: author.username, + name: author.name + } + return "(deleted)" unless author author_html = [] @@ -65,7 +71,7 @@ module ProjectsHelper author_html = author_html.join.html_safe if opts[:name] - link_to(author_html, user_path(author), class: "author-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}").html_safe + link_to(author_html, user_path(author), class: "author-link js-user-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}", data: data_attrs).html_safe else title = opts[:title].sub(":name", sanitize(author.name)) link_to(author_html, user_path(author), class: "author-link has-tooltip", title: title, data: { container: 'body' }).html_safe @@ -385,6 +391,10 @@ module ProjectsHelper end end + def sidebar_operations_link_path(project = @project) + metrics_project_environments_path(project) if can?(current_user, :read_environment, project) + end + def project_last_activity(project) if project.last_activity_at time_ago_with_tooltip(project.last_activity_at, placement: 'bottom', html_class: 'last_activity_time_ago') diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb index cf60696ef39..2f802e4eab8 100644 --- a/app/helpers/selects_helper.rb +++ b/app/helpers/selects_helper.rb @@ -29,6 +29,11 @@ module SelectsHelper classes = Array.wrap(opts[:class]) classes << 'ajax-groups-select' + # EE requires this line to be present, but there is no easy way of injecting + # this into EE without causing merge conflicts. Given this line is very + # simple and not really EE specific on its own, we just include it in CE. + classes << 'multiselect' if opts[:multiple] + opts[:class] = classes.join(' ') select2_tag(id, opts) diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index f51b96ba8ce..67c808b167a 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -164,7 +164,7 @@ module SortingHelper reverse_sort = issuable_reverse_sort_order_hash[sort_value] if reverse_sort - reverse_url = page_filter_path(sort: reverse_sort) + reverse_url = page_filter_path(sort: reverse_sort, label: true) else reverse_url = '#' link_class += ' disabled' diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index eb315058c3a..f2cad09e779 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -26,6 +26,10 @@ module Noteable DiscussionNote.noteable_types.include?(base_class_name) end + def supports_suggestion? + false + end + def discussions_rendered_on_frontend? false end diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb index af699eeebce..498996f4f80 100644 --- a/app/models/concerns/storage/legacy_namespace.rb +++ b/app/models/concerns/storage/legacy_namespace.rb @@ -4,6 +4,8 @@ module Storage module LegacyNamespace extend ActiveSupport::Concern + include Gitlab::ShellAdapter + def move_dir proj_with_tags = first_project_with_container_registry_tags diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index c32008aa9c7..279603496b0 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -66,10 +66,23 @@ class DiffNote < Note self.original_position.diff_refs == diff_refs end + def supports_suggestion? + return false unless noteable.supports_suggestion? && on_text? + # We don't want to trigger side-effects of `diff_file` call. + return false unless file = fetch_diff_file + return false unless line = file.line_for_position(self.original_position) + + line&.suggestible? + end + def discussion_first_note? self == discussion.first_note end + def banzai_render_context(field) + super.merge(suggestions_filter_enabled: supports_suggestion?) + end + private def enqueue_diff_file_creation_job diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 537f2a3a231..016c18ce6c8 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -3,8 +3,6 @@ class ProjectMember < Member SOURCE_TYPE = 'Project'.freeze - include Gitlab::ShellAdapter - belongs_to :project, foreign_key: 'source_id' # Make sure project member points only to project as it source diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 77e48ce11e8..baf320d84a1 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -363,6 +363,11 @@ class MergeRequest < ActiveRecord::Base end end + def supports_suggestion? + # Should be `true` when removing the FF. + Suggestion.feature_enabled? + end + # Calls `MergeWorker` to proceed with the merge process and # updates `merge_jid` with the MergeWorker#jid. # This helps tracking enqueued and ongoing merge jobs. diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 8865c164b11..3c9b1d32a53 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -3,7 +3,6 @@ class Namespace < ActiveRecord::Base include CacheMarkdownField include Sortable - include Gitlab::ShellAdapter include Gitlab::VisibilityLevel include Routable include AfterCommitQueue diff --git a/app/models/note.rb b/app/models/note.rb index a6ae4f58ac4..42237169722 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -69,6 +69,12 @@ class Note < ActiveRecord::Base belongs_to :last_edited_by, class_name: 'User' has_many :todos + + # The delete_all definition is required here in order + # to generate the correct DELETE sql for + # suggestions.delete_all calls + has_many :suggestions, -> { order(:relative_order) }, + inverse_of: :note, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_one :system_note_metadata has_one :note_diff_file, inverse_of: :diff_note, foreign_key: :diff_note_id @@ -110,7 +116,7 @@ class Note < ActiveRecord::Base scope :inc_author, -> { includes(:author) } scope :inc_relations_for_view, -> do includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji, - :system_note_metadata, :note_diff_file) + :system_note_metadata, :note_diff_file, :suggestions) end scope :with_notes_filter, -> (notes_filter) do @@ -226,6 +232,10 @@ class Note < ActiveRecord::Base Gitlab::HookData::NoteBuilder.new(self).build end + def supports_suggestion? + false + end + def for_commit? noteable_type == "Commit" end diff --git a/app/models/project.rb b/app/models/project.rb index f5dc58cd67f..9e65f7bdbca 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -655,6 +655,11 @@ class Project < ActiveRecord::Base end end + def latest_successful_build_for(job_name, ref = default_branch) + builds = latest_successful_builds_for(ref) + builds.find_by!(name: job_name) + end + def merge_base_commit(first_commit_id, second_commit_id) sha = repository.merge_base(first_commit_id, second_commit_id) commit_by(oid: sha) if sha @@ -2009,12 +2014,12 @@ class Project < ActiveRecord::Base def create_new_pool_repository pool = begin - create_or_find_pool_repository!(shard: Shard.by_name(repository_storage), source_project: self) + create_pool_repository!(shard: Shard.by_name(repository_storage), source_project: self) rescue ActiveRecord::RecordNotUnique - retry + pool_repository(true) end - pool.schedule + pool.schedule unless pool.scheduled? pool end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 6c1073265a1..d075440b147 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class ProtectedBranch < ActiveRecord::Base - include Gitlab::ShellAdapter include ProtectedRef protected_ref_access_levels :merge, :push diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb index 94746141945..d28ebabfe49 100644 --- a/app/models/protected_tag.rb +++ b/app/models/protected_tag.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class ProtectedTag < ActiveRecord::Base - include Gitlab::ShellAdapter include ProtectedRef validates :name, uniqueness: { scope: :project_id } diff --git a/app/models/repository.rb b/app/models/repository.rb index a9c167373c3..0ab7e711a01 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -17,7 +17,6 @@ class Repository #{REF_ENVIRONMENTS} ].freeze - include Gitlab::ShellAdapter include Gitlab::RepositoryCacheAdapter attr_accessor :full_path, :disk_path, :project, :is_wiki diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb new file mode 100644 index 00000000000..cec5ea30f9d --- /dev/null +++ b/app/models/suggestion.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class Suggestion < ApplicationRecord + FEATURE_FLAG = :diff_suggestions + + belongs_to :note, inverse_of: :suggestions + validates :note, presence: true + validates :commit_id, presence: true, if: :applied? + + delegate :original_position, :position, :diff_file, + :noteable, to: :note + + def self.feature_enabled? + Feature.enabled?(FEATURE_FLAG) + end + + def project + noteable.source_project + end + + def branch + noteable.source_branch + end + + # For now, suggestions only serve as a way to send patches that + # will change a single line (being able to apply multiple in the same place), + # which explains `from_line` and `to_line` being the same line. + # We'll iterate on that in https://gitlab.com/gitlab-org/gitlab-ce/issues/53310 + # when allowing multi-line suggestions. + def from_line + position.new_line + end + alias_method :to_line, :from_line + + def from_original_line + original_position.new_line + end + alias_method :to_original_line, :from_original_line + + # `from_line_index` and `to_line_index` represents diff/blob line numbers in + # index-like way (N-1). + def from_line_index + from_line - 1 + end + alias_method :to_line_index, :from_line_index + + def appliable? + return false unless note.supports_suggestion? + + !applied? && + noteable.opened? && + different_content? && + note.active? + end + + private + + def different_content? + from_content != to_content + end +end diff --git a/app/policies/suggestion_policy.rb b/app/policies/suggestion_policy.rb new file mode 100644 index 00000000000..301b7d965f5 --- /dev/null +++ b/app/policies/suggestion_policy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class SuggestionPolicy < BasePolicy + delegate { @subject.project } + + condition(:can_push_to_branch) do + Gitlab::UserAccess.new(@user, project: @subject.project).can_push_to_branch?(@subject.branch) + end + + rule { can_push_to_branch }.enable :apply_suggestion +end diff --git a/app/serializers/diff_line_entity.rb b/app/serializers/diff_line_entity.rb index 942714b7787..bfef6d3bde8 100644 --- a/app/serializers/diff_line_entity.rb +++ b/app/serializers/diff_line_entity.rb @@ -11,4 +11,6 @@ class DiffLineEntity < Grape::Entity expose :rich_text do |line| ERB::Util.html_escape(line.rich_text || line.text) end + + expose :suggestible?, as: :can_receive_suggestion end diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb index 58ab804a3c8..e3dc43240c6 100644 --- a/app/serializers/issue_board_entity.rb +++ b/app/serializers/issue_board_entity.rb @@ -17,7 +17,7 @@ class IssueBoardEntity < Grape::Entity end expose :milestone, expose_nil: false do |issue| - API::Entities::Project.represent issue.milestone, only: [:id, :title] + API::Entities::Milestone.represent issue.milestone, only: [:id, :title] end expose :assignees do |issue| diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index f33a1654d5e..9731b52f1ad 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -238,6 +238,8 @@ class MergeRequestWidgetEntity < IssuableEntity end end + expose :supports_suggestion?, as: :can_receive_suggestion + private delegate :current_user, to: :request diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index c6d27817411..1d3b59eb1b7 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -36,6 +36,7 @@ class NoteEntity < API::Entities::Note end end + expose :suggestions, using: SuggestionEntity expose :resolved?, as: :resolved expose :resolvable?, as: :resolvable diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb new file mode 100644 index 00000000000..4d0d4da10be --- /dev/null +++ b/app/serializers/suggestion_entity.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class SuggestionEntity < API::Entities::Suggestion + include RequestAwareEntity + + expose :current_user do + expose :can_apply do |suggestion| + Ability.allowed?(current_user, :apply_suggestion, suggestion) + end + end + + private + + def current_user + request.current_user + end +end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index e03789e3ca9..c4546f30235 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -36,6 +36,7 @@ module Notes if !only_commands && note.save todo_service.new_note(note, current_user) clear_noteable_diffs_cache(note) + Suggestions::CreateService.new(note).execute end if command_params.present? diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb index 35db409eb27..d2052bed646 100644 --- a/app/services/notes/update_service.rb +++ b/app/services/notes/update_service.rb @@ -14,6 +14,17 @@ module Notes TodoService.new.update_note(note, current_user, old_mentioned_users) end + if note.supports_suggestion? + Suggestion.transaction do + note.suggestions.delete_all + Suggestions::CreateService.new(note).execute + end + + # We need to refresh the previous suggestions call cache + # in order to get the new records. + note.reload + end + note end end diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index de8757006f1..a449a5dc3e9 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -4,10 +4,12 @@ class PreviewMarkdownService < BaseService def execute text, commands = explain_quick_actions(params[:text]) users = find_user_references(text) + suggestions = find_suggestions(text) success( text: text, users: users, + suggestions: suggestions, commands: commands.join(' '), markdown_engine: markdown_engine ) @@ -28,6 +30,12 @@ class PreviewMarkdownService < BaseService extractor.users.map(&:username) end + def find_suggestions(text) + return [] unless params[:preview_suggestions] + + Banzai::SuggestionsParser.parse(text) + end + def find_commands_target QuickActions::TargetService .new(project, current_user) diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb new file mode 100644 index 00000000000..d931d528c86 --- /dev/null +++ b/app/services/suggestions/apply_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Suggestions + class ApplyService < ::BaseService + def initialize(current_user) + @current_user = current_user + end + + def execute(suggestion) + unless suggestion.appliable? + return error('Suggestion is not appliable') + end + + params = file_update_params(suggestion) + result = ::Files::UpdateService.new(suggestion.project, @current_user, params).execute + + if result[:status] == :success + suggestion.update(commit_id: result[:result], applied: true) + end + + result + end + + private + + def file_update_params(suggestion) + diff_file = suggestion.diff_file + + file_path = diff_file.file_path + branch_name = suggestion.noteable.source_branch + file_content = new_file_content(suggestion) + commit_message = "Apply suggestion to #{file_path}" + + { + file_path: file_path, + branch_name: branch_name, + start_branch: branch_name, + commit_message: commit_message, + file_content: file_content + } + end + + def new_file_content(suggestion) + range = suggestion.from_line_index..suggestion.to_line_index + blob = suggestion.diff_file.new_blob + + blob.load_all_data! + content = blob.data.lines + content[range] = suggestion.to_content + + content.join + end + end +end diff --git a/app/services/suggestions/create_service.rb b/app/services/suggestions/create_service.rb new file mode 100644 index 00000000000..77e958cbe0c --- /dev/null +++ b/app/services/suggestions/create_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Suggestions + class CreateService + def initialize(note) + @note = note + end + + def execute + return unless @note.supports_suggestion? + + suggestions = Banzai::SuggestionsParser.parse(@note.note) + + # For single line suggestion we're only looking forward to + # change the line receiving the comment. Though, in + # https://gitlab.com/gitlab-org/gitlab-ce/issues/53310 + # we'll introduce a ```suggestion:L<x>-<y>, so this will + # slightly change. + comment_line = @note.position.new_line + + rows = + suggestions.map.with_index do |suggestion, index| + from_content = changing_lines(comment_line, comment_line) + + # The parsed suggestion doesn't have information about the correct + # ending characters (we may have a line break, or not), so we take + # this information from the last line being changed (last + # characters). + endline_chars = line_break_chars(from_content.lines.last) + to_content = "#{suggestion}#{endline_chars}" + + { + note_id: @note.id, + from_content: from_content, + to_content: to_content, + relative_order: index + } + end + + rows.in_groups_of(100, false) do |rows| + Gitlab::Database.bulk_insert('suggestions', rows) + end + end + + private + + def changing_lines(from_line, to_line) + @note.diff_file.new_blob_lines_between(from_line, to_line).join + end + + def line_break_chars(line) + match = /\r\n|\r|\n/.match(line) + match[0] if match + end + end +end diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb index 216acf79cbd..5feb0b0f05b 100644 --- a/app/validators/url_validator.rb +++ b/app/validators/url_validator.rb @@ -69,6 +69,7 @@ class UrlValidator < ActiveModel::EachValidator ports: [], allow_localhost: true, allow_local_network: true, + ascii_only: false, enforce_user: false } end diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index ac5916d129c..08a6359f777 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -36,6 +36,7 @@ = stylesheet_link_tag "print", media: "print" = stylesheet_link_tag "test", media: "all" if Rails.env.test? = stylesheet_link_tag 'performance_bar' if performance_bar_enabled? + = stylesheet_link_tag 'csslab' if Feature.enabled?(:csslab) = Gon::Base.render_data diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index b7d69539eb7..e8d0d809181 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -15,7 +15,7 @@ = brand_header_logo - logo_text = brand_header_logo_type - if logo_text.present? - %span.logo-text.d-none.d-sm-block + %span.logo-text.d-none.d-lg-block.prepend-left-8 = logo_text - if current_user diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index ea5f2b166b4..7057a5a142f 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -1,3 +1,5 @@ +-# WAIT! Before adding more items to the nav bar, please see +-# https://gitlab.com/gitlab-org/gitlab-ce/issues/49713 for more information. %ul.list-unstyled.navbar-sub-nav - if dashboard_nav_link?(:projects) = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown" }) do @@ -16,22 +18,22 @@ = render "layouts/nav/groups_dropdown/show" - if dashboard_nav_link?(:activity) - = nav_link(path: 'dashboard#activity', html_options: { class: "d-none d-lg-block d-xl-block" }) do + = nav_link(path: 'dashboard#activity', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: _('Activity') do = _('Activity') - if dashboard_nav_link?(:milestones) - = nav_link(controller: 'dashboard/milestones', html_options: { class: "d-none d-lg-block d-xl-block" }) do + = nav_link(controller: 'dashboard/milestones', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: _('Milestones') do = _('Milestones') - if dashboard_nav_link?(:snippets) - = nav_link(controller: 'dashboard/snippets', html_options: { class: "d-none d-lg-block d-xl-block" }) do + = nav_link(controller: 'dashboard/snippets', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: _('Snippets') do = _('Snippets') - if any_dashboard_nav_link?([:groups, :milestones, :activity, :snippets]) - %li.header-more.dropdown.d-lg-none.d-xl-none + %li.header-more.dropdown.d-xl-none{ class: ('d-lg-none' unless has_extra_nav_icons?) } %a{ href: "#", data: { toggle: "dropdown" } } = _('More') = sprite_icon('angle-down', css_class: 'caret-down') @@ -52,6 +54,21 @@ = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: _('Snippets') do = _('Snippets') + = render_if_exists 'dashboard/operations/nav_link' + - if can?(current_user, :read_instance_statistics) + = nav_link(controller: [:conversational_development_index, :cohorts]) do + = link_to instance_statistics_root_path, title: _('Instance Statistics'), aria: { label: _('Instance Statistics') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = _('Instance Statistics') + - if current_user.admin? + = nav_link(controller: 'admin/dashboard') do + = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = _('Admin Area') + - if Gitlab::Sherlock.enabled? + %li + = link_to sherlock_transactions_path, class: 'admin-icon', title: _('Sherlock Transactions'), + data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = _('Sherlock Transactions') + -# Shortcut to Dashboard > Projects - if dashboard_nav_link?(:projects) %li.hidden @@ -64,19 +81,17 @@ = link_to '#', class: 'dashboard-shortcuts-web-ide', title: _('Web IDE') do = _('Web IDE') - - if show_separator? - %li.line-separator.d-none.d-sm-block = render_if_exists 'dashboard/operations/nav_link' - if can?(current_user, :read_instance_statistics) - = nav_link(controller: [:conversational_development_index, :cohorts]) do + = nav_link(controller: [:conversational_development_index, :cohorts], html_options: { class: "d-none d-lg-block d-xl-block"}) do = link_to instance_statistics_root_path, title: _('Instance Statistics'), aria: { label: _('Instance Statistics') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = sprite_icon('chart', size: 18) - if current_user.admin? - = nav_link(controller: 'admin/dashboard') do - = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin area'), aria: { label: _('Admin area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = nav_link(controller: 'admin/dashboard', html_options: { class: "d-none d-lg-block d-xl-block"}) do + = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = sprite_icon('admin', size: 18) - if Gitlab::Sherlock.enabled? %li - = link_to sherlock_transactions_path, class: 'admin-icon', title: _('Sherlock Transactions'), + = link_to sherlock_transactions_path, class: 'admin-icon d-none d-lg-block d-xl-block', title: _('Sherlock Transactions'), data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('tachometer fw') diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index b89541a3c9f..bdd0108db0d 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -196,7 +196,7 @@ - if project_nav_tab? :operations = nav_link(controller: sidebar_operations_paths) do - = link_to metrics_project_environments_path(@project), class: 'shortcuts-operations' do + = link_to sidebar_operations_link_path, class: 'shortcuts-operations' do .nav-icon-container = sprite_icon('cloud-gear') %span.nav-item-name @@ -204,7 +204,7 @@ %ul.sidebar-sub-level-items = nav_link(controller: sidebar_operations_paths, html_options: { class: "fly-out-top-item" } ) do - = link_to metrics_project_environments_path(@project) do + = link_to sidebar_operations_link_path do %strong.fly-out-top-item-name = _('Operations') %li.divider.fly-out-top-item diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index cf273aab108..95c5eb32c7f 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -9,6 +9,6 @@ = render "projects/blob/auxiliary_viewer", blob: blob #blob-content-holder.blob-content-holder - %article.file-holder + %article.file-holder{ class: ('use-csslab' if Feature.enabled?(:csslab)) } = render 'projects/blob/header', blob: blob = render 'projects/blob/content', blob: blob diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml index eb65cd90ea8..ff460a3831c 100644 --- a/app/views/projects/blob/preview.html.haml +++ b/app/views/projects/blob/preview.html.haml @@ -1,7 +1,7 @@ .diff-file.file-holder .diff-content - if markup?(@blob.name) - .file-content.wiki + .file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } = markup(@blob.name, @content, legacy_render_context(params)) - else .file-content.code.js-syntax-highlight diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml index bd12cadf240..6edbfd91b21 100644 --- a/app/views/projects/blob/viewers/_markup.html.haml +++ b/app/views/projects/blob/viewers/_markup.html.haml @@ -2,5 +2,5 @@ - context = legacy_render_context(params) - unless context[:markdown_engine] == :redcarpet - context[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup) -.file-content.wiki +.file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } = markup(blob.name, blob.data, context) diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 4ebb029e48b..a0a03838b10 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -67,6 +67,7 @@ noteable_data: serialize_issuable(@merge_request), noteable_type: 'MergeRequest', target_type: 'merge_request', + help_page_path: nil, current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json} } #commits.commits.tab-pane @@ -76,6 +77,7 @@ = render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request) #js-diffs-app.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked?, endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', request.query_parameters), + help_page_path: nil, current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json, project_path: project_path(@merge_request.project)} } diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index f495b4eaf30..da48cb207a4 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -6,7 +6,7 @@ = render 'shared/snippets/header' .project-snippets - %article.file-holder.snippet-file-content + %article.file-holder.snippet-file-content{ class: ('use-csslab' if Feature.enabled?(:csslab)) } = render 'shared/snippets/blob' .row-content-block.top-block.content-component-block diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml index cc38ec12fd8..4d5fd55364c 100644 --- a/app/views/projects/wikis/show.html.haml +++ b/app/views/projects/wikis/show.html.haml @@ -26,7 +26,7 @@ = (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe .prepend-top-default.append-bottom-default - .wiki + .wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } = render_wiki_content(@page, legacy_render_context(params)) = render 'sidebar' diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 5295e656ab0..9eecfa39390 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -16,7 +16,7 @@ - if current_user .block.todo.hide-expanded = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true - .block.assignee + .block.assignee.qa-assignee-block = render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable, signed_in: current_user.present? = render_if_exists 'shared/issuable/sidebar_item_epic', issuable: issuable diff --git a/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml index 3521f71f409..60c34094108 100644 --- a/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml +++ b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml @@ -5,4 +5,4 @@ = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) - = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" + = link_to 'Assign to me', '#', class: "assign-to-me-link qa-assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index d11476738e4..dd2cd36eac2 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -31,12 +31,12 @@ data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('users') - .profile-header + .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] } .avatar-holder = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do = image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: '' - .user-info.prepend-left-default.append-right-default + .user-info .cover-title = @user.name @@ -81,10 +81,10 @@ = icon('briefcase') = @user.organization - - if @user.bio.present? - .cover-desc - %p.profile-user-bio - = @user.bio + - if @user.bio.present? + .cover-desc + %p.profile-user-bio + = @user.bio - unless profile_tabs.empty? .scrolling-tabs-container diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb index 9d4e67deb9c..bd429d526bf 100644 --- a/app/workers/repository_update_remote_mirror_worker.rb +++ b/app/workers/repository_update_remote_mirror_worker.rb @@ -5,7 +5,6 @@ class RepositoryUpdateRemoteMirrorWorker UpdateError = Class.new(StandardError) include ApplicationWorker - include Gitlab::ShellAdapter sidekiq_options retry: 3, dead: false |